diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index bafd9644..11598137 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -1,15 +1,17 @@ import hashlib +import io import json import logging import math import os import re +import shutil import uuid import zipfile -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, UploadFile +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse, Response from PIL import Image from starlette.requests import Request @@ -37,14 +39,26 @@ BinUpdateRequest, CornersRequest, CornersResponse, + CaptureCrop, CreateBinRequest, FingerHole, + PhotoStation, + PhotoStationCreateRequest, + PhotoStationListResponse, + PhotoStationSuggestion, + PhotoStationSuggestionsResponse, + PhotoStationUpdateRequest, + RedetectCornersResponse, + ReuseCornersRequest, + ReuseCornersResponse, + TraceRequest, + TraceResponse, + PolygonsRequest, GenerateRequest, GenerateResponse, PlacedTool, Point, Polygon, - PolygonsRequest, ProjectHealthResponse, SaveToolsRequest, SaveToolsResponse, @@ -58,8 +72,6 @@ ToolListResponse, ToolSummary, ToolUpdateRequest, - TraceRequest, - TraceResponse, UploadResponse, ) from app.services.ai_tracer import AITracer @@ -82,6 +94,7 @@ remove_project_from_tools, repair_project_links, ) +from app.services.photo_station_store import PhotoStationStore from app.services.project_store import ProjectStore from app.services.session_store import SessionStore from app.services.stl_generator_manifold import ManifoldSTLGenerator @@ -99,6 +112,7 @@ # per-user store registry _store_cache: dict[str, tuple[SessionStore, ToolStore, BinStore]] = {} _project_store_cache: dict[str, ProjectStore] = {} +_photo_station_store_cache: dict[str, PhotoStationStore] = {} def get_stores(user_id: str) -> tuple[SessionStore, ToolStore, BinStore]: @@ -121,6 +135,14 @@ def get_project_store(user_id: str) -> ProjectStore: return _project_store_cache[user_id] +def get_photo_station_store(user_id: str) -> PhotoStationStore: + if user_id not in _photo_station_store_cache: + user_path = settings.storage_path / user_id + ensure_user_dirs(user_path) + _photo_station_store_cache[user_id] = PhotoStationStore(user_path) + return _photo_station_store_cache[user_id] + + def _user_path(user_id: str) -> Path: # defence-in-depth: even if get_user_id is bypassed, block escaping storage root result = (settings.storage_path / user_id).resolve() @@ -131,6 +153,56 @@ def _user_path(user_id: str) -> Path: ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"} MAX_UPLOAD_DIM = 2048 +CAPTURE_CROP_ORIGIN_TOLERANCE = 0.001 +CAPTURE_CROP_SIZE_TOLERANCE = 0.001 +CAPTURE_CROP_MAX_BOUND = 1.0 + CAPTURE_CROP_SIZE_TOLERANCE + + +def _is_full_capture_crop(crop: CaptureCrop | None) -> bool: + if crop is None: + return True + return ( + crop.x <= CAPTURE_CROP_ORIGIN_TOLERANCE + and crop.y <= CAPTURE_CROP_ORIGIN_TOLERANCE + and crop.width >= 1.0 - CAPTURE_CROP_SIZE_TOLERANCE + and crop.height >= 1.0 - CAPTURE_CROP_SIZE_TOLERANCE + ) + + +def _parse_capture_crop(value: str | None) -> CaptureCrop | None: + if not value: + return None + try: + crop = CaptureCrop.model_validate(json.loads(value)) + except Exception as exc: + raise HTTPException(status_code=400, detail="invalid capture area") from exc + if crop.x + crop.width > CAPTURE_CROP_MAX_BOUND or crop.y + crop.height > CAPTURE_CROP_MAX_BOUND: + raise HTTPException(status_code=400, detail="capture area is outside the image") + return crop + + +def _crop_image(content: bytes, ext: str, crop: CaptureCrop | None) -> tuple[bytes, str]: + if _is_full_capture_crop(crop): + return content, ext + + img = Image.open(io.BytesIO(content)) + w, h = img.size + left = max(0, min(w - 1, int(round(crop.x * w)))) + top = max(0, min(h - 1, int(round(crop.y * h)))) + right = max(left + 4, min(w, int(round((crop.x + crop.width) * w)))) + bottom = max(top + 4, min(h, int(round((crop.y + crop.height) * h)))) + cropped = img.crop((left, top, right, bottom)) + + buf = io.BytesIO() + out_ext = ext if ext.lower() in (".jpg", ".jpeg", ".png") else ".png" + fmt = "JPEG" if out_ext.lower() in (".jpg", ".jpeg") else "PNG" + if fmt == "JPEG": + cropped = cropped.convert("RGB") + cropped.save(buf, format=fmt, quality=92) + else: + cropped.save(buf, format=fmt) + logging.info("cropped upload %dx%d -> %dx%d", w, h, cropped.width, cropped.height) + return buf.getvalue(), out_ext image_processor = ImageProcessor() @@ -168,13 +240,14 @@ def _get_tracer(tracer_id: str | None = None) -> AITracer: _tracers[tid] = AITracer(saliency_tracer=tid) return _tracers[tid] + polygon_scaler = PolygonScaler() stl_generator = ManifoldSTLGenerator() def _rel(abs_path: str | Path, user_path: Path) -> str: """store path relative to storage root (includes user_id prefix)""" - return str(Path(abs_path).resolve().relative_to(Path(settings.storage_path).resolve())) + return Path(abs_path).resolve().relative_to(Path(settings.storage_path).resolve()).as_posix() def _abs(rel_path: str | None) -> str | None: @@ -184,6 +257,135 @@ def _abs(rel_path: str | None) -> str | None: return str(settings.storage_path / rel_path) +def _safe_unlink(rel_path: str | None): + abs_path = _abs(rel_path) + if abs_path: + Path(abs_path).unlink(missing_ok=True) + + +def _copy_station_image(user_id: str, source_path: str | Path | None, station_id: str) -> str | None: + if not source_path: + return None + source = Path(source_path) + if not source.exists(): + return None + + up = _user_path(user_id) + station_dir = up / "station-photos" + station_dir.mkdir(parents=True, exist_ok=True) + target = station_dir / f"{station_id}{source.suffix}" + shutil.copy2(source, target) + return _rel(target, up) + + +def _station_image_referenced(user_id: str, rel_path: str | None) -> bool: + if not rel_path: + return False + normalized = rel_path.replace("\\", "/") + return any( + (station.image_path or "").replace("\\", "/") == normalized + for station in get_photo_station_store(user_id).all().values() + ) + + +MAX_STATION_DIMENSION_DELTA_PERCENT = 2.0 +STATION_DRIFT_WARNING_PERCENT = 1.5 + + +def _image_dimensions(content: bytes) -> tuple[int, int]: + img = Image.open(io.BytesIO(content)) + return img.size + + +def _session_image_dimensions(session: Session) -> tuple[int, int]: + if not session.original_image_width or not session.original_image_height: + raise HTTPException(status_code=400, detail="session has no upload dimensions") + return session.original_image_width, session.original_image_height + + +def _create_photo_station( + user_id: str, + session: Session, + name: str, + paper_size: str | None, + corners: list[Point] | None, + source_image_path: str | Path | None = None, +) -> PhotoStation: + if not corners or len(corners) != 4 or not paper_size: + raise HTTPException(status_code=400, detail="session must have confirmed corners") + + image_width, image_height = _session_image_dimensions(session) + now = _now_iso() + station_id = str(uuid.uuid4()) + source_image = source_image_path or _abs(session.original_image_path) or _abs(session.station_image_path) + station = PhotoStation( + id=station_id, + name=name.strip() or f"Station {now[:10]}", + image_width=image_width, + image_height=image_height, + image_path=_copy_station_image(user_id, source_image, station_id), + capture_crop=session.capture_crop, + paper_size=paper_size, + corners=corners, + created_at=now, + updated_at=now, + ) + get_photo_station_store(user_id).set(station.id, station) + return station + + +def _dimension_delta_percent(station_value: int, session_value: int) -> float: + if station_value <= 0: + return 100.0 + return abs(session_value - station_value) / station_value * 100.0 + + +def _scaled_station_corners(station: PhotoStation, image_width: int, image_height: int) -> list[Point]: + sx = image_width / station.image_width + sy = image_height / station.image_height + return [Point(x=p.x * sx, y=p.y * sy) for p in station.corners] + + +def _station_suggestion(station: PhotoStation, session: Session) -> PhotoStationSuggestion: + image_width, image_height = _session_image_dimensions(session) + width_delta = _dimension_delta_percent(station.image_width, image_width) + height_delta = _dimension_delta_percent(station.image_height, image_height) + max_delta = max(width_delta, height_delta) + match_status = "exact" if width_delta == 0 and height_delta == 0 else "near" if max_delta <= MAX_STATION_DIMENSION_DELTA_PERCENT else "far" + + warnings: list[str] = [] + if match_status == "near": + warnings.append("Image size differs from the saved station. Check corners before continuing.") + elif match_status == "far": + warnings.append("Image size differs too much from the saved station.") + + max_corner_drift_px: float | None = None + max_corner_drift_percent: float | None = None + if session.corners and len(session.corners) == 4: + scaled = _scaled_station_corners(station, image_width, image_height) + deltas = [ + math.hypot(detected.x - saved.x, detected.y - saved.y) + for detected, saved in zip(session.corners, scaled) + ] + max_corner_drift_px = max(deltas) if deltas else 0.0 + diagonal = math.hypot(image_width, image_height) + max_corner_drift_percent = (max_corner_drift_px / diagonal * 100.0) if diagonal else 0.0 + if max_corner_drift_percent >= STATION_DRIFT_WARNING_PERCENT: + warnings.append("Detected paper corners differ from this station.") + elif session.corners is None: + warnings.append("No paper was detected in this upload. Reused corners must be checked manually.") + + return PhotoStationSuggestion( + station=station, + match_status=match_status, + width_delta_percent=round(width_delta, 3), + height_delta_percent=round(height_delta, 3), + max_corner_drift_px=round(max_corner_drift_px, 2) if max_corner_drift_px is not None else None, + max_corner_drift_percent=round(max_corner_drift_percent, 3) if max_corner_drift_percent is not None else None, + warnings=warnings, + ) + + def _translate_points(points: list[Point], dx: float, dy: float) -> list[Point]: return [Point(x=p.x + dx, y=p.y + dy) for p in points] @@ -321,7 +523,7 @@ def _tool_image_context(tool: Tool, sessions: SessionStore, load_missing_dimensi def _now_iso() -> str: - return datetime.utcnow().isoformat() + return datetime.now(timezone.utc).isoformat() def _build_bin_from_tools( @@ -495,7 +697,13 @@ def _run_generate( @router.post("/upload", response_model=UploadResponse) -async def upload_image(request: Request, image: UploadFile, user_id: str = Depends(get_user_id)): +async def upload_image( + request: Request, + image: UploadFile = File(...), + station_id: str | None = Form(None), + capture_crop: str | None = Form(None), + user_id: str = Depends(get_user_id), +): if not image.content_type or not image.content_type.startswith("image/"): raise HTTPException(status_code=400, detail="file must be an image") @@ -512,24 +720,57 @@ async def upload_image(request: Request, image: UploadFile, user_id: str = Depen if len(content) > max_bytes: raise HTTPException(status_code=413, detail=f"file too large (max {settings.max_upload_mb}MB)") + station = None + if station_id: + station = get_photo_station_store(user_id).get(station_id) + if not station: + raise HTTPException(status_code=404, detail="photo station not found") + + requested_crop = _parse_capture_crop(capture_crop) + applied_crop = requested_crop if requested_crop is not None else station.capture_crop if station else None + content, ext, _ = ingest_image(content, ext, MAX_UPLOAD_DIM) + content, ext = _crop_image(content, ext, applied_crop) + image_width, image_height = _image_dimensions(content) image_path = up / "uploads" / f"{session_id}{ext}" image_path.write_bytes(content) - corners = image_processor.detect_paper_corners(str(image_path)) - corner_points = [Point(x=c[0], y=c[1]) for c in corners] if corners else None + paper_size = None + applied_station_id = None + if station: + width_delta = _dimension_delta_percent(station.image_width, image_width) + height_delta = _dimension_delta_percent(station.image_height, image_height) + if max(width_delta, height_delta) > MAX_STATION_DIMENSION_DELTA_PERCENT: + raise HTTPException(status_code=400, detail="photo station image size differs too much from this upload") + + corner_points = _scaled_station_corners(station, image_width, image_height) + paper_size = station.paper_size + station.last_used_at = _now_iso() + get_photo_station_store(user_id).set(station.id, station) + applied_station_id = station.id + else: + corners = image_processor.detect_paper_corners(str(image_path)) + corner_points = [Point(x=c[0], y=c[1]) for c in corners] if corners else None user_sessions.set(session_id, Session( id=session_id, - created_at=datetime.utcnow().isoformat(), + created_at=_now_iso(), original_image_path=_rel(image_path, up), + original_image_width=image_width, + original_image_height=image_height, + capture_crop=None if _is_full_capture_crop(applied_crop) else applied_crop, corners=corner_points, + paper_size=paper_size, )) return UploadResponse( session_id=session_id, image_url=f"/storage/{user_id}/uploads/{session_id}{ext}", detected_corners=corner_points, + image_width=image_width, + image_height=image_height, + corner_source="station" if applied_station_id else "detected" if corner_points else "none", + station_id=applied_station_id, ) @@ -554,12 +795,23 @@ async def set_corners(request: Request, session_id: str, req: CornersRequest, us if ds_ratio < 1.0: scale_factor /= ds_ratio - # original upload is no longer needed - orig = _abs(session.original_image_path) - if orig: - Path(orig).unlink(missing_ok=True) - up = _user_path(user_id) + orig_path = _abs(session.original_image_path) + created_station: PhotoStation | None = None + if req.save_station_name is not None: + created_station = _create_photo_station( + user_id=user_id, + session=session, + name=req.save_station_name, + paper_size=req.paper_size, + corners=req.corners, + source_image_path=orig_path, + ) + + # original upload is no longer needed for tracing; saved stations own their previews. + if orig_path: + Path(orig_path).unlink(missing_ok=True) + session.corrected_image_path = _rel(output_path, up) session.original_image_path = None session.corners = req.corners @@ -570,6 +822,142 @@ async def set_corners(request: Request, session_id: str, req: CornersRequest, us return CornersResponse( corrected_image_url=f"/storage/{session.corrected_image_path}", scale_factor=scale_factor, + station=created_station, + ) + + +@router.post("/sessions/{session_id}/redetect-corners", response_model=RedetectCornersResponse) +async def redetect_corners(request: Request, session_id: str, user_id: str = Depends(get_user_id)): + user_sessions, _, _ = get_stores(user_id) + session = user_sessions.get(session_id) + if not session or not session.original_image_path: + raise HTTPException(status_code=404, detail="session original image not found") + + detected = image_processor.detect_paper_corners(_abs(session.original_image_path)) + if not detected: + raise HTTPException(status_code=422, detail="paper corners not detected") + + corners = [Point(x=c[0], y=c[1]) for c in detected] + session.corners = corners + session.paper_size = session.paper_size or "a4" + user_sessions.set(session_id, session) + return RedetectCornersResponse(corners=corners) + + +@router.get("/photo-stations", response_model=PhotoStationListResponse) +async def list_photo_stations(request: Request, user_id: str = Depends(get_user_id)): + store = get_photo_station_store(user_id) + stations = list(store.all().values()) + stations.sort(key=lambda s: s.updated_at or s.created_at or "", reverse=True) + return PhotoStationListResponse(stations=stations) + + +@router.get("/photo-stations/{station_id}", response_model=PhotoStation) +async def get_photo_station(request: Request, station_id: str, user_id: str = Depends(get_user_id)): + station = get_photo_station_store(user_id).get(station_id) + if not station: + raise HTTPException(status_code=404, detail="photo station not found") + return station + + +@router.post("/photo-stations", response_model=PhotoStation) +async def create_photo_station(request: Request, req: PhotoStationCreateRequest, user_id: str = Depends(get_user_id)): + user_sessions, _, _ = get_stores(user_id) + session = user_sessions.get(req.session_id) + if not session: + raise HTTPException(status_code=404, detail="session not found") + + station = _create_photo_station( + user_id=user_id, + session=session, + name=req.name, + paper_size=req.paper_size or session.paper_size, + corners=req.corners or session.corners, + ) + return station + + +@router.patch("/photo-stations/{station_id}", response_model=PhotoStation) +async def update_photo_station(request: Request, station_id: str, req: PhotoStationUpdateRequest, user_id: str = Depends(get_user_id)): + store = get_photo_station_store(user_id) + station = store.get(station_id) + if not station: + raise HTTPException(status_code=404, detail="photo station not found") + + if req.name is not None: + station.name = req.name.strip() or station.name + if req.paper_size is not None: + station.paper_size = req.paper_size + if req.corners is not None: + if len(req.corners) != 4: + raise HTTPException(status_code=400, detail="station corners must contain four points") + station.corners = req.corners + station.updated_at = _now_iso() + store.set(station.id, station) + return station + + +@router.delete("/photo-stations/{station_id}", response_model=StatusResponse) +async def delete_photo_station(request: Request, station_id: str, user_id: str = Depends(get_user_id)): + station = get_photo_station_store(user_id).delete(station_id) + if not station: + raise HTTPException(status_code=404, detail="photo station not found") + _safe_unlink(station.image_path) + return StatusResponse(status="deleted") + + +@router.get("/sessions/{session_id}/station-suggestions", response_model=PhotoStationSuggestionsResponse) +async def list_photo_station_suggestions(request: Request, session_id: str, user_id: str = Depends(get_user_id)): + user_sessions, _, _ = get_stores(user_id) + session = user_sessions.get(session_id) + if not session: + raise HTTPException(status_code=404, detail="session not found") + + stations = list(get_photo_station_store(user_id).all().values()) + suggestions = [ + _station_suggestion(station, session) + for station in stations + ] + suggestions = [suggestion for suggestion in suggestions if suggestion.match_status != "far"] + suggestions.sort( + key=lambda suggestion: max( + suggestion.width_delta_percent, + suggestion.height_delta_percent, + suggestion.max_corner_drift_percent or 0.0, + ) + ) + return PhotoStationSuggestionsResponse(suggestions=suggestions, station_count=len(stations)) + + +@router.post("/sessions/{session_id}/reuse-corners", response_model=ReuseCornersResponse) +async def reuse_photo_station_corners(request: Request, session_id: str, req: ReuseCornersRequest, user_id: str = Depends(get_user_id)): + user_sessions, _, _ = get_stores(user_id) + session = user_sessions.get(session_id) + if not session or not session.original_image_path: + raise HTTPException(status_code=404, detail="session not found") + + store = get_photo_station_store(user_id) + station = store.get(req.station_id) + if not station: + raise HTTPException(status_code=404, detail="photo station not found") + + suggestion = _station_suggestion(station, session) + if suggestion.match_status == "far": + raise HTTPException(status_code=400, detail="photo station image size differs too much from this upload") + + image_width, image_height = _session_image_dimensions(session) + reused_corners = _scaled_station_corners(station, image_width, image_height) + session.corners = reused_corners + session.paper_size = station.paper_size + user_sessions.set(session_id, session) + + station.last_used_at = _now_iso() + store.set(station.id, station) + + return ReuseCornersResponse( + corners=reused_corners, + paper_size=station.paper_size, + suggestion=suggestion, ) @@ -593,7 +981,12 @@ async def get_available_keys(request: Request): @router.post("/sessions/{session_id}/trace", response_model=TraceResponse) -async def trace_tools(request: Request, session_id: str, req: TraceRequest, user_id: str = Depends(get_user_id)): +async def trace_tools( + request: Request, + session_id: str, + req: TraceRequest, + user_id: str = Depends(get_user_id), +): user_sessions, _, _ = get_stores(user_id) session = user_sessions.get(session_id) if not session or not session.corrected_image_path: @@ -616,9 +1009,10 @@ async def trace_tools(request: Request, session_id: str, req: TraceRequest, user mask_output_path = str(up / "processed" / f"{session_id}_mask.png") tracer = _get_tracer(tracer_id) + corrected_image_path = _abs(session.corrected_image_path) try: polygons, mask_path = await tracer.trace_tools( - _abs(session.corrected_image_path), + corrected_image_path, api_key, mask_output_path, ) @@ -650,11 +1044,19 @@ async def trace_tools(request: Request, session_id: str, req: TraceRequest, user if mask_path: mask_url = f"/storage/{user_id}/processed/{session_id}_mask.png" - return TraceResponse(polygons=polygons, mask_url=mask_url) + return TraceResponse( + polygons=polygons, + mask_url=mask_url, + ) @router.post("/sessions/{session_id}/trace-mask", response_model=TraceResponse) -async def trace_from_mask(request: Request, session_id: str, mask: UploadFile, user_id: str = Depends(get_user_id)): +async def trace_from_mask( + request: Request, + session_id: str, + mask: UploadFile, + user_id: str = Depends(get_user_id), +): """trace contours from a user-uploaded mask image""" user_sessions, _, _ = get_stores(user_id) session = user_sessions.get(session_id) @@ -674,7 +1076,8 @@ async def trace_from_mask(request: Request, session_id: str, mask: UploadFile, u mask_path = up / "processed" / f"{session_id}_mask.png" mask_path.write_bytes(content) - contours = _get_tracer()._trace_mask(str(mask_path), _abs(session.corrected_image_path)) + corrected_image_path = _abs(session.corrected_image_path) + contours = _get_tracer()._trace_mask(str(mask_path), corrected_image_path) if not contours: raise HTTPException(status_code=400, detail="no tool outlines found in mask") @@ -694,7 +1097,7 @@ async def trace_from_mask(request: Request, session_id: str, mask: UploadFile, u return TraceResponse( polygons=polygons, - mask_url=f"/storage/{user_id}/processed/{session_id}_mask.png" + mask_url=f"/storage/{user_id}/processed/{session_id}_mask.png", ) @@ -807,11 +1210,13 @@ async def delete_session(request: Request, session_id: str, user_id: str = Depen for rel in [ session.original_image_path, session.corrected_image_path, + session.mask_image_path, session.stl_path, ]: - p = _abs(rel) - if p: - Path(p).unlink(missing_ok=True) + _safe_unlink(rel) + + if session.station_image_path and not _station_image_referenced(user_id, session.station_image_path): + _safe_unlink(session.station_image_path) if session.stl_path: Path(_abs(session.stl_path)).with_suffix(".3mf").unlink(missing_ok=True) @@ -1111,7 +1516,7 @@ async def save_tools_from_session(request: Request, session_id: str, body: SaveT if source_transform else None ), thumbnail_path=thumbnail_path, - created_at=datetime.utcnow().isoformat(), + created_at=_now_iso(), )) tool_ids.append(tool_id) diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index aa862e44..1b22a205 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -12,6 +12,27 @@ class Point(BaseModel): y: float +class CaptureCrop(BaseModel): + x: float + y: float + width: float + height: float + + @field_validator("x", "y") + @classmethod + def validate_origin(cls, v: float) -> float: + if v < 0 or v > 1: + raise ValueError("capture crop origin must be between 0 and 1") + return v + + @field_validator("width", "height") + @classmethod + def validate_size(cls, v: float) -> float: + if v <= 0 or v > 1: + raise ValueError("capture crop size must be between 0 and 1") + return v + + class FingerHole(BaseModel): id: str x: float # center position in pixels @@ -47,16 +68,26 @@ class UploadResponse(BaseModel): session_id: str image_url: str detected_corners: list[Point] | None + image_width: int | None = None + image_height: int | None = None + corner_source: Literal["detected", "station", "none"] = "none" + station_id: str | None = None class CornersRequest(BaseModel): corners: list[Point] paper_size: PaperSize + save_station_name: str | None = None class CornersResponse(BaseModel): corrected_image_url: str scale_factor: float + station: "PhotoStation | None" = None + + +class RedetectCornersResponse(BaseModel): + corners: list[Point] class TraceRequest(BaseModel): @@ -215,6 +246,10 @@ class Session(BaseModel): tags: list[str] = [] created_at: str | None = None original_image_path: str | None = None + original_image_width: int | None = None + original_image_height: int | None = None + capture_crop: CaptureCrop | None = None + station_image_path: str | None = None corrected_image_path: str | None = None mask_image_path: str | None = None corners: list[Point] | None = None @@ -251,6 +286,81 @@ class StatusResponse(BaseModel): status: str +# --- photo stations --- + +PhotoStationMatchStatus = Literal["exact", "near", "far"] + + +class PhotoStation(BaseModel): + id: str + name: str + image_width: int + image_height: int + image_path: str | None = None + capture_crop: CaptureCrop | None = None + paper_size: PaperSize + corners: list[Point] + created_at: str | None = None + updated_at: str | None = None + last_used_at: str | None = None + + @field_validator("image_width", "image_height") + @classmethod + def validate_image_dimension(cls, v: int) -> int: + if v <= 0: + raise ValueError("station image dimensions must be positive") + return v + + @field_validator("corners") + @classmethod + def validate_corners(cls, v: list[Point]) -> list[Point]: + if len(v) != 4: + raise ValueError("station corners must contain four points") + return v + + +class PhotoStationSuggestion(BaseModel): + station: PhotoStation + match_status: PhotoStationMatchStatus + width_delta_percent: float = 0.0 + height_delta_percent: float = 0.0 + max_corner_drift_px: float | None = None + max_corner_drift_percent: float | None = None + warnings: list[str] = [] + + +class PhotoStationListResponse(BaseModel): + stations: list[PhotoStation] + + +class PhotoStationSuggestionsResponse(BaseModel): + suggestions: list[PhotoStationSuggestion] + station_count: int + + +class PhotoStationCreateRequest(BaseModel): + name: str + session_id: str + paper_size: PaperSize | None = None + corners: list[Point] | None = None + + +class PhotoStationUpdateRequest(BaseModel): + name: str | None = None + paper_size: PaperSize | None = None + corners: list[Point] | None = None + + +class ReuseCornersRequest(BaseModel): + station_id: str + + +class ReuseCornersResponse(BaseModel): + corners: list[Point] + paper_size: PaperSize + suggestion: PhotoStationSuggestion + + # --- tool library --- class Tool(BaseModel): diff --git a/backend/app/services/photo_station_store.py b/backend/app/services/photo_station_store.py new file mode 100644 index 00000000..b6b11151 --- /dev/null +++ b/backend/app/services/photo_station_store.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import json +import logging +import tempfile +import threading +from pathlib import Path +from typing import Optional + +from app.models.schemas import PhotoStation + +logger = logging.getLogger(__name__) + + +class PhotoStationStore: + def __init__(self, storage_path: Path): + self.file_path = storage_path / "photo-stations.json" + self._stations: dict[str, PhotoStation] = {} + self._lock = threading.Lock() + self._load() + + def _load(self): + if self.file_path.exists(): + try: + data = json.loads(self.file_path.read_text()) + dirty = False + for sid, sdata in data.items(): + station = PhotoStation.model_validate(sdata) + if station.image_path: + normalized = station.image_path.replace("\\", "/") + if normalized != station.image_path: + station.image_path = normalized + dirty = True + if "/processed/" in f"/{normalized}" and "_corrected" in normalized: + station.image_path = None + dirty = True + self._stations[sid] = station + if dirty: + self._save() + except OSError: + logger.error(f"Failed to load {self.file_path}: permission denied") + raise + except Exception as e: + logger.error(f"Failed to load {self.file_path}: {e}") + self._stations = {} + + def _save(self): + data = {sid: s.model_dump() for sid, s in self._stations.items()} + temp_fd, temp_path = tempfile.mkstemp( + dir=self.file_path.parent, + prefix=".photo-stations_", + suffix=".tmp", + ) + try: + with open(temp_fd, "w") as f: + json.dump(data, f, indent=2) + Path(temp_path).replace(self.file_path) + except Exception: + Path(temp_path).unlink(missing_ok=True) + raise + + def get(self, station_id: str) -> Optional[PhotoStation]: + with self._lock: + return self._stations.get(station_id) + + def set(self, station_id: str, station: PhotoStation): + with self._lock: + self._stations[station_id] = station + self._save() + + def delete(self, station_id: str) -> Optional[PhotoStation]: + with self._lock: + station = self._stations.pop(station_id, None) + if station: + self._save() + return station + + def all(self) -> dict[str, PhotoStation]: + with self._lock: + return self._stations.copy() diff --git a/backend/tests/test_photo_stations.py b/backend/tests/test_photo_stations.py new file mode 100644 index 00000000..fc08917c --- /dev/null +++ b/backend/tests/test_photo_stations.py @@ -0,0 +1,606 @@ +from fastapi.testclient import TestClient +from PIL import Image +import io +import json +import logging + +from app.config import ensure_user_dirs, settings +from app.main import app +from app.models.schemas import PhotoStation, Point, Session +from app.services.photo_station_store import PhotoStationStore +import app.api.routes as routes + + +def _api_client(tmp_path, monkeypatch): + monkeypatch.setattr(settings, "storage_path", tmp_path) + monkeypatch.setattr(routes.settings, "storage_path", tmp_path) + routes._store_cache.clear() + routes._project_store_cache.clear() + routes._photo_station_store_cache.clear() + ensure_user_dirs(tmp_path / "default") + return TestClient(app) + + +def _corners(size: float = 100.0): + return [ + Point(x=0, y=0), + Point(x=size, y=0), + Point(x=size, y=size), + Point(x=0, y=size), + ] + + +def _write_image(path, size=(120, 160)): + path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", size, color=(32, 64, 96)).save(path) + + +def _image_bytes(size=(120, 160), fmt="PNG"): + buf = io.BytesIO() + Image.new("RGB", size, color=(32, 64, 96)).save(buf, format=fmt) + return buf.getvalue() + + +def test_photo_station_store_round_trips(tmp_path): + store = PhotoStationStore(tmp_path) + station = PhotoStation( + id="station-1", + name="Desk station", + image_width=100, + image_height=100, + paper_size="a4", + corners=_corners(), + ) + + store.set(station.id, station) + reloaded = PhotoStationStore(tmp_path) + + assert reloaded.get("station-1").name == "Desk station" + assert reloaded.get("station-1").corners[2].x == 100 + + +def test_photo_station_store_migrates_legacy_image_paths(tmp_path): + legacy = { + "station-1": { + "id": "station-1", + "name": "Corrected legacy", + "image_width": 100, + "image_height": 100, + "image_path": "user-1\\processed\\old_corrected.jpg", + "paper_size": "a4", + "corners": [p.model_dump() for p in _corners()], + }, + "station-2": { + "id": "station-2", + "name": "Station photo", + "image_width": 100, + "image_height": 100, + "image_path": "default\\station-photos\\station-2.jpg", + "paper_size": "a4", + "corners": [p.model_dump() for p in _corners()], + }, + } + (tmp_path / "photo-stations.json").write_text(json.dumps(legacy)) + + store = PhotoStationStore(tmp_path) + + assert store.get("station-1").image_path is None + assert store.get("station-2").image_path == "default/station-photos/station-2.jpg" + + +def test_photo_station_store_logs_corrupt_json(tmp_path, caplog): + (tmp_path / "photo-stations.json").write_text("{invalid json!!!") + + with caplog.at_level(logging.ERROR): + store = PhotoStationStore(tmp_path) + + assert store.all() == {} + assert any("Failed to load" in record.message for record in caplog.records) + + +def test_create_station_from_confirmed_session(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + session_store, _, _ = routes.get_stores("default") + session_store.set("session-1", Session( + id="session-1", + original_image_width=120, + original_image_height=160, + corners=_corners(), + paper_size="letter", + )) + + resp = client.post("/api/photo-stations", json={ + "name": "Phone mount", + "session_id": "session-1", + }) + + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "Phone mount" + assert data["paper_size"] == "letter" + assert data["image_width"] == 120 + + +def test_create_station_copies_upload_to_station_owned_photo(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + original = tmp_path / "default" / "uploads" / "session-1.jpg" + _write_image(original) + session_store, _, _ = routes.get_stores("default") + session_store.set("session-1", Session( + id="session-1", + original_image_path="default/uploads/session-1.jpg", + original_image_width=120, + original_image_height=160, + corners=_corners(), + paper_size="letter", + )) + + resp = client.post("/api/photo-stations", json={ + "name": "Phone mount", + "session_id": "session-1", + }) + + assert resp.status_code == 200 + image_path = resp.json()["image_path"] + assert image_path.startswith("default/station-photos/") + assert "\\" not in image_path + assert (tmp_path / image_path).exists() + + +def test_create_station_requires_confirmed_corners(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + session_store, _, _ = routes.get_stores("default") + session_store.set("session-1", Session( + id="session-1", + original_image_width=120, + original_image_height=160, + )) + + resp = client.post("/api/photo-stations", json={ + "name": "Phone mount", + "session_id": "session-1", + }) + + assert resp.status_code == 400 + + +def test_create_station_rejects_wrong_corner_count(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + session_store, _, _ = routes.get_stores("default") + session_store.set("session-1", Session( + id="session-1", + original_image_width=120, + original_image_height=160, + paper_size="a4", + )) + + resp = client.post("/api/photo-stations", json={ + "name": "Phone mount", + "session_id": "session-1", + "paper_size": "a4", + "corners": [{"x": 0, "y": 0}, {"x": 1, "y": 0}, {"x": 1, "y": 1}], + }) + + assert resp.status_code == 400 + + +def test_get_photo_station_returns_single_station(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + station_store = routes.get_photo_station_store("default") + station_store.set("station-1", PhotoStation( + id="station-1", + name="Desk station", + image_width=100, + image_height=100, + paper_size="a4", + corners=_corners(), + )) + + ok = client.get("/api/photo-stations/station-1") + missing = client.get("/api/photo-stations/missing") + + assert ok.status_code == 200 + assert ok.json()["name"] == "Desk station" + assert missing.status_code == 404 + + +def test_list_photo_stations_returns_recent_first(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + station_store = routes.get_photo_station_store("default") + station_store.set("old", PhotoStation( + id="old", + name="Old station", + image_width=100, + image_height=100, + paper_size="a4", + corners=_corners(), + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-02T00:00:00+00:00", + )) + station_store.set("new", PhotoStation( + id="new", + name="New station", + image_width=100, + image_height=100, + paper_size="a4", + corners=_corners(), + created_at="2026-01-03T00:00:00+00:00", + updated_at="2026-01-04T00:00:00+00:00", + )) + + resp = client.get("/api/photo-stations") + + assert resp.status_code == 200 + assert [station["id"] for station in resp.json()["stations"]] == ["new", "old"] + + +def test_update_station_renames_and_edits_corners(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + station_store = routes.get_photo_station_store("default") + station_store.set("station-1", PhotoStation( + id="station-1", + name="Desk station", + image_width=100, + image_height=100, + paper_size="a4", + corners=_corners(), + )) + + resp = client.patch("/api/photo-stations/station-1", json={ + "name": "Phone station", + "paper_size": "letter", + "corners": [ + {"x": 5, "y": 6}, + {"x": 90, "y": 7}, + {"x": 88, "y": 92}, + {"x": 4, "y": 91}, + ], + }) + + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "Phone station" + assert data["paper_size"] == "letter" + assert data["corners"][0] == {"x": 5.0, "y": 6.0} + assert station_store.get("station-1").corners[2].x == 88 + + +def test_update_station_rejects_wrong_corner_count(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + station_store = routes.get_photo_station_store("default") + station_store.set("station-1", PhotoStation( + id="station-1", + name="Desk station", + image_width=100, + image_height=100, + paper_size="a4", + corners=_corners(), + )) + + resp = client.patch("/api/photo-stations/station-1", json={ + "corners": [{"x": 5, "y": 6}, {"x": 90, "y": 7}, {"x": 88, "y": 92}], + }) + + assert resp.status_code == 400 + + +def test_delete_station_removes_owned_photo(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + photo = tmp_path / "default" / "station-photos" / "station-1.jpg" + _write_image(photo) + station_store = routes.get_photo_station_store("default") + station_store.set("station-1", PhotoStation( + id="station-1", + name="Desk station", + image_width=100, + image_height=100, + image_path="default/station-photos/station-1.jpg", + paper_size="a4", + corners=_corners(), + )) + + resp = client.delete("/api/photo-stations/station-1") + + assert resp.status_code == 200 + assert station_store.get("station-1") is None + assert not photo.exists() + + +def test_station_suggestions_are_backend_owned_and_filter_far(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + session_store, _, _ = routes.get_stores("default") + station_store = routes.get_photo_station_store("default") + session_store.set("session-1", Session( + id="session-1", + original_image_path="default/uploads/session-1.jpg", + original_image_width=101, + original_image_height=100, + corners=_corners(101), + )) + station_store.set("near", PhotoStation( + id="near", + name="Near station", + image_width=100, + image_height=100, + paper_size="a4", + corners=_corners(), + )) + station_store.set("far", PhotoStation( + id="far", + name="Far station", + image_width=160, + image_height=100, + paper_size="a4", + corners=_corners(160), + )) + + resp = client.get("/api/sessions/session-1/station-suggestions") + + assert resp.status_code == 200 + data = resp.json() + assert data["station_count"] == 2 + assert [s["station"]["id"] for s in data["suggestions"]] == ["near"] + assert data["suggestions"][0]["match_status"] == "near" + + +def test_upload_with_station_reuses_crop_and_corners(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + station_store = routes.get_photo_station_store("default") + station_store.set("station-1", PhotoStation( + id="station-1", + name="Cropped station", + image_width=100, + image_height=100, + capture_crop={"x": 0.25, "y": 0, "width": 0.5, "height": 1}, + paper_size="a4", + corners=_corners(), + )) + + resp = client.post( + "/api/upload", + data={"station_id": "station-1"}, + files={"image": ("photo.png", _image_bytes(size=(200, 100)), "image/png")}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["corner_source"] == "station" + assert data["station_id"] == "station-1" + session_store, _, _ = routes.get_stores("default") + session = session_store.get(data["session_id"]) + assert session.original_image_width == 100 + assert session.original_image_height == 100 + assert session.capture_crop.x == 0.25 + assert session.corners[2].x == 100 + assert station_store.get("station-1").last_used_at is not None + assert station_store.get("station-1").updated_at is None + + +def test_upload_with_capture_crop_detects_on_cropped_image(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + + def detect(path): + assert Image.open(path).size == (50, 80) + return [(0, 0), (50, 0), (50, 80), (0, 80)] + + monkeypatch.setattr(routes.image_processor, "detect_paper_corners", detect) + + resp = client.post( + "/api/upload", + data={"capture_crop": json.dumps({"x": 0, "y": 0, "width": 0.5, "height": 1})}, + files={"image": ("photo.png", _image_bytes(size=(100, 80)), "image/png")}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["corner_source"] == "detected" + session_store, _, _ = routes.get_stores("default") + session = session_store.get(data["session_id"]) + assert session.original_image_width == 50 + assert session.original_image_height == 80 + assert session.capture_crop.width == 0.5 + + +def test_upload_rejects_capture_crop_outside_image(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + + resp = client.post( + "/api/upload", + data={"capture_crop": json.dumps({"x": 0.75, "y": 0, "width": 0.5, "height": 1})}, + files={"image": ("photo.png", _image_bytes(size=(100, 80)), "image/png")}, + ) + + assert resp.status_code == 400 + + +def test_reuse_station_scales_near_dimension_match(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + session_store, _, _ = routes.get_stores("default") + station_store = routes.get_photo_station_store("default") + session_store.set("session-1", Session( + id="session-1", + original_image_path="default/uploads/session-1.jpg", + original_image_width=102, + original_image_height=100, + corners=_corners(102), + )) + station_store.set("station-1", PhotoStation( + id="station-1", + name="Desk station", + image_width=100, + image_height=100, + paper_size="a4", + corners=_corners(), + )) + + resp = client.post("/api/sessions/session-1/reuse-corners", json={"station_id": "station-1"}) + + assert resp.status_code == 200 + data = resp.json() + assert data["paper_size"] == "a4" + assert data["corners"][1]["x"] == 102 + assert data["suggestion"]["match_status"] == "near" + assert data["suggestion"]["warnings"] + assert session_store.get("session-1").paper_size == "a4" + assert station_store.get("station-1").updated_at is None + + +def test_reuse_station_requires_original_upload(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + session_store, _, _ = routes.get_stores("default") + station_store = routes.get_photo_station_store("default") + session_store.set("session-1", Session( + id="session-1", + original_image_width=100, + original_image_height=100, + corners=_corners(), + )) + station_store.set("station-1", PhotoStation( + id="station-1", + name="Desk station", + image_width=100, + image_height=100, + paper_size="a4", + corners=_corners(), + )) + + resp = client.post("/api/sessions/session-1/reuse-corners", json={"station_id": "station-1"}) + + assert resp.status_code == 404 + + +def test_reuse_station_rejects_far_dimension_match(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + session_store, _, _ = routes.get_stores("default") + station_store = routes.get_photo_station_store("default") + session_store.set("session-1", Session( + id="session-1", + original_image_path="default/uploads/session-1.jpg", + original_image_width=140, + original_image_height=100, + corners=_corners(140), + )) + station_store.set("station-1", PhotoStation( + id="station-1", + name="Desk station", + image_width=100, + image_height=100, + paper_size="a4", + corners=_corners(), + )) + + resp = client.post("/api/sessions/session-1/reuse-corners", json={"station_id": "station-1"}) + + assert resp.status_code == 400 + + +def test_redetect_corners_updates_session_from_original_upload(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + original = tmp_path / "default" / "uploads" / "session-1.png" + _write_image(original, size=(120, 160)) + session_store, _, _ = routes.get_stores("default") + session_store.set("session-1", Session( + id="session-1", + original_image_path="default/uploads/session-1.png", + original_image_width=120, + original_image_height=160, + corners=_corners(), + paper_size="letter", + )) + monkeypatch.setattr(routes.image_processor, "detect_paper_corners", lambda *_: [(1, 2), (100, 3), (98, 120), (2, 118)]) + + resp = client.post("/api/sessions/session-1/redetect-corners") + + assert resp.status_code == 200 + assert resp.json()["corners"][0] == {"x": 1.0, "y": 2.0} + session = session_store.get("session-1") + assert session.corners[2].x == 98 + assert session.paper_size == "letter" + + +def test_delete_session_cleans_session_owned_files(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + paths = [ + tmp_path / "default" / "uploads" / "session-1.png", + tmp_path / "default" / "processed" / "session-1_corrected.png", + tmp_path / "default" / "processed" / "session-1_mask.png", + tmp_path / "default" / "outputs" / "session-1.stl", + tmp_path / "default" / "outputs" / "session-1.3mf", + tmp_path / "default" / "station-photos" / "legacy.png", + ] + for path in paths: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"data") + session_store, _, _ = routes.get_stores("default") + session_store.set("session-1", Session( + id="session-1", + original_image_path="default/uploads/session-1.png", + corrected_image_path="default/processed/session-1_corrected.png", + mask_image_path="default/processed/session-1_mask.png", + stl_path="default/outputs/session-1.stl", + station_image_path="default/station-photos/legacy.png", + )) + + resp = client.delete("/api/sessions/session-1") + + assert resp.status_code == 200 + assert session_store.get("session-1") is None + assert all(not path.exists() for path in paths) + + +def test_set_corners_without_station_does_not_copy_station_photo(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + original = tmp_path / "default" / "uploads" / "session-1.png" + corrected = tmp_path / "default" / "processed" / "session-1_corrected.png" + _write_image(original) + _write_image(corrected) + session_store, _, _ = routes.get_stores("default") + session_store.set("session-1", Session( + id="session-1", + original_image_path="default/uploads/session-1.png", + original_image_width=120, + original_image_height=160, + corners=_corners(), + paper_size="a4", + )) + monkeypatch.setattr(routes.image_processor, "apply_perspective_correction", lambda *_: (str(corrected), 1.0)) + + resp = client.post("/api/sessions/session-1/corners", json={ + "corners": [p.model_dump() for p in _corners()], + "paper_size": "a4", + }) + + assert resp.status_code == 200 + assert resp.json()["station"] is None + assert not (tmp_path / "default" / "station-photos").exists() + + +def test_set_corners_with_station_saves_station_photo(tmp_path, monkeypatch): + client = _api_client(tmp_path, monkeypatch) + original = tmp_path / "default" / "uploads" / "session-1.png" + corrected = tmp_path / "default" / "processed" / "session-1_corrected.png" + _write_image(original) + _write_image(corrected) + session_store, _, _ = routes.get_stores("default") + session_store.set("session-1", Session( + id="session-1", + original_image_path="default/uploads/session-1.png", + original_image_width=120, + original_image_height=160, + corners=_corners(), + paper_size="a4", + )) + monkeypatch.setattr(routes.image_processor, "apply_perspective_correction", lambda *_: (str(corrected), 1.0)) + + resp = client.post("/api/sessions/session-1/corners", json={ + "corners": [p.model_dump() for p in _corners()], + "paper_size": "a4", + "save_station_name": "Desk station", + }) + + assert resp.status_code == 200 + station = resp.json()["station"] + assert station["name"] == "Desk station" + assert station["image_path"].startswith("default/station-photos/") + assert (tmp_path / station["image_path"]).exists() + assert not original.exists()