Johnntirs's picture
Room Visualizer backend (Docker)
0cdb4e8
Raw
History Blame Contribute Delete
8.29 kB
"""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"}
@asynccontextmanager
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 ---------------------------------------------------------------
@app.get("/")
def root():
return {
"status": "ok",
"service": "room-visualizer-ai",
"provider": settings.IMAGE_PROVIDER,
"ml_available": ml_available(),
}
@app.post("/upload-room", response_model=UploadResponse)
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)
@app.post("/analyze-room", response_model=AnalyzeResponse)
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(),
)
@app.get("/get-catalog", response_model=list[CatalogItem])
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
]
@app.post("/generate-room", response_model=GenerateResponse)
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"]
],
)