Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,786 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - Image Orientation Management Tool
|
||||
|
||||
Provides visual image orientation filtering, fix code generation,
|
||||
and batch image rotation functionality.
|
||||
|
||||
Usage:
|
||||
python3 scripts/rotate_images.py sheet <images_directory>
|
||||
python3 scripts/rotate_images.py gen <images_directory>
|
||||
python3 scripts/rotate_images.py fix <fixes.json>
|
||||
python3 scripts/rotate_images.py auto <images_directory>
|
||||
"""
|
||||
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Union, Any, Optional
|
||||
|
||||
from console_encoding import configure_utf8_stdio
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
from PIL import (
|
||||
ExifTags,
|
||||
Image,
|
||||
ImageDraw,
|
||||
ImageFont,
|
||||
ImageOps,
|
||||
)
|
||||
|
||||
|
||||
ORIENTATION_TAG_ID = 274 # 0x0112
|
||||
|
||||
class ImageRotator:
|
||||
"""Image orientation manager"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the manager"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _repo_root() -> Path:
|
||||
# scripts/rotate_images.py -> skills/ppt-master/
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
@staticmethod
|
||||
def _normalize_task_path(path_str: str) -> str:
|
||||
p = (path_str or "").strip()
|
||||
if not p:
|
||||
return p
|
||||
|
||||
# common copy/paste artifacts
|
||||
p = re.sub(r"^file:(?:///?)+", "", p, flags=re.IGNORECASE)
|
||||
p = p.replace("\\", "/")
|
||||
p = re.sub(r"^\\./", "", p)
|
||||
return p
|
||||
|
||||
@staticmethod
|
||||
def _natural_sort_key(s: Union[str, Path]) -> List[Union[int, str]]:
|
||||
"""Natural sort key generator"""
|
||||
return [int(text) if text.isdigit() else text.lower()
|
||||
for text in re.split(r'(\d+)', str(s))]
|
||||
|
||||
def _save_in_place(
|
||||
self,
|
||||
img: Image.Image,
|
||||
file_path: Path,
|
||||
src_format: Optional[str],
|
||||
*,
|
||||
exif_bytes: Optional[bytes] = None,
|
||||
icc_profile: Optional[bytes] = None,
|
||||
) -> None:
|
||||
fmt = (src_format or "").upper()
|
||||
|
||||
save_kwargs: Dict[str, Any] = {}
|
||||
if icc_profile:
|
||||
save_kwargs["icc_profile"] = icc_profile
|
||||
if exif_bytes:
|
||||
save_kwargs["exif"] = exif_bytes
|
||||
|
||||
# Avoid passing unsupported params to formats (e.g. PNG doesn't take `quality`).
|
||||
if fmt in {"JPEG", "JPG"}:
|
||||
save_kwargs["quality"] = 95
|
||||
# keep it simple; avoid Pillow-version-specific kwargs like optimize/subsampling
|
||||
if img.mode not in {"RGB", "L"}:
|
||||
img = img.convert("RGB")
|
||||
elif fmt == "WEBP":
|
||||
save_kwargs["quality"] = 95
|
||||
|
||||
try:
|
||||
img.save(file_path, **save_kwargs)
|
||||
except TypeError:
|
||||
# Fallback: drop metadata kwargs that some formats/plugins may reject.
|
||||
save_kwargs.pop("exif", None)
|
||||
save_kwargs.pop("icc_profile", None)
|
||||
img.save(file_path, **save_kwargs)
|
||||
|
||||
def auto_fix_exif(self, target_dir: Union[str, Path]) -> int:
|
||||
"""Auto-fix EXIF orientation for all images in the directory
|
||||
|
||||
Args:
|
||||
target_dir: Target directory
|
||||
|
||||
Returns:
|
||||
Number of images fixed
|
||||
"""
|
||||
target_path = Path(target_dir)
|
||||
if not target_path.exists():
|
||||
return 0
|
||||
|
||||
print(f"[AUTO] Checking EXIF orientation information...")
|
||||
fixed_count = 0
|
||||
valid_exts = {'.jpg', '.jpeg', '.webp'} # PNG typically does not carry rotation EXIF
|
||||
|
||||
# Pre-collect file list to avoid issues caused by modifying during iteration
|
||||
files = [f for f in target_path.iterdir() if f.is_file() and f.suffix.lower() in valid_exts]
|
||||
|
||||
for f in files:
|
||||
if self._fix_single_exif(f):
|
||||
fixed_count += 1
|
||||
|
||||
if fixed_count > 0:
|
||||
print(f"[OK] Auto-fixed EXIF orientation for {fixed_count} image(s)")
|
||||
else:
|
||||
print(f"[INFO] No images requiring EXIF correction found")
|
||||
|
||||
return fixed_count
|
||||
|
||||
def generate_contact_sheet(
|
||||
self,
|
||||
target_dir: Union[str, Path],
|
||||
output_path: Optional[Union[str, Path]] = None,
|
||||
) -> str:
|
||||
"""Generate a labeled, read-only contact sheet for visual review."""
|
||||
target_path = Path(target_dir).resolve()
|
||||
if not target_path.is_dir():
|
||||
raise FileNotFoundError(f"Directory not found: {target_path}")
|
||||
|
||||
valid_exts = {
|
||||
'.bmp', '.jpeg', '.jpg', '.png', '.tif', '.tiff', '.webp'
|
||||
}
|
||||
files = sorted(
|
||||
(
|
||||
path for path in target_path.iterdir()
|
||||
if path.is_file() and path.suffix.lower() in valid_exts
|
||||
),
|
||||
key=lambda path: self._natural_sort_key(path.name),
|
||||
)
|
||||
if not files:
|
||||
raise ValueError("No image files found")
|
||||
|
||||
columns = 6
|
||||
thumbnail_width = 180
|
||||
thumbnail_height = 180
|
||||
label_height = 34
|
||||
padding = 12
|
||||
cell_width = thumbnail_width + padding * 2
|
||||
cell_height = thumbnail_height + label_height + padding * 2
|
||||
rows = (len(files) + columns - 1) // columns
|
||||
|
||||
sheet = Image.new(
|
||||
'RGB',
|
||||
(columns * cell_width, rows * cell_height),
|
||||
'white',
|
||||
)
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
try:
|
||||
font = ImageFont.truetype('DejaVuSans.ttf', 13)
|
||||
except OSError:
|
||||
font = ImageFont.load_default()
|
||||
resampling = getattr(Image, 'Resampling', Image)
|
||||
|
||||
for index, file_path in enumerate(files):
|
||||
column = index % columns
|
||||
row = index // columns
|
||||
cell_x = column * cell_width
|
||||
cell_y = row * cell_height
|
||||
draw.rectangle(
|
||||
(
|
||||
cell_x,
|
||||
cell_y,
|
||||
cell_x + cell_width - 1,
|
||||
cell_y + cell_height - 1,
|
||||
),
|
||||
outline='#D0D7DE',
|
||||
)
|
||||
|
||||
details = 'unreadable'
|
||||
try:
|
||||
with Image.open(file_path) as img:
|
||||
if getattr(img, 'is_animated', False):
|
||||
img.seek(0)
|
||||
prepared = ImageOps.exif_transpose(img)
|
||||
try:
|
||||
source_width, source_height = prepared.size
|
||||
preview = prepared.convert('RGBA')
|
||||
finally:
|
||||
if prepared is not img:
|
||||
prepared.close()
|
||||
|
||||
preview.thumbnail(
|
||||
(thumbnail_width, thumbnail_height),
|
||||
resampling.LANCZOS,
|
||||
)
|
||||
preview_x = cell_x + padding + (thumbnail_width - preview.width) // 2
|
||||
preview_y = cell_y + padding + (thumbnail_height - preview.height) // 2
|
||||
sheet.paste(preview, (preview_x, preview_y), preview)
|
||||
preview.close()
|
||||
details = f'{source_width}x{source_height}'
|
||||
except (
|
||||
EOFError,
|
||||
OSError,
|
||||
SyntaxError,
|
||||
ValueError,
|
||||
Image.DecompressionBombError,
|
||||
):
|
||||
draw.line(
|
||||
(
|
||||
cell_x + padding,
|
||||
cell_y + padding,
|
||||
cell_x + cell_width - padding,
|
||||
cell_y + thumbnail_height,
|
||||
),
|
||||
fill='#CF222E',
|
||||
width=2,
|
||||
)
|
||||
draw.line(
|
||||
(
|
||||
cell_x + cell_width - padding,
|
||||
cell_y + padding,
|
||||
cell_x + padding,
|
||||
cell_y + thumbnail_height,
|
||||
),
|
||||
fill='#CF222E',
|
||||
width=2,
|
||||
)
|
||||
|
||||
label = file_path.name
|
||||
if len(label) > 30:
|
||||
label = f'{label[:13]}...{label[-14:]}'
|
||||
label_y = cell_y + padding + thumbnail_height + 2
|
||||
for line_index, text in enumerate((label, details)):
|
||||
text_bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = text_bbox[2] - text_bbox[0]
|
||||
draw.text(
|
||||
(
|
||||
cell_x + (cell_width - text_width) / 2,
|
||||
label_y + line_index * 15,
|
||||
),
|
||||
text,
|
||||
fill='#24292F',
|
||||
font=font,
|
||||
)
|
||||
|
||||
if output_path is None:
|
||||
resolved_output = (
|
||||
target_path.parent
|
||||
/ 'analysis'
|
||||
/ f'{target_path.name}_orientation_contact_sheet.jpg'
|
||||
)
|
||||
else:
|
||||
resolved_output = Path(output_path).expanduser().resolve()
|
||||
|
||||
suffix = resolved_output.suffix.lower()
|
||||
if suffix not in {'.jpg', '.jpeg', '.png'}:
|
||||
sheet.close()
|
||||
raise ValueError("Contact sheet output must use .jpg, .jpeg, or .png")
|
||||
resolved_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if suffix == '.png':
|
||||
sheet.save(resolved_output, format='PNG', optimize=True)
|
||||
else:
|
||||
sheet.save(resolved_output, format='JPEG', quality=90, optimize=True)
|
||||
sheet.close()
|
||||
return str(resolved_output)
|
||||
|
||||
def generate_html_tool(self, target_dir: str, output_filename: str = "image_orientation_tool.html") -> str:
|
||||
"""Generate the image filtering HTML tool
|
||||
|
||||
Automatically performs EXIF correction before generating.
|
||||
"""
|
||||
target_path = Path(target_dir).resolve()
|
||||
repo_root = self._repo_root()
|
||||
|
||||
if not target_path.exists():
|
||||
raise FileNotFoundError(f"Directory not found: {target_path}")
|
||||
|
||||
# 1. Perform automatic EXIF correction first
|
||||
self.auto_fix_exif(target_path)
|
||||
|
||||
# 2. Generate HTML
|
||||
# Tool is generated in the parent directory (projects/)
|
||||
project_root = target_path.parent
|
||||
html_output_path = project_root / output_filename
|
||||
|
||||
# Collect images
|
||||
images = []
|
||||
valid_exts = {'.jpg', '.jpeg', '.png', '.webp', '.bmp'}
|
||||
|
||||
print(f"[SCAN] Scanning directory to generate webpage: {target_path}")
|
||||
|
||||
files = sorted(target_path.iterdir(), key=lambda p: self._natural_sort_key(p.name))
|
||||
|
||||
for f in files:
|
||||
if f.is_file() and f.suffix.lower() in valid_exts:
|
||||
try:
|
||||
# src is used for HTML display, keep path relative to the HTML file (e.g. "images/1.jpg")
|
||||
src_rel_path = f.relative_to(project_root).as_posix()
|
||||
|
||||
# path is used for JSON data, using path relative to the working directory (usually repo root)
|
||||
# e.g. "projects/Name/images/1.jpg"
|
||||
# We assume the script is run from the repo root, or target_path is already absolute
|
||||
# The safest approach is to compute a path relative to the repo root (avoids CWD changes making fixes.json unusable)
|
||||
try:
|
||||
repo_rel_path = f.relative_to(repo_root).as_posix()
|
||||
except ValueError:
|
||||
# If the file is not under CWD, fall back to absolute path
|
||||
repo_rel_path = str(f.resolve())
|
||||
|
||||
images.append({'src': src_rel_path, 'path': repo_rel_path})
|
||||
except ValueError:
|
||||
print(f"[WARN] Warning: {f.name} cannot compute relative path, skipped")
|
||||
continue
|
||||
|
||||
if not images:
|
||||
raise ValueError("No image files found")
|
||||
|
||||
json_data = json.dumps(images)
|
||||
|
||||
# Embed HTML template
|
||||
html_content = self._get_html_template().replace('__IMAGES__', json_data)
|
||||
|
||||
with open(html_output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
return str(html_output_path)
|
||||
|
||||
def apply_fixes(self, json_source: Union[str, List[Dict]]) -> Dict[str, int]:
|
||||
"""Apply image rotation fixes"""
|
||||
tasks = []
|
||||
json_file_dir: Optional[Path] = None
|
||||
|
||||
# Parse input
|
||||
if isinstance(json_source, str):
|
||||
if json_source.endswith('.json') or os.path.exists(json_source):
|
||||
json_file_dir = Path(json_source).resolve().parent
|
||||
with open(json_source, 'r', encoding='utf-8') as f:
|
||||
tasks = json.load(f)
|
||||
else:
|
||||
try:
|
||||
tasks = json.loads(json_source)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("Invalid input: not a file path nor a valid JSON string")
|
||||
elif isinstance(json_source, list):
|
||||
tasks = json_source
|
||||
|
||||
gif_paths = []
|
||||
for task in tasks:
|
||||
if not isinstance(task, dict):
|
||||
continue
|
||||
task_path = self._normalize_task_path(task.get('path', ''))
|
||||
if Path(task_path).suffix.lower() == '.gif':
|
||||
gif_paths.append(task_path)
|
||||
if gif_paths:
|
||||
raise ValueError(
|
||||
"GIF rotation is unsupported; preserve GIF files unchanged: "
|
||||
+ ", ".join(gif_paths)
|
||||
)
|
||||
|
||||
print(f"[WORK] Starting {len(tasks)} manual rotation task(s)...")
|
||||
print("=" * 60)
|
||||
|
||||
cwd = Path(os.getcwd())
|
||||
repo_root = self._repo_root()
|
||||
stats = {'total': len(tasks), 'success': 0}
|
||||
|
||||
for task in tasks:
|
||||
rel_path = self._normalize_task_path(task.get('path', ''))
|
||||
rotation = task.get('rotation')
|
||||
|
||||
if not rel_path or rotation is None:
|
||||
continue
|
||||
|
||||
# Absolute paths should stay absolute; repo-relative paths should resolve from repo root.
|
||||
target_file = Path(rel_path)
|
||||
if not target_file.is_absolute():
|
||||
# Prefer repo root (stable); also allow CWD and fixes.json location as fallbacks.
|
||||
candidates = [
|
||||
repo_root / rel_path,
|
||||
cwd / rel_path,
|
||||
]
|
||||
if json_file_dir:
|
||||
candidates.append(json_file_dir / rel_path)
|
||||
|
||||
# Compatibility with legacy logic / bare filenames (try finding under the projects directory)
|
||||
candidates.append(repo_root / 'projects' / rel_path)
|
||||
candidates.append(cwd / 'projects' / rel_path)
|
||||
if json_file_dir:
|
||||
candidates.append(json_file_dir / 'projects' / rel_path)
|
||||
|
||||
target_file = next((c for c in candidates if c.exists()), candidates[0])
|
||||
|
||||
if not target_file.exists():
|
||||
print(f"[SKIP] File not found: {rel_path}")
|
||||
continue
|
||||
|
||||
try:
|
||||
self._rotate_single_image(target_file, rotation)
|
||||
print(f"[OK] {target_file.name} rotated {rotation} degrees")
|
||||
stats['success'] += 1
|
||||
except Exception as e:
|
||||
print(f"[ERROR] {target_file.name}: {e}")
|
||||
|
||||
return stats
|
||||
|
||||
def _fix_single_exif(self, file_path: Path) -> bool:
|
||||
"""Check and fix EXIF orientation for a single image"""
|
||||
try:
|
||||
fixed_img: Optional[Image.Image] = None
|
||||
exif_bytes: Optional[bytes] = None
|
||||
icc_profile: Optional[bytes] = None
|
||||
src_format: Optional[str] = None
|
||||
|
||||
with Image.open(file_path) as img:
|
||||
exif = img.getexif()
|
||||
orientation = exif.get(ORIENTATION_TAG_ID, 1) if exif else None
|
||||
|
||||
if not orientation or orientation == 1:
|
||||
return False
|
||||
|
||||
print(f" [EXIF] Fixing: {file_path.name} (Orientation={orientation})")
|
||||
|
||||
# Apply rotation
|
||||
fixed_img = self._apply_exif_orientation(img, orientation)
|
||||
fixed_img.load()
|
||||
|
||||
# Remove the specific Orientation tag, keep other EXIF data
|
||||
if exif:
|
||||
exif[ORIENTATION_TAG_ID] = 1
|
||||
exif_bytes = exif.tobytes()
|
||||
|
||||
icc_profile = img.info.get('icc_profile')
|
||||
src_format = img.format
|
||||
|
||||
# Must save after the original file is closed (Windows requirement)
|
||||
if fixed_img is None:
|
||||
return False
|
||||
|
||||
self._save_in_place(
|
||||
fixed_img,
|
||||
file_path,
|
||||
src_format,
|
||||
exif_bytes=exif_bytes,
|
||||
icc_profile=icc_profile,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" [WARN] Failed to read EXIF for {file_path.name}: {e}")
|
||||
return False
|
||||
|
||||
def _get_exif_orientation(self, img: Image.Image) -> Optional[int]:
|
||||
"""Get the Orientation value"""
|
||||
try:
|
||||
exif = img._getexif()
|
||||
if exif:
|
||||
for tag, value in exif.items():
|
||||
if ExifTags.TAGS.get(tag) == 'Orientation':
|
||||
return value
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _apply_exif_orientation(self, img: Image.Image, orientation: int) -> Image.Image:
|
||||
"""Rotate image according to the Orientation value"""
|
||||
T = getattr(Image, "Transpose", Image)
|
||||
if orientation == 2:
|
||||
return img.transpose(T.FLIP_LEFT_RIGHT)
|
||||
if orientation == 3:
|
||||
return img.transpose(T.ROTATE_180)
|
||||
if orientation == 4:
|
||||
return img.transpose(T.FLIP_TOP_BOTTOM)
|
||||
if orientation == 5:
|
||||
return img.transpose(T.TRANSPOSE)
|
||||
if orientation == 6:
|
||||
return img.transpose(T.ROTATE_270)
|
||||
if orientation == 7:
|
||||
return img.transpose(T.TRANSVERSE)
|
||||
if orientation == 8:
|
||||
return img.transpose(T.ROTATE_90)
|
||||
return img
|
||||
|
||||
def _rotate_single_image(self, file_path: Path, rotation_deg: int):
|
||||
"""Manually rotate a single image"""
|
||||
T = getattr(Image, "Transpose", Image)
|
||||
with Image.open(file_path) as img:
|
||||
ccw_angle = (360 - int(rotation_deg)) % 360
|
||||
if ccw_angle == 0:
|
||||
return
|
||||
|
||||
if ccw_angle == 90:
|
||||
rotated = img.transpose(T.ROTATE_90)
|
||||
elif ccw_angle == 180:
|
||||
rotated = img.transpose(T.ROTATE_180)
|
||||
elif ccw_angle == 270:
|
||||
rotated = img.transpose(T.ROTATE_270)
|
||||
else:
|
||||
rotated = img.rotate(ccw_angle, expand=True)
|
||||
|
||||
rotated.load()
|
||||
|
||||
exif = img.getexif()
|
||||
exif_bytes: Optional[bytes] = None
|
||||
if exif:
|
||||
exif[ORIENTATION_TAG_ID] = 1
|
||||
exif_bytes = exif.tobytes()
|
||||
|
||||
icc_profile = img.info.get('icc_profile')
|
||||
src_format = img.format
|
||||
|
||||
self._save_in_place(
|
||||
rotated,
|
||||
file_path,
|
||||
src_format,
|
||||
exif_bytes=exif_bytes,
|
||||
icc_profile=icc_profile,
|
||||
)
|
||||
|
||||
def _get_html_template(self) -> str:
|
||||
"""Get HTML template content"""
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Image Orientation Tool</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 20px; background: #f0f2f5; color: #333; }
|
||||
.header {
|
||||
position: sticky; top: 0; background: rgba(255,255,255,0.95); padding: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08); z-index: 100;
|
||||
border-radius: 12px; margin-bottom: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
h2 { margin: 0; font-size: 1.5rem; color: #1a1a1a; }
|
||||
.instructions { color: #666; margin-top: 5px; font-size: 0.9rem; }
|
||||
|
||||
.grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
.card {
|
||||
background: white; border-radius: 12px; overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05); text-align: center;
|
||||
cursor: pointer; transition: all 0.2s ease;
|
||||
position: relative; border: 2px solid transparent;
|
||||
}
|
||||
.card:hover { transform: translateY(-4px); box-shadow: 0 8px 16px rgba(0,0,0,0.1); }
|
||||
.card.modified { border-color: #007bff; background: #f8fbff; }
|
||||
|
||||
.img-wrapper {
|
||||
height: 180px; width: 100%; display: flex; align-items: center; justify-content: center;
|
||||
background: #e9ecef; overflow: hidden; position: relative;
|
||||
}
|
||||
img {
|
||||
max-width: 100%; max-height: 100%; object-fit: contain;
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.info { padding: 10px; font-size: 11px; color: #555; word-break: break-all; border-top: 1px solid #eee; }
|
||||
|
||||
.badge {
|
||||
position: absolute; top: 10px; right: 10px;
|
||||
background: #007bff; color: white; padding: 4px 8px;
|
||||
border-radius: 20px; font-size: 11px; font-weight: bold;
|
||||
opacity: 0; transform: scale(0.8); transition: all 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
.card.modified .badge { opacity: 1; transform: scale(1); }
|
||||
|
||||
.btn {
|
||||
background: #007bff; color: white; border: none; padding: 10px 24px;
|
||||
border-radius: 8px; font-weight: 600; cursor: pointer; transition: background 0.2s;
|
||||
}
|
||||
.btn:hover { background: #0056b3; }
|
||||
|
||||
#output-modal {
|
||||
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: white; padding: 30px; border-radius: 16px; width: 80%; max-width: 600px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
textarea {
|
||||
width: 100%; height: 200px; padding: 10px; border: 1px solid #ddd; border-radius: 8px;
|
||||
font-family: inherit; resize: vertical; margin: 15px 0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h2>Image Orientation Fix</h2>
|
||||
<div class="instructions">Click an image to rotate (90 -> 180 -> 270 -> 0). Natural order sorting.</div>
|
||||
</div>
|
||||
<button class="btn" onclick="showCode()">Generate Fix Code</button>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="grid"></div>
|
||||
|
||||
<div id="output-modal" onclick="if(event.target===this)this.style.display='none'">
|
||||
<div class="modal-content">
|
||||
<h3>Copy the code below</h3>
|
||||
<p style="color:#666; font-size: 0.9em;">Copy this JSON content and send it to the AI assistant, or save it as 'fixes.json'.</p>
|
||||
<textarea id="output-area" readonly></textarea>
|
||||
<div style="text-align: right;">
|
||||
<button class="btn" onclick="document.getElementById('output-modal').style.display='none'">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const images = __IMAGES__;
|
||||
const grid = document.getElementById('grid');
|
||||
|
||||
images.forEach(item => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.setAttribute('data-rotation', 0);
|
||||
// use stable path for the data attribute
|
||||
card.setAttribute('data-path', item.path);
|
||||
|
||||
const filename = item.src.split('/').pop();
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="img-wrapper">
|
||||
<img src="${item.src}" alt="${filename}" loading="lazy">
|
||||
<div class="badge">0°</div>
|
||||
</div>
|
||||
<div class="info">${filename}</div>
|
||||
`;
|
||||
|
||||
card.onclick = function() {
|
||||
let rot = parseInt(this.getAttribute('data-rotation'));
|
||||
rot = (rot + 90) % 360;
|
||||
this.setAttribute('data-rotation', rot);
|
||||
|
||||
const img = this.querySelector('img');
|
||||
img.style.transform = `rotate(${rot}deg)`;
|
||||
|
||||
const badge = this.querySelector('.badge');
|
||||
badge.innerText = rot + '°';
|
||||
|
||||
if (rot > 0) {
|
||||
this.classList.add('modified');
|
||||
} else {
|
||||
this.classList.remove('modified');
|
||||
}
|
||||
};
|
||||
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
function showCode() {
|
||||
const tasks = [];
|
||||
document.querySelectorAll('.card').forEach(card => {
|
||||
const rot = parseInt(card.getAttribute('data-rotation'));
|
||||
if (rot > 0) {
|
||||
tasks.push({
|
||||
path: card.getAttribute('data-path'),
|
||||
rotation: rot
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const jsonStr = JSON.stringify(tasks, null, 2);
|
||||
const modal = document.getElementById('output-modal');
|
||||
const area = document.getElementById('output-area');
|
||||
|
||||
modal.style.display = 'flex';
|
||||
area.value = jsonStr;
|
||||
area.select();
|
||||
try { document.execCommand('copy'); } catch(e){}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the command-line parser."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Manage image orientation and manual rotation fixes.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""Examples:
|
||||
python3 scripts/rotate_images.py sheet projects/demo/images
|
||||
python3 scripts/rotate_images.py gen projects/demo/images
|
||||
python3 scripts/rotate_images.py fix fixes.json
|
||||
python3 scripts/rotate_images.py auto projects/demo/images
|
||||
""",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sheet = subparsers.add_parser(
|
||||
"sheet",
|
||||
help="Generate a static contact sheet without modifying images",
|
||||
)
|
||||
sheet.add_argument("images_directory", help="Images directory")
|
||||
sheet.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
help="Output .jpg or .png path (default: sibling analysis directory)",
|
||||
)
|
||||
|
||||
gen = subparsers.add_parser("gen", help="Generate the visual rotation HTML tool")
|
||||
gen.add_argument("images_directory", help="Images directory")
|
||||
|
||||
fix = subparsers.add_parser("fix", help="Apply rotations from a fixes JSON file")
|
||||
fix.add_argument("fixes_json", help="Path to fixes.json")
|
||||
|
||||
auto = subparsers.add_parser("auto", help="Automatically fix EXIF orientation")
|
||||
auto.add_argument("images_directory", help="Images directory")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Run the CLI entry point."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
rotator = ImageRotator()
|
||||
|
||||
if args.command == 'sheet':
|
||||
try:
|
||||
output_path = rotator.generate_contact_sheet(
|
||||
args.images_directory,
|
||||
args.output,
|
||||
)
|
||||
print(f"[REPORT] Image orientation contact sheet: {output_path}")
|
||||
except (OSError, ValueError) as e:
|
||||
print(f"[ERROR] Contact sheet generation failed: {e}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if args.command == 'gen':
|
||||
target_dir = args.images_directory
|
||||
try:
|
||||
output_path = rotator.generate_html_tool(target_dir)
|
||||
print(f"[OK] HTML tool created: {output_path}")
|
||||
print(f"[LINK] Open in browser: file:///{Path(output_path).as_posix()}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Generation failed: {e}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if args.command == 'fix':
|
||||
json_file = args.fixes_json
|
||||
try:
|
||||
stats = rotator.apply_fixes(json_file)
|
||||
print(f"\n[DONE] Processing complete: {stats['success']} succeeded / {stats['total']} total")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Execution failed: {e}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if args.command == 'auto':
|
||||
target_dir = args.images_directory
|
||||
try:
|
||||
# Only perform automatic EXIF fix
|
||||
count = rotator.auto_fix_exif(Path(target_dir))
|
||||
if count == 0:
|
||||
print("[INFO] No images requiring automatic fix found")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Automatic fix failed: {e}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
parser.error(f"Unknown command: {args.command}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user