Image capturing Flow updates

This commit is contained in:
sriram
2026-09-24 17:18:38 +05:30
parent f933ea10a1
commit fe208f4715
14 changed files with 1877 additions and 6 deletions

View File

@@ -2,19 +2,23 @@ from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from starlette.concurrency import run_in_threadpool
from app.api.deps import require_admin
from app.api.routers.brands import _row_to_product_out
from app.api.schemas import (
CaptureJobOut,
IdentifyOut,
ImageMatchOut,
ImageSearchOut,
ImageVectorSearchRequest,
ProvisionalProductOut,
SearchOut,
SourceProductOut,
)
from app.infrastructure import settings
from app.infrastructure.settings import (
IMAGE_SEARCH_DEFAULT_MIN_SCORE,
IMAGE_SEARCH_DEFAULT_TOP_K,
@@ -23,7 +27,7 @@ from app.infrastructure.settings import (
SEARCH_DEFAULT_TOP_K,
SEARCH_MAX_TOP_K,
)
from app.services import image_embedder
from app.services import capture_discovery, image_embedder
from app.services.catalog_search import search_catalog
from app.services.image_match import (
ImageSearchResult,
@@ -241,6 +245,7 @@ async def image_search_endpoint(
@router.post("/search/identify", response_model=IdentifyOut)
async def identify_endpoint(
request: Request,
file: UploadFile = File(..., description="The product photo (JPEG/PNG/WebP), ideally cropped to the pack"),
text: Optional[str] = Form(None, max_length=500,
description="OCR text read off the label; when absent the server reads it"),
@@ -290,4 +295,91 @@ async def identify_endpoint(
"deployment. Send the label as `text`, or embed the photo client-side and POST "
"the vector to /api/search/image-vector.",
)
return _to_identify_out(result)
out = _to_identify_out(result)
if settings.ENABLE_CAPTURE_DISCOVERY and not capture_discovery.is_confirmed(
result.matched_by, result.fallback_reason
):
outcome = await run_in_threadpool(
capture_discovery.handle_miss,
label_text=result.ocr_text, image_bytes=content, vector=vector,
brand=brand, category=category,
client=request.client.host if request.client else "unknown",
)
_apply_capture_outcome(out, outcome)
return out
def _apply_capture_outcome(out: IdentifyOut, outcome: capture_discovery.CaptureOutcome) -> None:
"""Fold a capture-to-catalog decision into the identify response.
The low-confidence rows the ladder returned are dropped whenever discovery
has an answer of its own: they are OTHER products (another Godrej line, a
rival detergent), and showing them beside "adding it now" invites the
colleague to pick the wrong one.
"""
out.discovery_status = outcome.status
out.discovery_job_id = outcome.job_id
out.discovery_message = outcome.message
out.provisional = ProvisionalProductOut(**outcome.provisional) if outcome.provisional else None
if outcome.status == capture_discovery.EXISTS and outcome.existing:
row = outcome.existing
card = _row_to_product_out(row, row.get("brand") or "")
out.results = [ImageMatchOut(**card.model_dump(), score=1.0, text_overlap=1.0)]
out.total = 1
out.matched_by = capture_discovery.MATCHED_BY_LABEL_EXACT
elif outcome.status == capture_discovery.PENDING:
out.results = []
out.total = 0
out.matched_by = capture_discovery.MATCHED_BY_DISCOVERY
@router.get("/search/identify/jobs/{job_id}", response_model=CaptureJobOut)
def capture_job_endpoint(job_id: str) -> CaptureJobOut:
"""A capture-to-catalog job started by /search/identify.
Poll until `status` is terminal (done, rejected, failed, interrupted). On
done, `product` is the stored catalog row - validation_status needs_review.
"""
job = capture_discovery.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="No capture job with that id.")
return _to_capture_job_out(job)
def _to_capture_job_out(job: capture_discovery.CaptureJob) -> CaptureJobOut:
from app.services.vector_store import get_product_by_image_id
product = None
validation_status = None
if job.status == capture_discovery.DONE and job.image_id:
row = get_product_by_image_id(job.parent, job.image_id)
if row:
product = _row_to_product_out(row, row.get("brand") or job.parent)
validation_status = row.get("validation_status")
return CaptureJobOut(
job_id=job.job_id, status=job.status, created_at=job.created_at,
updated_at=job.updated_at, provisional=job.provisional, product=product,
image_id=job.image_id, disposition=job.disposition,
validation_status=validation_status, retail_presence=job.retail_presence,
photo_used_as_image=job.photo_used_as_image, detail=job.detail,
warnings=job.warnings,
)
@router.get("/admin/captures", response_model=list[CaptureJobOut],
dependencies=[Depends(require_admin)])
def list_capture_jobs_endpoint(limit: int = Query(50, ge=1, le=200)) -> list[CaptureJobOut]:
"""Recent capture-to-catalog jobs, newest first - the review list for
products colleagues added from the field."""
return [_to_capture_job_out(job) for job in capture_discovery.list_jobs(limit)]
@router.get("/search/captures/{name}", include_in_schema=False)
def capture_photo_endpoint(name: str):
"""A colleague's capture photo - served only because a product with no web
image uses it as its image (CAPTURE_PUBLIC_BASE_URL)."""
found = capture_discovery.photo_path(name)
if found is None:
raise HTTPException(status_code=404, detail="No such photo.")
path, media_type = found
return FileResponse(path, media_type=media_type)