File size: 5,563 Bytes
de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 de1e3fc a270696 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | """Public photo search (spec §9, §11) — no auth required.
A visitor uploads 1-6 photos of their dog and (optionally) a ZIP code and gets back the found/unknown
dogs already in the system ranked by photo similarity. This is a read-only convenience query: it
embeds each photo transiently (nothing is stored), scores the whole set against the active
found/unknown pool (max similarity over every query x candidate image pair — see
``_dog_level_score``), and returns the closest profiles with their photos. It does NOT create a
case — that is still the explicit "I lost a dog" / "I found a dog" flow.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
from sqlalchemy.orm import Session
from ..db import get_db
from ..models.base import SubjectType
from ..services import datasets as ds
from ..services.geo import get_geo
from ..services.images import ImageValidationError, embed_bytes, predict_breeds_bytes
from ..services.matching import search_knowns_by_vectors, search_unknowns_by_vectors
from .helpers import pictures_for, rate_limit
router = APIRouter(prefix="/search", tags=["search"])
# When a ZIP is supplied we scope to this radius (miles). Generous + fail-open on unknown ZIPs so a
# possible match is never silently hidden (recall-first, spec §9.4); empty ZIP = nationwide.
DEFAULT_SEARCH_RADIUS = 100
# Multiple query photos help (different angles/lighting) but there's no benefit past a handful.
MAX_QUERY_IMAGES = 6
@router.post("/by-photo")
def search_by_photo(
request: Request,
files: list[UploadFile] = File(...),
zip: str | None = Form(None),
top_k: int = Form(12),
pool: str = Form("found"),
db: Session = Depends(get_db),
) -> dict:
"""Rank a pool against 1-6 uploaded photos of the same dog. Optional ZIP scopes by proximity.
``pool`` selects which side to search:
- ``found`` (default): the found/unknown pool — for someone who LOST a dog.
- ``lost``: the known/lost pool — for someone who FOUND a dog and wants its owner.
"""
rate_limit(request, key_prefix="search")
pool = pool if pool in ("found", "lost") else "found"
query_vecs = []
for f in files[:MAX_QUERY_IMAGES]:
data = f.file.read()
try:
query_vecs.append(embed_bytes(data))
except ImageValidationError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
if not query_vecs:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No image provided")
event_zip = (zip or "").strip() or None
radius = DEFAULT_SEARCH_RADIUS if event_zip else -1
top_k = max(1, min(top_k, 50))
subject_type = SubjectType.unknown if pool == "found" else SubjectType.known
search_fn = search_unknowns_by_vectors if pool == "found" else search_knowns_by_vectors
ranked, considered = search_fn(
db, query_vecs, event_zip=event_zip, radius_miles=radius, top_k=top_k
)
geo = get_geo()
results: list[dict] = []
for dog_id, score in ranked:
profile = ds.dog_profile(db, subject_type, dog_id)
if profile is None:
continue
photos = [p.model_dump() for p in pictures_for(db, subject_type, dog_id)]
distance = (
geo.distance_miles(event_zip, profile.get("zip")) if event_zip else None
)
results.append(
{
"dog": profile,
"score": score,
"distance_miles": round(distance, 1) if distance is not None else None,
"photos": photos,
}
)
from ..ml import get_embedder
e = get_embedder()
return {
"results": results,
"model": f"{e.name}/{e.version}",
"candidate_count": considered,
"zip": event_zip,
"radius_miles": radius,
"pool": pool,
}
@router.post("/breed")
def estimate_breed(
request: Request,
files: list[UploadFile] = File(...),
top_n: int = Form(5),
db: Session = Depends(get_db),
) -> dict:
"""Estimate a dog's breed(s) from 1-6 photos. Returns the top ``top_n`` (1–10) labels.
A single photo is a weak signal: the same dog shot from a different angle can flip the top
breed entirely. So every supplied photo is classified and the per-label scores are AVERAGED
across them (labels outside a photo's top-K count as 0 for that photo, which penalises breeds
only one photo agrees on). Read-only and stores nothing; ``db`` is unused but kept in the
signature so the route stays consistent with the rest of the router.
"""
rate_limit(request, key_prefix="search")
top_n = max(1, min(top_n, 10))
totals: dict[str, float] = {}
n_images = 0
for f in files[:MAX_QUERY_IMAGES]:
try:
preds = predict_breeds_bytes(f.file.read())
except ImageValidationError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
for label, score in preds:
totals[label] = totals.get(label, 0.0) + score
n_images += 1
if n_images == 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No image provided")
ranked = sorted(((lbl, s / n_images) for lbl, s in totals.items()), key=lambda x: -x[1])
from ..ml import get_breed_classifier
c = get_breed_classifier()
breeds = [{"label": label, "score": score} for label, score in ranked[:top_n]]
return {"breeds": breeds, "model": f"{c.name}/{c.version}", "images": n_images}
|