CoolFace
Apppublic

noa-strupinsky/SIFT

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
app.py642 linesDownload Raw Back to root
1"""2SIFT - Full Inspection Pipeline3"""4 5import os, json, base64, re, tempfile, traceback6from pathlib import Path7 8import cv29import numpy as np10from scipy.spatial import KDTree11from collections import defaultdict12 13from fastapi import FastAPI, UploadFile, File14from fastapi.responses import HTMLResponse, JSONResponse15 16from ultralytics import YOLO17import anthropic18 19os.environ["YOLO_CONFIG_DIR"] = "/tmp/Ultralytics"20 21PADDING          = 1022GLOBAL_CAM_IDX   = 223CALIB_PATH       = "calibration"24GOOD_CROPS_DIR   = "reference_nuts"25DEFECT_CROPS_DIR = "defects"26 27 28FLIP_H_INDICES    = {0, 1, 7, 8}29ROTATE180_INDICES = {2}30 31ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC", "")32 33 34SYSTEM_PROMPT = """You are a quality control inspector for square weld nuts.35You will be shown reference GOOD nuts, then reference DEFECT nuts, then one or more views of the SAME nut to inspect.36 37IMPORTANT — CAMERA VIEWS:38You will receive views from multiple cameras. Cameras 0, 1, 7, and 8 produce dark, low-quality images — IGNORE these entirely. Only inspect views from cameras 2, 3, 4, 5, and 6.39 40STEP 1 — DISCARD CHECK (before anything else):41Check each usable view (cams 2,3,4,5,6). Mark as DISCARD and stop if ALL usable views have:42- Nut chopped off, edges cut, or only partially visible43- Too blurry or out of focus to judge surface44- Nut too small (less than a quarter of the image)45If at least one usable view is clear, skip DISCARD and go to Step 2.46Discarded nuts are excluded from the inspection result entirely.47 48STEP 2 — INSPECT (usable views only):49Compare the nut directly against the reference DEFECT images you were shown.50Only classify as DEFECT if the nut visibly resembles one of those reference defect examples — same type of physical damage, same kind of surface anomaly.51Do not classify as DEFECT based on lighting, shadows, colour, or anything not visible in the reference defects.52Classify as GOOD if the nut matches the good references and does not resemble any defect reference.53If you cannot clearly tell, classify as GOOD — only flag what you can confidently match to a known defect.54 55OUTPUT — single JSON, nothing else:56{"verdict": "good" or "defect" or "discard", "confidence": 0.0-1.0, "reason": "brief reason"}"""57 58 59_models: dict = {}60 61 62def get_models() -> dict:63    if "yolo" in _models and "claude" in _models:64        return _models65    import psutil66    print(f"[startup] RAM available: {psutil.virtual_memory().available / 1e9:.1f} GB")67    if "yolo" not in _models:68        print("[startup] Loading YOLO...")69        try:70            _models["yolo"] = YOLO("capstone_yolo26_v1.pt")71            print("[startup] YOLO loaded")72        except Exception as e:73            raise RuntimeError(f"Failed to load YOLO: {e}")74    if "claude" not in _models:75        print("[startup] Initialising Anthropic client...")76        if not ANTHROPIC_API_KEY:77            raise RuntimeError("ANTHROPIC env var not set")78        _models["claude"]       = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)79        ref_crops    = load_reference_crops(n=4)80        defect_crops = load_defect_crops(n=3)81        # Pre-encode reference images once — avoids re-encoding on every nut call82        _models["ref_b64"]    = [to_b64_png(img) for img in ref_crops]83        _models["defect_b64"] = [to_b64_png(img) for img in defect_crops]84        print(f"[startup] Claude ready — {len(_models['ref_b64'])} good refs, {len(_models['defect_b64'])} defect refs")85    print("[startup] All models ready")86    return _models87 88 89def load_reference_crops(n: int = 5) -> list:90    ref_dir = Path(GOOD_CROPS_DIR)91    if not ref_dir.exists():92        print(f"[warn] {GOOD_CROPS_DIR} not found")93        return []94    paths = sorted(ref_dir.glob("*.png")) + sorted(ref_dir.glob("*.jpg"))95    print(f"[refs] Good crops:")96    crops = []97    for p in paths[:n]:98        img = cv2.imread(str(p))99        if img is not None:100            crops.append(img)101            print(f"  {p.name}")102    return crops103 104 105def load_defect_crops(n: int = 8) -> list:106    ref_dir = Path(DEFECT_CROPS_DIR)107    if not ref_dir.exists():108        print(f"[warn] {DEFECT_CROPS_DIR} not found")109        return []110    paths = sorted(ref_dir.glob("*.png")) + sorted(ref_dir.glob("*.jpg"))111    print(f"[refs] Defect crops:")112    crops = []113    for p in paths[:n]:114        img = cv2.imread(str(p))115        if img is not None:116            crops.append(img)117            print(f"  {p.name}")118    return crops119 120 121def to_b64_png(img_bgr: np.ndarray, upsample: int = 224) -> str:122    h, w = img_bgr.shape[:2]123    scale = upsample / max(h, w)124    if scale > 1:125        img_bgr = cv2.resize(img_bgr, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_CUBIC)126    _, buf = cv2.imencode(".png", img_bgr)127    return base64.standard_b64encode(buf).decode("utf-8")128 129 130def build_nut_content(crops: list, ref_b64: list, defect_b64: list) -> list:131    """Build the message content list for one nut using pre-encoded reference b64 strings."""132    content = []133    for i, b64 in enumerate(ref_b64):134        content.append({"type": "text", "text": f"Reference GOOD nut {i+1}:"})135        content.append({"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": b64}})136    for i, b64 in enumerate(defect_b64):137        content.append({"type": "text", "text": f"Reference DEFECT nut {i+1} — this nut is defective:"})138        content.append({"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": b64}})139    content.append({"type": "text", "text": f"Now inspect this nut. You are seeing {len(crops)} camera view(s) of the SAME nut. Consider all views together before deciding."})140    for i, crop in enumerate(crops):141        content.append({"type": "text", "text": f"View {i+1}:"})142        content.append({"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": to_b64_png(crop)}})143    content.append({"type": "text", "text": 'Output a single JSON object with no text before or after it. Example: {"verdict": "good", "confidence": 0.95, "reason": "surfaces match references across both views"}'})144    return content145 146 147def parse_claude_response(raw: str) -> dict:148    match = re.search(r'\{.*?\}', raw, re.DOTALL)149    if match:150        try:151            return json.loads(match.group())152        except Exception:153            pass154    return {"verdict": "defect", "confidence": 0.0, "reason": "parse error: " + raw[:80]}155 156 157def ask_claude_batch(client, nut_contents: dict, max_workers: int = 3, on_progress=None) -> dict:158    """159    Submit all nuts to Claude in parallel using threads, with rate-limit handling.160    max_workers=3 keeps concurrent requests low enough to avoid 429s on the free tier.161    nut_contents: {global_id: content_list}162    Returns: {global_id: parsed_result_dict}163    """164    import concurrent.futures165    import time as _time166    import threading167 168    semaphore = threading.Semaphore(max_workers)169    # Stagger launches so workers don't all fire simultaneously and spike token usage170    launch_lock = threading.Lock()171    launch_counter = [0]172    STAGGER_DELAY = 1.5  # seconds between each worker starting its first request173 174    done_counter = [0]175    done_lock = _threading.Lock()176 177    def call_one(gid_content):178        gid, content = gid_content179        # Stagger the start of each worker180        with launch_lock:181            delay = launch_counter[0] * STAGGER_DELAY182            launch_counter[0] += 1183        if delay > 0:184            _time.sleep(delay)185 186        max_retries = 5187        for attempt in range(max_retries):188            with semaphore:189                try:190                    response = client.messages.create(191                        model="claude-opus-4-6",192                        max_tokens=200,193                        system=SYSTEM_PROMPT,194                        messages=[{"role": "user", "content": content}],195                    )196                    raw = response.content[0].text.strip()197                    result = parse_claude_response(raw)198                    with done_lock:199                        done_counter[0] += 1200                        if on_progress:201                            on_progress(done_counter[0], len(nut_contents))202                    return gid, result203                except Exception as e:204                    err_str = str(e)205                    if "rate_limit" in err_str or "429" in err_str:206                        wait = 20 * (attempt + 1)207                        print(f"[parallel] nut {gid} rate limited — waiting {wait}s (attempt {attempt+1}/{max_retries})")208                        _time.sleep(wait)209                    else:210                        print(f"[parallel] nut {gid} error: {e}")211                        return gid, {"verdict": "defect", "confidence": 0.0, "reason": f"api error: {e}"}212        print(f"[parallel] nut {gid} failed after {max_retries} retries")213        return gid, {"verdict": "defect", "confidence": 0.0, "reason": "rate limit — max retries exceeded"}214 215    print(f"[parallel] Submitting {len(nut_contents)} nuts to Claude (max_workers={max_workers}, stagger={STAGGER_DELAY}s)...")216    t0 = _time.time()217    results = {}218    with concurrent.futures.ThreadPoolExecutor(max_workers=len(nut_contents)) as executor:219        for gid, result in executor.map(call_one, nut_contents.items()):220            results[gid] = result221    print(f"[parallel] Done in {_time.time()-t0:.1f}s — {len(results)} results")222    return results223 224 225app = FastAPI()226 227# Pipeline phase — updated during run_pipeline so UI can poll progress228import threading as _threading229_pipeline_lock = _threading.Lock()230_pipeline_status = {"phase": "idle", "detail": "", "nuts_done": 0, "nuts_total": 0}231 232def _set_phase(phase: str, detail: str = "", nuts_done: int = 0, nuts_total: int = 0):233    with _pipeline_lock:234        _pipeline_status.update({"phase": phase, "detail": detail,235                                  "nuts_done": nuts_done, "nuts_total": nuts_total})236    print(f"[phase] {phase} — {detail}", flush=True)237 238 239def preprocess_image(path: str, cam_idx: int) -> np.ndarray:240    img = cv2.imread(path)241    label = Path(path).stem  # e.g. "cam_2"242    real_idx = int(label.split("_")[1])  # e.g. 2243    if real_idx in FLIP_H_INDICES:244        img = cv2.flip(img, 1)245    if real_idx in ROTATE180_INDICES:246        img = cv2.rotate(img, cv2.ROTATE_180)247    return img248 249 250def yolo_instance_centroids(result) -> np.ndarray:251    if result.masks is None:252        return np.empty((0, 2))253    cents = []254    for poly in result.masks.xy:255        poly = np.asarray(poly, dtype=np.float32)256        M = cv2.moments(poly)257        if M["m00"] != 0:258            cents.append([M["m10"] / M["m00"], M["m01"] / M["m00"]])259        else:260            cents.append(poly.mean(axis=0).tolist())261    return np.vstack(cents) if cents else np.empty((0, 2))262 263 264def undistort_points(pts, K, dist, K_new):265    if len(pts) == 0:266        return pts267    pts_ud = cv2.undistortPoints(pts.reshape(-1, 1, 2).astype(np.float32), K, dist, P=K_new)268    return pts_ud.reshape(-1, 2)269 270 271def match_one_to_one(pts_global, pts_proj, threshold=40.0):272    if len(pts_global) == 0 or len(pts_proj) == 0:273        return []274    tree = KDTree(pts_global)275    dists, indices = tree.query(pts_proj, k=1)276    claims = defaultdict(list)277    for cam_idx, (d, g_idx) in enumerate(zip(dists, indices)):278        if d < threshold:279            claims[g_idx].append((d, cam_idx))280    matches = []281    for g_idx, claimants in claims.items():282        best = min(claimants, key=lambda x: x[0])[1]283        matches.append((best, g_idx))284    return matches285 286def build_cross_view_map(results, calib_path, labels):287    pts_global_dist = yolo_instance_centroids(results[GLOBAL_CAM_IDX])288    rg   = int(labels[GLOBAL_CAM_IDX].split("_")[1])289    K_g  = np.load(f"{calib_path}/cam{rg}/K.npy")290    d_g  = np.load(f"{calib_path}/cam{rg}/dist.npy")291    Kn_g = np.load(f"{calib_path}/cam{rg}/K_new.npy")292    pts_global = undistort_points(pts_global_dist, K_g, d_g, Kn_g)293    all_matches = {}294    for cam_idx in range(len(results)):295        if cam_idx == GLOBAL_CAM_IDX:296            continue297        pts_dist = yolo_instance_centroids(results[cam_idx])298        if len(pts_dist) == 0:299            all_matches[cam_idx] = []300            continue301        rc   = int(labels[cam_idx].split("_")[1])302        K    = np.load(f"{calib_path}/cam{rc}/K.npy")303        dist = np.load(f"{calib_path}/cam{rc}/dist.npy")304        Kn   = np.load(f"{calib_path}/cam{rc}/K_new.npy")305        H    = np.load(f"{calib_path}/cam{rc}/H.npy")306        pts_undist = undistort_points(pts_dist, K, dist, Kn)307        pts_proj   = cv2.perspectiveTransform(pts_undist.reshape(-1, 1, 2).astype(np.float32), H).reshape(-1, 2)308        all_matches[cam_idx] = match_one_to_one(pts_global, pts_proj)309    return all_matches, pts_global_dist310 311CANVAS_SIZE = 256  # fixed canvas size matching notebook312 313def extract_instance_crop(img, poly):314    """Extract nut crop on a fixed 256x256 white canvas — matches notebook exactly."""315    poly_arr = np.asarray(poly, dtype=np.int32)316    x, y, w, h = cv2.boundingRect(poly_arr)317    x0 = max(0, x - PADDING); y0 = max(0, y - PADDING)318    x1 = min(img.shape[1], x + w + PADDING)319    y1 = min(img.shape[0], y + h + PADDING)320    crop = img[y0:y1, x0:x1]321    if crop.size == 0:322        return None323    # Mask — rasterize at crop size, not full image324    shifted   = poly_arr - [x0, y0]325    mask_crop = np.zeros(crop.shape[:2], dtype=np.uint8)326    cv2.fillPoly(mask_crop, [shifted], 255)327    white_bg = np.full_like(crop, 255)328    isolated = np.where(mask_crop[:, :, None] > 0, crop, white_bg)329    # Place centred on fixed canvas330    canvas = np.full((CANVAS_SIZE, CANVAS_SIZE, 3), 255, dtype=np.uint8)331    h_i, w_i = isolated.shape[:2]332    if max(h_i, w_i) > CANVAS_SIZE - 20:333        scale    = (CANVAS_SIZE - 20) / max(h_i, w_i)334        isolated = cv2.resize(isolated, (int(w_i * scale), int(h_i * scale)))335        h_i, w_i = isolated.shape[:2]336    ox = (CANVAS_SIZE - w_i) // 2337    oy = (CANVAS_SIZE - h_i) // 2338    canvas[oy:oy + h_i, ox:ox + w_i] = isolated339    return canvas340 341 342def render_global_image(img_global, results_global, nut_results):343    vis = img_global.copy()344    if results_global.masks is None:345        return vis346    fused = {r["nut_id"]: r for r in nut_results}347    for global_id, poly in enumerate(results_global.masks.xy):348        r = fused.get(global_id)349        verdict = r["verdict"] if r else "good"350        if verdict == "defect":351            cv2.polylines(vis, [poly.astype(np.int32)], isClosed=True, color=(0, 0, 220), thickness=3)352    return vis353 354 355def render_camera_image(img, results_cam, cam_idx, global_to_cams, nut_results):356    vis = img.copy()357    if results_cam.masks is None:358        return vis359    fused = {r["nut_id"]: r for r in nut_results}360    local_to_global = {}361    for global_id, cam_map in global_to_cams.items():362        if cam_idx in cam_map:363            local_to_global[cam_map[cam_idx]] = global_id364    for local_idx, poly in enumerate(results_cam.masks.xy):365        global_id = local_to_global.get(local_idx)366        if global_id is None:367            continue368        r = fused.get(global_id)369        if r and r["verdict"] == "defect":370            cv2.polylines(vis, [poly.astype(np.int32)], isClosed=True, color=(0, 0, 220), thickness=3)371    return vis372 373 374def img_to_b64(img_bgr):375    _, buf = cv2.imencode(".png", img_bgr)376    return base64.b64encode(buf).decode()377 378 379def poly_centroid(poly):380    poly = np.asarray(poly, dtype=np.float32)381    M = cv2.moments(poly)382    if M["m00"] != 0:383        return int(M["m10"] / M["m00"]), int(M["m01"] / M["m00"])384    return int(poly[:, 0].mean()), int(poly[:, 1].mean())385 386 387def run_pipeline(image_paths: list):388    m           = get_models()389    yolo        = m["yolo"]390    client      = m["claude"]391    ref_b64     = m["ref_b64"]392    defect_b64  = m["defect_b64"]393 394    imgs    = [preprocess_image(p, i) for i, p in enumerate(image_paths)]395    labels  = [Path(p).stem for p in image_paths]396    _set_phase("yolo", "Running YOLO segmentation...")397    results = yolo(imgs, save=False, verbose=False, conf=0.25, iou=0.3)398 399    print(f"[debug] {len(image_paths)} images received:")400    for i, p in enumerate(image_paths):401        print(f"  slot {i} → {Path(p).name}")402 403    for i, r in enumerate(results):404        n = len(r.masks.xy) if r.masks else 0405        print(f"[debug] cam_idx={i} ({labels[i]}) → {n} nuts detected by YOLO")406 407    all_matches, _ = build_cross_view_map(results, CALIB_PATH, labels)408    _set_phase("matching", "Cross-view matching complete")409 410    n_global = len(results[GLOBAL_CAM_IDX].masks.xy) if results[GLOBAL_CAM_IDX].masks else 0411    print(f"[debug] n_global ({labels[GLOBAL_CAM_IDX]}) detections = {n_global}")412 413    global_to_cams = {gid: {GLOBAL_CAM_IDX: gid} for gid in range(n_global)}414    for cam_idx, matches in all_matches.items():415        print(f"[debug] cam_idx={cam_idx} ({labels[cam_idx]}) matched {len(matches)} nuts to global")416        for local_idx, global_id in matches:417            if global_id in global_to_cams:418                global_to_cams[global_id][cam_idx] = local_idx419 420    print(f"[debug] per-nut camera coverage (excluding global):")421    nuts_with_views = 0422    nuts_skipped    = 0423    for gid, cam_map in global_to_cams.items():424        non_global = [c for c in cam_map if c != GLOBAL_CAM_IDX]425        if non_global:426            nuts_with_views += 1427            print(f"  nut {gid:>3} → cameras {non_global}")428        else:429            nuts_skipped += 1430            print(f"  nut {gid:>3} → NO non-global views — will be skipped")431    print(f"[debug] {nuts_with_views} nuts will be sent to Claude, {nuts_skipped} will be skipped")432 433    nut_results = []434 435    # ── Phase 1: gather all crops ─────────────────────────────────────────────436    # Build crops for every nut first, then submit all to Claude in one batch.437    nut_data = {}  # global_id -> {crops, cam_crops_b64, cx_rel, cy_rel}438 439    for global_id in range(n_global):440        cam_map = global_to_cams.get(global_id, {})441        crops_for_nut = []442        cam_crops_b64 = {}443 444        for cam_idx in sorted(cam_map.keys()):445            if cam_idx == GLOBAL_CAM_IDX:446                continue447            local_idx = cam_map[cam_idx]448            res = results[cam_idx]449            if res.masks is None or local_idx >= len(res.masks.xy):450                print(f"[debug] nut {global_id} cam_idx={cam_idx} — no mask found (local_idx={local_idx})")451                continue452            poly = res.masks.xy[local_idx]453            crop = extract_instance_crop(imgs[cam_idx], poly)454            if crop is None or crop.size == 0:455                print(f"[debug] nut {global_id} cam_idx={cam_idx} — empty crop, skipping")456                continue457            crops_for_nut.append(crop)458            cam_crops_b64[cam_idx] = img_to_b64(crop)459            print(f"[debug] nut {global_id} cam_idx={cam_idx} — crop extracted {crop.shape}")460 461        if not crops_for_nut:462            print(f"[debug] nut {global_id} — no crops, skipping Claude")463            continue464 465        cx, cy = 0, 0466        if results[GLOBAL_CAM_IDX].masks and global_id < len(results[GLOBAL_CAM_IDX].masks.xy):467            cx, cy = poly_centroid(results[GLOBAL_CAM_IDX].masks.xy[global_id])468        gh, gw = imgs[GLOBAL_CAM_IDX].shape[:2]469 470        nut_data[global_id] = {471            "crops":         crops_for_nut,472            "cam_crops_b64": cam_crops_b64,473            "cam_map":       cam_map,474            "cx_rel":        round(cx / gw, 4),475            "cy_rel":        round(cy / gh, 4),476        }477 478    print(f"[batch] {len(nut_data)} nuts ready for Claude inspection")479    _set_phase("claude", f"Sending {len(nut_data)} nuts to Claude...", nuts_total=len(nut_data))480 481    # ── Phase 2: submit all nuts to Claude Batch API in one shot ──────────────482    nut_contents = {483        gid: build_nut_content(d["crops"], ref_b64, defect_b64)484        for gid, d in nut_data.items()485    }486    batch_results = ask_claude_batch(487        client, nut_contents,488        on_progress=lambda done, total: _set_phase(489            "claude", f"Claude Vision — {done}/{total} nuts done", nuts_done=done, nuts_total=total490        )491    )492 493    # ── Phase 3: assemble final results ──────────────────────────────────────494    for global_id, d in nut_data.items():495        r          = batch_results.get(global_id, {"verdict": "defect", "confidence": 0.0, "reason": "missing batch result"})496        verdict    = r.get("verdict", "defect")497        confidence = float(r.get("confidence", 0.0))498        reason     = r.get("reason", "")499        print(f"[claude] nut {global_id} → {verdict} ({confidence:.2f}) — {reason}")500 501        nut_results.append({502            "nut_id":     global_id,503            "verdict":    verdict,504            "confidence": confidence,505            "reason":     reason,506            "is_defect":  verdict == "defect",507            "cam_crops":  d["cam_crops_b64"],508            "cx_rel":     d["cx_rel"],509            "cy_rel":     d["cy_rel"],510            "cam_map":    {str(k): v for k, v in d["cam_map"].items()},511        })512 513    global_annotated = render_global_image(imgs[GLOBAL_CAM_IDX], results[GLOBAL_CAM_IDX], nut_results)514 515    # Auto-crop dark/white borders to focus on the tray area516    # Convert to grayscale, threshold to find content region517    gray = cv2.cvtColor(global_annotated, cv2.COLOR_BGR2GRAY)518    # Pixels brighter than 10 are "content" (not black border)519    _, mask = cv2.threshold(gray, 10, 255, cv2.THRESH_BINARY)520    coords = cv2.findNonZero(mask)521    if coords is not None:522        x_c, y_c, w_c, h_c = cv2.boundingRect(coords)523        # Add small padding524        pad = 20525        x0 = max(0, x_c - pad)526        y0 = max(0, y_c - pad)527        x1 = min(global_annotated.shape[1], x_c + w_c + pad)528        y1 = min(global_annotated.shape[0], y_c + h_c + pad)529        global_annotated = global_annotated[y0:y1, x0:x1]530        crop_h, crop_w = global_annotated.shape[:2]531        orig_h, orig_w = imgs[GLOBAL_CAM_IDX].shape[:2]532    else:533        x0, y0 = 0, 0534        crop_w, crop_h = global_annotated.shape[1], global_annotated.shape[0]535        orig_h, orig_w = imgs[GLOBAL_CAM_IDX].shape[:2]536 537    # Rotate 90° counter-clockwise538    global_annotated = cv2.rotate(global_annotated, cv2.ROTATE_90_COUNTERCLOCKWISE)539    global_b64 = img_to_b64(global_annotated)540 541    # Transform hotspot coordinates to match crop + CCW rotation:542    # 1. Crop offset: pixel coords in cropped image = (orig_px - x0, orig_py - y0)543    # 2. Normalise to cropped dims544    # 3. CCW rotation: new_cx_rel = old_cy_rel_cropped, new_cy_rel = 1 - old_cx_rel_cropped545    for r in nut_results:546        # Convert back from normalised-original to pixel547        px = r["cx_rel"] * orig_w548        py = r["cy_rel"] * orig_h549        # Apply crop offset and normalise to cropped size550        cx_cropped = (px - x0) / crop_w551        cy_cropped = (py - y0) / crop_h552        # Clamp to [0,1]553        cx_cropped = max(0.0, min(1.0, cx_cropped))554        cy_cropped = max(0.0, min(1.0, cy_cropped))555        # Apply CCW rotation556        r["cx_rel"] = round(cy_cropped, 4)557        r["cy_rel"] = round(1.0 - cx_cropped, 4)558 559    cam_overviews = []560    for cam_idx, img in enumerate(imgs):561        if cam_idx == GLOBAL_CAM_IDX:562            cam_overviews.append({"label": labels[cam_idx], "b64": global_b64})563        else:564            cam_vis = render_camera_image(img, results[cam_idx], cam_idx, global_to_cams, nut_results)565            cam_overviews.append({"label": labels[cam_idx], "b64": img_to_b64(cam_vis)})566 567    n_defects = sum(1 for r in nut_results if r["verdict"] == "defect")568 569    print(f"[debug] pipeline complete — {len(nut_results)} nuts processed, {n_defects} defects")570    _set_phase("done", f"{len(nut_results)} nuts inspected, {n_defects} defects",571               nuts_done=len(nut_results), nuts_total=len(nut_results))572 573    return {574        "nut_results":      nut_results,575        "global_image_b64": global_b64,576        "cam_overviews":    cam_overviews,577        "n_nuts":           len(nut_results),578        "n_defects":        n_defects,579        "global_cam_label": labels[GLOBAL_CAM_IDX],580        "img_width":        imgs[GLOBAL_CAM_IDX].shape[1],581        "img_height":       imgs[GLOBAL_CAM_IDX].shape[0],582    }583 584 585@app.get("/health")586def health():587    return {"status": "ok"}588 589 590@app.get("/pipeline-status")591def pipeline_status():592    with _pipeline_lock:593        return dict(_pipeline_status)594 595 596@app.post("/inspect")597async def inspect(images: list[UploadFile] = File(...)):598    tmp_dir   = tempfile.mkdtemp()599    img_paths = []600    for f in images:601        dest = os.path.join(tmp_dir, Path(f.filename).name)602        with open(dest, "wb") as fh:603            fh.write(await f.read())604        img_paths.append(dest)605    img_paths.sort(key=lambda p: Path(p).name)606 607    import asyncio608    loop = asyncio.get_event_loop()609 610    try:611        # Run pipeline in a thread so the event loop stays free to serve612        # /pipeline-status polls while inference is running613        data = await loop.run_in_executor(None, run_pipeline, img_paths)614    except RuntimeError as e:615        _models.clear()616        return JSONResponse({"error": str(e), "traceback": traceback.format_exc()}, status_code=500)617    except Exception:618        return JSONResponse({"error": traceback.format_exc()}, status_code=500)619 620    for r in data["nut_results"]:621        r["cam_crops"] = {str(k): v for k, v in r["cam_crops"].items()}622 623    return JSONResponse(data)624 625 626@app.get("/", response_class=HTMLResponse)627def root():628    return """<html><body style="background:#0f0f0f;color:#eee;font-family:sans-serif;padding:40px">629    <h1 style="color:#ff6b35">SIFT Inspection API</h1>630    <p>POST images to <code>/inspect</code>. Use the operator UI (index.html) to run inspections.</p>631    </body></html>"""632 633 634if __name__ == "__main__":635    import uvicorn636    uvicorn.run(637        app,638        host="0.0.0.0",639        port=7860,640        timeout_keep_alive=600,   # keep connection alive for 10 min (was 300)641        h11_max_incomplete_event_size=1024*1024*50,  # 50MB — handles large image uploads642    )