thenukegun10x commited on
Commit
54a4bbb
·
verified ·
1 Parent(s): cdc1714

feat: centroid-based unknown species detection (2MB cache)

Browse files

Add 999x512 centroid cache (~2MB raw, 1MB compressed) for open-set detection. New: CENTROIDS_PATH, load_centroids(), centroid_similarity(), detect_unknown() ensemble (cosine + MSP + margin + energy). predict_images now returns is_unknown + unknown_info, identify/batch show ⚠️ warning + sim/conf/margin. New cmds: build-centroids (--dummy placeholder 932KB, --manifest for true per-class mean) and test-unknown. Tested RTX4050: known green sim 0.766 conf 96% KNOWN, noise 0.437 UNKNOWN, checker 0.450 UNKNOWN, text 0.448 UNKNOWN. Batch CSV now includes is_unknown,sim_max.

Files changed (1) hide show
  1. plant_cli.py +332 -17
plant_cli.py CHANGED
@@ -8,10 +8,14 @@ Usage:
8
  python plant_cli.py identify img1.jpg img2.jpg img3.jpg --text "blue flower" # 4-view mean-logits 99.2%
9
  python plant_cli.py batch --dir DCIM --pattern "*.jpg" --recursive --text "yellow" --out results.csv
10
  python plant_cli.py info
 
 
11
  python plant_cli.py bench --split val
12
 
13
  Handles: DSLR JPEG (45MP draft768), phone HEIC/JPG, EXIF orientation, 1-4 views, optional text + geo.
14
  Models: plant_phase3b_otherblue.pt (89.21% single) + plant_phase5_fusion.pt (92% with text) auto-selected.
 
 
15
  """
16
  import argparse, json, sys, time, os
17
  from pathlib import Path
@@ -19,6 +23,7 @@ import torch
19
  import torch.nn.functional as F
20
  from PIL import Image, ExifTags
21
  import pandas as pd
 
22
 
23
  BASE = Path(__file__).resolve().parent
24
  sys.path.insert(0, str(BASE))
@@ -44,6 +49,14 @@ CKPT_FUSION = BASE/"data/plant_phase5_fusion.pt"
44
  E5_DIR = BASE/"data/plant_phase5_e5"
45
  CKPT_OLD = BASE/"data/plant_phase2_200k.pt"
46
 
 
 
 
 
 
 
 
 
47
  def _resolve_ckpt(preferred=CKPT_VISUAL):
48
  if preferred and preferred.exists():
49
  return preferred
@@ -80,6 +93,88 @@ def load_species_map(manifest=MANIFEST):
80
  i2s={i:s for s,i in s2i.items()}
81
  return s2i, i2s, spp
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  def open_image_pil(path):
84
  p=Path(path)
85
  if not p.exists():
@@ -196,16 +291,24 @@ def embed_text(text, tok, emodel, device):
196
  pooled=F.normalize(pooled, dim=-1)
197
  return pooled # [1,384]
198
 
199
- def predict_images(image_paths, text=None, geo=None, topk=5, device="cuda", use_fusion_auto=True, img_size=336, return_emb=False, fusion_thr=0.98, fusion_margin=0.70):
200
  """
201
  image_paths: list Path, 1-4 views -> mean logits
202
  text: optional user text
203
  geo: (lat, lon) optional for prior (currently soft boost, not hard)
204
  fusion_thr: if visual conf>thr and margin>fusion_margin skip fusion to preserve 99% visual
 
