Image preview
This commit is contained in:
parent
d3d0ab9c63
commit
da5fdc3e4f
@ -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
150
tools/preview_image.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user