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 .read_metadata import handle as read_png_metadata_handler
|
||||||
from .contact_sheet import handle as contact_sheet_handler
|
from .contact_sheet import handle as contact_sheet_handler
|
||||||
from .list_directory import handle as list_directory_details_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
|
# Central registry of all available tools
|
||||||
TOOL_REGISTRY = [
|
TOOL_REGISTRY = [
|
||||||
@ -72,4 +73,18 @@ TOOL_REGISTRY = [
|
|||||||
},
|
},
|
||||||
handler=list_directory_details_handler
|
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