Image Classification
LiteRT
LiteRT
ONNX
English
vision
botany
western-australia
dinov3
mixture-of-experts
adaround
fp8
int8
android
biodiversity
flora
Instructions to use thenukegun10x/PLantDetect-WA with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- LiteRT
How to use thenukegun10x/PLantDetect-WA with LiteRT:
# No code snippets available yet for this library. # To use this model, check the repository files and the library's documentation. # Want to help? PRs adding snippets are welcome at: # https://github.com/huggingface/huggingface.js
- Notebooks
- Google Colab
- Kaggle
feat: centroid-based unknown species detection (2MB cache)
Browse filesAdd 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.
- 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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["
|
|
|
|
|
|
|
| 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)
|