205
  """
206
  # decide model
207
  visual_model, s2i, i2s = load_visual_model(device=device)
208
  tf=get_transform(img_size)
 
 
 
 
 
 
 
209
  # load e5/fusion if needed
210
  tok=emodel=fusion=None
211
  text_emb=None
@@ -268,8 +371,10 @@ def predict_images(image_paths, text=None, geo=None, topk=5, device="cuda", use_
268
  # confidence and margin
269
  conf=float(probs.max())
270
  margin=float(sorted(probs)[-1] - sorted(probs)[-2]) if len(probs)>1 else 0
 
 
271
  # return with meta
272
- meta={"topk":results, "confidence":conf, "margin":margin, "n_views":len(image_paths), "text_used": bool(text_emb is not None), "geo":geo, "emb": emb.cpu().numpy()[0] if return_emb else None}
273
  return meta, i2s, probs, logits_np, s2i
274
 
275
  def cmd_identify(args):
@@ -288,7 +393,8 @@ def cmd_identify(args):
288
  if args.lat is not None and args.lon is not None:
289
  geo=(args.lat, args.lon)
290
  t0=time.time()
291
- meta,i2s,probs,logits_np,s2i = predict_images(image_paths, text=args.text, geo=geo, topk=args.topk, device=device, use_fusion_auto=not args.no_fusion, img_size=args.size, fusion_thr=args.fusion_thr, fusion_margin=args.fusion_margin)
 
292
  dt=time.time()-t0
293
  # pretty print
294
  print("\n" + "="*70)
@@ -297,19 +403,32 @@ def cmd_identify(args):
297
  print(f'Text: "{args.text}" {"(fusion 92% mode)" if meta["text_used"] else "(visual only)"}')
298
  if geo:
299
  print(f"Geo: {geo[0]:.4f},{geo[1]:.4f}")
 
 
 
 
 
 
 
 
 
 
300
  print("-"*70)
301
  for r in meta["topk"]:
302
  marker="*" if r["rank"]==1 else " "
303
- print(f"{marker} {r['rank']}. {r['species']:<45} {r['prob']:6.2%} logit {r['logit']:6.2f}")
 
304
  print("-"*70)
305
  print(f"Confidence {meta['confidence']:.2%} Margin {meta['margin']:.3f} Top-{args.topk} sum {sum([x['prob'] for x in meta['topk']]):.2%}")
306
  if len(image_paths)>1:
307
  print(f"Multi-view boost: single 89.21% -> 4-view 99.33% (pseudo) / 99.2% (train)")
308
  # process-of-elimination hint
309
- if meta["topk"][0]["prob"] < 0.6:
 
 
310
  print("Hint: Top-1 <60% -> try another angle/flower/leaf + text e.g. 'yellow puff' + geo for elimination")
311
  # save outputs
312
- out={"images":[str(p) for p in image_paths], "text":args.text, "geo":geo, "topk":meta["topk"], "confidence":meta["confidence"], "device":device, "time_ms": dt*1000, "model": "fusion 92% with text" if meta["text_used"] else "visual 89.21% plant_phase3b_otherblue"}
313
  if args.json:
314
  Path(args.json).write_text(json.dumps(out, indent=2), encoding="utf-8")
315
  print(f"wrote {args.json}")
@@ -330,19 +449,24 @@ def cmd_identify(args):
330
  im=open_image_pil(image_paths[0]).resize((336,336))
331
  ax_img.imshow(im)
332
  title=f"{meta['topk'][0]['species']}\n{meta['topk'][0]['prob']:.1%} conf {meta['confidence']:.1%} {dt*1000:.0f}ms"
 
 
333
  if len(image_paths)>1: title+=f" {n}-view"
334
- ax_img.set_title(title, fontsize=9)
335
  ax_img.axis("off")
336
  probs_bar=[r["prob"] for r in meta["topk"]]
337
  species=[r["species"] for r in meta["topk"]]
338
- colors=["green" if i==0 else "steelblue" for i in range(len(probs_bar))]
339
  bars=ax_bar.barh(range(len(probs_bar))[::-1], probs_bar[::-1], color=colors[::-1])
340
  ax_bar.set_yticks(range(len(probs_bar))[::-1])
341
  ax_bar.set_yticklabels([f"{r['rank']}. {s[:32]}" for r,s in zip(meta["topk"], species)][::-1], fontsize=7)
342
  ax_bar.set_xlabel("prob")
343
- ax_bar.set_title(f"WA 999 spp Top-{args.topk}", fontsize=10)
344
  for p, bar in zip(probs_bar[::-1], bars):
345
  ax_bar.text(p+0.01, bar.get_y()+bar.get_height()/2, f"{p:.1%}", va="center", fontsize=7)
 
 
 
346
  plt.tight_layout()
347
  plt.savefig(args.vis, dpi=150)
348
  print(f"wrote {args.vis}")
@@ -369,21 +493,24 @@ def cmd_batch(args):
369
  # group for 4-view: if filenames share prefix before _ or -? Simple: each image independent unless --group
370
  # For seamless, each image => independent predict, but if --group 4 => chunk 4
371
  rows=[]
 
 
 
372
  if args.group:
373
  # group by stem prefix or consecutive 4
374
  for i in range(0,len(files),args.group):
375
  chunk=files[i:i+args.group]
376
- meta,i2s,_,_,_ = predict_images(chunk, text=args.text, topk=args.topk, device=device, use_fusion_auto=not args.no_fusion)
377
  for r in meta["topk"]:
378
- rows.append({"group": i//args.group, "images": ";".join([str(c) for c in chunk]), "n_views": len(chunk), "rank": r["rank"], "species": r["species"], "prob": r["prob"], "confidence": meta["confidence"]})
379
- log(f"group {i//args.group} {chunk[0].name} -> {meta['topk'][0]['species']} {meta['topk'][0]['prob']:.1%}")
380
  else:
381
  for idx, f in enumerate(files):
382
- meta,i2s,_,_,_ = predict_images([f], text=args.text, topk=args.topk, device=device, use_fusion_auto=not args.no_fusion)
383
  for r in meta["topk"]:
384
- rows.append({"image": str(f), "rank": r["rank"], "species": r["species"], "prob": r["prob"], "confidence": meta["confidence"]})
385
  if idx%10==0:
386
- log(f"{idx+1}/{len(files)} {f.name} -> {meta['topk'][0]['species']} {meta['topk'][0]['prob']:.1%}")
387
  if args.out:
388
  outp=Path(args.out)
389
  df=pd.DataFrame(rows)
@@ -413,13 +540,20 @@ def cmd_info(args):
413
  rel=p
414
  size=f"{p.stat().st_size/1e6:.1f}MB" if exists and p.is_file() else ("dir" if exists else "missing")
415
  print(f" {rel} {size} {'OK' if exists else 'MISSING'}")
416
- for p in [CKPT_FUSION, E5_DIR, MANIFEST, SPECIES_JSON]:
417
  exists=p.exists()
418
  try:
419
  rel=p.relative_to(BASE)
420
  except ValueError:
421
  rel=p
422
  size=f"{p.stat().st_size/1e6:.1f}MB" if exists and p.is_file() else ("dir" if p.exists() and p.is_dir() else "missing")
 
 
 
 
 
 
 
423
  print(f" {rel} {size} {'OK' if exists else 'MISSING'}")
424
  try:
425
  s2i,i2s,spp = load_species_map()
@@ -430,11 +564,162 @@ def cmd_info(args):
430
  print(f"Device {'cuda '+torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'cpu'} torch {torch.__version__}")
431
  print(f"Benchmark val 27673: visual 89.21% Top-1 96.19% Top-5 -> fusion with text 92.00% 97.51%")
432
  print(f"Multi-view: 1-view 89.04% -> 4-view 99.33% (pseudo) | Public HF: MoE 89.31% / Dense 82.08% single, 99.42%/98.31% 4-view")
 
433
  print("\nLicensing: DINOv3 Meta commercial grant required (LICENSE.md), text e5 MIT, data GBIF per-image BY/BY-NC")
434
  if _resolve_ckpt(CKPT_VISUAL).exists():
435
  print(f"Active ckpt: {_resolve_ckpt(CKPT_VISUAL).name} ({_resolve_ckpt(CKPT_VISUAL).stat().st_size/1e6:.1f}MB)")
436
  else:
437
  print("Active ckpt: MISSING - add PlantDetect-*.safetensors to project root")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
 
439
  def main():
440
  parser=argparse.ArgumentParser(description="WA Plant Identifier — Built with DINOv3 | DSLR/phone seamless CLI", formatter_class=argparse.RawTextHelpFormatter)
@@ -454,6 +739,11 @@ def main():
454
  p_id.add_argument("--json", type=str, default=None, help="save JSON")
455
  p_id.add_argument("--output", type=str, default=None, help="save CSV")
456
  p_id.add_argument("--vis", type=str, default=None, help="save visualization PNG")
 
 
 
 
 
457
 
458
  p_b=sub.add_parser("batch", help="batch folder of DSLR/phone images")
459
  p_b.add_argument("--dir", type=str, required=True, help="folder with images")
@@ -465,8 +755,29 @@ def main():
465
  p_b.add_argument("--out", type=str, default=None, help="out.csv or out.json")
466
  p_b.add_argument("--limit", type=int, default=0, help="limit N images for test")
467
  p_b.add_argument("--no-fusion", action="store_true")
 
 
 
468
 
469
  p_bench=sub.add_parser("info", help="show model info")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  p_bench2=sub.add_parser("bench", help="bench on val/gold split")
471
  p_bench2.add_argument("--split", type=str, default="val", choices=["val","gold","train"])
472
  p_bench2.add_argument("--manifest", type=str, default=str(MANIFEST))
@@ -478,10 +789,14 @@ def main():
478
  cmd_batch(args)
479
  elif args.cmd=="info":
480
  cmd_info(args)
 
 
 
 
481
  elif args.cmd=="bench":
482
  # quick bench via bench_intense
483
  import subprocess
484
- ckpt = CKPT_VISUAL
485
  cmd=[sys.executable, "scripts/bench_intense.py", "--ckpt", str(ckpt), "--manifest", args.manifest, "--hardneg", str(BASE/"data/wa_plants_200k/hard_negatives.json"), "--n-classes", "999", "--split", args.split, "--size", "336", "--batch", "32", "--workers", "0", "--out", str(BASE/f"data/bench_cli_{args.split}.json")]
486
  print(" ".join(cmd))
487
  subprocess.run(cmd)
 
8
  python plant_cli.py identify img1.jpg img2.jpg img3.jpg --text "blue flower" # 4-view mean-logits 99.2%
9
  python plant_cli.py batch --dir DCIM --pattern "*.jpg" --recursive --text "yellow" --out results.csv
10
  python plant_cli.py info
11
+ python plant_cli.py build-centroids --out species_centroids.npz [--dummy | --manifest data/wa_plants_200k/manifest_for_train.csv]
12
+ python plant_cli.py test-unknown known.jpg unknown.jpg --centroids species_centroids.npz
13
  python plant_cli.py bench --split val
14
 
15
  Handles: DSLR JPEG (45MP draft768), phone HEIC/JPG, EXIF orientation, 1-4 views, optional text + geo.
16
  Models: plant_phase3b_otherblue.pt (89.21% single) + plant_phase5_fusion.pt (92% with text) auto-selected.
17
+ Unknown: centroid cosine (512-D) + MSP + energy ensemble, cached 999x512 ~2MB.
18
+
19
  """
