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