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.
- scansplitter/__init__.py +3 -0
- scansplitter/api.py +551 -0
- scansplitter/cli.py +151 -0
- scansplitter/detector.py +283 -0
- scansplitter/models.py +33 -0
- scansplitter/pdf_handler.py +47 -0
- scansplitter/processor.py +182 -0
- scansplitter/rotator.py +227 -0
- scansplitter/session.py +134 -0
- scansplitter/static/assets/index-DVIT8L0q.js +512 -0
- scansplitter/static/assets/index-Dv3sZjWj.css +1 -0
- scansplitter/static/index.html +14 -0
- scansplitter/static/vite.svg +1 -0
- scansplitter-0.1.0.dist-info/METADATA +165 -0
- scansplitter-0.1.0.dist-info/RECORD +19 -0
- scansplitter-0.1.0.dist-info/WHEEL +5 -0
- scansplitter-0.1.0.dist-info/entry_points.txt +2 -0
- scansplitter-0.1.0.dist-info/licenses/LICENSE +645 -0
- scansplitter-0.1.0.dist-info/top_level.txt +1 -0
scansplitter/__init__.py
ADDED
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()
|