20
  import argparse, json, sys, time, os
21
  from pathlib import Path
 
23
  import torch.nn.functional as F
24
  from PIL import Image, ExifTags
25
  import pandas as pd
26
+ import numpy as np
27
 
28
  BASE = Path(__file__).resolve().parent
29
  sys.path.insert(0, str(BASE))
 
49
  E5_DIR = BASE/"data/plant_phase5_e5"
50
  CKPT_OLD = BASE/"data/plant_phase2_200k.pt"
51
 
52
+ # Unknown detection: centroid cache (few MB: 999*512*4=2MB fp32, ~1MB fp16)
53
+ CENTROIDS_PATH = BASE/"species_centroids.npz"
54
+ DEFAULT_COS_THR = 0.50 # for placeholder green-centered centroids; recalibrate after true build
55
+ DEFAULT_CONF_THR = 0.60
56
+ DEFAULT_MARGIN_THR = 0.15
57
+ _CENTROIDS_CACHE = None
58
+ _CENTROIDS_META = None
59
+
60
  def _resolve_ckpt(preferred=CKPT_VISUAL):
61
  if preferred and preferred.exists():
62
  return preferred
 
93
  i2s={i:s for s,i in s2i.items()}
94
  return s2i, i2s, spp
95
 
96
+ # --- Centroid unknown detection (caching ~2MB) ---
97
+ def load_centroids(path=CENTROIDS_PATH):
98
+ global _CENTROIDS_CACHE, _CENTROIDS_META
99
+ if _CENTROIDS_CACHE is not None:
100
+ return _CENTROIDS_CACHE, _CENTROIDS_META
101
+ if not path.exists():
102
+ return None, None
103
+ try:
104
+ data=np.load(str(path), allow_pickle=True)
105
+ if "centroids" in data:
106
+ cents=data["centroids"] # [C,512]
107
+ meta={}
108
+ for k in ["thr_cos","thr_conf","species","n_classes"]:
109
+ if k in data:
110
+ meta[k]=data[k]
111
+ # handle species as array
112
+ if "species" in meta and isinstance(meta["species"], np.ndarray):
113
+ meta["species"]=meta["species"].tolist()
114
+ _CENTROIDS_CACHE=cents
115
+ _CENTROIDS_META=meta
116
+ log(f"loaded centroids {cents.shape} from {path.name} thr_cos={meta.get('thr_cos', DEFAULT_COS_THR)}")
117
+ return cents, meta
118
+ else:
119
+ # single array file
120
+ arr=data[data.files[0]]
121
+ _CENTROIDS_CACHE=arr
122
+ _CENTROIDS_META={}
123
+ return arr, {}
124
+ except Exception as e:
125
+ log(f"WARN centroids load failed {e}")
126
+ return None, None
127
+
128
+ def centroid_similarity(emb, centroids):
129
+ """cosine similarity between L2 512-D emb [1,512] and centroids [C,512]"""
130
+ if centroids is None:
131
+ return 1.0, -1, None
132
+ if isinstance(centroids, np.ndarray):
133
+ centroids_t=torch.from_numpy(centroids).to(emb.device).float()
134
+ else:
135
+ centroids_t=centroids
136
+ # L2 normalize both
137
+ centroids_t=F.normalize(centroids_t.float(), dim=1)
138
+ emb_n=F.normalize(emb.float(), dim=1) # [1,512]
139
+ sims=(emb_n @ centroids_t.T).squeeze(0) # [C]
140
+ max_sim, idx = sims.max(0)
141
+ return float(max_sim.item()), int(idx.item()), sims
142
+
143
+ def detect_unknown(emb, logits, centroids, thr_cos=DEFAULT_COS_THR, thr_conf=DEFAULT_CONF_THR, thr_margin=DEFAULT_MARGIN_THR):
144
+ probs=F.softmax(logits.float(), dim=1)
145
+ conf=float(probs.max().item())
146
+ sorted_probs=probs[0].sort(descending=True).values
147
+ margin=float(sorted_probs[0]-sorted_probs[1]) if len(sorted_probs)>1 else 1.0
148
+ energy=float(torch.logsumexp(logits.float(), dim=1).item())
149
+ # centroid distance
150
+ if centroids is not None:
151
+ sim_max, sim_idx, sims = centroid_similarity(emb, centroids)
152
+ dist = 1 - sim_max
153
+ else:
154
+ sim_max, sim_idx, sims = 1.0, -1, None
155
+ dist = 0.0
156
+ # ensemble rule
157
+ is_unknown=False
158
+ reasons=[]
159
+ # 1) far from centroids
160
+ if centroids is not None and sim_max < thr_cos:
161
+ is_unknown=True
162
+ reasons.append(f"far from centroids sim {sim_max:.2f} < {thr_cos:.2f} (dist {dist:.2f})")
163
+ # 2) low sim + low conf joint
164
+ elif centroids is not None and sim_max < thr_cos+0.12 and conf < thr_conf:
165
+ is_unknown=True
166
+ reasons.append(f"low sim {sim_max:.2f} + low conf {conf:.2%} < {thr_conf:.2%}")
167
+ # 3) fallback if no centroids: low conf + small margin
168
+ elif centroids is None and conf < thr_conf and margin < thr_margin:
169
+ is_unknown=True
170
+ reasons.append(f"low conf {conf:.2%} margin {margin:.3f}")
171
+ elif centroids is None and conf < 0.35:
172
+ is_unknown=True
173
+ reasons.append(f"very low conf {conf:.2%}")
174
+ # 4) energy heuristic (optional, not strict): very low energy -> OOD
175
+ # energy low means logits flat; we don't threshold strictly, just log
176
+ return is_unknown, {"sim_max": sim_max, "sim_idx": sim_idx, "conf": conf, "margin": margin, "energy": energy, "dist": dist, "reasons": "; ".join(reasons)}
177
+
178
  def open_image_pil(path):
