summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--poc_eval_notebook.ipynb436
1 files changed, 381 insertions, 55 deletions
diff --git a/poc_eval_notebook.ipynb b/poc_eval_notebook.ipynb
index 55f337f..5865f7e 100644
--- a/poc_eval_notebook.ipynb
+++ b/poc_eval_notebook.ipynb
@@ -49,7 +49,7 @@
"import matplotlib.patches as patches\n",
"from PIL import Image\n",
"\n",
- "from transformers import AutoModel, AutoProcessor\n",
+ "from transformers import AutoImageProcessor, AutoModel, AutoProcessor\n",
"\n",
"from huggingface_hub import whoami\n",
"from sam3.model_builder import build_sam3_image_model, download_ckpt_from_hf\n",
@@ -62,6 +62,18 @@
"SAM3_POINT_HINT_BOX_RADIUS_PX = 12\n",
"SAM3_MAX_POINT_HINTS = 4\n",
"SAM3_TORCH_DTYPE = torch.bfloat16 if DEVICE == \"cuda\" else torch.float32\n",
+ "DINO_MODEL_ID = \"facebook/dinov3-vitb16-pretrain-lvd1689m\"\n",
+ "SIGCLIP_FUSION_WEIGHT = 0.60\n",
+ "DINO_FUSION_WEIGHT = 0.40\n",
+ "DINO_SIM_TEMPERATURE = 0.10\n",
+ "LOW_CONF_THRESHOLD = 0.50\n",
+ "MIXED_MARGIN_THRESHOLD = 0.15\n",
+ "MAX_MIXED_ENTRIES = 3\n",
+ "MIN_MIXED_PERCENT = 10\n",
+ "MATERIAL_PROTOTYPE_IMAGE_PATHS = {\n",
+ " # \"wood\": [\"/content/prototypes/wood_01.jpg\", \"/content/prototypes/wood_02.jpg\"],\n",
+ " # \"steel\": [\"/content/prototypes/steel_01.jpg\"],\n",
+ "}\n",
"SIGCLIP_MODEL_ID = \"google/siglip-base-patch16-224\"\n",
"\n",
"CSV_PATH = Path(\"/content/poc_eval_sheet.csv\")\n",
@@ -918,10 +930,244 @@
" \"pred_material_conf_top1\": float(top_scores[0]),\n",
" \"topk_labels\": top_labels,\n",
" \"topk_scores\": [float(s) for s in top_scores],\n",
+ " \"per_material_probs\": {k: float(v) for k, v in agg_scores.items()},\n",
" \"candidate_materials\": candidate_materials,\n",
" }\n",
"\n",
"\n",
+ "def l2_normalize_np(vec: np.ndarray, eps: float = 1e-12):\n",
+ " n = float(np.linalg.norm(vec))\n",
+ " if n < eps:\n",
+ " return vec\n",
+ " return vec / n\n",
+ "\n",
+ "\n",
+ "def extract_dinov3_embedding(image: Image.Image, dino_model, dino_processor):\n",
+ " inputs = dino_processor(images=image, return_tensors=\"pt\")\n",
+ " inputs = {k: v.to(DEVICE) if hasattr(v, \"to\") else v for k, v in inputs.items()}\n",
+ " with torch.inference_mode():\n",
+ " outputs = dino_model(**inputs)\n",
+ " emb = outputs.pooler_output[0].detach().float().cpu().numpy()\n",
+ " return l2_normalize_np(emb)\n",
+ "\n",
+ "\n",
+ "def build_dino_prototype_bank(material_image_paths: dict, dino_model, dino_processor):\n",
+ " bank = {}\n",
+ " missing = []\n",
+ " for material in ALL_CANONICAL_MATERIALS:\n",
+ " paths = material_image_paths.get(material, [])\n",
+ " embs = []\n",
+ " for p in paths:\n",
+ " if not Path(p).exists():\n",
+ " missing.append(p)\n",
+ " continue\n",
+ " img = load_image(str(p))\n",
+ " embs.append(extract_dinov3_embedding(img, dino_model, dino_processor))\n",
+ " if embs:\n",
+ " bank[material] = l2_normalize_np(np.mean(np.stack(embs, axis=0), axis=0))\n",
+ " return bank, missing\n",
+ "\n",
+ "\n",
+ "def normalize_prob_dict(score_dict: dict, keys: list):\n",
+ " vals = np.array([float(score_dict.get(k, 0.0)) for k in keys], dtype=np.float64)\n",
+ " vals = np.clip(vals, a_min=0.0, a_max=None)\n",
+ " s = float(vals.sum())\n",
+ " if s <= 0:\n",
+ " vals = np.ones_like(vals) / max(len(vals), 1)\n",
+ " else:\n",
+ " vals = vals / s\n",
+ " return {k: float(v) for k, v in zip(keys, vals)}\n",
+ "\n",
+ "\n",
+ "def classify_material_dinov3(\n",
+ " image_views: list,\n",
+ " object_label: str | None,\n",
+ " dino_model,\n",
+ " dino_processor,\n",
+ " prototype_bank: dict,\n",
+ " top_k: int = 3,\n",
+ " view_weights: list | None = None,\n",
+ "):\n",
+ " candidate_materials = resolve_candidate_materials(object_label)\n",
+ "\n",
+ " if view_weights is None:\n",
+ " view_weights = [1.0 / len(image_views)] * len(image_views)\n",
+ " if len(view_weights) != len(image_views):\n",
+ " raise ValueError(\"view_weights length must match image_views length\")\n",
+ "\n",
+ " proto_materials = [m for m in candidate_materials if m in prototype_bank]\n",
+ " prototype_coverage = float(len(proto_materials) / max(len(candidate_materials), 1))\n",
+ "\n",
+ " if len(proto_materials) < 2:\n",
+ " probs = {m: 1.0 / len(candidate_materials) for m in candidate_materials}\n",
+ " ranked = sorted(probs.items(), key=lambda kv: kv[1], reverse=True)\n",
+ " top_items = ranked[: min(top_k, len(ranked))]\n",
+ " return {\n",
+ " \"pred_material_top1\": top_items[0][0],\n",
+ " \"pred_material_top3\": \"|\".join([x[0] for x in top_items]),\n",
+ " \"pred_material_conf_top1\": float(top_items[0][1]),\n",
+ " \"topk_labels\": [x[0] for x in top_items],\n",
+ " \"topk_scores\": [float(x[1]) for x in top_items],\n",
+ " \"per_material_probs\": probs,\n",
+ " \"candidate_materials\": candidate_materials,\n",
+ " \"prototype_coverage\": prototype_coverage,\n",
+ " \"prototypes_available\": False,\n",
+ " }\n",
+ "\n",
+ " agg = {m: 0.0 for m in candidate_materials}\n",
+ " for image, weight in zip(image_views, view_weights):\n",
+ " emb = extract_dinov3_embedding(image, dino_model, dino_processor)\n",
+ " sims = []\n",
+ " for m in candidate_materials:\n",
+ " if m in prototype_bank:\n",
+ " sims.append(float(np.dot(emb, prototype_bank[m])))\n",
+ " else:\n",
+ " sims.append(-1e3)\n",
+ " sims = np.array(sims, dtype=np.float64)\n",
+ " probs = np.exp((sims - np.max(sims)) / max(DINO_SIM_TEMPERATURE, 1e-6))\n",
+ " probs = probs / max(probs.sum(), 1e-12)\n",
+ " for m, p in zip(candidate_materials, probs.tolist()):\n",
+ " agg[m] += float(weight) * float(p)\n",
+ "\n",
+ " agg = normalize_prob_dict(agg, candidate_materials)\n",
+ " ranked = sorted(agg.items(), key=lambda kv: kv[1], reverse=True)\n",
+ " top_items = ranked[: min(top_k, len(ranked))]\n",
+ "\n",
+ " return {\n",
+ " \"pred_material_top1\": top_items[0][0],\n",
+ " \"pred_material_top3\": \"|\".join([x[0] for x in top_items]),\n",
+ " \"pred_material_conf_top1\": float(top_items[0][1]),\n",
+ " \"topk_labels\": [x[0] for x in top_items],\n",
+ " \"topk_scores\": [float(x[1]) for x in top_items],\n",
+ " \"per_material_probs\": agg,\n",
+ " \"candidate_materials\": candidate_materials,\n",
+ " \"prototype_coverage\": prototype_coverage,\n",
+ " \"prototypes_available\": True,\n",
+ " }\n",
+ "\n",
+ "\n",
+ "def fuse_material_predictions(sigclip_out: dict, dino_out: dict):\n",
+ " mats = sorted(set(sigclip_out[\"candidate_materials\"]) | set(dino_out[\"candidate_materials\"]))\n",
+ " p_sig = normalize_prob_dict(sigclip_out.get(\"per_material_probs\", {}), mats)\n",
+ " p_dino = normalize_prob_dict(dino_out.get(\"per_material_probs\", {}), mats)\n",
+ "\n",
+ " w_sig = float(SIGCLIP_FUSION_WEIGHT)\n",
+ " w_dino = float(DINO_FUSION_WEIGHT) if dino_out.get(\"prototypes_available\", False) else 0.0\n",
+ " if w_sig + w_dino <= 0:\n",
+ " w_sig = 1.0\n",
+ " w_sum = w_sig + w_dino\n",
+ " w_sig, w_dino = w_sig / w_sum, w_dino / w_sum\n",
+ "\n",
+ " fused = {m: w_sig * p_sig[m] + w_dino * p_dino[m] for m in mats}\n",
+ " fused = normalize_prob_dict(fused, mats)\n",
+ "\n",
+ " ranked = sorted(fused.items(), key=lambda kv: kv[1], reverse=True)\n",
+ " top3 = ranked[: min(3, len(ranked))]\n",
+ "\n",
+ " sig_top1 = max(p_sig.items(), key=lambda kv: kv[1])[0]\n",
+ " dino_top1 = max(p_dino.items(), key=lambda kv: kv[1])[0]\n",
+ "\n",
+ " return {\n",
+ " \"pred_material_top1\": ranked[0][0],\n",
+ " \"pred_material_top3\": \"|\".join([x[0] for x in top3]),\n",
+ " \"pred_material_conf_top1\": float(ranked[0][1]),\n",
+ " \"topk_labels\": [x[0] for x in top3],\n",
+ " \"topk_scores\": [float(x[1]) for x in top3],\n",
+ " \"per_material_probs\": fused,\n",
+ " \"candidate_materials\": mats,\n",
+ " \"weights\": {\"sigclip\": w_sig, \"dinov3\": w_dino},\n",
+ " \"disagreement\": {\n",
+ " \"top1_match\": bool(sig_top1 == dino_top1),\n",
+ " \"sigclip_top1\": sig_top1,\n",
+ " \"dinov3_top1\": dino_top1,\n",
+ " },\n",
+ " }\n",
+ "\n",
+ "\n",
+ "def build_mixed_materials(prob_dict: dict):\n",
+ " ranked = sorted(prob_dict.items(), key=lambda kv: kv[1], reverse=True)\n",
+ " selected = ranked[: max(2, MAX_MIXED_ENTRIES)]\n",
+ " vals = np.array([max(0.0, float(v)) for _, v in selected], dtype=np.float64)\n",
+ " vals = vals / max(vals.sum(), 1e-12)\n",
+ "\n",
+ " pairs = []\n",
+ " for (m, _), p in zip(selected, vals.tolist()):\n",
+ " pairs.append({\"material\": m, \"percentage\": int(round(100 * p))})\n",
+ "\n",
+ " pairs = [x for x in pairs if x[\"percentage\"] >= MIN_MIXED_PERCENT]\n",
+ " if len(pairs) < 2:\n",
+ " return []\n",
+ "\n",
+ " total = sum(x[\"percentage\"] for x in pairs)\n",
+ " if total <= 0:\n",
+ " return []\n",
+ " for x in pairs:\n",
+ " x[\"percentage\"] = int(round(100 * x[\"percentage\"] / total))\n",
+ " diff = 100 - sum(x[\"percentage\"] for x in pairs)\n",
+ " pairs[-1][\"percentage\"] += diff\n",
+ "\n",
+ " pairs = sorted(pairs, key=lambda x: x[\"percentage\"], reverse=True)[:MAX_MIXED_ENTRIES]\n",
+ " total2 = sum(x[\"percentage\"] for x in pairs)\n",
+ " if total2 != 100:\n",
+ " pairs[-1][\"percentage\"] += 100 - total2\n",
+ " return pairs\n",
+ "\n",
+ "\n",
+ "def suggest_material_decision(fused_out: dict):\n",
+ " probs = fused_out[\"per_material_probs\"]\n",
+ " ranked = sorted(probs.items(), key=lambda kv: kv[1], reverse=True)\n",
+ " top1_mat, top1_conf = ranked[0]\n",
+ " top2_conf = ranked[1][1] if len(ranked) > 1 else 0.0\n",
+ " margin12 = float(top1_conf - top2_conf)\n",
+ "\n",
+ " low_conf = float(top1_conf) < float(LOW_CONF_THRESHOLD)\n",
+ " mixed_suggestion = build_mixed_materials(probs)\n",
+ "\n",
+ " if low_conf:\n",
+ " suggested_mode = \"unknown\"\n",
+ " elif margin12 < float(MIXED_MARGIN_THRESHOLD) and len(mixed_suggestion) >= 2:\n",
+ " suggested_mode = \"mixed\"\n",
+ " else:\n",
+ " suggested_mode = \"single\"\n",
+ "\n",
+ " return {\n",
+ " \"suggested_mode\": suggested_mode,\n",
+ " \"suggested_single_material\": top1_mat if suggested_mode == \"single\" else None,\n",
+ " \"suggested_mixed_materials\": mixed_suggestion if suggested_mode == \"mixed\" else [],\n",
+ " \"low_confidence_flag\": bool(low_conf),\n",
+ " \"requires_user_confirmation\": bool(low_conf),\n",
+ " \"margin12\": margin12,\n",
+ " }\n",
+ "\n",
+ "\n",
+ "def validate_mixed_materials(mixed_materials):\n",
+ " if not isinstance(mixed_materials, list):\n",
+ " return False, \"mixed_materials must be a list\"\n",
+ " if len(mixed_materials) < 2 or len(mixed_materials) > MAX_MIXED_ENTRIES:\n",
+ " return False, f\"mixed_materials must have 2..{MAX_MIXED_ENTRIES} entries\"\n",
+ "\n",
+ " total = 0\n",
+ " for item in mixed_materials:\n",
+ " if not isinstance(item, dict) or \"material\" not in item or \"percentage\" not in item:\n",
+ " return False, \"each mixed entry must be {material, percentage}\"\n",
+ " pct = int(item[\"percentage\"])\n",
+ " if pct < MIN_MIXED_PERCENT:\n",
+ " return False, f\"each mixed percentage must be >= {MIN_MIXED_PERCENT}\"\n",
+ " total += pct\n",
+ " if total != 100:\n",
+ " return False, \"mixed percentages must sum to 100\"\n",
+ " return True, \"\"\n",
+ "\n",
+ "\n",
+ "def encode_final_material_for_csv(final_mode, final_single, final_mixed):\n",
+ " if final_mode == \"single\":\n",
+ " return str(final_single)\n",
+ " if final_mode == \"mixed\":\n",
+ " parts = [f\"{x['material']}={x['percentage']}\" for x in final_mixed]\n",
+ " return \"mixed:\" + \"|\".join(parts)\n",
+ " return \"unknown\"\n",
+ "\n",
+ "\n",
"def save_passport(passport: dict, out_dir: Path) -> Path:\n",
" out_dir.mkdir(parents=True, exist_ok=True)\n",
" sample_id = passport[\"sample_id\"]\n",
@@ -1000,6 +1246,23 @@
"sigclip_model = AutoModel.from_pretrained(SIGCLIP_MODEL_ID).to(DEVICE)\n",
"sigclip_model.eval()\n",
"\n",
+ "print(\"Loading DINOv3 model...\")\n",
+ "dino_processor = AutoImageProcessor.from_pretrained(DINO_MODEL_ID)\n",
+ "dino_model = AutoModel.from_pretrained(DINO_MODEL_ID).to(DEVICE)\n",
+ "dino_model.eval()\n",
+ "\n",
+ "print(\"Building DINO prototype bank...\")\n",
+ "dino_prototype_bank, missing_proto_paths = build_dino_prototype_bank(\n",
+ " MATERIAL_PROTOTYPE_IMAGE_PATHS,\n",
+ " dino_model=dino_model,\n",
+ " dino_processor=dino_processor,\n",
+ ")\n",
+ "print(f\"DINO prototypes loaded: {len(dino_prototype_bank)} materials\")\n",
+ "if missing_proto_paths:\n",
+ " print(\"Missing prototype files:\")\n",
+ " for p in missing_proto_paths:\n",
+ " print(f\"- {p}\")\n",
+ "\n",
"print(\"Models ready.\")"
]
},
@@ -1167,7 +1430,7 @@
" crop_bbox = crop_bbox_rgb(img, seg[\"bbox_xywh\"], pad=16)\n",
"\n",
" clf_start = pd.Timestamp.utcnow()\n",
- " clf = classify_material_sigclip(\n",
+ " clf_sig = classify_material_sigclip(\n",
" image_views=[crop_masked, crop_bbox, img],\n",
" object_label=pred_object,\n",
" sigclip_model=sigclip_model,\n",
@@ -1175,6 +1438,29 @@
" top_k=3,\n",
" view_weights=[0.5, 0.35, 0.15],\n",
" )\n",
+ " clf_dino = classify_material_dinov3(\n",
+ " image_views=[crop_masked, crop_bbox],\n",
+ " object_label=pred_object,\n",
+ " dino_model=dino_model,\n",
+ " dino_processor=dino_processor,\n",
+ " prototype_bank=dino_prototype_bank,\n",
+ " top_k=3,\n",
+ " view_weights=[0.65, 0.35],\n",
+ " )\n",
+ " clf_fused = fuse_material_predictions(clf_sig, clf_dino)\n",
+ " decision = suggest_material_decision(clf_fused)\n",
+ "\n",
+ " clf = {\n",
+ " **clf_fused,\n",
+ " \"sigclip\": clf_sig,\n",
+ " \"dinov3\": clf_dino,\n",
+ " \"suggested_mode\": decision[\"suggested_mode\"],\n",
+ " \"suggested_single_material\": decision[\"suggested_single_material\"],\n",
+ " \"suggested_mixed_materials\": decision[\"suggested_mixed_materials\"],\n",
+ " \"low_confidence_flag\": decision[\"low_confidence_flag\"],\n",
+ " \"requires_user_confirmation\": decision[\"requires_user_confirmation\"],\n",
+ " \"margin12\": decision[\"margin12\"],\n",
+ " }\n",
" clf_end = pd.Timestamp.utcnow()\n",
"\n",
" t1 = pd.Timestamp.utcnow()\n",
@@ -1209,7 +1495,8 @@
" f\"sam={seg['sam_score']:.3f} sel={seg['selection_score']:.3f} \"\n",
" f\"hint_iou={seg['selection_metrics']['hint_iou']:.3f} \"\n",
" f\"pt_cov={seg['selection_metrics']['point_coverage']:.3f} \"\n",
- " f\"top3={clf['pred_material_top3']}\"\n",
+ " f\"top3={clf['pred_material_top3']} mode={clf['suggested_mode']} \"\n",
+ " f\"w=({clf['weights']['sigclip']:.2f},{clf['weights']['dinov3']:.2f})\"\n",
" )\n",
"\n",
" fig, axes = plt.subplots(1, 2, figsize=(12, 5))\n",
@@ -1248,10 +1535,12 @@
"# Example override format:\n",
"# USER_REVIEW_OVERRIDES = {\n",
"# \"S003\": {\n",
- "# \"segmentation_accepted\": 0,\n",
- "# \"user_edited_material\": 1,\n",
- "# \"user_final_material\": \"aluminum\",\n",
- "# \"edit_reason\": \"frame dominates crop\",\n",
+ "# \"segmentation_accepted\": 1,\n",
+ "# \"final_material_mode\": \"mixed\", # single|mixed|unknown\n",
+ "# \"final_single_material\": None,\n",
+ "# \"final_mixed_materials\": [{\"material\": \"wood\", \"percentage\": 60}, {\"material\": \"steel\", \"percentage\": 40}],\n",
+ "# \"confirmed_by_user\": True,\n",
+ "# \"edit_reason\": \"edge case\",\n",
"# \"status\": \"ok\",\n",
"# \"error_type\": \"\",\n",
"# }\n",
@@ -1260,16 +1549,35 @@
"\n",
"for run in RUN_ROWS:\n",
" sid = run[\"sample_id\"]\n",
- " pred_top1 = run[\"clf\"][\"pred_material_top1\"]\n",
+ " clf = run[\"clf\"]\n",
" override = USER_REVIEW_OVERRIDES.get(sid, {})\n",
"\n",
- " user_edited_material = int(override.get(\"user_edited_material\", 0))\n",
- " user_final_material = override.get(\"user_final_material\", pred_top1)\n",
+ " suggested_mode = clf[\"suggested_mode\"]\n",
+ " suggested_single = clf.get(\"suggested_single_material\")\n",
+ " suggested_mixed = clf.get(\"suggested_mixed_materials\", [])\n",
+ "\n",
+ " final_mode = override.get(\"final_material_mode\", suggested_mode)\n",
+ " final_single = override.get(\"final_single_material\", suggested_single if final_mode == \"single\" else None)\n",
+ " final_mixed = override.get(\"final_mixed_materials\", suggested_mixed if final_mode == \"mixed\" else [])\n",
+ "\n",
+ " requires_confirm = bool(clf.get(\"requires_user_confirmation\", False))\n",
+ " confirmed_by_user = bool(override.get(\"confirmed_by_user\", not requires_confirm))\n",
+ "\n",
+ " if final_mode == \"mixed\":\n",
+ " ok, err = validate_mixed_materials(final_mixed)\n",
+ " if not ok:\n",
+ " raise ValueError(f\"{sid}: invalid final_mixed_materials -> {err}\")\n",
+ "\n",
+ " user_edited = int(final_mode != suggested_mode)\n",
"\n",
" run[\"review\"] = {\n",
" \"segmentation_accepted\": int(override.get(\"segmentation_accepted\", DEFAULT_SEGMENTATION_ACCEPTED)),\n",
- " \"user_edited_material\": user_edited_material,\n",
- " \"user_final_material\": user_final_material,\n",
+ " \"final_material_mode\": final_mode,\n",
+ " \"final_single_material\": final_single,\n",
+ " \"final_mixed_materials\": final_mixed,\n",
+ " \"requires_user_confirmation\": requires_confirm,\n",
+ " \"confirmed_by_user\": confirmed_by_user,\n",
+ " \"user_edited_material\": user_edited,\n",
" \"edit_reason\": override.get(\"edit_reason\", \"\"),\n",
" \"status\": override.get(\"status\", DEFAULT_STATUS),\n",
" \"error_type\": override.get(\"error_type\", DEFAULT_ERROR_TYPE),\n",
@@ -1278,10 +1586,11 @@
"pd.DataFrame([\n",
" {\n",
" \"sample_id\": r[\"sample_id\"],\n",
- " \"pred_material_top1\": r[\"clf\"][\"pred_material_top1\"],\n",
- " \"user_final_material\": r[\"review\"][\"user_final_material\"],\n",
+ " \"suggested_mode\": r[\"clf\"][\"suggested_mode\"],\n",
+ " \"final_mode\": r[\"review\"][\"final_material_mode\"],\n",
+ " \"requires_confirm\": r[\"review\"][\"requires_user_confirmation\"],\n",
+ " \"confirmed\": r[\"review\"][\"confirmed_by_user\"],\n",
" \"user_edited_material\": r[\"review\"][\"user_edited_material\"],\n",
- " \"segmentation_accepted\": r[\"review\"][\"segmentation_accepted\"],\n",
" \"status\": r[\"review\"][\"status\"],\n",
" }\n",
" for r in RUN_ROWS\n",
@@ -1300,6 +1609,26 @@
" clf = run[\"clf\"]\n",
" timing = run[\"timing\"]\n",
"\n",
+ " if review.get(\"requires_user_confirmation\", False) and not review.get(\"confirmed_by_user\", False):\n",
+ " raise ValueError(f\"{run['sample_id']}: low-confidence result requires explicit user confirmation before save\")\n",
+ "\n",
+ " final_mode = review.get(\"final_material_mode\", \"unknown\")\n",
+ " final_single = review.get(\"final_single_material\", None)\n",
+ " final_mixed = review.get(\"final_mixed_materials\", [])\n",
+ "\n",
+ " if final_mode == \"mixed\":\n",
+ " ok, err = validate_mixed_materials(final_mixed)\n",
+ " if not ok:\n",
+ " raise ValueError(f\"{run['sample_id']}: invalid mixed output -> {err}\")\n",
+ " elif final_mode == \"single\":\n",
+ " if not final_single:\n",
+ " raise ValueError(f\"{run['sample_id']}: final_single_material is required for single mode\")\n",
+ " elif final_mode == \"unknown\":\n",
+ " final_single = None\n",
+ " final_mixed = []\n",
+ " else:\n",
+ " raise ValueError(f\"{run['sample_id']}: unknown final_material_mode={final_mode}\")\n",
+ "\n",
" passport = {\n",
" \"sample_id\": run[\"sample_id\"],\n",
" \"image_id\": run[\"image_id\"],\n",
@@ -1316,12 +1645,34 @@
" },\n",
" \"material\": {\n",
" \"ground_truth\": run[\"ground_truth_material\"],\n",
- " \"pred_top1\": clf[\"pred_material_top1\"],\n",
- " \"pred_top3\": clf[\"pred_material_top3\"].split(\"|\"),\n",
- " \"pred_conf_top1\": clf[\"pred_material_conf_top1\"],\n",
- " \"candidate_materials\": clf[\"candidate_materials\"],\n",
- " \"topk_scores\": clf[\"topk_scores\"],\n",
- " \"user_final\": review.get(\"user_final_material\", clf[\"pred_material_top1\"]),\n",
+ " \"predicted_topk\": [\n",
+ " {\"material\": m, \"score\": float(s)}\n",
+ " for m, s in zip(clf[\"topk_labels\"], clf[\"topk_scores\"])\n",
+ " ],\n",
+ " \"predicted_confidence_top1\": clf[\"pred_material_conf_top1\"],\n",
+ " \"suggested_mode\": clf[\"suggested_mode\"],\n",
+ " \"suggested_single_material\": clf.get(\"suggested_single_material\"),\n",
+ " \"suggested_mixed_materials\": clf.get(\"suggested_mixed_materials\", []),\n",
+ " \"final_material_mode\": final_mode,\n",
+ " \"final_single_material\": final_single,\n",
+ " \"final_mixed_materials\": final_mixed,\n",
+ " \"low_confidence_flag\": bool(clf.get(\"low_confidence_flag\", False)),\n",
+ " \"requires_user_confirmation\": bool(review.get(\"requires_user_confirmation\", False)),\n",
+ " \"confirmed_by_user\": bool(review.get(\"confirmed_by_user\", False)),\n",
+ " \"fusion_weights\": clf.get(\"weights\", {}),\n",
+ " \"model_outputs\": {\n",
+ " \"sigclip\": {\n",
+ " \"topk_labels\": clf[\"sigclip\"].get(\"topk_labels\", []),\n",
+ " \"topk_scores\": clf[\"sigclip\"].get(\"topk_scores\", []),\n",
+ " },\n",
+ " \"dinov3\": {\n",
+ " \"topk_labels\": clf[\"dinov3\"].get(\"topk_labels\", []),\n",
+ " \"topk_scores\": clf[\"dinov3\"].get(\"topk_scores\", []),\n",
+ " \"prototype_coverage\": clf[\"dinov3\"].get(\"prototype_coverage\", 0.0),\n",
+ " \"prototypes_available\": bool(clf[\"dinov3\"].get(\"prototypes_available\", False)),\n",
+ " },\n",
+ " },\n",
+ " \"user_final\": encode_final_material_for_csv(final_mode, final_single, final_mixed),\n",
" \"user_edited\": bool(review.get(\"user_edited_material\", 0)),\n",
" \"edit_reason\": review.get(\"edit_reason\", \"\"),\n",
" },\n",
@@ -1340,7 +1691,7 @@
" },\n",
" \"status\": review.get(\"status\", \"ok\"),\n",
" \"error_type\": review.get(\"error_type\", \"\"),\n",
- " \"model_name\": \"SigCLIP\",\n",
+ " \"model_name\": \"SigCLIP+DINOv3\",\n",
" \"created_at_utc\": datetime.now(timezone.utc).isoformat(),\n",
" }\n",
"\n",
@@ -1358,10 +1709,10 @@
" \"pred_material_top1\": clf[\"pred_material_top1\"],\n",
" \"pred_material_top3\": clf[\"pred_material_top3\"],\n",
" \"pred_material_conf_top1\": clf[\"pred_material_conf_top1\"],\n",
- " \"model_name\": \"SigCLIP\",\n",
+ " \"model_name\": \"SigCLIP+DINOv3\",\n",
" \"segmentation_accepted\": review.get(\"segmentation_accepted\", 1),\n",
" \"user_edited_material\": review.get(\"user_edited_material\", 0),\n",
- " \"user_final_material\": review.get(\"user_final_material\", clf[\"pred_material_top1\"]),\n",
+ " \"user_final_material\": encode_final_material_for_csv(final_mode, final_single, final_mixed),\n",
" \"edit_reason\": review.get(\"edit_reason\", \"\"),\n",
" \"end_to_end_time_sec\": timing[\"end_to_end_time_sec\"],\n",
" \"segmentation_time_sec\": timing[\"segmentation_time_sec\"],\n",
@@ -1433,39 +1784,14 @@
"metadata": {},
"outputs": [],
"source": [
- "def model_slice(df_in: pd.DataFrame, model_name: str) -> pd.DataFrame:\n",
- " return df_in[df_in[\"model_name\"].fillna(\"\").str.lower() == model_name.lower()]\n",
- "\n",
"df_eval = pd.read_csv(CSV_PATH)\n",
- "sigclip_df = model_slice(df_eval, \"SigCLIP\")\n",
- "dinov3_df = model_slice(df_eval, \"DINOv3\")\n",
- "\n",
- "if sigclip_df.empty:\n",
- " print(\"No SigCLIP rows yet.\")\n",
- "else:\n",
- " print(\"SigCLIP metrics:\")\n",
- " print(compute_metrics(sigclip_df)[\"metrics\"])\n",
- "\n",
- "if dinov3_df.empty:\n",
- " print(\"No DINOv3 rows yet.\")\n",
+ "if df_eval.empty:\n",
+ " print(\"No evaluation rows yet.\")\n",
"else:\n",
- " print(\"DINOv3 metrics:\")\n",
- " print(compute_metrics(dinov3_df)[\"metrics\"])\n",
- "\n",
- "if not sigclip_df.empty and not dinov3_df.empty:\n",
- " m_sig = compute_metrics(sigclip_df)[\"metrics\"]\n",
- " m_dino = compute_metrics(dinov3_df)[\"metrics\"]\n",
- "\n",
- " top1_gain = m_dino[\"top1_accuracy\"] - m_sig[\"top1_accuracy\"]\n",
- " edit_drop = m_sig[\"manual_edit_rate\"] - m_dino[\"manual_edit_rate\"]\n",
- " time_increase = (m_dino[\"median_e2e_time_sec\"] - m_sig[\"median_e2e_time_sec\"]) / max(m_sig[\"median_e2e_time_sec\"], 1e-9)\n",
- "\n",
- " promote = (top1_gain >= 0.05 or edit_drop >= 0.10) and time_increase <= 0.20\n",
- "\n",
- " print(f\"Top-1 gain (DINOv3 - SigCLIP): {top1_gain:.3f}\")\n",
- " print(f\"Edit-rate drop (SigCLIP - DINOv3): {edit_drop:.3f}\")\n",
- " print(f\"Median time increase: {time_increase:.3f}\")\n",
- " print(f\"Promote DINOv3: {'YES' if promote else 'NO'}\")"
+ " for name in sorted(df_eval[\"model_name\"].dropna().unique().tolist()):\n",
+ " sub = df_eval[df_eval[\"model_name\"] == name]\n",
+ " print(f\"Model={name}\")\n",
+ " print(compute_metrics(sub)[\"metrics\"])"
]
}
],