File size: 9,680 Bytes
b2d14a6 | 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 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | """Compatibility layer for the web studio (web/src/App.tsx): POST /api/compare with `reference` + `current`.
Returns the schema the studio already renders, computed by the CarPrint pipeline, plus extra CarPrint fields
(congruent_cells, zones, cohort_size, integrity). Errors use a plain-string `detail`, as the studio expects.
"""
from __future__ import annotations
import base64
import os
import time
from pathlib import Path
import cv2
import numpy as np
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response
from carprint.core.image_io import load_image
from carprint.identity.features import match
from carprint.identity.geometry import fit_zones
from carprint.integrity.hashing import DUPLICATE_MAX_DISTANCE, hamming, phash64, sha256_hex
router = APIRouter()
MAX_BYTES = 8 * 1024 * 1024
WEB_ROOT = Path(os.environ.get("CARPRINT_WEB", Path(__file__).resolve().parents[1] / "web"))
DECISION_EN = {
"SAME": "Same vehicle: individual marks agree in {cells} independent areas",
"DIFFERENT": "Different vehicle: the design aligns, but the reference's individual marks are absent",
"REVIEW": "Not enough individual evidence for an automatic decision",
"RECAPTURE_REQUIRED": "Photo quality is too low for a decision. Please retake",
}
QUALITY_EN = {
"blurry": "A photo is blurred. Hold the phone still and retake.",
"too_dark": "A photo is too dark. Use the flash or a torch.",
"overexposed": "A photo is overexposed. Change the angle.",
"glare": "Strong glare on the car. Move to remove the reflection.",
"car_too_small": "The car is too small in the frame. Move closer.",
"no_car": "No car was found in the frame.",
}
LIMITATIONS = ("CarPrint decides SAME only from individual marks (chips, curb rash, scratches) after discounting features "
"shared by every car of the same model. Identical new cars without marks stay REVIEW and need a VIN check. "
"Findings are evidence for a person, not an automatic charge or legal assessment.")
ALGORITHM = ("ALIKED + LightGlue, MAGSAC++ planar zones with a-contrario (ORSA) gate, same-model cohort "
"distinctiveness, congruent-cell voting")
def _data_url(rgb: np.ndarray, max_side: int = 1400) -> str:
h, w = rgb.shape[:2]
s = min(1.0, max_side / max(h, w))
if s < 1:
rgb = cv2.resize(rgb, (round(w * s), round(h * s)), interpolation=cv2.INTER_AREA)
ok, enc = cv2.imencode(".jpg", cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, 85])
return "data:image/jpeg;base64," + base64.b64encode(enc.tobytes()).decode()
def _read(upload: UploadFile) -> bytes:
data = upload.file.read(MAX_BYTES + 1)
if not data or len(data) > MAX_BYTES:
raise HTTPException(413, "Each image must be between 1 byte and 8 MB.")
if not (data.startswith(b"\xff\xd8\xff") or data.startswith(b"\x89PNG\r\n\x1a\n")
or (data.startswith(b"RIFF") and data[8:12] == b"WEBP")):
raise HTTPException(415, "Supported image formats are JPG, PNG and WebP.")
return data
def build_router(main):
"""`main` is api.main (passed in to avoid a circular import)."""
@router.get("/api/health")
def health():
h = main.health()
return {"status": "ok", "algorithm": ALGORITHM, "version": main.app.version, **h}
@router.post("/api/compare")
def compare(reference: UploadFile = File(...), current: UploadFile = File(...), model: str = Form("")):
t0 = time.perf_counter()
blobs = [_read(reference), _read(current)]
for b in blobs:
try:
img = load_image(b)
except Exception:
raise HTTPException(422, "Could not read the image. Please try a different file.")
if min(img.rgb.shape[:2]) < 96:
raise HTTPException(422, "The image is too small. Use at least 96 × 96 pixels.")
ins = []
for b in blobs:
i = main.Inspection(f"ins_{os.urandom(8).hex()}", f"veh_{os.urandom(12).hex()}", "owner", "compare", "",
main.now() + main.timedelta(hours=2), model.strip().lower())
main.INSPECTIONS[i.inspection_id] = i
main._ingest(i, b, "extra", "gallery")
ins.append(i)
res = main._verify(ins[0], ins[1])
ref, cur = ins[0].frames[0], ins[1].frames[0]
zones = [z for z in res["evidence"]]
best = max(zones, key=lambda z: z["inliers"], default=None)
verdict = res["decision"]
cells = max((z.get("congruent_cells", 0) for z in zones), default=0)
registered = best is not None and best["log10_nfa"] < main.REGISTRATION_NFA
rh, rw = ref.frame.rgb.shape[:2]
aligned, coverage, err, inliers, tentative = None, 0.0, None, 0, 0
zone = None
if registered:
v_zone = next((z for z in main.LAST_ZONES.get(res["verification_id"], []) if z.zone.n_inliers == best["inliers"]), None)
zone = v_zone.zone if v_zone is not None else None
elif not zones:
# Identity evidence may be empty (e.g. the second photo is an excluded copy); geometry is still shown.
with main.compute_lock:
m, _ = match(ref.frame.feats, cur.frame.feats)
if len(m) >= 10:
found = fit_zones(ref.frame.feats.keypoints[m[:, 0]], cur.frame.feats.keypoints[m[:, 1]], rw * rh)
zone = max(found, key=lambda z: z.n_inliers, default=None)
if zone is not None and zone.log10_nfa >= main.REGISTRATION_NFA:
zone = None
if zone is not None:
aligned = cv2.warpPerspective(cur.frame.rgb, np.linalg.inv(zone.H), (rw, rh))
x, y, w, h = zone.bbox0
coverage = round(100.0 * (w * h) / (rw * rh), 1)
err, inliers, tentative = round(zone.reproj_err_px, 2), zone.n_inliers, zone.n_tentative
registered = True
warnings = []
for f in (ref, cur):
warnings += [QUALITY_EN[i] for i in f.frame.quality.issues if i in QUALITY_EN]
copy = blobs[0] == blobs[1] or hamming(phash64(ref.frame.rgb), phash64(cur.frame.rgb)) <= DUPLICATE_MAX_DISTANCE
if copy:
warnings.append("The second photo is a copy or a transformed copy of the first (perceptual hash): it is "
"excluded from identity evidence. Alignment is shown for illustration only.")
if verdict == "REVIEW":
warnings.append("Photograph the same side of the car from a similar angle; include wheels and any marks.")
warnings.append("Matching shape alone never proves identity: features shared by the model are discounted.")
evidence_img = main.ARTIFACTS / res["evidence"][0]["match_viz_url"].rsplit("/", 1)[-1] if res["evidence"] else None
evidence = cv2.cvtColor(cv2.imread(str(evidence_img)), cv2.COLOR_BGR2RGB) if evidence_img and evidence_img.exists() \
else np.concatenate([cv2.resize(ref.frame.rgb, (480, 360)), cv2.resize(cur.frame.rgb, (480, 360))], axis=1)
return {
"decision": verdict,
"geometry": "MATCHED" if registered else "INSUFFICIENT",
"message": ("Copy detected: the second photo is not an independent capture" if copy
else DECISION_EN.get(verdict, verdict).format(cells=cells)),
"inliers": int(inliers),
"candidate_matches": int(tentative),
"reprojection_error_px": err,
"coverage_percent": coverage,
"elapsed_ms": round((time.perf_counter() - t0) * 1000),
"warnings": list(dict.fromkeys(warnings)),
"evidence_image": _data_url(evidence),
"reference_image": _data_url(ref.frame.rgb),
"aligned_image": _data_url(aligned) if aligned is not None else None,
"algorithm": ALGORITHM,
"algorithm_version": f"carprint-{main.app.version}",
"sha256": [sha256_hex(b) for b in blobs],
"limitations": LIMITATIONS,
# CarPrint extras
"congruent_cells": cells, "score_raw": res["score_raw"], "cohort_size": res["cohort_size"],
"zones": zones, "integrity": res["integrity"], "copy_detected": copy,
"damage_timeline": res["damage_timeline"],
"cost_estimate_new": res.get("cost_estimate_new"), "cost_estimate_current": res.get("cost_estimate_current"),
}
@router.get("/api/example/{view}")
def example(view: str):
if view not in ("reference", "current"):
raise HTTPException(404, "Photo not found.")
image = cv2.imread(str(WEB_ROOT / "public" / "images" / "car-studio.png"))
if image is None:
raise HTTPException(503, "Sample images are unavailable.")
image = image[300:850, 350:1500] # synthetic, disclosed sample (AI-generated studio image)
if view == "current":
h, w = image.shape[:2]
image = cv2.warpAffine(image, cv2.getRotationMatrix2D((w / 2, h / 2), 3, 0.96), (w, h),
borderMode=cv2.BORDER_REFLECT)
image = cv2.convertScaleAbs(image, alpha=0.92, beta=8)
ok, enc = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, 85])
return Response(enc.tobytes(), media_type="image/jpeg", headers={"Cache-Control": "public, max-age=3600"})
return router
|