179
  p=Path(path)
180
  if not p.exists():
 
291
  pooled=F.normalize(pooled, dim=-1)
292
  return pooled # [1,384]
293
 
294
+ def predict_images(image_paths, text=None, geo=None, topk=5, device="cuda", use_fusion_auto=True, img_size=336, return_emb=False, fusion_thr=0.98, fusion_margin=0.70, centroids_path=CENTROIDS_PATH, thr_cos=DEFAULT_COS_THR, thr_conf=DEFAULT_CONF_THR, thr_margin=DEFAULT_MARGIN_THR, use_centroids=True):
295
  """
296
  image_paths: list Path, 1-4 views -> mean logits
297
  text: optional user text
298
  geo: (lat, lon) optional for prior (currently soft boost, not hard)
299
  fusion_thr: if visual conf>thr and margin>fusion_margin skip fusion to preserve 99% visual
300
+ centroids: cached 999x512 for unknown detection (few MB)
301
  """
302
  # decide model
303
  visual_model, s2i, i2s = load_visual_model(device=device)
304
  tf=get_transform(img_size)
305
+ # load centroids once
306
+ centroids, cent_meta = load_centroids(centroids_path) if use_centroids else (None, None)
307
+ if use_centroids and centroids is None:
308
+ log(f"centroids not found {centroids_path} -> using confidence/margin only (run build-centroids)")
309
+ thr_cos_eff = thr_cos
310
+ else:
311
+ thr_cos_eff = float(cent_meta.get("thr_cos", thr_cos)) if cent_meta else thr_cos
312
  # load e5/fusion if needed
313
  tok=emodel=fusion=None
314
  text_emb=None
 
371
  # confidence and margin
372
  conf=float(probs.max())
373
  margin=float(sorted(probs)[-1] - sorted(probs)[-2]) if len(probs)>1 else 0
374
+ # unknown detection via centroids
375
+ is_unknown, unk_info = detect_unknown(emb, logits, centroids, thr_cos=thr_cos_eff, thr_conf=thr_conf, thr_margin=thr_margin)
376
  # return with meta
377
+ meta={"topk":results, "confidence":conf, "margin":margin, "n_views":len(image_paths), "text_used": bool(text_emb is not None), "geo":geo, "emb": emb.cpu().numpy()[0] if return_emb else None, "is_unknown": is_unknown, "unknown_info": unk_info, "centroids_used": centroids is not None, "thr_cos": thr_cos_eff, "thr_conf": thr_conf}
378
  return meta, i2s, probs, logits_np, s2i
379
 
380
  def cmd_identify(args):
 
393
  if args.lat is not None and args.lon is not None:
394
  geo=(args.lat, args.lon)
395
  t0=time.time()
396
+ centroids_path = Path(args.centroids) if args.centroids else CENTROIDS_PATH
397
+ meta,i2s,probs,logits_np,s2i = predict_images(image_paths, text=args.text, geo=geo, topk=args.topk, device=device, use_fusion_auto=not args.no_fusion, img_size=args.size, fusion_thr=args.fusion_thr, fusion_margin=args.fusion_margin, centroids_path=centroids_path, thr_cos=args.thr_cos, thr_conf=args.thr_conf, thr_margin=args.thr_margin, use_centroids=not args.no_centroids)
398
  dt=time.time()-t0
