Image vector to product details
This commit is contained in:
@@ -5,12 +5,12 @@ import logging
|
||||
import requests
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.schemas import AuthConfigOut, HealthOut, ImageVectorsOut
|
||||
from app.api.schemas import AuthConfigOut, HealthOut, ImageVectorsOut, OcrOut
|
||||
from app.infrastructure.security import auth_config_summary
|
||||
from app.infrastructure.settings import (
|
||||
ENABLE_IMAGE_VECTORS, OLLAMA_BASE_URL, OLLAMA_MODEL_NAME, EMBEDDINGS_MODEL,
|
||||
)
|
||||
from app.services import image_embedder
|
||||
from app.services import image_embedder, ocr_service
|
||||
from app.services.vector_store import _connect # internal, but handy for a connectivity probe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -63,4 +63,7 @@ def health() -> HealthOut:
|
||||
# usual cause (the model file is not in the image), and it must be
|
||||
# visible from outside the container. status() never loads the model.
|
||||
image_vectors=ImageVectorsOut(enabled=ENABLE_IMAGE_VECTORS, **image_embedder.status()),
|
||||
# And for server-side OCR behind /search/identify: "ocr_unavailable"
|
||||
# in a response has one of three causes, and this names it.
|
||||
ocr=OcrOut(**ocr_service.status()),
|
||||
)
|
||||
|
||||
@@ -3,10 +3,12 @@ from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.api.routers.brands import _row_to_product_out
|
||||
from app.api.schemas import (
|
||||
IdentifyOut,
|
||||
ImageMatchOut,
|
||||
ImageSearchOut,
|
||||
ImageVectorSearchRequest,
|
||||
@@ -30,6 +32,7 @@ from app.services.image_match import (
|
||||
parse_vector_param,
|
||||
search_by_vector,
|
||||
)
|
||||
from app.services.product_identify import IdentifyResult, identify_product
|
||||
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
@@ -101,8 +104,19 @@ def _to_image_search_out(result: ImageSearchResult) -> ImageSearchOut:
|
||||
)
|
||||
|
||||
|
||||
def _to_identify_out(result: IdentifyResult) -> IdentifyOut:
|
||||
return IdentifyOut(
|
||||
**_to_image_search_out(result.search).model_dump(),
|
||||
matched_by=result.matched_by,
|
||||
ocr_text=result.ocr_text,
|
||||
ocr_source=result.ocr_source,
|
||||
image_top_score=None if result.image_top_score is None else round(float(result.image_top_score), 4),
|
||||
fallback_reason=result.fallback_reason,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/search/image-vector", response_model=ImageSearchOut)
|
||||
def image_vector_search_endpoint(body: ImageVectorSearchRequest) -> ImageSearchOut:
|
||||
def image_vector_search_endpoint(body: ImageVectorSearchRequest):
|
||||
"""Products that look like the photo whose embedding is `vector`.
|
||||
|
||||
`vector` is the 1024-float, L2-normalised MobileNetV3-Small embedding the
|
||||
@@ -112,8 +126,22 @@ def image_vector_search_endpoint(body: ImageVectorSearchRequest) -> ImageSearchO
|
||||
among products that share one photo. An explicit `brand` is a hard
|
||||
filter; a brand recognised from `text` falls back to every brand when it
|
||||
finds nothing (`scope_fallback`).
|
||||
|
||||
With `text_fallback: true` the request runs the /search/identify ladder
|
||||
instead: when the best image score is under IMAGE_IDENTIFY_MIN_IMAGE_SCORE
|
||||
the label `text` is resolved against the catalogue, and the response is
|
||||
an IdentifyOut (this response plus matched_by, fallback_reason, ...).
|
||||
Off by default so the app's existing calls are answered exactly as before.
|
||||
"""
|
||||
try:
|
||||
if body.text_fallback:
|
||||
identified = identify_product(
|
||||
vector=body.vector, image_bytes=None, text=body.text, brand=body.brand,
|
||||
category=body.category, top_k=body.top_k, min_score=body.min_score,
|
||||
)
|
||||
# A Response bypasses response_model, which is the point: the
|
||||
# default path keeps its declared ImageSearchOut contract.
|
||||
return JSONResponse(content=_to_identify_out(identified).model_dump(mode="json"))
|
||||
result = search_by_vector(
|
||||
body.vector, text=body.text, brand=body.brand, category=body.category,
|
||||
top_k=body.top_k, min_score=body.min_score,
|
||||
@@ -199,3 +227,67 @@ async def image_search_endpoint(
|
||||
except InvalidVectorError as exc: # cannot happen for a model output, but the route must not 500
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
return _to_image_search_out(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identify: image first, label text second
|
||||
# ---------------------------------------------------------------------------
|
||||
# A phone photo of a pack scores ~0.63 against the catalog's render of the
|
||||
# same pack, so /search/image alone cannot confirm a product. This route
|
||||
# runs the ladder in app/services/product_identify.py: the image match when
|
||||
# it clears IMAGE_IDENTIFY_MIN_IMAGE_SCORE, otherwise the label text - the
|
||||
# client's `text`, else what the server reads off the photo (ocr_service) -
|
||||
# resolved through the catalogue's text embeddings and product names.
|
||||
|
||||
@router.post("/search/identify", response_model=IdentifyOut)
|
||||
async def identify_endpoint(
|
||||
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"),
|
||||
brand: Optional[str] = Form(None, max_length=120),
|
||||
category: Optional[str] = Form(None, max_length=120),
|
||||
top_k: int = Form(IMAGE_SEARCH_DEFAULT_TOP_K, ge=1, le=IMAGE_SEARCH_MAX_TOP_K),
|
||||
min_score: float = Form(IMAGE_SEARCH_DEFAULT_MIN_SCORE, ge=-1.0, le=1.0),
|
||||
) -> IdentifyOut:
|
||||
"""Which catalog product is in this photo.
|
||||
|
||||
`matched_by` says which rung answered and therefore which space each
|
||||
result's `score` is in; `fallback_reason` is set whenever the answer is
|
||||
a best effort rather than a confirmed match. Works text-only on a
|
||||
deployment without the image model (fallback_reason
|
||||
"image_embedder_unavailable"); 503 only when neither a vector nor server
|
||||
OCR is possible and no `text` was sent - GET /api/health -> image_vectors
|
||||
and ocr say which is missing.
|
||||
"""
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="The uploaded image is empty.")
|
||||
if len(content) > IMAGE_VECTOR_MAX_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Image is {len(content) / 1_048_576:.1f} MB; the limit is "
|
||||
f"{IMAGE_VECTOR_MAX_BYTES // 1_048_576} MB. Crop or downscale it.",
|
||||
)
|
||||
vector = None
|
||||
if await run_in_threadpool(image_embedder.available):
|
||||
vector = await run_in_threadpool(image_embedder.embedding_for_bytes, content)
|
||||
if vector is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Could not decode the image (unsupported format, corrupt data, or too many pixels).",
|
||||
)
|
||||
try:
|
||||
result = await run_in_threadpool(
|
||||
identify_product, vector=vector, image_bytes=content, text=text, brand=brand,
|
||||
category=category, top_k=top_k, min_score=min_score,
|
||||
)
|
||||
except InvalidVectorError as exc: # cannot happen for a model output, but the route must not 500
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
if vector is None and result.fallback_reason == "ocr_unavailable":
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Neither the image embedding model nor server-side OCR is available on this "
|
||||
"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)
|
||||
|
||||
@@ -138,6 +138,17 @@ class ImageVectorsOut(BaseModel):
|
||||
state: str = "unknown"
|
||||
|
||||
|
||||
class OcrOut(BaseModel):
|
||||
"""Whether POST /api/search/identify can read a label off a photo itself.
|
||||
Reported without loading the engine. `runtime_importable=false` after a
|
||||
deploy means the rapidocr wheel was not installed (requirements-ocr.txt);
|
||||
`onnxruntime_importable=false` means its engine was not."""
|
||||
enabled: bool = True
|
||||
runtime_importable: bool = False
|
||||
onnxruntime_importable: bool = False
|
||||
state: str = "unknown"
|
||||
|
||||
|
||||
class HealthOut(BaseModel):
|
||||
status: str
|
||||
database: bool
|
||||
@@ -148,6 +159,7 @@ class HealthOut(BaseModel):
|
||||
# Defaulted so a client of this schema still validates against a
|
||||
# deployment predating the field.
|
||||
image_vectors: ImageVectorsOut = Field(default_factory=ImageVectorsOut)
|
||||
ocr: OcrOut = Field(default_factory=OcrOut)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -251,6 +263,16 @@ class ImageVectorSearchRequest(BaseModel):
|
||||
top_k: int = Field(IMAGE_SEARCH_DEFAULT_TOP_K, ge=1, le=IMAGE_SEARCH_MAX_TOP_K)
|
||||
min_score: float = Field(IMAGE_SEARCH_DEFAULT_MIN_SCORE, ge=-1.0, le=1.0,
|
||||
description="Drop matches with cosine similarity below this")
|
||||
# Opt-in, so a client that never asked for it gets exactly the response
|
||||
# it always did. With it, the route runs the identify ladder: when the
|
||||
# best image score is under IMAGE_IDENTIFY_MIN_IMAGE_SCORE, `text` is
|
||||
# resolved against the catalogue instead, and the response is an
|
||||
# IdentifyOut (ImageSearchOut plus matched_by / fallback_reason / ...).
|
||||
text_fallback: bool = Field(
|
||||
False,
|
||||
description="Fall back to resolving `text` when the image match is below the identify floor; "
|
||||
"the response then carries the IdentifyOut fields",
|
||||
)
|
||||
|
||||
@field_validator("vector")
|
||||
@classmethod
|
||||
@@ -281,6 +303,23 @@ class ImageSearchOut(BaseModel):
|
||||
query_text: Optional[str] = None
|
||||
|
||||
|
||||
class IdentifyOut(ImageSearchOut):
|
||||
"""POST /search/identify: an ImageSearchOut plus which rung answered.
|
||||
|
||||
`matched_by` names the space each result's `score` is in: "image_vector"
|
||||
(cosine of the 1024-d photo embedding) or "text" (cosine of the 384-d
|
||||
MiniLM embedding of the label). `image_top_score` always carries the
|
||||
image side. The answer is confirmed when matched_by is "text", or
|
||||
"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"
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RAG chat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -433,6 +433,42 @@ IMAGE_SEARCH_DEFAULT_MIN_SCORE = float(os.getenv("IMAGE_SEARCH_DEFAULT_MIN_SCORE
|
||||
# the floor for hnsw.ef_search on that query so the index does not drop them.
|
||||
IMAGE_SEARCH_MAX_FETCH_K = int(os.getenv("IMAGE_SEARCH_MAX_FETCH_K", "100"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identify a product from a phone photo - POST /api/search/identify
|
||||
# (app/services/product_identify.py), with server-side OCR
|
||||
# (app/services/ocr_service.py) and the label-text resolver
|
||||
# (app/services/label_match.py). Public, read-only.
|
||||
# ---------------------------------------------------------------------------
|
||||
# A phone photo of a pack against the catalog's render of it scored 0.63 on
|
||||
# img_vector (feasibility test), so the image alone cannot confirm a product.
|
||||
# Below this floor the ladder falls through to the label text: whatever the
|
||||
# client OCR'd, else what the server reads off the photo itself.
|
||||
IMAGE_IDENTIFY_MIN_IMAGE_SCORE = float(os.getenv("IMAGE_IDENTIFY_MIN_IMAGE_SCORE", "0.70"))
|
||||
# The text side is a MiniLM cosine (1 - embedding <=> q) in a different space
|
||||
# from the image score. A nearest-neighbour query always returns SOMETHING, so
|
||||
# a text match counts as found only when the label shares a name/size token
|
||||
# with the row, or the cosine clears this floor.
|
||||
IMAGE_IDENTIFY_MIN_TEXT_SCORE = float(os.getenv("IMAGE_IDENTIFY_MIN_TEXT_SCORE", "0.60"))
|
||||
# Server-side OCR (rapidocr, PP-OCR models on onnxruntime CPU; models ship
|
||||
# inside the wheel, nothing is downloaded). Loaded lazily on the first photo
|
||||
# that needs it, never at boot; a missing wheel means "no server OCR" and
|
||||
# GET /api/health -> ocr says so. tests/conftest.py pins it OFF.
|
||||
ENABLE_SERVER_OCR = _bool("ENABLE_SERVER_OCR", "true")
|
||||
# rapidocr's Global.text_score: recognised lines below this confidence are
|
||||
# dropped before the label is assembled.
|
||||
OCR_MIN_CONFIDENCE = float(os.getenv("OCR_MIN_CONFIDENCE", "0.5"))
|
||||
# The photo is downscaled so its longer side is at most this before
|
||||
# detection. The latency knob: a 12MP capture takes ~3x longer than 1280px
|
||||
# and reads no better off a pack label.
|
||||
OCR_MAX_SIDE_PX = int(os.getenv("OCR_MAX_SIDE_PX", "1280"))
|
||||
# onnxruntime intra-op threads per session; the read itself is serialised by
|
||||
# a lock (the engine is not thread-safe).
|
||||
OCR_NUM_THREADS = int(os.getenv("OCR_NUM_THREADS", "2"))
|
||||
# The assembled label is capped here, on a word boundary. 500 is the limit of
|
||||
# the `text` field on the image-search routes, so client and server text are
|
||||
# bounded alike.
|
||||
OCR_MAX_CHARS = int(os.getenv("OCR_MAX_CHARS", "500"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# USDA FoodData Central - nutrition for loose, unbranded commodities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -81,6 +81,43 @@ def _flatten_alpha_to_bgr(image_bytes: bytes) -> Optional[np.ndarray]:
|
||||
return np.ascontiguousarray(np.asarray(rgb, dtype=np.uint8)[:, :, ::-1])
|
||||
|
||||
|
||||
def decode_bgr(image_bytes: bytes) -> Optional[np.ndarray]:
|
||||
"""`image_bytes` as a full-resolution uint8 BGR array, or None.
|
||||
|
||||
Step 1 of `preprocess()`, on its own because the OCR service
|
||||
(app/services/ocr_service.py) needs the same decode - pixel-bomb guard,
|
||||
EXIF orientation, transparency flattened onto white - but the WHOLE frame:
|
||||
the 224px centre crop that follows here would throw away the label.
|
||||
|
||||
Never raises: an undecodable or oversized file is None (see preprocess).
|
||||
"""
|
||||
if not image_bytes:
|
||||
return None
|
||||
try:
|
||||
import cv2
|
||||
from PIL import Image
|
||||
|
||||
# Header only: the pixel-bomb guard and the transparency check both
|
||||
# come from the file's metadata, no decode yet.
|
||||
probe = Image.open(io.BytesIO(image_bytes))
|
||||
if probe.width * probe.height > IMAGE_VECTOR_MAX_PIXELS:
|
||||
logger.debug("image rejected: %dx%d exceeds pixel cap", probe.width, probe.height)
|
||||
return None
|
||||
has_alpha = "A" in probe.getbands() or "transparency" in probe.info
|
||||
|
||||
if has_alpha:
|
||||
bgr = _flatten_alpha_to_bgr(image_bytes)
|
||||
else:
|
||||
bgr = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||
if bgr is None or bgr.ndim != 3 or bgr.shape[2] != 3:
|
||||
logger.debug("image rejected: OpenCV could not decode it to BGR")
|
||||
return None
|
||||
return bgr
|
||||
except Exception as exc: # noqa: BLE001 - see preprocess()
|
||||
logger.debug("image undecodable: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def preprocess(image_bytes: bytes) -> Optional[np.ndarray]:
|
||||
"""Decode `image_bytes` into the model's input tensor, or None.
|
||||
|
||||
@@ -111,27 +148,11 @@ def preprocess(image_bytes: bytes) -> Optional[np.ndarray]:
|
||||
Never raises - pytest runs warnings as errors, and a
|
||||
DecompressionBombWarning is one of the things the broad except absorbs.
|
||||
"""
|
||||
if not image_bytes:
|
||||
bgr = decode_bgr(image_bytes) # 1
|
||||
if bgr is None:
|
||||
return None
|
||||
try:
|
||||
import cv2
|
||||
from PIL import Image
|
||||
|
||||
# Header only: the pixel-bomb guard and the transparency check both
|
||||
# come from the file's metadata, no decode yet.
|
||||
probe = Image.open(io.BytesIO(image_bytes))
|
||||
if probe.width * probe.height > IMAGE_VECTOR_MAX_PIXELS:
|
||||
logger.debug("image rejected: %dx%d exceeds pixel cap", probe.width, probe.height)
|
||||
return None
|
||||
has_alpha = "A" in probe.getbands() or "transparency" in probe.info
|
||||
|
||||
if has_alpha:
|
||||
bgr = _flatten_alpha_to_bgr(image_bytes)
|
||||
else:
|
||||
bgr = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR) # 1
|
||||
if bgr is None or bgr.ndim != 3 or bgr.shape[2] != 3:
|
||||
logger.debug("image rejected: OpenCV could not decode it to BGR")
|
||||
return None
|
||||
|
||||
h, w = bgr.shape[:2] # 2
|
||||
side = min(w, h)
|
||||
|
||||
360
app/services/label_match.py
Normal file
360
app/services/label_match.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""Find catalog products from the TEXT on a pack: OCR label -> `embedding` + names.
|
||||
|
||||
WHERE THIS SITS
|
||||
---------------
|
||||
The second rung of POST /api/search/identify (app/services/product_identify.py).
|
||||
When the photo's img_vector cannot confirm a product - a phone photo scores
|
||||
~0.63 against the catalog's render of the same pack - the label text takes
|
||||
over: the client's OCR `text`, or what app/services/ocr_service.py read off
|
||||
the photo. This module turns that text into ranked catalog rows.
|
||||
|
||||
WHY NOT catalog_search.search_catalog()
|
||||
---------------------------------------
|
||||
GET /api/search is tuned for a human typing in a search box, and two of its
|
||||
habits are wrong for OCR output:
|
||||
|
||||
* it passes the FUZZY category detection through as a hard filter, and that
|
||||
detector scores "taste" as Oral Care (matches "paste") and "Colgate" as
|
||||
Chocolates - a misread word would delete the right product from the result;
|
||||
* its lexical arm ANDs every term, so one smudged word means zero rows.
|
||||
|
||||
So this module goes to the store functions directly, never filters on a
|
||||
category it inferred, and asks the lexical arm for only the two most
|
||||
distinctive words.
|
||||
|
||||
HOW A MATCH IS RANKED
|
||||
---------------------
|
||||
Both arms run against the brand the label names (query_intent's
|
||||
`extract_brand_mention`), else every active brand:
|
||||
|
||||
* semantic - `embed_texts([f"{brand} {label}"])` (the stored vector is of
|
||||
"{brand} {name} {category} {description}", store_catalog_pipeline.py, so
|
||||
the brand prefix keeps the query in-distribution) -> `semantic_search`;
|
||||
* lexical - `lexical_search` on the two longest non-brand, non-size words,
|
||||
which tolerates a misread elsewhere on the label.
|
||||
|
||||
Merged rows are ordered by `text_overlap` FIRST (image_match's scorer: 3 per
|
||||
shared size token, 1 per shared word - the size is what separates the 89g,
|
||||
300g and 1kg rows of one product), then by `text_extra` ascending (name
|
||||
words the label never said - what separates "Dairy Milk" from "Dairy Milk
|
||||
Fruit & Nut"), then by cosine. `score` on a text row is
|
||||
`1 - (embedding <=> q)` in MiniLM's 384-d space: a different space from the
|
||||
image score, and the response says which one it is (`matched_by`).
|
||||
|
||||
A nearest-neighbour query always returns something, so `is_confident()`
|
||||
draws the line: the best row shares a token with the label, or its cosine
|
||||
clears IMAGE_IDENTIFY_MIN_TEXT_SCORE. Below that the caller treats the text
|
||||
rung as a miss. Read-only: nothing here writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from app.infrastructure.settings import (
|
||||
IMAGE_IDENTIFY_MIN_TEXT_SCORE,
|
||||
IMAGE_SEARCH_DEFAULT_TOP_K,
|
||||
IMAGE_SEARCH_MAX_FETCH_K,
|
||||
IMAGE_SEARCH_MAX_TOP_K,
|
||||
OCR_MAX_CHARS,
|
||||
)
|
||||
from app.services.image_match import _STOP, ImageSearchResult, _row_text, text_overlap, tokens
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Words a pack prints that name no product: the nutrition panel, the
|
||||
# regulatory block, storage advice, the price line. Dropped before either
|
||||
# arm sees the label so "ENERGY 480 kcal PROTEIN 7g" does not become the
|
||||
# query. Lowercase; compared after image_match.tokens' own normalisation.
|
||||
_NOISE: Set[str] = {
|
||||
"nutrition", "nutritional", "information", "facts", "typical", "values", "value",
|
||||
"energy", "kcal", "kj", "calories", "protein", "carbohydrate", "carbohydrates",
|
||||
"carbs", "sugar", "sugars", "fat", "fats", "saturated", "trans", "sodium",
|
||||
"cholesterol", "fibre", "fiber", "dietary", "serving", "servings", "serve",
|
||||
"serves", "amount", "ingredients", "ingredient", "contains", "allergen",
|
||||
"allergens", "allergy", "advice", "mfg", "mfd", "manufactured", "manufacturer",
|
||||
"marketed", "packed", "packer", "lot", "batch", "best", "before", "expiry",
|
||||
"exp", "use", "date", "fssai", "lic", "license", "licence", "mrp", "price",
|
||||
"incl", "inclusive", "all", "taxes", "tax", "ltd", "pvt", "limited", "india",
|
||||
"www", "com", "http", "https", "customer", "care", "helpline", "email", "call",
|
||||
"store", "cool", "dry", "place", "keep", "away", "sunlight", "hygienic",
|
||||
"net", "wt", "weight", "qty", "quantity", "approx", "veg", "vegetarian",
|
||||
"non", "product", "code", "unit", "units",
|
||||
}
|
||||
# A nutrition-panel basis ("per 100 g", "per serve") carries a quantity that
|
||||
# is NOT the pack size; left in, "100g" would score 3 points of overlap and
|
||||
# hand the match to the 100g sibling. Removed as a span, before tokenising.
|
||||
_PER_BASIS_RE = re.compile(
|
||||
r"\bper\s*\d+(?:\.\d+)?\s*(?:kg|gms|gm|g|ml|ltr|litre|l)\b|\bper\s+serv\w*\b",
|
||||
re.I,
|
||||
)
|
||||
# A nutrient with its value ("Protein 7 g", "Sugars: 20.5g", "Energy 480
|
||||
# kcal") is also a quantity that is not the pack size. The name and the
|
||||
# number go together, so the pair is removed as one span.
|
||||
_NUTRIENT_VALUE_RE = re.compile(
|
||||
r"\b(?:energy|calories|protein|carbohydrates?|carbs|sugars?|added\s+sugars?|"
|
||||
r"total\s+fat|fat|saturated(?:\s+fat)?|trans(?:\s+fat)?|cholesterol|sodium|salt|"
|
||||
r"dietary\s+fibre|dietary\s+fiber|fibre|fiber|calcium|iron|potassium|vitamin\s*\w*)"
|
||||
r"\s*[:\-]?\s*(?:<\s*)?\d+(?:\.\d+)?\s*(?:kg|mg|mcg|g|kcal|kj|cal|ml|%)?\b",
|
||||
re.I,
|
||||
)
|
||||
# What is left of a nutrition line once the names are gone: values in units
|
||||
# no pack is sold in.
|
||||
_NON_PACK_VALUE_RE = re.compile(r"\b\d+(?:\.\d+)?\s*(?:mg|mcg|kcal|kj|cal)\b|\d+(?:\.\d+)?\s*%", re.I)
|
||||
_URL_RE = re.compile(r"(?:https?://|www\.)\S+|\b\S+\.(?:com|in|org|net)\b", re.I)
|
||||
_PRICE_RE = re.compile(r"(?:₹|rs\.?|inr)\s*\d+(?:[.,]\d+)?", re.I)
|
||||
_SIZE_RE = re.compile(r"^\d+(?:\.\d+)?(?:kg|gms|gm|g|ml|ltr|litre|l|pcs|pc|n)$", re.I)
|
||||
_WORD_OR_SIZE_RE = re.compile(r"\d+(?:\.\d+)?\s*(?:kg|gms|gm|g|ml|ltr|litre|l|pcs|pc|n)\b|[a-z0-9]+(?:[-'&][a-z0-9]+)*", re.I)
|
||||
|
||||
_LEXICAL_TERMS = 2
|
||||
_LONG_NUMBER = 6 # digits; an FSSAI licence is 14, a phone number 10, a barcode 8-13
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def clean_label(text: Optional[str]) -> str:
|
||||
"""The label as a query: boilerplate out, sizes kept, repeats folded.
|
||||
|
||||
Spans first (a per-100g basis, a URL, a price), then tokens: a size such
|
||||
as "300 g" is kept whole (as "300 g" - image_match.tokens normalises it
|
||||
later), a word in _NOISE or shorter than two characters is dropped, a
|
||||
repeat keeps its first position. Capped at OCR_MAX_CHARS on a word
|
||||
boundary like the OCR output it usually is.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
lowered = " ".join(str(text).split())
|
||||
lowered = _PER_BASIS_RE.sub(" ", lowered)
|
||||
lowered = _NUTRIENT_VALUE_RE.sub(" ", lowered)
|
||||
lowered = _NON_PACK_VALUE_RE.sub(" ", lowered)
|
||||
lowered = _URL_RE.sub(" ", lowered)
|
||||
lowered = _PRICE_RE.sub(" ", lowered)
|
||||
|
||||
kept: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
for match in _WORD_OR_SIZE_RE.finditer(lowered):
|
||||
piece = match.group(0)
|
||||
key = piece.lower().replace(" ", "")
|
||||
if _SIZE_RE.match(key):
|
||||
pass # a size stays, whatever its length
|
||||
elif len(key) < 2 or key in _NOISE or key in _STOP:
|
||||
continue
|
||||
elif key.isdigit() and len(key) >= _LONG_NUMBER:
|
||||
continue # a licence, phone or barcode number names no product
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
kept.append(" ".join(piece.split()))
|
||||
|
||||
out = " ".join(kept)
|
||||
if len(out) > OCR_MAX_CHARS:
|
||||
cut = out.rfind(" ", 0, OCR_MAX_CHARS + 1)
|
||||
out = out[:cut if cut > 0 else OCR_MAX_CHARS].rstrip()
|
||||
return out
|
||||
|
||||
|
||||
def _lexical_terms(words: Set[str], brand: Optional[str]) -> List[str]:
|
||||
"""The two longest label words that are not the brand: length is a cheap
|
||||
proxy for distinctiveness ("britannia" > "gold" > "go"), and two ANDed
|
||||
terms survive one misread elsewhere on the label."""
|
||||
brand_words = set(tokens(brand)[0]) if brand else set()
|
||||
candidates = sorted((w for w in words if w not in brand_words and not w.isdigit()),
|
||||
key=lambda w: (-len(w), w))
|
||||
return candidates[:_LEXICAL_TERMS]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ranking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def text_extra(words: Set[str], row: Dict[str, Any]) -> int:
|
||||
"""How many words of the row's NAME the label does not account for.
|
||||
|
||||
`text_overlap` is recall - how much of the label the row explains - and
|
||||
on its own it cannot tell "Dairy Milk 50g" from "Dairy Milk Fruit & Nut
|
||||
50g": both explain every word of a "Dairy Milk 50 g" label. The variant
|
||||
carries words the label never said, and this counts them, so the plain
|
||||
product outranks its variants and a "sugar free" or "family pack" row
|
||||
does not win on a label that mentions neither. Only the name and the
|
||||
sizes are looked at, never the description.
|
||||
"""
|
||||
if not words:
|
||||
return 0
|
||||
row_words, _ = tokens(_row_text(row))
|
||||
return len(row_words - words)
|
||||
|
||||
|
||||
def label_rank_key(row: Dict[str, Any]) -> tuple:
|
||||
"""Best first: what the label says, what the row adds, then the vector.
|
||||
|
||||
Overlap outranks cosine because the size token is the only thing that
|
||||
tells the pack sizes of one product apart, and their descriptions - and
|
||||
so their vectors - are near-identical. Unexplained name words come next
|
||||
for the same reason (a variant's vector is as close as the original's).
|
||||
"""
|
||||
return (
|
||||
-float(row.get("text_overlap", 0.0)),
|
||||
int(row.get("text_extra", 0)),
|
||||
-round(float(row.get("score", 0.0)), 3),
|
||||
str(row.get("product_name") or row.get("title") or "").lower(),
|
||||
str(row.get("image_id") or ""),
|
||||
)
|
||||
|
||||
|
||||
def is_confident(rows: List[Dict[str, Any]]) -> bool:
|
||||
"""Whether the best text row is a find rather than the nearest stranger."""
|
||||
if not rows:
|
||||
return False
|
||||
best = rows[0]
|
||||
try:
|
||||
overlap = float(best.get("text_overlap", 0.0))
|
||||
score = float(best.get("score", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return overlap >= 1.0 or score >= IMAGE_IDENTIFY_MIN_TEXT_SCORE
|
||||
|
||||
|
||||
def _normalise_row(row: Dict[str, Any], scoped_table: Optional[str]) -> Dict[str, Any]:
|
||||
"""Give a store row the keys image_match's rows have.
|
||||
|
||||
Brand tables carry no `brand` column, so semantic_search / lexical_search
|
||||
fill `brand` with the caller's brand when scoped and with the TABLE
|
||||
SUFFIX ("hindustan_unilever") when not; neither adds `brand_table`. The
|
||||
identify response is built by the same card builder as the image rows,
|
||||
so both get the display name and the table here.
|
||||
"""
|
||||
from app.services.vector_store import display_name_for_suffix
|
||||
|
||||
row = dict(row)
|
||||
raw_brand = str(row.get("brand") or "")
|
||||
if scoped_table:
|
||||
row["brand_table"] = scoped_table
|
||||
else:
|
||||
row["brand_table"] = f"brand_{raw_brand}" if raw_brand else ""
|
||||
row["brand"] = display_name_for_suffix(raw_brand) if raw_brand else raw_brand
|
||||
distance = row.get("distance")
|
||||
try:
|
||||
row["score"] = 1.0 - float(distance) if distance is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
row["score"] = 0.0
|
||||
return row
|
||||
|
||||
|
||||
def _candidates(
|
||||
query_text: str,
|
||||
embedding: Optional[List[float]],
|
||||
words: Set[str],
|
||||
brand: Optional[str],
|
||||
category: Optional[str],
|
||||
fetch_k: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Both arms, merged on (brand_table, image_id); the semantic row wins a
|
||||
tie because it carries the real distance for the full query."""
|
||||
from app.services.vector_store import _table_name, lexical_search, semantic_search
|
||||
|
||||
scoped_table = _table_name(brand) if brand else None
|
||||
merged: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
||||
|
||||
if embedding is not None:
|
||||
try:
|
||||
for row in semantic_search(query_embedding=embedding, brand=brand,
|
||||
top_k=fetch_k, category=category):
|
||||
norm = _normalise_row(row, scoped_table)
|
||||
merged.setdefault((norm["brand_table"], str(norm.get("image_id") or "")), norm)
|
||||
except Exception as exc: # noqa: BLE001 - one arm failing must not lose the other
|
||||
logger.warning("label semantic arm failed: %s", exc)
|
||||
|
||||
terms = _lexical_terms(words, brand)
|
||||
for attempt in (terms, terms[:1]):
|
||||
if not attempt:
|
||||
break
|
||||
try:
|
||||
rows = lexical_search(attempt, brand=brand, limit=fetch_k, category=category,
|
||||
query_embedding=embedding, exact_phrase=" ".join(attempt))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("label lexical arm failed: %s", exc)
|
||||
rows = []
|
||||
for row in rows:
|
||||
norm = _normalise_row(row, scoped_table)
|
||||
merged.setdefault((norm["brand_table"], str(norm.get("image_id") or "")), norm)
|
||||
if rows:
|
||||
break
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolve_label(
|
||||
text: Optional[str],
|
||||
brand: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
top_k: int = IMAGE_SEARCH_DEFAULT_TOP_K,
|
||||
) -> ImageSearchResult:
|
||||
"""The catalog rows the label text names, best first, at most `top_k`.
|
||||
|
||||
Never raises: no text, no model and no database all give an empty result.
|
||||
`query_text` on the result is the cleaned label the search actually used.
|
||||
"""
|
||||
cleaned = clean_label(text)
|
||||
top_k = max(1, min(int(top_k), IMAGE_SEARCH_MAX_TOP_K))
|
||||
if not cleaned:
|
||||
return ImageSearchResult(query_text=None, top_k=top_k)
|
||||
fetch_k = min(max(top_k * 3, 30), IMAGE_SEARCH_MAX_FETCH_K)
|
||||
|
||||
explicit = (brand or "").strip() or None
|
||||
detected = explicit
|
||||
if detected is None:
|
||||
try:
|
||||
from app.services.query_intent import extract_brand_mention
|
||||
detected = extract_brand_mention(cleaned)
|
||||
except Exception as exc: # noqa: BLE001 - brand detection is an optimisation
|
||||
logger.debug("brand detection skipped: %s", exc)
|
||||
detected = None
|
||||
|
||||
query = cleaned
|
||||
if detected and detected.lower() not in cleaned.lower():
|
||||
query = f"{detected} {cleaned}"
|
||||
|
||||
embedding: Optional[List[float]] = None
|
||||
try:
|
||||
from app.services.embeddings_service import embed_texts
|
||||
vectors = embed_texts([query])
|
||||
embedding = list(vectors[0]) if vectors else None
|
||||
except Exception as exc: # noqa: BLE001 - lexical-only, like catalog_search does
|
||||
logger.warning("label embedding failed: %s. Lexical-only.", exc)
|
||||
embedding = None
|
||||
|
||||
words, sizes = tokens(cleaned)
|
||||
|
||||
def _run(scope: Optional[str]) -> List[Dict[str, Any]]:
|
||||
rows = _candidates(query, embedding, words, scope, category, fetch_k)
|
||||
for row in rows:
|
||||
row["text_overlap"] = text_overlap(words, sizes, row)
|
||||
row["text_extra"] = text_extra(words, row)
|
||||
rows.sort(key=label_rank_key)
|
||||
return rows
|
||||
|
||||
rows = _run(detected)
|
||||
scoped = detected is not None
|
||||
fallback = False
|
||||
if detected and not explicit and not is_confident(rows):
|
||||
# The OCR named a brand whose table does not hold this label - a
|
||||
# misread, or a sub-brand mapped to the wrong parent. Try everywhere.
|
||||
wider = _run(None)
|
||||
if is_confident(wider) or not rows:
|
||||
rows, scoped, fallback = wider, False, True
|
||||
|
||||
return ImageSearchResult(
|
||||
rows=rows[:top_k],
|
||||
detected_brand=detected,
|
||||
scoped_to_brand=scoped,
|
||||
scope_fallback=fallback,
|
||||
min_score=0.0,
|
||||
query_text=cleaned,
|
||||
top_k=top_k,
|
||||
)
|
||||
245
app/services/ocr_service.py
Normal file
245
app/services/ocr_service.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Read the label text off a product photo, server-side: rapidocr on onnxruntime.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
POST /api/search/identify falls through to the label text when the image
|
||||
vector cannot confirm a product (a phone photo of a pack scores ~0.63 against
|
||||
the catalog's render of it). The Nearle app OCRs on-device and sends `text`;
|
||||
a plain camera upload has no such text, so this module reads it here.
|
||||
|
||||
WHAT IT PRODUCES
|
||||
----------------
|
||||
One string: the recognised lines in detection order (top-to-bottom, which is
|
||||
reading order for a pack front), each kept only above OCR_MIN_CONFIDENCE,
|
||||
exact repeats dropped, capped at OCR_MAX_CHARS on a word boundary. It is
|
||||
deliberately the same shape as the `text` field the routes already accept, so
|
||||
app/services/label_match.py cannot tell the two apart.
|
||||
|
||||
THE ENGINE
|
||||
----------
|
||||
rapidocr (PP-OCR detection + classification + recognition, ONNX). The models
|
||||
ship inside the wheel and are verified by checksum at construction, so the
|
||||
first call makes no network request. Two things to know about it:
|
||||
|
||||
* Its metadata demands opencv-python (the GUI build), which would unpack over
|
||||
this project's opencv-python-headless and fail on libGL in the slim image -
|
||||
breaking image_embedder too. It is therefore installed with `--no-deps`
|
||||
(requirements-ocr.txt) and its real dependencies are listed in
|
||||
requirements.txt. Nothing here needs the GUI build.
|
||||
* `RapidOCR.__call__` mutates the engine's own state, so one lock serialises
|
||||
every read, exactly as image_embedder does for its interpreter.
|
||||
|
||||
FAILURE IS SILENT BY DESIGN
|
||||
---------------------------
|
||||
Imported lazily, on the first photo that needs it. A missing wheel, a missing
|
||||
onnxruntime, a broken model: `available()` is False after ONE warning,
|
||||
`read_text()` is None, and the identify route reports `ocr_unavailable`
|
||||
instead of failing. GET /api/health -> ocr says which of those it was.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import warnings
|
||||
from typing import Any, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.infrastructure.settings import (
|
||||
ENABLE_SERVER_OCR,
|
||||
OCR_MAX_CHARS,
|
||||
OCR_MAX_SIDE_PX,
|
||||
OCR_MIN_CONFIDENCE,
|
||||
OCR_NUM_THREADS,
|
||||
)
|
||||
from app.services import image_embedder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_engine: Any = None
|
||||
_disabled_reason: Optional[str] = None
|
||||
_warned = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _disable(reason: str, *, warn: bool = True) -> None:
|
||||
"""Record why OCR is off; say so once. Caller holds _lock."""
|
||||
global _disabled_reason, _engine, _warned
|
||||
_disabled_reason = reason
|
||||
_engine = None
|
||||
if warn and not _warned:
|
||||
_warned = True
|
||||
logger.warning("server OCR disabled: %s (identify falls back to client text)", reason)
|
||||
|
||||
|
||||
def _load() -> None:
|
||||
"""Construct the engine. Caller holds _lock. Never raises."""
|
||||
global _engine
|
||||
if not ENABLE_SERVER_OCR:
|
||||
# A deliberate setting, not a fault: no warning line for it.
|
||||
_disable("ENABLE_SERVER_OCR is false", warn=False)
|
||||
return
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
# Import-time deprecation chatter from onnxruntime / omegaconf /
|
||||
# shapely would be a hard error under pytest's filterwarnings=error.
|
||||
warnings.simplefilter("ignore")
|
||||
from rapidocr import RapidOCR
|
||||
|
||||
engine = RapidOCR(params={
|
||||
"Global.text_score": float(OCR_MIN_CONFIDENCE),
|
||||
# Its logger does not propagate and installs its own INFO
|
||||
# handler; keep the per-call chatter out of the container log.
|
||||
"Global.log_level": "warning",
|
||||
"EngineConfig.onnxruntime.intra_op_num_threads": max(1, int(OCR_NUM_THREADS)),
|
||||
})
|
||||
except Exception as exc: # noqa: BLE001 - ImportError, a missing engine, a bad model, libGL
|
||||
_disable(f"rapidocr is not usable ({exc})")
|
||||
return
|
||||
_engine = engine
|
||||
logger.info("server OCR ready: rapidocr (%d thread(s))", OCR_NUM_THREADS)
|
||||
|
||||
|
||||
def _ensure_loaded() -> bool:
|
||||
"""Caller holds _lock."""
|
||||
if _engine is None and _disabled_reason is None:
|
||||
_load()
|
||||
return _engine is not None
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
"""True when the engine can be used. Loads it on first call."""
|
||||
with _lock:
|
||||
return _ensure_loaded()
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
"""Diagnostics for /api/health - NEVER loads the engine.
|
||||
|
||||
Same idea as image_embedder.status(): the failure this module absorbs is
|
||||
one log line at first use, so the same facts go on the health endpoint
|
||||
where "why does identify say ocr_unavailable?" is one curl away.
|
||||
"""
|
||||
try:
|
||||
import importlib.util
|
||||
runtime = importlib.util.find_spec("rapidocr") is not None
|
||||
onnx = importlib.util.find_spec("onnxruntime") is not None
|
||||
except Exception: # noqa: BLE001 - a broken finder counts as "not importable"
|
||||
runtime = onnx = False
|
||||
with _lock:
|
||||
if _engine is not None:
|
||||
state = "ready"
|
||||
elif _disabled_reason:
|
||||
state = f"disabled: {_disabled_reason}"
|
||||
elif not ENABLE_SERVER_OCR:
|
||||
state = "disabled: ENABLE_SERVER_OCR is false"
|
||||
else:
|
||||
state = "not loaded yet (loads on first identify)"
|
||||
return {
|
||||
"enabled": bool(ENABLE_SERVER_OCR),
|
||||
"runtime_importable": runtime,
|
||||
"onnxruntime_importable": onnx,
|
||||
"state": state,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _downscale(bgr: np.ndarray, max_side: int) -> np.ndarray:
|
||||
"""`bgr` with its longer side at most `max_side` (INTER_AREA), else as is."""
|
||||
h, w = bgr.shape[:2]
|
||||
longest = max(h, w)
|
||||
if max_side <= 0 or longest <= max_side:
|
||||
return bgr
|
||||
import cv2
|
||||
|
||||
scale = max_side / float(longest)
|
||||
size = (max(1, int(round(w * scale))), max(1, int(round(h * scale))))
|
||||
return cv2.resize(bgr, size, interpolation=cv2.INTER_AREA)
|
||||
|
||||
|
||||
def _join_lines(output: Any) -> Optional[str]:
|
||||
"""The engine's lines as one label string, or None when it read nothing.
|
||||
|
||||
Detection order is kept (top-to-bottom is reading order on a pack front);
|
||||
a line below OCR_MIN_CONFIDENCE is dropped (the engine already filters on
|
||||
Global.text_score - this is belt and braces for a fake or a future
|
||||
engine); an exact repeat is dropped (a pack often prints its name twice);
|
||||
the whole is capped at OCR_MAX_CHARS on a word boundary so the server's
|
||||
text is bounded exactly like the routes' `text` field.
|
||||
"""
|
||||
if output is None:
|
||||
return None
|
||||
txts = getattr(output, "txts", None)
|
||||
scores = getattr(output, "scores", None)
|
||||
if not txts:
|
||||
return None
|
||||
if scores is None or len(scores) != len(txts):
|
||||
scores = [1.0] * len(txts)
|
||||
|
||||
lines: List[str] = []
|
||||
seen = set()
|
||||
for raw, score in zip(txts, scores):
|
||||
try:
|
||||
if float(score) < OCR_MIN_CONFIDENCE:
|
||||
continue
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
line = " ".join(str(raw or "").split())
|
||||
if not line:
|
||||
continue
|
||||
key = line.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
lines.append(line)
|
||||
|
||||
text = " ".join(lines)
|
||||
if len(text) > OCR_MAX_CHARS:
|
||||
cut = text.rfind(" ", 0, OCR_MAX_CHARS + 1)
|
||||
text = text[:cut if cut > 0 else OCR_MAX_CHARS].rstrip()
|
||||
return text or None
|
||||
|
||||
|
||||
def read_text(image_bytes: bytes) -> Optional[str]:
|
||||
"""The label text in `image_bytes`, or None (undecodable, nothing read, no engine).
|
||||
|
||||
The frame goes in as a BGR array - decoded by image_embedder.decode_bgr so
|
||||
the pixel cap, EXIF orientation and alpha handling are the same as the
|
||||
vector path's - and downscaled to OCR_MAX_SIDE_PX first. It is never
|
||||
handed to the engine as raw bytes: that would bypass both guards.
|
||||
"""
|
||||
bgr = image_embedder.decode_bgr(image_bytes)
|
||||
if bgr is None:
|
||||
return None
|
||||
try:
|
||||
bgr = _downscale(bgr, OCR_MAX_SIDE_PX)
|
||||
except Exception as exc: # noqa: BLE001 - a resize failure is "nothing read"
|
||||
logger.debug("ocr downscale failed: %s", exc)
|
||||
return None
|
||||
with _lock:
|
||||
if not _ensure_loaded():
|
||||
return None
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
output = _engine(bgr)
|
||||
except Exception as exc: # noqa: BLE001 - one bad photo must not poison the engine
|
||||
logger.debug("ocr failed: %s", exc)
|
||||
return None
|
||||
return _join_lines(output)
|
||||
|
||||
|
||||
def _reset() -> None:
|
||||
"""Tests only: forget the engine and the disabled state."""
|
||||
global _engine, _disabled_reason, _warned
|
||||
with _lock:
|
||||
_engine = None
|
||||
_disabled_reason = None
|
||||
_warned = False
|
||||
162
app/services/product_identify.py
Normal file
162
app/services/product_identify.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Identify the product in a phone photo: image vector first, label text second.
|
||||
|
||||
THE LADDER
|
||||
----------
|
||||
photo ─► img_vector kNN (image_match.search_by_vector)
|
||||
│ best score >= IMAGE_IDENTIFY_MIN_IMAGE_SCORE
|
||||
│ -> matched_by="image_vector", fallback_reason=None
|
||||
▼ else
|
||||
label text: the client's `text` (ocr_source="client"),
|
||||
else what ocr_service reads off the photo ("server")
|
||||
▼
|
||||
label_match.resolve_label
|
||||
│ confident -> matched_by="text", fallback_reason=<why the image lost>
|
||||
▼ else
|
||||
the image rows as they were (they may still be right - the 0.63
|
||||
in the feasibility test WAS the product), matched_by=
|
||||
"image_vector" or "none", fallback_reason="text_no_match"
|
||||
|
||||
`fallback_reason` is the reason for the LAST step the ladder took:
|
||||
|
||||
image_below_threshold the image arm ran but did not clear the floor
|
||||
no_image_match the image arm ran and found nothing at all
|
||||
image_embedder_unavailable no vector could be computed (no model here)
|
||||
no_text no label to fall back to and no photo to read
|
||||
ocr_unavailable no client text, and server OCR is off / missing
|
||||
ocr_empty server OCR ran and read nothing usable
|
||||
text_no_match the label was resolved but nothing was confident
|
||||
|
||||
The rule for a client: the answer is confirmed when `matched_by == "text"`,
|
||||
or `matched_by == "image_vector"` with no `fallback_reason`. Everything else
|
||||
is a best effort the client should show as such.
|
||||
|
||||
The two `score`s are in different spaces (1024-d image, 384-d text);
|
||||
`image_top_score` always carries the image side so the client can see both.
|
||||
Server OCR is invoked only when it is needed - never when the image is
|
||||
confident and never when the client sent text - because it costs a second.
|
||||
Read-only: every function this calls only reads.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from app.infrastructure.settings import (
|
||||
IMAGE_IDENTIFY_MIN_IMAGE_SCORE,
|
||||
IMAGE_SEARCH_DEFAULT_MIN_SCORE,
|
||||
IMAGE_SEARCH_DEFAULT_TOP_K,
|
||||
)
|
||||
from app.services import ocr_service
|
||||
from app.services.image_match import ImageSearchResult, search_by_vector
|
||||
from app.services.label_match import is_confident, resolve_label
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MATCHED_BY_IMAGE = "image_vector"
|
||||
MATCHED_BY_TEXT = "text"
|
||||
MATCHED_BY_NONE = "none"
|
||||
|
||||
|
||||
@dataclass
|
||||
class IdentifyResult:
|
||||
search: ImageSearchResult
|
||||
matched_by: str = MATCHED_BY_NONE
|
||||
ocr_text: Optional[str] = None
|
||||
ocr_source: Optional[str] = None # "client" | "server"
|
||||
image_top_score: Optional[float] = None
|
||||
fallback_reason: Optional[str] = None
|
||||
|
||||
|
||||
def _image_rung(
|
||||
vector: Optional[Sequence[float]],
|
||||
text: Optional[str],
|
||||
brand: Optional[str],
|
||||
category: Optional[str],
|
||||
top_k: int,
|
||||
min_score: float,
|
||||
min_image_score: float,
|
||||
) -> tuple[ImageSearchResult, Optional[float], Optional[str]]:
|
||||
"""(image result, its best score, why it is not the answer - or None)."""
|
||||
if vector is None:
|
||||
empty = ImageSearchResult(min_score=min_score, top_k=top_k, query_text=(text or "").strip() or None)
|
||||
return empty, None, "image_embedder_unavailable"
|
||||
result = search_by_vector(vector, text=text, brand=brand, category=category,
|
||||
top_k=top_k, min_score=min_score)
|
||||
if not result.rows:
|
||||
return result, None, "no_image_match"
|
||||
try:
|
||||
top = float(result.rows[0].get("score", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
top = 0.0
|
||||
if top >= min_image_score:
|
||||
return result, top, None
|
||||
return result, top, "image_below_threshold"
|
||||
|
||||
|
||||
def identify_product(
|
||||
*,
|
||||
vector: Optional[Sequence[float]],
|
||||
image_bytes: Optional[bytes],
|
||||
text: Optional[str] = None,
|
||||
brand: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
top_k: int = IMAGE_SEARCH_DEFAULT_TOP_K,
|
||||
min_score: float = IMAGE_SEARCH_DEFAULT_MIN_SCORE,
|
||||
min_image_score: float = IMAGE_IDENTIFY_MIN_IMAGE_SCORE,
|
||||
) -> IdentifyResult:
|
||||
"""Run the ladder. `vector` is the photo's embedding (None when this
|
||||
deployment cannot compute one); `image_bytes` the photo for server OCR
|
||||
(None on the vector-only route). Raises only InvalidVectorError, from
|
||||
search_by_vector, for a vector that cannot be searched with.
|
||||
"""
|
||||
label = (text or "").strip() or None
|
||||
|
||||
image, top, reason = _image_rung(vector, label, brand, category, top_k, min_score, min_image_score)
|
||||
if reason is None:
|
||||
return IdentifyResult(search=image, matched_by=MATCHED_BY_IMAGE, image_top_score=top,
|
||||
ocr_text=label, ocr_source="client" if label else None)
|
||||
|
||||
# --- the label ----------------------------------------------------------
|
||||
ocr_source: Optional[str] = None
|
||||
if label:
|
||||
ocr_source = "client"
|
||||
elif image_bytes:
|
||||
if ocr_service.available():
|
||||
label = ocr_service.read_text(image_bytes)
|
||||
if label:
|
||||
ocr_source = "server"
|
||||
else:
|
||||
reason = "ocr_empty"
|
||||
else:
|
||||
reason = "ocr_unavailable"
|
||||
else:
|
||||
reason = "no_text"
|
||||
|
||||
if not label:
|
||||
return IdentifyResult(
|
||||
search=image,
|
||||
matched_by=MATCHED_BY_IMAGE if image.rows else MATCHED_BY_NONE,
|
||||
image_top_score=top,
|
||||
fallback_reason=reason,
|
||||
)
|
||||
|
||||
resolved = resolve_label(label, brand=brand, category=category, top_k=top_k)
|
||||
if is_confident(resolved.rows):
|
||||
return IdentifyResult(
|
||||
search=resolved,
|
||||
matched_by=MATCHED_BY_TEXT,
|
||||
ocr_text=label,
|
||||
ocr_source=ocr_source,
|
||||
image_top_score=top,
|
||||
fallback_reason=reason, # why the image rung was not the answer
|
||||
)
|
||||
|
||||
return IdentifyResult(
|
||||
search=image,
|
||||
matched_by=MATCHED_BY_IMAGE if image.rows else MATCHED_BY_NONE,
|
||||
ocr_text=label,
|
||||
ocr_source=ocr_source,
|
||||
image_top_score=top,
|
||||
fallback_reason="text_no_match",
|
||||
)
|
||||
Reference in New Issue
Block a user