返回 ppt-master
rotate_images.py
根目录 / skills / ppt-master / scripts / rotate_images.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Image Orientation Management Tool
4
5 Provides visual image orientation filtering, fix code generation,
6 and batch image rotation functionality.
7
8 Usage:
9 python3 scripts/rotate_images.py gen <images_directory>
10 python3 scripts/rotate_images.py fix <fixes.json>
11 python3 scripts/rotate_images.py auto <images_directory>
12 """
13
14
15 import argparse
16 import json
17 import os
18 import re
19 from pathlib import Path
20 from typing import List, Dict, Union, Any, Optional
21
22 from console_encoding import configure_utf8_stdio
23
24 configure_utf8_stdio()
25
26 from PIL import Image, ExifTags
27
28
29 ORIENTATION_TAG_ID = 274 # 0x0112
30
31 class ImageRotator:
32 """Image orientation manager"""
33
34 def __init__(self):
35 """Initialize the manager"""
36 pass
37
38 @staticmethod
39 def _repo_root() -> Path:
40 # scripts/rotate_images.py -> skills/ppt-master/
41 return Path(__file__).resolve().parent.parent
42
43 @staticmethod
44 def _normalize_task_path(path_str: str) -> str:
45 p = (path_str or "").strip()
46 if not p:
47 return p
48
49 # common copy/paste artifacts
50 p = re.sub(r"^file:(?:///?)+", "", p, flags=re.IGNORECASE)
51 p = p.replace("\\", "/")
52 p = re.sub(r"^\\./", "", p)
53 return p
54
55 @staticmethod
56 def _natural_sort_key(s: Union[str, Path]) -> List[Union[int, str]]:
57 """Natural sort key generator"""
58 return [int(text) if text.isdigit() else text.lower()
59 for text in re.split(r'(\d+)', str(s))]
60
61 def _save_in_place(
62 self,
63 img: Image.Image,
64 file_path: Path,
65 src_format: Optional[str],
66 *,
67 exif_bytes: Optional[bytes] = None,
68 icc_profile: Optional[bytes] = None,
69 ) -> None:
70 fmt = (src_format or "").upper()
71
72 save_kwargs: Dict[str, Any] = {}
73 if icc_profile:
74 save_kwargs["icc_profile"] = icc_profile
75 if exif_bytes:
76 save_kwargs["exif"] = exif_bytes
77
78 # Avoid passing unsupported params to formats (e.g. PNG doesn't take `quality`).
79 if fmt in {"JPEG", "JPG"}:
80 save_kwargs["quality"] = 95
81 # keep it simple; avoid Pillow-version-specific kwargs like optimize/subsampling
82 if img.mode not in {"RGB", "L"}:
83 img = img.convert("RGB")
84 elif fmt == "WEBP":
85 save_kwargs["quality"] = 95
86
87 try:
88 img.save(file_path, **save_kwargs)
89 except TypeError:
90 # Fallback: drop metadata kwargs that some formats/plugins may reject.
91 save_kwargs.pop("exif", None)
92 save_kwargs.pop("icc_profile", None)
93 img.save(file_path, **save_kwargs)
94
95 def auto_fix_exif(self, target_dir: Union[str, Path]) -> int:
96 """Auto-fix EXIF orientation for all images in the directory
97
98 Args:
99 target_dir: Target directory
100
101 Returns:
102 Number of images fixed
103 """
104 target_path = Path(target_dir)
105 if not target_path.exists():
106 return 0
107
108 print(f"[AUTO] Checking EXIF orientation information...")
109 fixed_count = 0
110 valid_exts = {'.jpg', '.jpeg', '.webp'} # PNG typically does not carry rotation EXIF
111
112 # Pre-collect file list to avoid issues caused by modifying during iteration
113 files = [f for f in target_path.iterdir() if f.is_file() and f.suffix.lower() in valid_exts]
114
115 for f in files:
116 if self._fix_single_exif(f):
117 fixed_count += 1
118
119 if fixed_count > 0:
120 print(f"[OK] Auto-fixed EXIF orientation for {fixed_count} image(s)")
121 else:
122 print(f"[INFO] No images requiring EXIF correction found")
123
124 return fixed_count
125
126 def generate_html_tool(self, target_dir: str, output_filename: str = "image_orientation_tool.html") -> str:
127 """Generate the image filtering HTML tool
128
129 Automatically performs EXIF correction before generating.
130 """
131 target_path = Path(target_dir).resolve()
132 repo_root = self._repo_root()
133
134 if not target_path.exists():
135 raise FileNotFoundError(f"Directory not found: {target_path}")
136
137 # 1. Perform automatic EXIF correction first
138 self.auto_fix_exif(target_path)
139
140 # 2. Generate HTML
141 # Tool is generated in the parent directory (projects/)
142 project_root = target_path.parent
143 html_output_path = project_root / output_filename
144
145 # Collect images
146 images = []
147 valid_exts = {'.jpg', '.jpeg', '.png', '.webp', '.bmp'}
148
149 print(f"[SCAN] Scanning directory to generate webpage: {target_path}")
150
151 files = sorted(target_path.iterdir(), key=lambda p: self._natural_sort_key(p.name))
152
153 for f in files:
154 if f.is_file() and f.suffix.lower() in valid_exts:
155 try:
156 # src is used for HTML display, keep path relative to the HTML file (e.g. "images/1.jpg")
157 src_rel_path = f.relative_to(project_root).as_posix()
158
159 # path is used for JSON data, using path relative to the working directory (usually repo root)
160 # e.g. "projects/Name/images/1.jpg"
161 # We assume the script is run from the repo root, or target_path is already absolute
162 # The safest approach is to compute a path relative to the repo root (avoids CWD changes making fixes.json unusable)
163 try:
164 repo_rel_path = f.relative_to(repo_root).as_posix()
165 except ValueError:
166 # If the file is not under CWD, fall back to absolute path
167 repo_rel_path = str(f.resolve())
168
169 images.append({'src': src_rel_path, 'path': repo_rel_path})
170 except ValueError:
171 print(f"[WARN] Warning: {f.name} cannot compute relative path, skipped")
172 continue
173
174 if not images:
175 raise ValueError("No image files found")
176
177 json_data = json.dumps(images)
178
179 # Embed HTML template
180 html_content = self._get_html_template().replace('__IMAGES__', json_data)
181
182 with open(html_output_path, 'w', encoding='utf-8') as f:
183 f.write(html_content)
184
185 return str(html_output_path)
186
187 def apply_fixes(self, json_source: Union[str, List[Dict]]) -> Dict[str, int]:
188 """Apply image rotation fixes"""
189 tasks = []
190 json_file_dir: Optional[Path] = None
191
192 # Parse input
193 if isinstance(json_source, str):
194 if json_source.endswith('.json') or os.path.exists(json_source):
195 json_file_dir = Path(json_source).resolve().parent
196 with open(json_source, 'r', encoding='utf-8') as f:
197 tasks = json.load(f)
198 else:
199 try:
200 tasks = json.loads(json_source)
201 except json.JSONDecodeError:
202 raise ValueError("Invalid input: not a file path nor a valid JSON string")
203 elif isinstance(json_source, list):
204 tasks = json_source
205
206 print(f"[WORK] Starting {len(tasks)} manual rotation task(s)...")
207 print("=" * 60)
208
209 cwd = Path(os.getcwd())
210 repo_root = self._repo_root()
211 stats = {'total': len(tasks), 'success': 0}
212
213 for task in tasks:
214 rel_path = self._normalize_task_path(task.get('path', ''))
215 rotation = task.get('rotation')
216
217 if not rel_path or rotation is None:
218 continue
219
220 # Absolute paths should stay absolute; repo-relative paths should resolve from repo root.
221 target_file = Path(rel_path)
222 if not target_file.is_absolute():
223 # Prefer repo root (stable); also allow CWD and fixes.json location as fallbacks.
224 candidates = [
225 repo_root / rel_path,
226 cwd / rel_path,
227 ]
228 if json_file_dir:
229 candidates.append(json_file_dir / rel_path)
230
231 # Compatibility with legacy logic / bare filenames (try finding under the projects directory)
232 candidates.append(repo_root / 'projects' / rel_path)
233 candidates.append(cwd / 'projects' / rel_path)
234 if json_file_dir:
235 candidates.append(json_file_dir / 'projects' / rel_path)
236
237 target_file = next((c for c in candidates if c.exists()), candidates[0])
238
239 if not target_file.exists():
240 print(f"[SKIP] File not found: {rel_path}")
241 continue
242
243 try:
244 self._rotate_single_image(target_file, rotation)
245 print(f"[OK] {target_file.name} rotated {rotation} degrees")
246 stats['success'] += 1
247 except Exception as e:
248 print(f"[ERROR] {target_file.name}: {e}")
249
250 return stats
251
252 def _fix_single_exif(self, file_path: Path) -> bool:
253 """Check and fix EXIF orientation for a single image"""
254 try:
255 fixed_img: Optional[Image.Image] = None
256 exif_bytes: Optional[bytes] = None
257 icc_profile: Optional[bytes] = None
258 src_format: Optional[str] = None
259
260 with Image.open(file_path) as img:
261 exif = img.getexif()
262 orientation = exif.get(ORIENTATION_TAG_ID, 1) if exif else None
263
264 if not orientation or orientation == 1:
265 return False
266
267 print(f" [EXIF] Fixing: {file_path.name} (Orientation={orientation})")
268
269 # Apply rotation
270 fixed_img = self._apply_exif_orientation(img, orientation)
271 fixed_img.load()
272
273 # Remove the specific Orientation tag, keep other EXIF data
274 if exif:
275 exif[ORIENTATION_TAG_ID] = 1
276 exif_bytes = exif.tobytes()
277
278 icc_profile = img.info.get('icc_profile')
279 src_format = img.format
280
281 # Must save after the original file is closed (Windows requirement)
282 if fixed_img is None:
283 return False
284
285 self._save_in_place(
286 fixed_img,
287 file_path,
288 src_format,
289 exif_bytes=exif_bytes,
290 icc_profile=icc_profile,
291 )
292 return True
293 except Exception as e:
294 print(f" [WARN] Failed to read EXIF for {file_path.name}: {e}")
295 return False
296
297 def _get_exif_orientation(self, img: Image.Image) -> Optional[int]:
298 """Get the Orientation value"""
299 try:
300 exif = img._getexif()
301 if exif:
302 for tag, value in exif.items():
303 if ExifTags.TAGS.get(tag) == 'Orientation':
304 return value
305 except Exception:
306 pass
307 return None
308
309 def _apply_exif_orientation(self, img: Image.Image, orientation: int) -> Image.Image:
310 """Rotate image according to the Orientation value"""
311 T = getattr(Image, "Transpose", Image)
312 if orientation == 2:
313 return img.transpose(T.FLIP_LEFT_RIGHT)
314 if orientation == 3:
315 return img.transpose(T.ROTATE_180)
316 if orientation == 4:
317 return img.transpose(T.FLIP_TOP_BOTTOM)
318 if orientation == 5:
319 return img.transpose(T.TRANSPOSE)
320 if orientation == 6:
321 return img.transpose(T.ROTATE_270)
322 if orientation == 7:
323 return img.transpose(T.TRANSVERSE)
324 if orientation == 8:
325 return img.transpose(T.ROTATE_90)
326 return img
327
328 def _rotate_single_image(self, file_path: Path, rotation_deg: int):
329 """Manually rotate a single image"""
330 T = getattr(Image, "Transpose", Image)
331 with Image.open(file_path) as img:
332 ccw_angle = (360 - int(rotation_deg)) % 360
333 if ccw_angle == 0:
334 return
335
336 if ccw_angle == 90:
337 rotated = img.transpose(T.ROTATE_90)
338 elif ccw_angle == 180:
339 rotated = img.transpose(T.ROTATE_180)
340 elif ccw_angle == 270:
341 rotated = img.transpose(T.ROTATE_270)
342 else:
343 rotated = img.rotate(ccw_angle, expand=True)
344
345 rotated.load()
346
347 exif = img.getexif()
348 exif_bytes: Optional[bytes] = None
349 if exif:
350 exif[ORIENTATION_TAG_ID] = 1
351 exif_bytes = exif.tobytes()
352
353 icc_profile = img.info.get('icc_profile')
354 src_format = img.format
355
356 self._save_in_place(
357 rotated,
358 file_path,
359 src_format,
360 exif_bytes=exif_bytes,
361 icc_profile=icc_profile,
362 )
363
364 def _get_html_template(self) -> str:
365 """Get HTML template content"""
366 return """
367 <!DOCTYPE html>
368 <html lang="en">
369 <head>
370 <meta charset="UTF-8">
371 <title>Image Orientation Tool</title>
372 <style>
373 body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 20px; background: #f0f2f5; color: #333; }
374 .header {
375 position: sticky; top: 0; background: rgba(255,255,255,0.95); padding: 20px;
376 box-shadow: 0 4px 20px rgba(0,0,0,0.08); z-index: 100;
377 border-radius: 12px; margin-bottom: 20px;
378 backdrop-filter: blur(10px);
379 display: flex; justify-content: space-between; align-items: center;
380 }
381 h2 { margin: 0; font-size: 1.5rem; color: #1a1a1a; }
382 .instructions { color: #666; margin-top: 5px; font-size: 0.9rem; }
383
384 .grid {
385 display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
386 gap: 15px;
387 }
388 .card {
389 background: white; border-radius: 12px; overflow: hidden;
390 box-shadow: 0 2px 8px rgba(0,0,0,0.05); text-align: center;
391 cursor: pointer; transition: all 0.2s ease;
392 position: relative; border: 2px solid transparent;
393 }
394 .card:hover { transform: translateY(-4px); box-shadow: 0 8px 16px rgba(0,0,0,0.1); }
395 .card.modified { border-color: #007bff; background: #f8fbff; }
396
397 .img-wrapper {
398 height: 180px; width: 100%; display: flex; align-items: center; justify-content: center;
399 background: #e9ecef; overflow: hidden; position: relative;
400 }
401 img {
402 max-width: 100%; max-height: 100%; object-fit: contain;
403 transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
404 }
405
406 .info { padding: 10px; font-size: 11px; color: #555; word-break: break-all; border-top: 1px solid #eee; }
407
408 .badge {
409 position: absolute; top: 10px; right: 10px;
410 background: #007bff; color: white; padding: 4px 8px;
411 border-radius: 20px; font-size: 11px; font-weight: bold;
412 opacity: 0; transform: scale(0.8); transition: all 0.2s;
413 box-shadow: 0 2px 4px rgba(0,0,0,0.2);
414 }
415 .card.modified .badge { opacity: 1; transform: scale(1); }
416
417 .btn {
418 background: #007bff; color: white; border: none; padding: 10px 24px;
419 border-radius: 8px; font-weight: 600; cursor: pointer; transition: background 0.2s;
420 }
421 .btn:hover { background: #0056b3; }
422
423 #output-modal {
424 display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
425 background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;
426 }
427 .modal-content {
428 background: white; padding: 30px; border-radius: 16px; width: 80%; max-width: 600px;
429 box-shadow: 0 10px 40px rgba(0,0,0,0.2);
430 }
431 textarea {
432 width: 100%; height: 200px; padding: 10px; border: 1px solid #ddd; border-radius: 8px;
433 font-family: inherit; resize: vertical; margin: 15px 0;
434 background: #f8f9fa;
435 }
436 </style>
437 </head>
438 <body>
439
440 <div class="header">
441 <div>
442 <h2>Image Orientation Fix</h2>
443 <div class="instructions">Click an image to rotate (90 -> 180 -> 270 -> 0). Natural order sorting.</div>
444 </div>
445 <button class="btn" onclick="showCode()">Generate Fix Code</button>
446 </div>
447
448 <div class="grid" id="grid"></div>
449
450 <div id="output-modal" onclick="if(event.target===this)this.style.display='none'">
451 <div class="modal-content">
452 <h3>Copy the code below</h3>
453 <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>
454 <textarea id="output-area" readonly></textarea>
455 <div style="text-align: right;">
456 <button class="btn" onclick="document.getElementById('output-modal').style.display='none'">Close</button>
457 </div>
458 </div>
459 </div>
460
461 <script>
462 const images = __IMAGES__;
463 const grid = document.getElementById('grid');
464
465 images.forEach(item => {
466 const card = document.createElement('div');
467 card.className = 'card';
468 card.setAttribute('data-rotation', 0);
469 // use stable path for the data attribute
470 card.setAttribute('data-path', item.path);
471
472 const filename = item.src.split('/').pop();
473
474 card.innerHTML = `
475 <div class="img-wrapper">
476 <img src="${item.src}" alt="${filename}" loading="lazy">
477 <div class="badge">0°</div>
478 </div>
479 <div class="info">${filename}</div>
480 `;
481
482 card.onclick = function() {
483 let rot = parseInt(this.getAttribute('data-rotation'));
484 rot = (rot + 90) % 360;
485 this.setAttribute('data-rotation', rot);
486
487 const img = this.querySelector('img');
488 img.style.transform = `rotate(${rot}deg)`;
489
490 const badge = this.querySelector('.badge');
491 badge.innerText = rot + '°';
492
493 if (rot > 0) {
494 this.classList.add('modified');
495 } else {
496 this.classList.remove('modified');
497 }
498 };
499
500 grid.appendChild(card);
501 });
502
503 function showCode() {
504 const tasks = [];
505 document.querySelectorAll('.card').forEach(card => {
506 const rot = parseInt(card.getAttribute('data-rotation'));
507 if (rot > 0) {
508 tasks.push({
509 path: card.getAttribute('data-path'),
510 rotation: rot
511 });
512 }
513 });
514
515 const jsonStr = JSON.stringify(tasks, null, 2);
516 const modal = document.getElementById('output-modal');
517 const area = document.getElementById('output-area');
518
519 modal.style.display = 'flex';
520 area.value = jsonStr;
521 area.select();
522 try { document.execCommand('copy'); } catch(e){}
523 }
524 </script>
525 </body>
526 </html>
527 """
528
529 def build_parser() -> argparse.ArgumentParser:
530 """Build the command-line parser."""
531 parser = argparse.ArgumentParser(
532 description="Manage image orientation and manual rotation fixes.",
533 formatter_class=argparse.RawDescriptionHelpFormatter,
534 epilog="""Examples:
535 python3 scripts/rotate_images.py gen projects/demo/images
536 python3 scripts/rotate_images.py fix fixes.json
537 python3 scripts/rotate_images.py auto projects/demo/images
538 """,
539 )
540 subparsers = parser.add_subparsers(dest="command", required=True)
541
542 gen = subparsers.add_parser("gen", help="Generate the visual rotation HTML tool")
543 gen.add_argument("images_directory", help="Images directory")
544
545 fix = subparsers.add_parser("fix", help="Apply rotations from a fixes JSON file")
546 fix.add_argument("fixes_json", help="Path to fixes.json")
547
548 auto = subparsers.add_parser("auto", help="Automatically fix EXIF orientation")
549 auto.add_argument("images_directory", help="Images directory")
550 return parser
551
552
553 def main(argv: list[str] | None = None) -> int:
554 """Run the CLI entry point."""
555 parser = build_parser()
556 args = parser.parse_args(argv)
557 rotator = ImageRotator()
558
559 if args.command == 'gen':
560 target_dir = args.images_directory
561 try:
562 output_path = rotator.generate_html_tool(target_dir)
563 print(f"[OK] HTML tool created: {output_path}")
564 print(f"[LINK] Open in browser: file:///{Path(output_path).as_posix()}")
565 except Exception as e:
566 print(f"[ERROR] Generation failed: {e}")
567 return 1
568 return 0
569
570 if args.command == 'fix':
571 json_file = args.fixes_json
572 try:
573 stats = rotator.apply_fixes(json_file)
574 print(f"\n[DONE] Processing complete: {stats['success']} succeeded / {stats['total']} total")
575 except Exception as e:
576 print(f"[ERROR] Execution failed: {e}")
577 return 1
578 return 0
579
580 if args.command == 'auto':
581 target_dir = args.images_directory
582 try:
583 # Only perform automatic EXIF fix
584 count = rotator.auto_fix_exif(Path(target_dir))
585 if count == 0:
586 print("[INFO] No images requiring automatic fix found")
587 except Exception as e:
588 print(f"[ERROR] Automatic fix failed: {e}")
589 return 1
590 return 0
591
592 parser.error(f"Unknown command: {args.command}")
593
594 if __name__ == '__main__':
595 raise SystemExit(main())
596
596 lines PYTHON