noa-strupinsky/SIFT
0
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 )