Image preview

This commit is contained in:
moosecrap 2026-07-23 00:06:23 -07:00
parent d3d0ab9c63
commit da5fdc3e4f
2 changed files with 165 additions and 0 deletions

View File

@ -20,6 +20,7 @@ from .read_image import handle as read_image_handler
from .read_metadata import handle as read_png_metadata_handler
from .contact_sheet import handle as contact_sheet_handler
from .list_directory import handle as list_directory_details_handler
from .preview_image import handle as preview_image_handler
# Central registry of all available tools
TOOL_REGISTRY = [
@ -72,4 +73,18 @@ TOOL_REGISTRY = [
},
handler=list_directory_details_handler
),
Tool(
name="preview_image",
description="Generates small thumbnails (~70 tokens) and detailed info for images. Can take file paths, or indices/ranges from a contact sheet.",
schema={
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to an image file or a directory containing images"},
"indices": {"type": "string", "description": "1-based indices or ranges (e.g., '1, 3-5, 10') to preview from the directory. Required if path is a directory."},
"sort_by": {"type": "string", "description": "Sort order to resolve indices: 'mtime' (default), 'name', or 'size'."},
},
"required": ["path"],
},
handler=preview_image_handler
),
]

150
tools/preview_image.py Normal file
View File

@ -0,0 +1,150 @@
import math
import base64
import logging
import io
import datetime
from pathlib import Path
from typing import Any, Dict, List, Tuple
from PIL import Image
from tools.utils import format_relative_time, ToolError, get_file_info_list, sort_file_list
logger = logging.getLogger("MattCP")
def parse_indices(indices_str: str) -> List[int]:
"""
Parses a string like '11, 31-33, 44' into a list of 1-based indices.
Preserves the order specified in the string.
"""
indices = []
parts = [p.strip() for p in indices_str.split(',')]
for part in parts:
if not part:
continue
if '-' in part:
try:
start, end = map(int, part.split('-'))
# Expand range in order. Handle reverse ranges as well.
step = 1 if start <= end else -1
for i in range(start, end + step, step):
indices.append(i)
except ValueError:
raise ToolError(f"Invalid range format: {part}")
else:
try:
indices.append(int(part))
except ValueError:
raise ToolError(f"Invalid index format: {part}")
return indices
def calculate_patch_dimensions(orig_w: int, orig_h: int, target_tokens: int = 70):
"""
Calculates optimal pixel dimensions to hit a token budget of at least target_tokens.
The algorithm tries anchoring both Width and Height to find the smallest patch
count that satisfies the target while preserving aspect ratio.
"""
ar = orig_w / orig_h
# Case A: Width is the anchor (non-padded)
w_anchor = 1
while True:
w = w_anchor * 48
h = w / ar
hp = math.ceil(h / 48)
if w_anchor * hp >= target_tokens:
res_a = {"w": w, "h": round(h), "tokens": w_anchor * hp}
break
w_anchor += 1
# Case B: Height is the anchor (non-padded)
h_anchor = 1
while True:
h = h_anchor * 48
w = h * ar
wp = math.ceil(w / 48)
if h_anchor * wp >= target_tokens:
res_b = {"w": round(w), "h": h, "tokens": h_anchor * wp}
break
h_anchor += 1
# Pick the one with the smaller token count
best = res_a if res_a["tokens"] <= res_b["tokens"] else res_b
return max(1, best["w"]), max(1, best["h"])
async def handle(args: Dict[str, Any]):
"""
Generates small thumbnails of images with detailed info.
Can take a single file path, or a directory with indices/ranges from contact_sheet.
Thumbnails are optimized for ~70 token usage.
"""
path_str = args.get("path")
indices_str = args.get("indices")
sort_by = args.get("sort_by", "mtime")
if not path_str:
raise ToolError("Missing path argument")
path = Path(path_str)
# Store as (path, index_label) where index_label is None if direct path
target_files: List[Tuple[Path, Any]] = []
if path.is_file():
target_files.append((path, None))
elif path.is_dir():
if not indices_str:
raise ToolError("Indices are required when providing a directory path. Example: '1, 5-10'")
exts = ('.png', '.jpg', '.jpeg', '.webp', '.bmp')
file_info_list = get_file_info_list(path, extensions=exts)
file_info_list = sort_file_list(file_info_list, sort_by)
indices = parse_indices(indices_str)
for idx in indices:
# contact_sheet uses 1-based indexing
if 1 <= idx <= len(file_info_list):
target_files.append((file_info_list[idx-1]["path"], idx))
else:
logger.warning(f"Index {idx} out of range for directory {path_str}")
else:
raise ToolError(f"Path {path_str} is neither a file nor a directory.")
if not target_files:
return {"text": "No valid images found to preview."}
results = []
for file_path, index_label in target_files:
try:
stats = file_path.stat()
with Image.open(file_path) as img:
orig_w, orig_h = img.size
target_w, target_h = calculate_patch_dimensions(orig_w, orig_h)
# Use thumbnail() to preserve aspect ratio and fit within the target bounding box.
thumb = img.convert("RGB")
thumb.thumbnail((target_w, target_h), Image.Resampling.LANCZOS)
buf = io.BytesIO()
thumb.save(buf, format='JPEG', quality=95)
img_data = base64.b64encode(buf.getvalue()).decode("utf-8")
# Gather info
mtime_rel = format_relative_time(stats.st_mtime)
mtime_abs = datetime.datetime.fromtimestamp(stats.st_mtime).strftime('%Y-%m-%d %H:%M')
size_kb = stats.st_size / 1024
# Prepend index if this image was selected via index/range
idx_prefix = f"[Index: {index_label}] " if index_label is not None else ""
info_text = (
f"{idx_prefix}File: {file_path.name}\n"
f"Resolution: {orig_w}x{orig_h}\n"
f"Size: {size_kb:.1f}KB\n"
f"Modified: {mtime_rel} ({mtime_abs})"
)
results.append({"type": "text", "text": info_text})
results.append({"type": "image", "data": img_data, "mimeType": "image/jpeg"})
except Exception as e:
results.append({"type": "text", "text": f"Error processing {file_path.name}: {e}"})
return results