scansplitter 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """ScanSplitter - Automatically detect, split, and rotate photos from scanned images."""
2
+
3
+ __version__ = "0.1.0"
scansplitter/api.py ADDED
@@ -0,0 +1,551 @@
1
+ """FastAPI backend for ScanSplitter."""
2
+
3
+ import base64
4
+ import io
5
+ import uuid
6
+ import zipfile
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.responses import FileResponse, Response
13
+ from fastapi.staticfiles import StaticFiles
14
+ from PIL import Image
15
+ from pydantic import BaseModel
16
+
17
+ from .detector import DetectedRegion, crop_rotated_region, detect_photos
18
+ from .pdf_handler import extract_images_from_pdf, is_pdf
19
+ from .rotator import auto_rotate
20
+ from .session import Session, get_session_manager
21
+
22
+ app = FastAPI(title="ScanSplitter API", version="0.1.0")
23
+
24
+ # CORS for development
25
+ app.add_middleware(
26
+ CORSMiddleware,
27
+ allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
28
+ allow_credentials=True,
29
+ allow_methods=["*"],
30
+ allow_headers=["*"],
31
+ )
32
+
33
+
34
+ # --- Pydantic Models ---
35
+
36
+
37
+ class BoundingBox(BaseModel):
38
+ """A rotatable bounding box."""
39
+
40
+ id: str
41
+ center_x: float
42
+ center_y: float
43
+ width: float
44
+ height: float
45
+ angle: float # degrees
46
+
47
+
48
+ class UploadResponse(BaseModel):
49
+ """Response from file upload."""
50
+
51
+ session_id: str
52
+ filename: str
53
+ page_count: int
54
+ image_width: int
55
+ image_height: int
56
+
57
+
58
+ class DetectRequest(BaseModel):
59
+ """Request for detection."""
60
+
61
+ session_id: str
62
+ page: int = 1
63
+ min_area: float = 2.0 # percentage
64
+ max_area: float = 80.0 # percentage
65
+
66
+
67
+ class DetectResponse(BaseModel):
68
+ """Response from detection."""
69
+
70
+ boxes: list[BoundingBox]
71
+ image_url: str
72
+
73
+
74
+ class CropRequest(BaseModel):
75
+ """Request for cropping with adjusted boxes."""
76
+
77
+ session_id: str
78
+ page: int = 1
79
+ boxes: list[BoundingBox]
80
+ auto_rotate: bool = True
81
+
82
+
83
+ class CroppedImage(BaseModel):
84
+ """A cropped image result."""
85
+
86
+ id: str
87
+ data: str # base64 encoded
88
+ width: int
89
+ height: int
90
+ rotation_applied: int
91
+
92
+
93
+ class CropResponse(BaseModel):
94
+ """Response from cropping."""
95
+
96
+ images: list[CroppedImage]
97
+
98
+
99
+ class ImageData(BaseModel):
100
+ """Image data for export."""
101
+
102
+ id: str
103
+ data: str # base64 encoded
104
+ name: str
105
+
106
+
107
+ class ExportRequest(BaseModel):
108
+ """Request for export."""
109
+
110
+ session_id: str
111
+ format: str = "jpeg" # jpeg or png
112
+ quality: int = 85
113
+ names: dict[str, str] | None = None # id -> custom name (legacy)
114
+ images: list[ImageData] | None = None # Direct image data with rotations applied
115
+
116
+
117
+ class ExportLocalRequest(BaseModel):
118
+ """Request for local export."""
119
+
120
+ session_id: str
121
+ output_directory: str
122
+ format: str = "jpeg" # jpeg or png
123
+ quality: int = 85
124
+ names: dict[str, str] | None = None # id -> custom name (legacy)
125
+ images: list[ImageData] | None = None # Direct image data with rotations applied
126
+ overwrite: bool = False # Whether to overwrite existing files
127
+
128
+
129
+ # --- Helper Functions ---
130
+
131
+
132
+ def get_session_or_404(session_id: str) -> Session:
133
+ """Get session or raise 404."""
134
+ session = get_session_manager().get_session(session_id)
135
+ if session is None:
136
+ raise HTTPException(status_code=404, detail="Session not found")
137
+ return session
138
+
139
+
140
+ def load_page_image(session: Session, filename: str, page: int) -> Image.Image:
141
+ """Load a specific page from an uploaded file."""
142
+ file_info = session.files.get(filename)
143
+ if file_info is None:
144
+ raise HTTPException(status_code=404, detail=f"File not found: {filename}")
145
+
146
+ file_path = Path(file_info["path"])
147
+
148
+ if file_info.get("is_pdf"):
149
+ # Extract specific page from PDF
150
+ images = extract_images_from_pdf(file_path, dpi=150) # Lower DPI for preview
151
+ if page < 1 or page > len(images):
152
+ raise HTTPException(status_code=400, detail=f"Invalid page number: {page}")
153
+ return images[page - 1]
154
+ else:
155
+ # Load image directly
156
+ return Image.open(file_path).convert("RGB")
157
+
158
+
159
+ def image_to_base64(image: Image.Image, format: str = "JPEG", quality: int = 85) -> str:
160
+ """Convert PIL Image to base64 string."""
161
+ buffer = io.BytesIO()
162
+ if format.upper() == "JPEG":
163
+ image.save(buffer, format="JPEG", quality=quality)
164
+ else:
165
+ image.save(buffer, format="PNG", optimize=True)
166
+ return base64.b64encode(buffer.getvalue()).decode("utf-8")
167
+
168
+
169
+ def box_to_detected_region(box: BoundingBox, image: Image.Image) -> DetectedRegion:
170
+ """Convert BoundingBox to DetectedRegion for cropping."""
171
+ # Calculate axis-aligned bounding box from rotated rect
172
+ import math
173
+
174
+ import numpy as np
175
+
176
+ cx, cy = box.center_x, box.center_y
177
+ w, h = box.width, box.height
178
+ angle_rad = math.radians(box.angle)
179
+
180
+ # Get corners of rotated rectangle
181
+ cos_a, sin_a = math.cos(angle_rad), math.sin(angle_rad)
182
+ corners = []
183
+ for dx, dy in [(-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2)]:
184
+ x = cx + dx * cos_a - dy * sin_a
185
+ y = cy + dx * sin_a + dy * cos_a
186
+ corners.append((x, y))
187
+
188
+ corners = np.array(corners)
189
+ x_min, y_min = corners.min(axis=0)
190
+ x_max, y_max = corners.max(axis=0)
191
+
192
+ return DetectedRegion(
193
+ center=(cx, cy),
194
+ size=(w, h),
195
+ angle=box.angle,
196
+ area=w * h,
197
+ area_ratio=(w * h) / (image.width * image.height),
198
+ x=int(max(0, x_min)),
199
+ y=int(max(0, y_min)),
200
+ width=int(min(image.width, x_max) - max(0, x_min)),
201
+ height=int(min(image.height, y_max) - max(0, y_min)),
202
+ )
203
+
204
+
205
+ # --- API Endpoints ---
206
+
207
+
208
+ @app.get("/api/health")
209
+ async def health_check():
210
+ """Health check endpoint."""
211
+ return {"status": "ok"}
212
+
213
+
214
+ @app.post("/api/upload", response_model=UploadResponse)
215
+ async def upload_file(file: UploadFile = File(...)):
216
+ """Upload a file and create a session."""
217
+ if file.filename is None:
218
+ raise HTTPException(status_code=400, detail="No filename provided")
219
+
220
+ # Create session
221
+ session = get_session_manager().create_session()
222
+
223
+ # Save file
224
+ file_path = session.directory / file.filename
225
+ content = await file.read()
226
+ file_path.write_bytes(content)
227
+
228
+ # Determine page count and dimensions
229
+ is_pdf_file = is_pdf(file_path)
230
+ if is_pdf_file:
231
+ images = extract_images_from_pdf(file_path, dpi=72) # Low DPI just for count
232
+ page_count = len(images)
233
+ # Get dimensions from first page at full res
234
+ first_page = extract_images_from_pdf(file_path, dpi=150)[0]
235
+ width, height = first_page.width, first_page.height
236
+ else:
237
+ page_count = 1
238
+ image = Image.open(file_path)
239
+ width, height = image.size
240
+ image.close()
241
+
242
+ # Store file info
243
+ session.files[file.filename] = {
244
+ "path": str(file_path),
245
+ "is_pdf": is_pdf_file,
246
+ "page_count": page_count,
247
+ }
248
+
249
+ return UploadResponse(
250
+ session_id=session.id,
251
+ filename=file.filename,
252
+ page_count=page_count,
253
+ image_width=width,
254
+ image_height=height,
255
+ )
256
+
257
+
258
+ @app.get("/api/image/{session_id}/{filename}")
259
+ async def get_image(session_id: str, filename: str, page: int = 1):
260
+ """Get an uploaded image or PDF page."""
261
+ session = get_session_or_404(session_id)
262
+ image = load_page_image(session, filename, page)
263
+
264
+ # Convert to JPEG for serving
265
+ buffer = io.BytesIO()
266
+ image.save(buffer, format="JPEG", quality=90)
267
+ buffer.seek(0)
268
+
269
+ return Response(content=buffer.getvalue(), media_type="image/jpeg")
270
+
271
+
272
+ @app.post("/api/detect", response_model=DetectResponse)
273
+ async def detect_boxes(request: DetectRequest):
274
+ """Detect bounding boxes in an image."""
275
+ session = get_session_or_404(request.session_id)
276
+
277
+ # Get first file (we only support one at a time for now)
278
+ if not session.files:
279
+ raise HTTPException(status_code=400, detail="No files uploaded")
280
+
281
+ filename = list(session.files.keys())[0]
282
+ image = load_page_image(session, filename, request.page)
283
+
284
+ # Run detection
285
+ regions = detect_photos(
286
+ image,
287
+ min_area_ratio=request.min_area / 100,
288
+ max_area_ratio=request.max_area / 100,
289
+ )
290
+
291
+ # Convert to BoundingBox format
292
+ boxes = []
293
+ for region in regions:
294
+ boxes.append(
295
+ BoundingBox(
296
+ id=uuid.uuid4().hex[:8],
297
+ center_x=region.center[0],
298
+ center_y=region.center[1],
299
+ width=region.size[0],
300
+ height=region.size[1],
301
+ angle=region.angle,
302
+ )
303
+ )
304
+
305
+ # Build image URL
306
+ image_url = f"/api/image/{request.session_id}/{filename}?page={request.page}"
307
+
308
+ return DetectResponse(boxes=boxes, image_url=image_url)
309
+
310
+
311
+ @app.post("/api/crop", response_model=CropResponse)
312
+ async def crop_images(request: CropRequest):
313
+ """Crop images using user-adjusted bounding boxes."""
314
+ import cv2
315
+ import numpy as np
316
+
317
+ session = get_session_or_404(request.session_id)
318
+
319
+ if not session.files:
320
+ raise HTTPException(status_code=400, detail="No files uploaded")
321
+
322
+ filename = list(session.files.keys())[0]
323
+ image = load_page_image(session, filename, request.page)
324
+
325
+ # Convert to OpenCV format
326
+ cv_image = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
327
+
328
+ cropped_images = []
329
+ for box in request.boxes:
330
+ # Convert box to DetectedRegion
331
+ region = box_to_detected_region(box, image)
332
+
333
+ # Crop the region
334
+ cropped_cv = crop_rotated_region(cv_image, region)
335
+
336
+ # Convert back to PIL
337
+ cropped_rgb = cv2.cvtColor(cropped_cv, cv2.COLOR_BGR2RGB)
338
+ cropped_pil = Image.fromarray(cropped_rgb)
339
+
340
+ # Auto-rotate if enabled
341
+ rotation_applied = 0
342
+ if request.auto_rotate:
343
+ cropped_pil, rotation_applied = auto_rotate(cropped_pil)
344
+
345
+ # Convert to base64
346
+ data = image_to_base64(cropped_pil)
347
+
348
+ cropped_images.append(
349
+ CroppedImage(
350
+ id=box.id,
351
+ data=data,
352
+ width=cropped_pil.width,
353
+ height=cropped_pil.height,
354
+ rotation_applied=rotation_applied,
355
+ )
356
+ )
357
+
358
+ # Save to session for export
359
+ cropped_path = session.directory / f"cropped_{box.id}.jpg"
360
+ cropped_pil.save(cropped_path, "JPEG", quality=95)
361
+ session.cropped_images.append(cropped_path)
362
+
363
+ return CropResponse(images=cropped_images)
364
+
365
+
366
+ @app.post("/api/export")
367
+ async def export_zip(request: ExportRequest):
368
+ """Export cropped images as a ZIP file."""
369
+ session = get_session_or_404(request.session_id)
370
+
371
+ # Use provided image data if available (includes client-side rotations)
372
+ if request.images:
373
+ zip_path = session.directory / "export.zip"
374
+ ext = "png" if request.format.lower() == "png" else "jpg"
375
+
376
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
377
+ for img_data in request.images:
378
+ # Decode base64 and re-encode in requested format
379
+ img_bytes = base64.b64decode(img_data.data)
380
+ img = Image.open(io.BytesIO(img_bytes))
381
+
382
+ buffer = io.BytesIO()
383
+ if request.format.lower() == "png":
384
+ img.save(buffer, "PNG", optimize=True)
385
+ else:
386
+ img.save(buffer, "JPEG", quality=request.quality)
387
+
388
+ filename = f"{img_data.name}.{ext}"
389
+ zf.writestr(filename, buffer.getvalue())
390
+
391
+ return FileResponse(
392
+ zip_path,
393
+ media_type="application/zip",
394
+ filename="scansplitter_export.zip",
395
+ )
396
+
397
+ # Legacy fallback: use cached images from session
398
+ if not session.cropped_images:
399
+ raise HTTPException(status_code=400, detail="No cropped images to export")
400
+
401
+ zip_path = session.directory / "export.zip"
402
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
403
+ for i, img_path in enumerate(session.cropped_images, 1):
404
+ if img_path.exists():
405
+ # Re-encode in requested format
406
+ img = Image.open(img_path)
407
+
408
+ buffer = io.BytesIO()
409
+ if request.format.lower() == "png":
410
+ img.save(buffer, "PNG", optimize=True)
411
+ ext = "png"
412
+ else:
413
+ img.save(buffer, "JPEG", quality=request.quality)
414
+ ext = "jpg"
415
+
416
+ # Get custom name if provided, otherwise use default
417
+ img_id = img_path.stem.replace("cropped_", "")
418
+ if request.names and img_id in request.names:
419
+ filename = f"{request.names[img_id]}.{ext}"
420
+ else:
421
+ filename = f"photo_{i:03d}.{ext}"
422
+
423
+ zf.writestr(filename, buffer.getvalue())
424
+
425
+ return FileResponse(
426
+ zip_path,
427
+ media_type="application/zip",
428
+ filename="scansplitter_export.zip",
429
+ )
430
+
431
+
432
+ @app.post("/api/export-local")
433
+ async def export_local(request: ExportLocalRequest):
434
+ """Export cropped images to a local directory."""
435
+ session = get_session_or_404(request.session_id)
436
+
437
+ # Validate output directory
438
+ output_path = Path(request.output_directory).expanduser().resolve()
439
+
440
+ if not output_path.exists():
441
+ raise HTTPException(status_code=400, detail=f"Directory does not exist: {output_path}")
442
+ if not output_path.is_dir():
443
+ raise HTTPException(status_code=400, detail=f"Path is not a directory: {output_path}")
444
+
445
+ ext = "png" if request.format.lower() == "png" else "jpg"
446
+
447
+ # Build list of filenames that would be created
448
+ filenames: list[str] = []
449
+ if request.images:
450
+ filenames = [f"{img_data.name}.{ext}" for img_data in request.images]
451
+ elif session.cropped_images:
452
+ for i, img_path in enumerate(session.cropped_images, 1):
453
+ if img_path.exists():
454
+ img_id = img_path.stem.replace("cropped_", "")
455
+ if request.names and img_id in request.names:
456
+ filenames.append(f"{request.names[img_id]}.{ext}")
457
+ else:
458
+ filenames.append(f"photo_{i:03d}.{ext}")
459
+
460
+ # Check for existing files if overwrite is not enabled
461
+ if not request.overwrite:
462
+ existing_files = [f for f in filenames if (output_path / f).exists()]
463
+ if existing_files:
464
+ raise HTTPException(
465
+ status_code=409,
466
+ detail={
467
+ "message": "Files already exist",
468
+ "existing_files": existing_files,
469
+ "count": len(existing_files),
470
+ },
471
+ )
472
+
473
+ exported_files = []
474
+
475
+ try:
476
+ # Use provided image data if available (includes client-side rotations)
477
+ if request.images:
478
+ for img_data in request.images:
479
+ # Decode base64 and re-encode in requested format
480
+ img_bytes = base64.b64decode(img_data.data)
481
+ img = Image.open(io.BytesIO(img_bytes))
482
+
483
+ filename = f"{img_data.name}.{ext}"
484
+ output_file = output_path / filename
485
+
486
+ if request.format.lower() == "png":
487
+ img.save(output_file, "PNG", optimize=True)
488
+ else:
489
+ img.save(output_file, "JPEG", quality=request.quality)
490
+
491
+ exported_files.append(str(output_file))
492
+ else:
493
+ # Legacy fallback: use cached images from session
494
+ if not session.cropped_images:
495
+ raise HTTPException(status_code=400, detail="No cropped images to export")
496
+
497
+ for i, img_path in enumerate(session.cropped_images, 1):
498
+ if img_path.exists():
499
+ img = Image.open(img_path)
500
+
501
+ img_id = img_path.stem.replace("cropped_", "")
502
+ if request.names and img_id in request.names:
503
+ filename = f"{request.names[img_id]}.{ext}"
504
+ else:
505
+ filename = f"photo_{i:03d}.{ext}"
506
+
507
+ output_file = output_path / filename
508
+
509
+ if request.format.lower() == "png":
510
+ img.save(output_file, "PNG", optimize=True)
511
+ else:
512
+ img.save(output_file, "JPEG", quality=request.quality)
513
+
514
+ exported_files.append(str(output_file))
515
+
516
+ except PermissionError:
517
+ raise HTTPException(status_code=403, detail=f"Permission denied writing to: {output_path}")
518
+ except Exception as e:
519
+ raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
520
+
521
+ return {"status": "success", "files": exported_files, "count": len(exported_files)}
522
+
523
+
524
+ @app.delete("/api/session/{session_id}")
525
+ async def delete_session(session_id: str):
526
+ """Delete a session and its files."""
527
+ success = get_session_manager().delete_session(session_id)
528
+ if not success:
529
+ raise HTTPException(status_code=404, detail="Session not found")
530
+ return {"status": "deleted"}
531
+
532
+
533
+ # --- Static Files (for production) ---
534
+
535
+ # Get the static directory path relative to this file
536
+ STATIC_DIR = Path(__file__).parent / "static"
537
+
538
+
539
+ @app.on_event("startup")
540
+ async def mount_static_files():
541
+ """Mount static files for serving the frontend if available."""
542
+ if STATIC_DIR.exists() and (STATIC_DIR / "index.html").exists():
543
+ app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
544
+ print(f"Serving frontend from {STATIC_DIR}")
545
+ else:
546
+ print(f"No frontend found at {STATIC_DIR}, running API only")
547
+
548
+
549
+ def create_app() -> FastAPI:
550
+ """Create the FastAPI app."""
551
+ return app
scansplitter/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ """Command-line interface for ScanSplitter."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .processor import process_file
8
+
9
+
10
+ def main():
11
+ """Main CLI entry point."""
12
+ parser = argparse.ArgumentParser(
13
+ prog="scansplitter",
14
+ description="Automatically detect, split, and rotate photos from scanned images.",
15
+ )
16
+
17
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
18
+
19
+ # API command (FastAPI backend)
20
+ api_parser = subparsers.add_parser("api", help="Launch the FastAPI backend server")
21
+ api_parser.add_argument(
22
+ "--host",
23
+ type=str,
24
+ default="127.0.0.1",
25
+ help="Host to bind to (default: 127.0.0.1)",
26
+ )
27
+ api_parser.add_argument(
28
+ "--port",
29
+ type=int,
30
+ default=8000,
31
+ help="Port to bind to (default: 8000)",
32
+ )
33
+ api_parser.add_argument(
34
+ "--reload",
35
+ action="store_true",
36
+ help="Enable auto-reload for development",
37
+ )
38
+
39
+ # Process command
40
+ process_parser = subparsers.add_parser("process", help="Process files from command line")
41
+ process_parser.add_argument(
42
+ "files",
43
+ nargs="+",
44
+ type=Path,
45
+ help="Input files (images or PDFs)",
46
+ )
47
+ process_parser.add_argument(
48
+ "-o",
49
+ "--output",
50
+ type=Path,
51
+ default=Path("./output"),
52
+ help="Output directory (default: ./output)",
53
+ )
54
+ process_parser.add_argument(
55
+ "--no-rotate",
56
+ action="store_true",
57
+ help="Disable auto-rotation",
58
+ )
59
+ process_parser.add_argument(
60
+ "--min-area",
61
+ type=float,
62
+ default=2.0,
63
+ help="Minimum photo size as percentage (default: 2)",
64
+ )
65
+ process_parser.add_argument(
66
+ "--max-area",
67
+ type=float,
68
+ default=80.0,
69
+ help="Maximum photo size as percentage (default: 80)",
70
+ )
71
+ process_parser.add_argument(
72
+ "--format",
73
+ choices=["png", "jpg"],
74
+ default="png",
75
+ help="Output format (default: png)",
76
+ )
77
+
78
+ args = parser.parse_args()
79
+
80
+ if args.command == "api" or args.command is None:
81
+ import uvicorn
82
+
83
+ from .api import app as fastapi_app
84
+
85
+ host = getattr(args, "host", "127.0.0.1")
86
+ port = getattr(args, "port", 8000)
87
+ reload = getattr(args, "reload", False)
88
+
89
+ print(f"Starting ScanSplitter API server at http://{host}:{port}")
90
+ print("API docs available at /docs")
91
+ uvicorn.run(
92
+ "scansplitter.api:app" if reload else fastapi_app,
93
+ host=host,
94
+ port=port,
95
+ reload=reload,
96
+ )
97
+
98
+ elif args.command == "process":
99
+ process_files_cli(args)
100
+
101
+
102
+ def process_files_cli(args):
103
+ """Process files from CLI arguments."""
104
+ # Validate input files
105
+ for file_path in args.files:
106
+ if not file_path.exists():
107
+ print(f"Error: File not found: {file_path}", file=sys.stderr)
108
+ sys.exit(1)
109
+
110
+ # Create output directory
111
+ args.output.mkdir(parents=True, exist_ok=True)
112
+
113
+ total_saved = 0
114
+
115
+ for file_path in args.files:
116
+ print(f"Processing: {file_path}")
117
+
118
+ results = process_file(
119
+ file_path,
120
+ auto_rotate_enabled=not args.no_rotate,
121
+ min_area_ratio=args.min_area / 100,
122
+ max_area_ratio=args.max_area / 100,
123
+ )
124
+
125
+ print(f" Found {len(results)} photo(s)")
126
+
127
+ for result in results:
128
+ # Generate output filename
129
+ base_name = Path(result.source_file).stem
130
+ if result.source_page is not None:
131
+ output_name = f"{base_name}_page{result.source_page}_{result.index + 1}.{args.format}"
132
+ else:
133
+ output_name = f"{base_name}_{result.index + 1}.{args.format}"
134
+
135
+ output_path = args.output / output_name
136
+
137
+ # Save image
138
+ if args.format == "jpg":
139
+ result.image.save(output_path, "JPEG", quality=95)
140
+ else:
141
+ result.image.save(output_path, "PNG")
142
+
143
+ rotation_info = f" (rotated {result.rotation_applied}°)" if result.rotation_applied else ""
144
+ print(f" Saved: {output_path}{rotation_info}")
145
+ total_saved += 1
146
+
147
+ print(f"\nDone! Saved {total_saved} photo(s) to {args.output}")
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()