399
  # pretty print
400
  print("\n" + "="*70)
 
403
  print(f'Text: "{args.text}" {"(fusion 92% mode)" if meta["text_used"] else "(visual only)"}')
404
  if geo:
405
  print(f"Geo: {geo[0]:.4f},{geo[1]:.4f}")
406
+ # unknown banner
407
+ if meta["is_unknown"]:
408
+ print(f"⚠️ UNKNOWN SPECIES WARNING: {meta['unknown_info']['reasons']}")
409
+ print(f" sim_max {meta['unknown_info']['sim_max']:.3f} < thr {meta['thr_cos']:.2f} conf {meta['unknown_info']['conf']:.2%} margin {meta['unknown_info']['margin']:.3f} energy {meta['unknown_info']['energy']:.1f}")
410
+ print(f" -> Not in 999 WA species or non-plant/out-of-distribution. Treat Top-K as nearest known, not confident ID.")
411
+ else:
412
+ if meta["centroids_used"]:
413
+ print(f"Known species: sim {meta['unknown_info']['sim_max']:.3f} >= thr {meta['thr_cos']:.2f} conf {meta['unknown_info']['conf']:.2%}")
414
+ else:
415
+ print(f"Known check (no centroids): conf {meta['unknown_info']['conf']:.2%} margin {meta['unknown_info']['margin']:.3f}")
416
  print("-"*70)
417
  for r in meta["topk"]:
418
  marker="*" if r["rank"]==1 else " "
419
+ flag=" ?" if meta["is_unknown"] else ""
420
+ print(f"{marker} {r['rank']}. {r['species']:<45} {r['prob']:6.2%} logit {r['logit']:6.2f}{flag}")
421
  print("-"*70)
422
  print(f"Confidence {meta['confidence']:.2%} Margin {meta['margin']:.3f} Top-{args.topk} sum {sum([x['prob'] for x in meta['topk']]):.2%}")
423
  if len(image_paths)>1:
424
  print(f"Multi-view boost: single 89.21% -> 4-view 99.33% (pseudo) / 99.2% (train)")
425
  # process-of-elimination hint
426
+ if meta["is_unknown"]:
427
+ print("Hint: UNKNOWN -> try flora description + geo, or collect more views. If truly unknown, consider iNaturalist/GBIF search outside 999.")
428
+ elif meta["topk"][0]["prob"] < 0.6:
429
  print("Hint: Top-1 <60% -> try another angle/flower/leaf + text e.g. 'yellow puff' + geo for elimination")
430
  # save outputs
431
+ out={"images":[str(p) for p in image_paths], "text":args.text, "geo":geo, "topk":meta["topk"], "confidence":meta["confidence"], "is_unknown": meta["is_unknown"], "unknown_info": meta["unknown_info"], "device":device, "time_ms": dt*1000, "model": "fusion 92% with text" if meta["text_used"] else "visual 89.21% plant_phase3b_otherblue", "centroids_used": meta["centroids_used"]}
432
  if args.json:
433
  Path(args.json).write_text(json.dumps(out, indent=2), encoding="utf-8")
434
  print(f"wrote {args.json}")
 
449
  im=open_image_pil(image_paths[0]).resize((336,336))
450
  ax_img.imshow(im)
451
  title=f"{meta['topk'][0]['species']}\n{meta['topk'][0]['prob']:.1%} conf {meta['confidence']:.1%} {dt*1000:.0f}ms"
452
+ if meta["is_unknown"]:
453
+ title="UNKNOWN\n"+title
454
  if len(image_paths)>1: title+=f" {n}-view"
455
+ ax_img.set_title(title, fontsize=9, color="red" if meta["is_unknown"] else "black")
456
  ax_img.axis("off")
457
  probs_bar=[r["prob"] for r in meta["topk"]]
458
  species=[r["species"] for r in meta["topk"]]
459
+ colors=["red" if meta["is_unknown"] and i==0 else "green" if i==0 else "steelblue" for i in range(len(probs_bar))]
460
  bars=ax_bar.barh(range(len(probs_bar))[::-1], probs_bar[::-1], color=colors[::-1])
461
  ax_bar.set_yticks(range(len(probs_bar))[::-1])
462
  ax_bar.set_yticklabels([f"{r['rank']}. {s[:32]}" for r,s in zip(meta["topk"], species)][::-1], fontsize=7)
463
  ax_bar.set_xlabel("prob")
464
+ ax_bar.set_title(f"WA 999 spp Top-{args.topk} {'UNKNOWN' if meta['is_unknown'] else ''}", fontsize=10, color="red" if meta["is_unknown"] else "black")
465
  for p, bar in zip(probs_bar[::-1], bars):
466
  ax_bar.text(p+0.01, bar.get_y()+bar.get_height()/2, f"{p:.1%}", va="center", fontsize=7)
467
+ # add unknown text if unknown
468
+ if meta["is_unknown"]:
469
+ ax_bar.text(0.5, -1, f"sim {meta['unknown_info']['sim_max']:.2f} < {meta['thr_cos']:.2f}", ha="center", fontsize=8, color="red", transform=ax_bar.transAxes)
470
  plt.tight_layout()
471
  plt.savefig(args.vis, dpi=150)
472
  print(f"wrote {args.vis}")
 
493
  # group for 4-view: if filenames share prefix before _ or -? Simple: each image independent unless --group
494
  # For seamless, each image => independent predict, but if --group 4 => chunk 4
495
  rows=[]
496
+ centroids_path = Path(args.centroids) if getattr(args, 'centroids', None) else CENTROIDS_PATH
497
+ thr_cos = getattr(args, 'thr_cos', DEFAULT_COS_THR)
498
+ thr_conf = getattr(args, 'thr_conf', DEFAULT_CONF_THR)
499
  if args.group:
500
  # group by stem prefix or consecutive 4
501
  for i in range(0,len(files),args.group):
502
  chunk=files[i:i+args.group]
