Spaces:
Sleeping
Sleeping
| """FastAPI application: routes, CORS, static mounts. | |
| Endpoints (spec section 9): | |
| POST /upload-room - accept 3-10 images, preprocess, store, return ids | |
| POST /analyze-room - detect + floor/usable polygon + free space for one image | |
| GET /get-catalog - catalog JSON, filterable, recommendation-ranked | |
| POST /generate-room - synchronous placement + inpaint, returns output image URL | |
| There is intentionally NO /remove-object endpoint (object removal is OUT). | |
| """ | |
| from __future__ import annotations | |
| import uuid | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.staticfiles import StaticFiles | |
| from pydantic import BaseModel | |
| from sqlalchemy.orm import Session | |
| from . import pipeline | |
| from .config import settings | |
| from .db import get_db, init_db | |
| from .models import Generation, RoomImage, UploadSession | |
| from .modules import recommend | |
| from .modules.input_preprocess import preprocess_image | |
| from .modules.ml_runtime import ml_available | |
| from .schemas import ( | |
| AnalyzeResponse, | |
| CatalogItem, | |
| DetectedObject, | |
| GenerateResponse, | |
| ImageMeta, | |
| Placement, | |
| UploadResponse, | |
| ) | |
| ALLOWED_TYPES = {"image/jpeg", "image/jpg", "image/png", "image/webp"} | |
| async def lifespan(app: FastAPI): | |
| init_db() | |
| yield | |
| app = FastAPI(title="Room Visualizer AI", version="1.0.0", lifespan=lifespan) | |
| if settings.CORS_ALLOW_ALL: | |
| # Dev-friendly: lets Expo web and a phone on the LAN call the API. | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=False, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| else: | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=settings.cors_origins, | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| app.mount("/static", StaticFiles(directory=str(settings.STATIC_DIR)), name="static") | |
| def abs_url(request: Request, rel: str | None) -> str | None: | |
| if not rel: | |
| return None | |
| if rel.startswith("http"): | |
| return rel | |
| # Build from the incoming request host so the same backend serves the web app | |
| # (127.0.0.1) and a phone on the LAN (the dev machine's IP) automatically. | |
| base = settings.PUBLIC_BASE_URL or str(request.base_url).rstrip("/") | |
| return f"{base}/{rel.lstrip('/')}" | |
| # ----- request bodies ------------------------------------------------------- | |
| class AnalyzeRequest(BaseModel): | |
| session_id: str | |
| image_id: str | |
| class GenerateRequest(BaseModel): | |
| session_id: str | |
| image_id: str | |
| item_ids: list[str] | |
| hints: dict | None = None | |
| # ----- routes --------------------------------------------------------------- | |
| def root(): | |
| return { | |
| "status": "ok", | |
| "service": "room-visualizer-ai", | |
| "provider": settings.IMAGE_PROVIDER, | |
| "ml_available": ml_available(), | |
| } | |
| def upload_room( | |
| request: Request, | |
| files: list[UploadFile] = File(...), | |
| session_id: str | None = Form(None), | |
| db: Session = Depends(get_db), | |
| ) -> UploadResponse: | |
| # Supports two modes against the same endpoint: | |
| # - one-shot: the web app sends all files at once (no session_id); | |
| # - incremental: the mobile app uses the native uploader (one file per | |
| # request) and passes session_id to append to the same room. | |
| if session_id: | |
| session = db.get(UploadSession, session_id) | |
| if session is None: | |
| raise HTTPException(404, "Upload session not found.") | |
| else: | |
| session = UploadSession() | |
| db.add(session) | |
| db.flush() # populate session.id | |
| if len(session.images) + len(files) > settings.MAX_IMAGES: | |
| raise HTTPException(400, f"Too many images (max {settings.MAX_IMAGES} per room).") | |
| for f in files: | |
| data = f.file.read() | |
| if len(data) > settings.MAX_FILE_MB * 1024 * 1024: | |
| raise HTTPException(400, f"{f.filename} exceeds {settings.MAX_FILE_MB} MB limit.") | |
| img_id = uuid.uuid4().hex | |
| raw_path = settings.UPLOAD_DIR / f"{img_id}_raw" | |
| out_path = settings.UPLOAD_DIR / f"{img_id}.jpg" | |
| raw_path.write_bytes(data) | |
| try: | |
| # preprocess_image validates the bytes are a decodable image. | |
| res = preprocess_image(raw_path, out_path) | |
| except ValueError as exc: | |
| raw_path.unlink(missing_ok=True) | |
| raise HTTPException(400, str(exc)) from exc | |
| finally: | |
| raw_path.unlink(missing_ok=True) | |
| db.add( | |
| RoomImage( | |
| id=img_id, | |
| session_id=session.id, | |
| filename=f.filename or f"{img_id}.jpg", | |
| path=str(out_path), | |
| rel_path=res.rel_path, | |
| width=res.width, | |
| height=res.height, | |
| ) | |
| ) | |
| db.commit() | |
| images = [ | |
| ImageMeta(id=im.id, url=abs_url(request, im.rel_path), width=im.width, height=im.height) | |
| for im in session.images | |
| ] | |
| return UploadResponse(session_id=session.id, images=images) | |
| def analyze_room( | |
| req: AnalyzeRequest, request: Request, db: Session = Depends(get_db) | |
| ) -> AnalyzeResponse: | |
| rec = db.get(RoomImage, req.image_id) | |
| if rec is None or rec.session_id != req.session_id: | |
| raise HTTPException(404, "Image not found for this session.") | |
| result = pipeline.analyze_room(Path(rec.path)) | |
| return AnalyzeResponse( | |
| session_id=req.session_id, | |
| image_id=req.image_id, | |
| width=result.width, | |
| height=result.height, | |
| detected_objects=[ | |
| DetectedObject(label=d.label, confidence=round(d.confidence, 3), box=d.box) | |
| for d in result.detected_objects | |
| ], | |
| floor_polygon=result.usable_polygon, | |
| blocked_areas=result.blocked_areas, | |
| free_space_ratio=round(result.free_ratio, 4), | |
| depth_url=abs_url(request, result.depth_rel_path), | |
| ml_used=ml_available(), | |
| ) | |
| def get_catalog( | |
| request: Request, | |
| category: str | None = None, | |
| styles: str | None = None, | |
| ) -> list[CatalogItem]: | |
| style_list = [s.strip() for s in styles.split(",") if s.strip()] if styles else [] | |
| items = recommend.filter_and_rank(category=category, styles=style_list) | |
| return [ | |
| CatalogItem( | |
| id=it["id"], | |
| name=it["name"], | |
| category=it["category"], | |
| dimensions=it["dimensions"], | |
| price=it["price"], | |
| image_url=abs_url(request, it["image_url"]), | |
| style_tags=it["style_tags"], | |
| score=it.get("score"), | |
| ) | |
| for it in items | |
| ] | |
| def generate_room( | |
| req: GenerateRequest, request: Request, db: Session = Depends(get_db) | |
| ) -> GenerateResponse: | |
| rec = db.get(RoomImage, req.image_id) | |
| if rec is None or rec.session_id != req.session_id: | |
| raise HTTPException(404, "Image not found for this session.") | |
| if not req.item_ids: | |
| raise HTTPException(400, "Select at least one catalog item.") | |
| catalog = {c["id"]: c for c in recommend.load_catalog()} | |
| items = [catalog[i] for i in req.item_ids if i in catalog] | |
| if not items: | |
| raise HTTPException(400, "No valid catalog items selected.") | |
| result = pipeline.generate_room(Path(rec.path), items, req.hints) | |
| gen = Generation( | |
| session_id=req.session_id, | |
| image_id=req.image_id, | |
| item_ids=",".join(req.item_ids), | |
| provider=result["provider"], | |
| output_path=result["output_path"], | |
| output_rel_path=result["output_rel_path"], | |
| ) | |
| db.add(gen) | |
| db.commit() | |
| return GenerateResponse( | |
| generation_id=gen.id, | |
| image_url=abs_url(request, result["output_rel_path"]), | |
| provider=result["provider"], | |
| placements=[ | |
| Placement(item_id=p.item_id, footprint_px=p.footprint_px, scale_note=p.scale_note) | |
| for p in result["placements"] | |
| ], | |
| ) | |