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)

View File

@@ -313,11 +313,48 @@ class IdentifyOut(ImageSearchOut):
"image_vector" with no `fallback_reason`; otherwise `fallback_reason`
says why the best effort shown is unconfirmed (see product_identify.py).
"""
matched_by: str = "none" # "image_vector" | "text" | "none"
matched_by: str = "none" # "image_vector" | "text" | "label_exact"
# | "discovery_pending" | "none"
ocr_text: Optional[str] = None # the label text the ladder used
ocr_source: Optional[str] = None # "client" | "server"
image_top_score: Optional[float] = None
fallback_reason: Optional[str] = None
# Capture-to-catalog (ENABLE_CAPTURE_DISCOVERY). All None when the answer
# was confirmed or the feature is off. See app/services/capture_discovery.py.
discovery_status: Optional[str] = None # "pending" | "exists" | "needs_input" | "busy"
discovery_job_id: Optional[str] = None # poll GET /api/search/identify/jobs/{id}
discovery_message: Optional[str] = None # one line to show the colleague
provisional: Optional["ProvisionalProductOut"] = None
class ProvisionalProductOut(BaseModel):
"""What the label says, before the pipeline has run. Not a catalog row."""
brand: str
product_name: str
size: Optional[str] = None
category: Optional[str] = None
hsn_code: Optional[str] = None
gst_percent: Optional[float] = None
hsn_gst_needs_review: Optional[bool] = None
visible_in_search: bool = True # False: brand is not in ACTIVE_BRANDS
source: str = "label"
class CaptureJobOut(BaseModel):
"""GET /search/identify/jobs/{job_id}: one capture-to-catalog job."""
job_id: str
status: str # queued | running | done | rejected | failed | interrupted
created_at: float
updated_at: float
provisional: ProvisionalProductOut
product: Optional[ProductOut] = None # the stored row, once done
image_id: Optional[str] = None
disposition: Optional[str] = None # inserted | backfilled | unchanged
validation_status: Optional[str] = None
retail_presence: Optional[dict] = None
photo_used_as_image: bool = False
detail: Optional[str] = None
warnings: List[str] = Field(default_factory=list)
# ---------------------------------------------------------------------------