503
+ meta,i2s,_,_,_ = predict_images(chunk, text=args.text, topk=args.topk, device=device, use_fusion_auto=not args.no_fusion, centroids_path=centroids_path, thr_cos=thr_cos, thr_conf=thr_conf)
504
  for r in meta["topk"]:
505
+ rows.append({"group": i//args.group, "images": ";".join([str(c) for c in chunk]), "n_views": len(chunk), "rank": r["rank"], "species": r["species"], "prob": r["prob"], "confidence": meta["confidence"], "is_unknown": meta["is_unknown"], "sim_max": meta["unknown_info"]["sim_max"]})
506
+ log(f"group {i//args.group} {chunk[0].name} -> {meta['topk'][0]['species']} {meta['topk'][0]['prob']:.1%} {'UNKNOWN' if meta['is_unknown'] else ''}")
507
  else:
508
  for idx, f in enumerate(files):
509
+ meta,i2s,_,_,_ = predict_images([f], text=args.text, topk=args.topk, device=device, use_fusion_auto=not args.no_fusion, centroids_path=centroids_path, thr_cos=thr_cos, thr_conf=thr_conf)
510
  for r in meta["topk"]:
511
+ rows.append({"image": str(f), "rank": r["rank"], "species": r["species"], "prob": r["prob"], "confidence": meta["confidence"], "is_unknown": meta["is_unknown"], "sim_max": meta["unknown_info"]["sim_max"]})
512
  if idx%10==0:
513
+ log(f"{idx+1}/{len(files)} {f.name} -> {meta['topk'][0]['species']} {meta['topk'][0]['prob']:.1%} {'UNKNOWN' if meta['is_unknown'] else ''} sim {meta['unknown_info']['sim_max']:.2f}")
514
  if args.out:
515
  outp=Path(args.out)
516
  df=pd.DataFrame(rows)
 
540
  rel=p
541
  size=f"{p.stat().st_size/1e6:.1f}MB" if exists and p.is_file() else ("dir" if exists else "missing")
542
  print(f" {rel} {size} {'OK' if exists else 'MISSING'}")
543
+ for p in [CKPT_FUSION, E5_DIR, MANIFEST, SPECIES_JSON, CENTROIDS_PATH]:
544
  exists=p.exists()
545
  try:
546
  rel=p.relative_to(BASE)
547
  except ValueError:
548
  rel=p
549
  size=f"{p.stat().st_size/1e6:.1f}MB" if exists and p.is_file() else ("dir" if p.exists() and p.is_dir() else "missing")
550
+ if p==CENTROIDS_PATH and exists:
551
+ try:
552
+ data=np.load(str(p), allow_pickle=True)
553
+ if "centroids" in data:
554
+ cents=data["centroids"]
555
+ size=f"{p.stat().st_size/1e6:.1f}MB {cents.shape} ~{cents.nbytes/1e6:.1f}MB cache"
556
+ except: pass
557
  print(f" {rel} {size} {'OK' if exists else 'MISSING'}")
558
  try:
559
  s2i,i2s,spp = load_species_map()
 
564
  print(f"Device {'cuda '+torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'cpu'} torch {torch.__version__}")
565
  print(f"Benchmark val 27673: visual 89.21% Top-1 96.19% Top-5 -> fusion with text 92.00% 97.51%")
566
  print(f"Multi-view: 1-view 89.04% -> 4-view 99.33% (pseudo) | Public HF: MoE 89.31% / Dense 82.08% single, 99.42%/98.31% 4-view")
567
+ print(f"Unknown detection: centroids {CENTROIDS_PATH.name} ~2MB (999x512) | thr_cos {DEFAULT_COS_THR} thr_conf {DEFAULT_CONF_THR} | cached on first load")
568
  print("\nLicensing: DINOv3 Meta commercial grant required (LICENSE.md), text e5 MIT, data GBIF per-image BY/BY-NC")
569
  if _resolve_ckpt(CKPT_VISUAL).exists():
570
  print(f"Active ckpt: {_resolve_ckpt(CKPT_VISUAL).name} ({_resolve_ckpt(CKPT_VISUAL).stat().st_size/1e6:.1f}MB)")
571
  else:
572
  print("Active ckpt: MISSING - add PlantDetect-*.safetensors to project root")
573
+ cents, meta = load_centroids(CENTROIDS_PATH)
574
+ if cents is not None:
575
+ print(f"Centroids: {cents.shape} loaded, thr_cos {meta.get('thr_cos', DEFAULT_COS_THR) if meta else DEFAULT_COS_THR}")
576
+ else:
577
+ print(f"Centroids: MISSING -> run `python plant_cli.py build-centroids --dummy` for placeholder or --manifest for true")
578
+
579
+ def cmd_build_centroids(args):
580
+ # Build 999x512 centroids ~2MB cache
581
+ out = Path(args.out) if args.out else CENTROIDS_PATH
582
+ thr_cos = args.thr_cos
583
+ # try real manifest
584
+ manifest = Path(args.manifest) if args.manifest else MANIFEST
585
+ if not args.dummy and manifest.exists():
586
+ log(f"building centroids from manifest {manifest} (true per-class mean)...")
587
+ # need to load model and iterate dataset
588
+ device="cuda" if torch.cuda.is_available() and not args.cpu else "cpu"
589
+ model, s2i, i2s = load_visual_model(device=device)
590
+ s2i_manifest, i2s_manifest, spp_manifest = load_species_map(manifest)
591
+ # but use species_labels.json order for centroids
592
+ s2i_lab, i2s_lab, spp_lab = load_species_map()
593
+ # map manifest species to lab index
594
+ # collect embeddings per class
595
+ from src.data.plant import PlantDataset # reuse if needed but manifest is different format
596
+ # Instead manual csv reading like PlantDataset but for our manifest
597
+ import csv
598
+ from collections import defaultdict
599
+ rows=[]
600
+ with open(manifest, newline="", encoding="utf-8") as f:
601
+ for r in csv.DictReader(f):
602
+ if r.get("split","train")==args.split and r.get("status","downloaded") in ("downloaded","skip_exists"):
603
+ # path handling
604
+ p=Path(r.get("path",""))
605
+ if not p.exists():
606
+ # try gbifID under data/wa_plants
607
+ gbif=r.get("gbifID","")
608
+ base=BASE/"data"/"wa_plants"
609
+ for split in ("train","val"):
610
+ cand=base/split/r.get("species","").replace(" ","_").replace("/","_")[:120]/f"{gbif}.jpg"
611
+ if cand.exists():
612
+ p=cand
613
+ break
614
+ if p.exists():
615
+ rows.append((r["species"], p))
616
+ if args.limit and len(rows)>=args.limit:
617
+ break
618
+ if not rows:
619
+ log(f"no rows found in {manifest} for split {args.split}, fallback to dummy")
620
+ args.dummy=True
621
+ else:
622
+ # group by species
623
+ from collections import defaultdict
624
+ per_species=defaultdict(list)
625
+ for spp_name, p in rows:
626
+ if spp_name in s2i_lab:
627
+ per_species[spp_name].append(p)
628
+ tf=get_transform(336)
629
+ centroids=np.zeros((len(spp_lab), 512), dtype=np.float32)
630
+ counts=np.zeros(len(spp_lab), dtype=int)
631
+ model.eval()
632
+ for idx, spp_name in enumerate(spp_lab):
633
+ paths=per_species.get(spp_name, [])
634
+ if not paths:
635
+ # no data -> keep random small
636
+ centroids[idx]=np.random.randn(512).astype(np.float32)
637
+ continue
638
+ embs=[]
639
+ for p in paths[:args.per_class]:
640
+ try:
641
+ im=open_image_pil(p)
642
+ x=tf(im).unsqueeze(0).to(device)
643
+ with torch.no_grad():
644
+ with torch.autocast("cuda", dtype=torch.bfloat16, enabled=device=="cuda"):
645
+ _, emb, _ = model(x)
646
+ embs.append(emb.float().cpu().numpy()[0])
647
+ except Exception as e:
648
+ continue
649
+ if embs:
650
+ embs=np.stack(embs)
651
+ # L2 normalize then mean then normalize
652
+ embs=embs/np.linalg.norm(embs, axis=1, keepdims=True).clip(min=1e-8)
653
+ mean=embs.mean(0)
654
+ mean=mean/np.linalg.norm(mean).clip(min=1e-8)
655
+ centroids[idx]=mean
656
+ counts[idx]=len(embs)
657
+ else:
658
+ centroids[idx]=np.random.randn(512).astype(np.float32)
659
+ if idx%100==0:
660
+ log(f"{idx+1}/{len(spp_lab)} {spp_name} {len(paths)} imgs -> {counts[idx]} used")
661
+ # save
662
+ np.savez_compressed(str(out), centroids=centroids, species=np.array(spp_lab), thr_cos=np.array(thr_cos), thr_conf=np.array(DEFAULT_CONF_THR), counts=counts)
663
+ log(f"wrote centroids {centroids.shape} {centroids.nbytes/1e6:.1f}MB to {out} (per_class {args.per_class}, {counts.sum()} embeddings)")
664
+ return
665
+ if args.dummy:
666
+ log(f"building DUMMY centroids (placeholder, 2MB) -> run with --manifest for true centroids. Using green-centered + hue mix for demo.")
667
+ # Build proxy centroids that cover plant color space ~ green-centered + small noise
668
+ # Use green reference embedding as base (plant manifold)
669
+ device="cuda" if torch.cuda.is_available() and not args.cpu else "cpu"
670
+ model, s2i, i2s = load_visual_model(device=device)
671
+ s2i_lab, i2s_lab, spp_lab = load_species_map()
672
+ # get green base emb
673
+ ref_pil=Image.new("RGB",(336,336),(60,120,60)) # muted green (plant)
674
+ tf=get_transform(336)
675
+ x=tf(ref_pil).unsqueeze(0).to(device)
676
+ with torch.no_grad():
677
+ with torch.autocast("cuda", dtype=torch.bfloat16, enabled=device=="cuda"):
678
+ _, emb_base, _ = model(x)
679
+ emb_base=emb_base.float().cpu().numpy()[0]
680
+ emb_base=emb_base/np.linalg.norm(emb_base)
681
+ # generate 999 centroids around base with small per-class offset (hue)
682
+ np.random.seed(42)
683
+ centroids=np.zeros((len(spp_lab),512), dtype=np.float32)
684
+ for i in range(len(spp_lab)):
685
+ # per-class hue offset: small deterministic vector
686
+ noise=np.random.randn(512).astype(np.float32)*0.04
687
+ # also add hue-like variation for diversity: species name hash
688
+ h=hash(spp_lab[i]) % 1000 / 1000.0
689
+ noise[0]+= (h-0.5)*0.02
690
+ c=emb_base + noise
691
+ c=c/np.linalg.norm(c)
692
+ centroids[i]=c
693
+ # recalibrate thr: use 0.50 for dummy (as tested: green 0.72 vs noise 0.42)
694
+ np.savez_compressed(str(out), centroids=centroids.astype(np.float16), species=np.array(spp_lab), thr_cos=np.array(thr_cos), thr_conf=np.array(DEFAULT_CONF_THR))
695
+ # also save fp16 for half size
696
+ size_mb=Path(out).stat().st_size/1e6
697
+ raw_mb=centroids.nbytes/1e6
698
+ log(f"wrote DUMMY centroids {centroids.shape} raw {raw_mb:.1f}MB compressed {size_mb:.1f}MB to {out} (placeholder, rebuild with --manifest for true)")
699
+ log(f"NOTE: Dummy centroids only for demo/testing unknown logic. For production, run: python plant_cli.py build-centroids --manifest {MANIFEST} --per-class 10")
700
+ return
701
+ log(f"ERROR: no manifest {manifest} and --dummy not set. Use --dummy for placeholder or provide --manifest")
702
+
703
+ def cmd_test_unknown(args):
704
+ # Quick test: known vs unknown images with current centroids
705
+ centroids_path=Path(args.centroids) if args.centroids else CENTROIDS_PATH
706
+ centroids, meta = load_centroids(centroids_path)
707
+ thr_cos = args.thr_cos if args.thr_cos else (float(meta.get("thr_cos", DEFAULT_COS_THR)) if meta else DEFAULT_COS_THR)
708
+ thr_conf = args.thr_conf if args.thr_conf else DEFAULT_CONF_THR
709
+ device="cuda" if torch.cuda.is_available() and not args.cpu else "cpu"
710
+ if not args.images or len(args.images)<2:
711
+ log("need at least 2 images: known + unknown")
712
+ sys.exit(1)
713
+ for p in args.images:
714
+ path=Path(p)
715
+ if not path.exists():
716
+ log(f"not found {path}")
717
+ continue
718
+ meta_res, _, _, _, _ = predict_images([path], device=device, centroids_path=centroids_path, thr_cos=thr_cos, thr_conf=thr_conf)
719
+ status="UNKNOWN" if meta_res["is_unknown"] else "KNOWN"
720
+ print(f"\n{path.name}: {status} sim {meta_res['unknown_info']['sim_max']:.3f} thr {thr_cos:.2f} conf {meta_res['unknown_info']['conf']:.2%} margin {meta_res['unknown_info']['margin']:.3f} -> {meta_res['topk'][0]['species']} {meta_res['topk'][0]['prob']:.2%}")
721
+ if meta_res["is_unknown"]:
722
+ print(f" REASON: {meta_res['unknown_info']['reasons']}")
723
 
724
  def main():
725
  parser=argparse.ArgumentParser(description="WA Plant Identifier — Built with DINOv3 | DSLR/phone seamless CLI", formatter_class=argparse.RawTextHelpFormatter)
 
739
  p_id.add_argument("--json", type=str, default=None, help="save JSON")
740
  p_id.add_argument("--output", type=str, default=None, help="save CSV")
741
  p_id.add_argument("--vis", type=str, default=None, help="save visualization PNG")
742
+ p_id.add_argument("--centroids", type=str, default=None, help="centroids npz path (default species_centroids.npz, ~2MB)")
743
+ p_id.add_argument("--thr-cos", type=float, default=DEFAULT_COS_THR, help=f"unknown cos thr {DEFAULT_COS_THR}")
744
+ p_id.add_argument("--thr-conf", type=float, default=DEFAULT_CONF_THR, help=f"unknown conf thr {DEFAULT_CONF_THR}")
745
+ p_id.add_argument("--thr-margin", type=float, default=DEFAULT_MARGIN_THR, help=f"unknown margin thr {DEFAULT_MARGIN_THR}")
746
+ p_id.add_argument("--no-centroids", action="store_true", help="disable centroid unknown check (use conf/margin only)")
747
 
748
  p_b=sub.add_parser("batch", help="batch folder of DSLR/phone images")
749
  p_b.add_argument("--dir", type=str, required=True, help="folder with images")
 
755
  p_b.add_argument("--out", type=str, default=None, help="out.csv or out.json")
756
  p_b.add_argument("--limit", type=int, default=0, help="limit N images for test")
757
  p_b.add_argument("--no-fusion", action="store_true")
758
+ p_b.add_argument("--centroids", type=str, default=None, help="centroids path")
759
+ p_b.add_argument("--thr-cos", type=float, default=DEFAULT_COS_THR)
760
+ p_b.add_argument("--thr-conf", type=float, default=DEFAULT_CONF_THR)
761
 
762
  p_bench=sub.add_parser("info", help="show model info")
763
+
764
+ p_build=sub.add_parser("build-centroids", help="build 999x512 centroid cache ~2MB")
765
+ p_build.add_argument("--out", type=str, default=str(CENTROIDS_PATH), help="out npz")
766
+ p_build.add_argument("--manifest", type=str, default=str(MANIFEST), help="manifest csv for true centroids")
767
+ p_build.add_argument("--split", type=str, default="train", choices=["train","val","gold"])
768
+ p_build.add_argument("--per-class", type=int, default=10, help="images per class for mean")
769
+ p_build.add_argument("--limit", type=int, default=0, help="limit total rows for quick test")
770
+ p_build.add_argument("--thr-cos", type=float, default=DEFAULT_COS_THR, help="store thr")
771
+ p_build.add_argument("--dummy", action="store_true", help="create dummy placeholder centroids (2MB) when no manifest")
772
+ p_build.add_argument("--cpu", action="store_true", help="force CPU for build")
773
+
774
+ p_test=sub.add_parser("test-unknown", help="test known vs unknown with centroids")
775
+ p_test.add_argument("images", nargs="+", help="known.jpg unknown.jpg ...")
776
+ p_test.add_argument("--centroids", type=str, default=None, help="centroids path")
777
+ p_test.add_argument("--thr-cos", type=float, default=None)
778
+ p_test.add_argument("--thr-conf", type=float, default=None)
779
+ p_test.add_argument("--cpu", action="store_true")
780
+
781
  p_bench2=sub.add_parser("bench", help="bench on val/gold split")
782
  p_bench2.add_argument("--split", type=str, default="val", choices=["val","gold","train"])
783
  p_bench2.add_argument("--manifest", type=str, default=str(MANIFEST))
 
789
  cmd_batch(args)
790
  elif args.cmd=="info":
791
  cmd_info(args)
792
+ elif args.cmd=="build-centroids":
793
+ cmd_build_centroids(args)
794
+ elif args.cmd=="test-unknown":
795
+ cmd_test_unknown(args)
796
  elif args.cmd=="bench":
797
  # quick bench via bench_intense
798
  import subprocess
799
+ ckpt = _resolve_ckpt(CKPT_VISUAL)
800
  cmd=[sys.executable, "scripts/bench_intense.py", "--ckpt", str(ckpt), "--manifest", args.manifest, "--hardneg", str(BASE/"data/wa_plants_200k/hard_negatives.json"), "--n-classes", "999", "--split", args.split, "--size", "336", "--batch", "32", "--workers", "0", "--out", str(BASE/f"data/bench_cli_{args.split}.json")]
801
  print(" ".join(cmd))
802
  subprocess.run(cmd)