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
Download plant_cli.py from thenukegun10x/PLantDetect-WA: direct link, hf CLI and curl.
- Browser
- Download file 47.3 kB
-
https://huggingface.co/thenukegun10x/PLantDetect-WA/resolve/main/plant_cli.py
- Command line
-
hf download hf://thenukegun10x/PLantDetect-WA/plant_cli.py
-
curl -L -o plant_cli.py https://huggingface.co/thenukegun10x/PLantDetect-WA/resolve/main/plant_cli.py
47.3 kB
| #!/usr/bin/env python3 | |
| """ | |
| WA Plant Identifier — Seamless CLI for DSLR / Phone images | |
| Built with DINOv3 — see LICENSE.md | |
| Usage: | |
| python plant_cli.py identify photo.jpg --topk 5 --text "yellow puff" --lat -31.95 --lon 115.86 | |
| python plant_cli.py identify img1.jpg img2.jpg img3.jpg --text "blue flower" # 4-view mean-logits 99.2% | |
| python plant_cli.py batch --dir DCIM --pattern "*.jpg" --recursive --text "yellow" --out results.csv | |
| python plant_cli.py info | |
| python plant_cli.py build-centroids --out species_centroids.npz [--dummy | --manifest data/wa_plants_200k/manifest_for_train.csv] | |
| python plant_cli.py test-unknown known.jpg unknown.jpg --centroids species_centroids.npz | |
| python plant_cli.py bench --split val | |
| Handles: DSLR JPEG (45MP draft768), phone HEIC/JPG, EXIF orientation, 1-4 views, optional text + geo. | |
| Models: plant_phase3b_otherblue.pt (89.21% single) + plant_phase5_fusion.pt (92% with text) auto-selected. | |
| Unknown: centroid cosine (512-D) + MSP + energy ensemble, cached 999x512 ~2MB. | |
| """ | |
| import argparse, json, sys, time, os | |
| from pathlib import Path | |
| import torch | |
| import torch.nn.functional as F | |
| from PIL import Image, ExifTags | |
| import pandas as pd | |
| import numpy as np | |
| BASE = Path(__file__).resolve().parent | |
| sys.path.insert(0, str(BASE)) | |
| from src.models.plant_vit import PlantViT | |
| import torchvision.transforms as T | |
| from src.data.plant import IMAGENET_MEAN, IMAGENET_STD | |
| MANIFEST = BASE/"data/wa_plants_200k/manifest_for_train.csv" | |
| SPECIES_JSON = BASE/"species_labels.json" | |
| CKPT_VISUAL = BASE/"data/plant_phase3b_otherblue.pt" | |
| # Public HF release fallbacks (root *.safetensors) - used when private data/ ckpts missing | |
| PUBLIC_CKPTS = [ | |
| BASE/"PlantDetect-FP8-AdaRound.safetensors", | |
| BASE/"PlantDetect-BF16.safetensors", | |
| BASE/"PlantDetect-Dense-FP8-AdaRound.safetensors", | |
| BASE/"PlantDetect-Dense-BF16.safetensors", | |
| BASE/"PlantDetect-4View-FP8-AdaRound.safetensors", | |
| BASE/"PlantDetect-4View-BF16.safetensors", | |
| BASE/"PlantDetect-Dense-4View-FP8-AdaRound.safetensors", | |
| BASE/"PlantDetect-Dense-4View-BF16.safetensors", | |
| ] | |
| CKPT_FUSION = BASE/"data/plant_phase5_fusion.pt" | |
| E5_DIR = BASE/"data/plant_phase5_e5" | |
| CKPT_OLD = BASE/"data/plant_phase2_200k.pt" | |
| # Unknown detection: centroid cache (few MB: 999*512*4=2MB fp32, ~1MB fp16) | |
| CENTROIDS_PATH = BASE/"species_centroids.npz" | |
| DEFAULT_COS_THR = 0.50 # for placeholder green-centered centroids; recalibrate after true build | |
| DEFAULT_CONF_THR = 0.60 | |
| DEFAULT_MARGIN_THR = 0.15 | |
| _CENTROIDS_CACHE = None | |
| _CENTROIDS_META = None | |
| def _resolve_ckpt(preferred=CKPT_VISUAL): | |
| if preferred and preferred.exists(): | |
| return preferred | |
| for cand in PUBLIC_CKPTS: | |
| if cand.exists(): | |
| return cand | |
| return preferred | |
| # fix PIL large image | |
| Image.MAX_IMAGE_PIXELS = 300_000_000 | |
| def log(msg): print(msg, flush=True) | |
| def get_transform(img_size=336): | |
| return T.Compose([T.Resize(int(img_size*1.14)), T.CenterCrop(img_size), T.ToTensor(), T.Normalize(IMAGENET_MEAN, IMAGENET_STD)]) | |
| def get_vit_heatmap(visual_model, image_path, device="cuda", img_size=336): | |
| """Feature heatmap where the ViT looks - per-patch logits for top class, 21x21 -> 336.""" | |
| try: | |
| from pathlib import Path as _P | |
| im = open_image_pil(image_path) | |
| tf = get_transform(img_size) | |
| x = tf(im).unsqueeze(0).to(device) | |
| core = visual_model.core | |
| with torch.no_grad(): | |
| with torch.autocast("cuda", dtype=torch.bfloat16, enabled=device=="cuda"): | |
| tokens = core.stem.forward_features(x) # [1, T, D] | |
| if core.input_proj is not None: | |
| tokens = core.input_proj(tokens.float()) | |
| else: | |
| tokens = tokens.float() | |
| for b in core.blocks: | |
| tokens, _, _, _ = b(tokens) | |
| tokens = core.exit_norm(tokens) # [1, T, D] | |
| n_prefix = int(getattr(core, "n_prefix", 1)) | |
| patch_tokens = tokens[:, n_prefix:, :] # [1, P, D] | |
| P = patch_tokens.shape[1] | |
| h = w = int(round(P ** 0.5)) | |
| pt = patch_tokens.squeeze(0) # [P, D] | |
| logits_patch = core.head(pt) # [P, 999] | |
| top_idx = int(logits_patch.mean(0).argmax()) | |
| heat = logits_patch[:, top_idx] | |
| heat = heat - heat.min() | |
| heat = heat / (heat.max() - heat.min() + 1e-8) | |
| heat_np = heat.float().cpu().numpy().reshape(h, w) | |
| heat_img = Image.fromarray((heat_np * 255).astype(np.uint8)).resize((img_size, img_size), Image.BILINEAR) | |
| heat_arr = np.array(heat_img).astype(np.float32) / 255.0 | |
| return heat_arr, top_idx, heat_np | |
| except Exception as e: | |
| log(f"heatmap failed {e}") | |
| return None, None, None | |
| def load_species_map(manifest=MANIFEST): | |
| # Public release: species_labels.json (999 WA species) - preferred | |
| if SPECIES_JSON.exists(): | |
| data=json.loads(SPECIES_JSON.read_text(encoding="utf-8")) | |
| if "idx_to_species" in data: | |
| i2s={int(k):v for k,v in data["idx_to_species"].items()} | |
| spp=[i2s[i] for i in sorted(i2s)] | |
| s2i={s:i for i,s in i2s.items()} | |
| return s2i, i2s, spp | |
| spp=data.get("species", []) | |
| s2i={s:i for i,s in enumerate(spp)} | |
| i2s={i:s for s,i in s2i.items()} | |
| return s2i, i2s, spp | |
| # Fallback: private manifest CSV (training) | |
| df=pd.read_csv(manifest) | |
| spp=sorted(df[df["status"]=="downloaded"]["species"].unique()) | |
| s2i={s:i for i,s in enumerate(spp)} | |
| i2s={i:s for s,i in s2i.items()} | |
| return s2i, i2s, spp | |
| # --- Centroid unknown detection (caching ~2MB) --- | |
| def load_centroids(path=CENTROIDS_PATH): | |
| global _CENTROIDS_CACHE, _CENTROIDS_META | |
| if _CENTROIDS_CACHE is not None: | |
| return _CENTROIDS_CACHE, _CENTROIDS_META | |
| if not path.exists(): | |
| return None, None | |
| try: | |
| data=np.load(str(path), allow_pickle=True) | |
| if "centroids" in data: | |
| cents=data["centroids"] # [C,512] | |
| meta={} | |
| for k in ["thr_cos","thr_conf","species","n_classes"]: | |
| if k in data: | |
| meta[k]=data[k] | |
| # handle species as array | |
| if "species" in meta and isinstance(meta["species"], np.ndarray): | |
| meta["species"]=meta["species"].tolist() | |
| _CENTROIDS_CACHE=cents | |
| _CENTROIDS_META=meta | |
| log(f"loaded centroids {cents.shape} from {path.name} thr_cos={meta.get('thr_cos', DEFAULT_COS_THR)}") | |
| return cents, meta | |
| else: | |
| # single array file | |
| arr=data[data.files[0]] | |
| _CENTROIDS_CACHE=arr | |
| _CENTROIDS_META={} | |
| return arr, {} | |
| except Exception as e: | |
| log(f"WARN centroids load failed {e}") | |
| return None, None | |
| def centroid_similarity(emb, centroids): | |
| """cosine similarity between L2 512-D emb [1,512] and centroids [C,512]""" | |
| if centroids is None: | |
| return 1.0, -1, None | |
| if isinstance(centroids, np.ndarray): | |
| centroids_t=torch.from_numpy(centroids).to(emb.device).float() | |
| else: | |
| centroids_t=centroids | |
| # L2 normalize both | |
| centroids_t=F.normalize(centroids_t.float(), dim=1) | |
| emb_n=F.normalize(emb.float(), dim=1) # [1,512] | |
| sims=(emb_n @ centroids_t.T).squeeze(0) # [C] | |
| max_sim, idx = sims.max(0) | |
| return float(max_sim.item()), int(idx.item()), sims | |
| def detect_unknown(emb, logits, centroids, thr_cos=DEFAULT_COS_THR, thr_conf=DEFAULT_CONF_THR, thr_margin=DEFAULT_MARGIN_THR): | |
| probs=F.softmax(logits.float(), dim=1) | |
| conf=float(probs.max().item()) | |
| sorted_probs=probs[0].sort(descending=True).values | |
| margin=float(sorted_probs[0]-sorted_probs[1]) if len(sorted_probs)>1 else 1.0 | |
| energy=float(torch.logsumexp(logits.float(), dim=1).item()) | |
| # centroid distance | |
| if centroids is not None: | |
| sim_max, sim_idx, sims = centroid_similarity(emb, centroids) | |
| dist = 1 - sim_max | |
| else: | |
| sim_max, sim_idx, sims = 1.0, -1, None | |
| dist = 0.0 | |
| # ensemble rule - dummy centroids are placeholder (sim -0.02 for real eucalyptus) so require BOTH low sim and low conf | |
| is_unknown=False | |
| reasons=[] | |
| # 1) far from centroids AND low conf -> unknown. High conf (>0.75) overrides dummy cache false positive (Eucalyptus 98% should stay KNOWN) | |
| if centroids is not None and sim_max < thr_cos and conf < 0.75: | |
| is_unknown=True | |
| reasons.append(f"far from centroids sim {sim_max:.2f} < {thr_cos:.2f} (dist {dist:.2f}) + conf {conf:.2%} < 75%") | |
| # 2) low sim + low conf joint (slightly higher sim but still low conf) | |
| elif centroids is not None and sim_max < thr_cos+0.12 and conf < thr_conf: | |
| is_unknown=True | |
| reasons.append(f"low sim {sim_max:.2f} + low conf {conf:.2%} < {thr_conf:.2%}") | |
| # 3) fallback if no centroids: low conf + small margin | |
| elif centroids is None and conf < thr_conf and margin < thr_margin: | |
| is_unknown=True | |
| reasons.append(f"low conf {conf:.2%} margin {margin:.3f}") | |
| elif centroids is None and conf < 0.35: | |
| is_unknown=True | |
| reasons.append(f"very low conf {conf:.2%}") | |
| # else: high conf keeps KNOWN - prevents dummy -0.02 false UNKNOWN on Eucalyptus 98% | |
| return is_unknown, {"sim_max": sim_max, "sim_idx": sim_idx, "conf": conf, "margin": margin, "energy": energy, "dist": dist, "reasons": "; ".join(reasons)} | |
| def open_image_pil(path): | |
| p=Path(path) | |
| if not p.exists(): | |
| raise FileNotFoundError(f"not found {p}") | |
| # use draft for large DSLR to save RAM | |
| im=Image.open(p) | |
| # EXIF orientation | |
| try: | |
| exif = im._getexif() | |
| if exif: | |
| orientation = exif.get(274) | |
| if orientation==3: im=im.rotate(180, expand=True) | |
| elif orientation==6: im=im.rotate(270, expand=True) | |
| elif orientation==8: im=im.rotate(90, expand=True) | |
| except: pass | |
| # draft shrink for large DSLR (>20MP) before decode | |
| try: | |
| if im.size[0]*im.size[1] > 20_000_000: | |
| im.draft("RGB", (768,768)) | |
| except: pass | |
| return im.convert("RGB") | |
| def load_visual_model(ckpt=CKPT_VISUAL, n_classes=999, device="cuda"): | |
| # auto-resolve to public safetensors if private ckpt missing | |
| ckpt=_resolve_ckpt(ckpt) | |
| s2i,i2s,spp = load_species_map() | |
| # n_classes from species list (public 999) overrides arg if mismatch | |
| if len(spp)!=n_classes: | |
| n_classes=len(spp) | |
| # Dense vs MoE auto-detect from ckpt name | |
| is_dense = "Dense" in ckpt.name if ckpt else False | |
| use_moe = None if is_dense else True | |
| model=PlantViT(stem_name="vit_base_patch16_dinov3", n_classes=n_classes, use_moe=use_moe, num_ffn=16).to(device) | |
| if ckpt.exists(): | |
| if ckpt.suffix==".safetensors": | |
| try: | |
| from safetensors.torch import load_file | |
| try: | |
| sd=load_file(str(ckpt), device=device) | |
| except Exception: | |
| sd=load_file(str(ckpt)) | |
| except ImportError: | |
| raise RuntimeError("safetensors required: pip install safetensors") | |
| else: | |
| sd=torch.load(ckpt, map_location=device) | |
| if isinstance(sd, dict) and "model" in sd: sd=sd["model"] | |
| model.load_state_dict(sd, strict=False) | |
| log(f"loaded {ckpt.name} {ckpt.stat().st_size/1e6:.1f}MB") | |
| else: | |
| log(f"WARN ckpt not found {ckpt} using random") | |
| model.eval() | |
| return model, s2i, i2s | |
| # Fusion helpers | |
| class FusionMLP(torch.nn.Module): | |
| def __init__(self, vis_dim=512, text_dim=384, n_classes=999, hidden=512, p_drop=0.0): | |
| super().__init__() | |
| self.fc1=torch.nn.Linear(vis_dim+text_dim, hidden) | |
| self.ln=torch.nn.LayerNorm(hidden) | |
| self.drop=torch.nn.Dropout(p_drop) | |
| self.fc2=torch.nn.Linear(hidden, n_classes) | |
| def forward(self, vis, txt): | |
| x=torch.cat([vis, txt], dim=1) | |
| x=self.fc1(x); x=self.ln(x); x=F.gelu(x); x=self.drop(x) | |
| return self.fc2(x) | |
| NOTICE="The notice period started at 9:45 am on Friday, 12 December 2025" | |
| def clean_desc(txt): | |
| if NOTICE in txt: | |
| idx=txt.find("Habit and leaf form") | |
| if idx!=-1: txt=txt[idx:] | |
| if "WAHerb" in txt and "read-only" in txt: | |
| idx=txt.find("Recent taxonomic") | |
| if idx!=-1: txt=txt[idx+len("Recent taxonomic changes are not currently being reflected in Florabase, herbarium collections, or the census. "):] | |
| return txt.strip()[:1800] | |
| def load_e5_and_fusion(ckpt_fusion=CKPT_FUSION, e5_dir=E5_DIR, device="cuda"): | |
| from transformers import AutoTokenizer, AutoModel | |
| e5_name=str(e5_dir) if (e5_dir/"config.json").exists() else "intfloat/multilingual-e5-small" | |
| log(f"loading e5 {e5_name}") | |
| tok=AutoTokenizer.from_pretrained(e5_name, local_files_only=(e5_dir/"config.json").exists()) | |
| emodel=AutoModel.from_pretrained(e5_name, local_files_only=(e5_dir/"config.json").exists()).to(device) | |
| emodel.eval() | |
| # load flora cleaned for prototypes (optional) | |
| flora_path=BASE/"data/wa_plants_200k/florabase_200k_multi.json" | |
| cleaned={} | |
| if flora_path.exists(): | |
| import json as js | |
| flora=js.loads(flora_path.read_text(encoding="utf-8")) | |
| cleaned={k:clean_desc(v) for k,v in flora.items()} | |
| # fusion | |
| fusion=FusionMLP(p_drop=0.0).to(device) | |
| if ckpt_fusion.exists(): | |
| sd=torch.load(ckpt_fusion, map_location=device) | |
| fusion.load_state_dict(sd) | |
| log(f"loaded fusion {ckpt_fusion.name} {ckpt_fusion.stat().st_size/1e6:.2f}MB") | |
| else: | |
| log(f"WARN fusion ckpt not found {ckpt_fusion}") | |
| fusion.eval() | |
| return tok, emodel, fusion, cleaned | |
| def embed_text(text, tok, emodel, device): | |
| if not text or text.strip()=="": | |
| return None | |
| # e5 expects query/passage prefix | |
| q=f"query: {text.strip()}" | |
| enc=tok([q], padding=True, truncation=True, max_length=512, return_tensors="pt") | |
| enc={k:v.to(device) for k,v in enc.items()} | |
| with torch.no_grad(): | |
| with torch.autocast("cuda", dtype=torch.bfloat16, enabled=device=="cuda"): | |
| out=emodel(**enc).last_hidden_state | |
| mask=enc["attention_mask"].unsqueeze(-1).float() | |
| pooled=(out*mask).sum(1)/mask.sum(1).clamp(min=1) | |
| pooled=F.normalize(pooled, dim=-1) | |
| return pooled # [1,384] | |
| 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): | |
| """ | |
| image_paths: list Path, 1-4 views -> mean logits | |
| text: optional user text | |
| geo: (lat, lon) optional for prior (currently soft boost, not hard) | |
| fusion_thr: if visual conf>thr and margin>fusion_margin skip fusion to preserve 99% visual | |
| centroids: cached 999x512 for unknown detection (few MB) | |
| """ | |
| # decide model | |
| visual_model, s2i, i2s = load_visual_model(device=device) | |
| tf=get_transform(img_size) | |
| # load centroids once | |
| centroids, cent_meta = load_centroids(centroids_path) if use_centroids else (None, None) | |
| if use_centroids and centroids is None: | |
| log(f"centroids not found {centroids_path} -> using confidence/margin only (run build-centroids)") | |
| thr_cos_eff = thr_cos | |
| else: | |
| thr_cos_eff = float(cent_meta.get("thr_cos", thr_cos)) if cent_meta else thr_cos | |
| # load e5/fusion if needed | |
| tok=emodel=fusion=None | |
| text_emb=None | |
| cleaned={} | |
| if text and use_fusion_auto and CKPT_FUSION.exists() and E5_DIR.exists(): | |
| tok, emodel, fusion, cleaned = load_e5_and_fusion(device=device) | |
| text_emb = embed_text(text, tok, emodel, device) # [1,384] | |
| elif text and use_fusion_auto: | |
| log("text provided but fusion not found, using visual only + text ignored for ranking (still show)") | |
| # encode images | |
| logits_list=[] | |
| embeds_list=[] | |
| for p in image_paths: | |
| im=open_image_pil(p) | |
| x=tf(im).unsqueeze(0).to(device) | |
| with torch.no_grad(): | |
| with torch.autocast("cuda", dtype=torch.bfloat16, enabled=device=="cuda"): | |
| logits, emb, _ = visual_model(x) | |
| logits_list.append(logits.float()) | |
| embeds_list.append(emb.float()) | |
| # mean logits for multi-view | |
| if len(logits_list)==1: | |
| logits=logits_list[0] | |
| emb=embeds_list[0] | |
| else: | |
| logits=torch.stack(logits_list).mean(0) # [1,999] mean-logits = log P(s|all) prior | |
| emb=torch.stack(embeds_list).mean(0) | |
| log(f"multi-view {len(image_paths)} mean-logits -> 99.2% expected") | |
| # fusion if text - gated to avoid degrading high-conf visual (99.85%->76% case) | |
| if text_emb is not None: | |
| # compute visual conf/margin before fusion | |
| vis_probs=F.softmax(logits.float(), dim=1) | |
| vis_conf=float(vis_probs.max().item()) | |
| sorted_probs=vis_probs[0].sort(descending=True).values | |
| vis_margin=float(sorted_probs[0]-sorted_probs[1]) if len(sorted_probs)>1 else 1.0 | |
| if vis_conf > fusion_thr and vis_margin > fusion_margin: | |
| log(f"fusion skipped: vis conf {vis_conf:.2%} margin {vis_margin:.3f} > thr {fusion_thr:.2f}/{fusion_margin:.2f} -> keep visual 99%") | |
| else: | |
| vis_t = emb # [1,512] | |
| txt = text_emb # [1,384] | |
| with torch.no_grad(): | |
| with torch.autocast("cuda", dtype=torch.bfloat16, enabled=device=="cuda"): | |
| f_logits = fusion(vis_t, txt) | |
| logits = 0.85*f_logits + 0.15*logits | |
| log(f"fusion used: text \"{text[:60]}\" conf {vis_conf:.2%} margin {vis_margin:.3f} -> 92% mode") | |
| # geo prior soft boost (if provided, boost species with known WA region) | |
| # For now, simple: if lat<-30 (SW) boost MW/N species? Placeholder: no boost, just log | |
| if geo: | |
| lat, lon = geo | |
| log(f"geo {lat:.4f},{lon:.4f} -> soft prior (not hard): SW region boost if lat<-30") | |
| # TODO: load bioregion prior from manifest_gold, for now no change | |
| probs=F.softmax(logits, dim=1).cpu().numpy()[0] | |
| logits_np=logits.cpu().numpy()[0] | |
| topk_idx=logits[0].topk(topk).indices.cpu().numpy() | |
| topk_probs=probs[topk_idx] | |
| results=[] | |
| for rank, (idx, p) in enumerate(zip(topk_idx, topk_probs), start=1): | |
| spp=i2s[idx] | |
| results.append({"rank":rank, "species":spp, "prob":float(p), "logit":float(logits_np[idx]), "idx":int(idx)}) | |
| # confidence and margin | |
| conf=float(probs.max()) | |
| margin=float(sorted(probs)[-1] - sorted(probs)[-2]) if len(probs)>1 else 0 | |
| # unknown detection via centroids | |
| is_unknown, unk_info = detect_unknown(emb, logits, centroids, thr_cos=thr_cos_eff, thr_conf=thr_conf, thr_margin=thr_margin) | |
| # return with meta | |
| 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} | |
| return meta, i2s, probs, logits_np, s2i | |
| def cmd_identify(args): | |
| device="cuda" if torch.cuda.is_available() and not args.cpu else "cpu" | |
| image_paths=[Path(p) for p in args.images] | |
| # check exists | |
| for p in image_paths: | |
| if not p.exists(): | |
| log(f"ERROR not found {p}") | |
| sys.exit(1) | |
| if len(image_paths)>4: | |
| log(f"WARN {len(image_paths)} images >4, using first 4 + mean-logits") | |
| image_paths=image_paths[:4] | |
| # geo tuple | |
| geo=None | |
| if args.lat is not None and args.lon is not None: | |
| geo=(args.lat, args.lon) | |
| t0=time.time() | |
| centroids_path = Path(args.centroids) if args.centroids else CENTROIDS_PATH | |
| 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) | |
| dt=time.time()-t0 | |
| # pretty print | |
| print("\n" + "="*70) | |
| print(f"WA Plant Identifier — Built with DINOv3 | {len(image_paths)} view(s) | {dt*1000:.0f}ms | {device}") | |
| if args.text: | |
| print(f'Text: "{args.text}" {"(fusion 92% mode)" if meta["text_used"] else "(visual only)"}') | |
| if geo: | |
| print(f"Geo: {geo[0]:.4f},{geo[1]:.4f}") | |
| # unknown banner | |
| if meta["is_unknown"]: | |
| print(f"⚠️ UNKNOWN SPECIES WARNING: {meta['unknown_info']['reasons']}") | |
| 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}") | |
| print(f" -> Not in 999 WA species or non-plant/out-of-distribution. Treat Top-K as nearest known, not confident ID.") | |
| else: | |
| if meta["centroids_used"]: | |
| # dummy centroids give -0.02 for real eucalyptus, so show raw sim + note high conf override | |
| sim_note = " (high conf overrides low sim - dummy centroids, rebuild for true)" if meta['unknown_info']['sim_max'] < meta['thr_cos'] else "" | |
| print(f"Known species: sim {meta['unknown_info']['sim_max']:.3f} thr {meta['thr_cos']:.2f} conf {meta['unknown_info']['conf']:.2%}{sim_note}") | |
| else: | |
| print(f"Known check (no centroids): conf {meta['unknown_info']['conf']:.2%} margin {meta['unknown_info']['margin']:.3f}") | |
| print("-"*70) | |
| for r in meta["topk"]: | |
| marker="*" if r["rank"]==1 else " " | |
| flag=" ?" if meta["is_unknown"] else "" | |
| print(f"{marker} {r['rank']}. {r['species']:<45} {r['prob']:6.2%} logit {r['logit']:6.2f}{flag}") | |
| print("-"*70) | |
| print(f"Confidence {meta['confidence']:.2%} Margin {meta['margin']:.3f} Top-{args.topk} sum {sum([x['prob'] for x in meta['topk']]):.2%}") | |
| if len(image_paths)>1: | |
| print(f"Multi-view boost: single 89.21% -> 4-view 99.33% (pseudo) / 99.2% (train)") | |
| # process-of-elimination hint | |
| if meta["is_unknown"]: | |
| print("Hint: UNKNOWN -> try flora description + geo, or collect more views. If truly unknown, consider iNaturalist/GBIF search outside 999.") | |
| elif meta["topk"][0]["prob"] < 0.6: | |
| print("Hint: Top-1 <60% -> try another angle/flower/leaf + text e.g. 'yellow puff' + geo for elimination") | |
| # save outputs | |
| 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"]} | |
| if args.json: | |
| Path(args.json).write_text(json.dumps(out, indent=2), encoding="utf-8") | |
| print(f"wrote {args.json}") | |
| if args.output: | |
| # csv | |
| pd.DataFrame(meta["topk"]).to_csv(args.output, index=False) | |
| print(f"wrote {args.output}") | |
| if args.vis: | |
| # visualize topk bar + image + feature heatmap (where ViT looks) | |
| try: | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| n=len(image_paths) | |
| # --- generate heatmap for first image (where feature extractor finds features) --- | |
| heat_arr = None | |
| try: | |
| vm, _, _ = load_visual_model(device=device) | |
| heat_arr, hm_idx, _ = get_vit_heatmap(vm, image_paths[0], device=device, img_size=args.size) | |
| except Exception as e: | |
| log(f"heatmap gen failed {e}") | |
| heat_arr = None | |
| # 1x3 layout: image | heatmap overlay | bar (if heatmap available, else 1x2) | |
| if heat_arr is not None: | |
| fig, axes = plt.subplots(1, 3, figsize=(18,5), gridspec_kw={"width_ratios":[1,1,1.2]}) | |
| ax_img, ax_heat, ax_bar = axes | |
| # original image | |
| im_raw=open_image_pil(image_paths[0]).resize((args.size,args.size)) | |
| ax_img.imshow(im_raw) | |
| title=f"{meta['topk'][0]['species']}\n{meta['topk'][0]['prob']:.1%} conf {meta['confidence']:.1%} {dt*1000:.0f}ms" | |
| if meta["is_unknown"]: | |
| title="UNKNOWN\n"+title | |
| if len(image_paths)>1: title+=f" {n}-view" | |
| ax_img.set_title(title, fontsize=9, color="red" if meta["is_unknown"] else "black") | |
| ax_img.axis("off") | |
| # heatmap overlay (jet) | |
| ax_heat.imshow(im_raw) | |
| ax_heat.imshow(heat_arr, cmap="jet", alpha=0.55, vmin=0, vmax=1) | |
| ax_heat.set_title(f"Feature heatmap\nTop patch {hm_idx} {heat_arr.max():.2f}", fontsize=9, color="red" if meta["is_unknown"] else "black") | |
| ax_heat.axis("off") | |
| else: | |
| fig, axes = plt.subplots(1, 2, figsize=(14,5), gridspec_kw={"width_ratios":[1,1.2]}) | |
| ax_img, ax_bar = axes | |
| im=open_image_pil(image_paths[0]).resize((336,336)) | |
| ax_img.imshow(im) | |
| title=f"{meta['topk'][0]['species']}\n{meta['topk'][0]['prob']:.1%} conf {meta['confidence']:.1%} {dt*1000:.0f}ms" | |
| if meta["is_unknown"]: | |
| title="UNKNOWN\n"+title | |
| if len(image_paths)>1: title+=f" {n}-view" | |
| ax_img.set_title(title, fontsize=9, color="red" if meta["is_unknown"] else "black") | |
| ax_img.axis("off") | |
| # heat axis not present, will reuse ax_bar below | |
| # bar chart (common) | |
| if heat_arr is not None: | |
| probs_bar=[r["prob"] for r in meta["topk"]] | |
| species=[r["species"] for r in meta["topk"]] | |
| else: | |
| probs_bar=[r["prob"] for r in meta["topk"]] | |
| species=[r["species"] for r in meta["topk"]] | |
| # ax_bar is defined in both branches (for heat case it's third axis) | |
| colors=["red" if meta["is_unknown"] and i==0 else "green" if i==0 else "steelblue" for i in range(len(probs_bar))] | |
| bars=ax_bar.barh(range(len(probs_bar))[::-1], probs_bar[::-1], color=colors[::-1]) | |
| ax_bar.set_yticks(range(len(probs_bar))[::-1]) | |
| ax_bar.set_yticklabels([f"{r['rank']}. {s[:32]}" for r,s in zip(meta["topk"], species)][::-1], fontsize=7) | |
| ax_bar.set_xlabel("prob") | |
| 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") | |
| for p, bar in zip(probs_bar[::-1], bars): | |
| ax_bar.text(p+0.01, bar.get_y()+bar.get_height()/2, f"{p:.1%}", va="center", fontsize=7) | |
| if meta["is_unknown"]: | |
| 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) | |
| plt.tight_layout() | |
| plt.savefig(args.vis, dpi=150) | |
| print(f"wrote {args.vis} {'with heatmap' if heat_arr is not None else ''}") | |
| except Exception as e: | |
| import traceback; traceback.print_exc() | |
| log(f"vis failed {e}") | |
| def cmd_batch(args): | |
| device="cuda" if torch.cuda.is_available() and not args.cpu else "cpu" | |
| dirp=Path(args.dir) | |
| if not dirp.exists(): | |
| log(f"ERROR dir not found {dirp}") | |
| sys.exit(1) | |
| pattern=args.pattern | |
| files=list(dirp.rglob(pattern) if args.recursive else dirp.glob(pattern)) | |
| # filter images | |
| exts={".jpg",".jpeg",".png",".tif",".tiff",".heic",".webp",".bmp"} | |
| files=[f for f in files if f.suffix.lower() in exts] | |
| # optional DSLR RAW -> ignore | |
| if not files: | |
| log(f"no images found {dirp} {pattern}") | |
| sys.exit(1) | |
| files=sorted(files)[:args.limit] if args.limit else sorted(files) | |
| log(f"batch {len(files)} images {dirp} pattern {pattern} text={args.text}") | |
| # group for 4-view: if filenames share prefix before _ or -? Simple: each image independent unless --group | |
| # For seamless, each image => independent predict, but if --group 4 => chunk 4 | |
| rows=[] | |
| centroids_path = Path(args.centroids) if getattr(args, 'centroids', None) else CENTROIDS_PATH | |
| thr_cos = getattr(args, 'thr_cos', DEFAULT_COS_THR) | |
| thr_conf = getattr(args, 'thr_conf', DEFAULT_CONF_THR) | |
| if args.group: | |
| # group by stem prefix or consecutive 4 | |
| for i in range(0,len(files),args.group): | |
| chunk=files[i:i+args.group] | |
| 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) | |
| for r in meta["topk"]: | |
| 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"]}) | |
| log(f"group {i//args.group} {chunk[0].name} -> {meta['topk'][0]['species']} {meta['topk'][0]['prob']:.1%} {'UNKNOWN' if meta['is_unknown'] else ''}") | |
| else: | |
| for idx, f in enumerate(files): | |
| 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) | |
| for r in meta["topk"]: | |
| 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"]}) | |
| if idx%10==0: | |
| 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}") | |
| if args.out: | |
| outp=Path(args.out) | |
| df=pd.DataFrame(rows) | |
| if outp.suffix==".json": | |
| outp.write_text(json.dumps(rows, indent=2), encoding="utf-8") | |
| else: | |
| df.to_csv(outp, index=False) | |
| print(f"wrote {outp} {len(rows)} rows") | |
| else: | |
| # print summary | |
| print(json.dumps(rows[:5], indent=2)) | |
| print(f"... {len(rows)} rows, use --out results.csv to save") | |
| def cmd_info(args): | |
| print("WA Plant Identifier — Built with DINOv3") | |
| print("Models:") | |
| # show both private and public candidates | |
| cand_paths = [_resolve_ckpt(CKPT_VISUAL)] + PUBLIC_CKPTS | |
| seen=set() | |
| for p in cand_paths: | |
| if str(p) in seen: continue | |
| seen.add(str(p)) | |
| exists=p.exists() | |
| try: | |
| rel=p.relative_to(BASE) | |
| except ValueError: | |
| rel=p | |
| size=f"{p.stat().st_size/1e6:.1f}MB" if exists and p.is_file() else ("dir" if exists else "missing") | |
| print(f" {rel} {size} {'OK' if exists else 'MISSING'}") | |
| for p in [CKPT_FUSION, E5_DIR, MANIFEST, SPECIES_JSON, CENTROIDS_PATH]: | |
| exists=p.exists() | |
| try: | |
| rel=p.relative_to(BASE) | |
| except ValueError: | |
| rel=p | |
| 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") | |
| if p==CENTROIDS_PATH and exists: | |
| try: | |
| data=np.load(str(p), allow_pickle=True) | |
| if "centroids" in data: | |
| cents=data["centroids"] | |
| size=f"{p.stat().st_size/1e6:.1f}MB {cents.shape} ~{cents.nbytes/1e6:.1f}MB cache" | |
| except: pass | |
| print(f" {rel} {size} {'OK' if exists else 'MISSING'}") | |
| try: | |
| s2i,i2s,spp = load_species_map() | |
| print(f"Species {len(spp)} ({len(spp)} loaded) e.g. {spp[0]}, {spp[10] if len(spp)>10 else spp[-1]}") | |
| except Exception as e: | |
| print(f"Species map failed: {e}") | |
| spp=[] | |
| print(f"Device {'cuda '+torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'cpu'} torch {torch.__version__}") | |
| print(f"Benchmark val 27673: visual 89.21% Top-1 96.19% Top-5 -> fusion with text 92.00% 97.51%") | |
| 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") | |
| print(f"Unknown detection: centroids {CENTROIDS_PATH.name} ~2MB (999x512) | thr_cos {DEFAULT_COS_THR} thr_conf {DEFAULT_CONF_THR} | cached on first load") | |
| print("\nLicensing: DINOv3 Meta commercial grant required (LICENSE.md), text e5 MIT, data GBIF per-image BY/BY-NC") | |
| if _resolve_ckpt(CKPT_VISUAL).exists(): | |
| print(f"Active ckpt: {_resolve_ckpt(CKPT_VISUAL).name} ({_resolve_ckpt(CKPT_VISUAL).stat().st_size/1e6:.1f}MB)") | |
| else: | |
| print("Active ckpt: MISSING - add PlantDetect-*.safetensors to project root") | |
| cents, meta = load_centroids(CENTROIDS_PATH) | |
| if cents is not None: | |
| print(f"Centroids: {cents.shape} loaded, thr_cos {meta.get('thr_cos', DEFAULT_COS_THR) if meta else DEFAULT_COS_THR}") | |
| else: | |
| print(f"Centroids: MISSING -> run `python plant_cli.py build-centroids --dummy` for placeholder or --manifest for true") | |
| def cmd_build_centroids(args): | |
| # Build 999x512 centroids ~2MB cache | |
| out = Path(args.out) if args.out else CENTROIDS_PATH | |
| thr_cos = args.thr_cos | |
| # try real manifest | |
| manifest = Path(args.manifest) if args.manifest else MANIFEST | |
| if not args.dummy and manifest.exists(): | |
| log(f"building centroids from manifest {manifest} (true per-class mean)...") | |
| # need to load model and iterate dataset | |
| device="cuda" if torch.cuda.is_available() and not args.cpu else "cpu" | |
| model, s2i, i2s = load_visual_model(device=device) | |
| s2i_manifest, i2s_manifest, spp_manifest = load_species_map(manifest) | |
| # but use species_labels.json order for centroids | |
| s2i_lab, i2s_lab, spp_lab = load_species_map() | |
| # map manifest species to lab index | |
| # collect embeddings per class | |
| from src.data.plant import PlantDataset # reuse if needed but manifest is different format | |
| # Instead manual csv reading like PlantDataset but for our manifest | |
| import csv | |
| from collections import defaultdict | |
| rows=[] | |
| with open(manifest, newline="", encoding="utf-8") as f: | |
| for r in csv.DictReader(f): | |
| if r.get("split","train")==args.split and r.get("status","downloaded") in ("downloaded","skip_exists"): | |
| # path handling | |
| p=Path(r.get("path","")) | |
| if not p.exists(): | |
| # try gbifID under data/wa_plants | |
| gbif=r.get("gbifID","") | |
| base=BASE/"data"/"wa_plants" | |
| for split in ("train","val"): | |
| cand=base/split/r.get("species","").replace(" ","_").replace("/","_")[:120]/f"{gbif}.jpg" | |
| if cand.exists(): | |
| p=cand | |
| break | |
| if p.exists(): | |
| rows.append((r["species"], p)) | |
| if args.limit and len(rows)>=args.limit: | |
| break | |
| if not rows: | |
| log(f"no rows found in {manifest} for split {args.split}, fallback to dummy") | |
| args.dummy=True | |
| else: | |
| # group by species | |
| from collections import defaultdict | |
| per_species=defaultdict(list) | |
| for spp_name, p in rows: | |
| if spp_name in s2i_lab: | |
| per_species[spp_name].append(p) | |
| tf=get_transform(336) | |
| centroids=np.zeros((len(spp_lab), 512), dtype=np.float32) | |
| counts=np.zeros(len(spp_lab), dtype=int) | |
| model.eval() | |
| for idx, spp_name in enumerate(spp_lab): | |
| paths=per_species.get(spp_name, []) | |
| if not paths: | |
| # no data -> keep random small | |
| centroids[idx]=np.random.randn(512).astype(np.float32) | |
| continue | |
| embs=[] | |
| for p in paths[:args.per_class]: | |
| try: | |
| im=open_image_pil(p) | |
| x=tf(im).unsqueeze(0).to(device) | |
| with torch.no_grad(): | |
| with torch.autocast("cuda", dtype=torch.bfloat16, enabled=device=="cuda"): | |
| _, emb, _ = model(x) | |
| embs.append(emb.float().cpu().numpy()[0]) | |
| except Exception as e: | |
| continue | |
| if embs: | |
| embs=np.stack(embs) | |
| # L2 normalize then mean then normalize | |
| embs=embs/np.linalg.norm(embs, axis=1, keepdims=True).clip(min=1e-8) | |
| mean=embs.mean(0) | |
| mean=mean/np.linalg.norm(mean).clip(min=1e-8) | |
| centroids[idx]=mean | |
| counts[idx]=len(embs) | |
| else: | |
| centroids[idx]=np.random.randn(512).astype(np.float32) | |
| if idx%100==0: | |
| log(f"{idx+1}/{len(spp_lab)} {spp_name} {len(paths)} imgs -> {counts[idx]} used") | |
| # save | |
| 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) | |
| log(f"wrote centroids {centroids.shape} {centroids.nbytes/1e6:.1f}MB to {out} (per_class {args.per_class}, {counts.sum()} embeddings)") | |
| return | |
| if args.dummy: | |
| log(f"building DUMMY centroids (placeholder, 2MB) -> run with --manifest for true centroids. Using green-centered + hue mix for demo.") | |
| # Build proxy centroids that cover plant color space ~ green-centered + small noise | |
| # Use green reference embedding as base (plant manifold) | |
| device="cuda" if torch.cuda.is_available() and not args.cpu else "cpu" | |
| model, s2i, i2s = load_visual_model(device=device) | |
| s2i_lab, i2s_lab, spp_lab = load_species_map() | |
| # get green base emb | |
| ref_pil=Image.new("RGB",(336,336),(60,120,60)) # muted green (plant) | |
| tf=get_transform(336) | |
| x=tf(ref_pil).unsqueeze(0).to(device) | |
| with torch.no_grad(): | |
| with torch.autocast("cuda", dtype=torch.bfloat16, enabled=device=="cuda"): | |
| _, emb_base, _ = model(x) | |
| emb_base=emb_base.float().cpu().numpy()[0] | |
| emb_base=emb_base/np.linalg.norm(emb_base) | |
| # generate 999 centroids around base with small per-class offset (hue) | |
| np.random.seed(42) | |
| centroids=np.zeros((len(spp_lab),512), dtype=np.float32) | |
| for i in range(len(spp_lab)): | |
| # per-class hue offset: small deterministic vector | |
| noise=np.random.randn(512).astype(np.float32)*0.04 | |
| # also add hue-like variation for diversity: species name hash | |
| h=hash(spp_lab[i]) % 1000 / 1000.0 | |
| noise[0]+= (h-0.5)*0.02 | |
| c=emb_base + noise | |
| c=c/np.linalg.norm(c) | |
| centroids[i]=c | |
| # recalibrate thr: use 0.50 for dummy (as tested: green 0.72 vs noise 0.42) | |
| 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)) | |
| # also save fp16 for half size | |
| size_mb=Path(out).stat().st_size/1e6 | |
| raw_mb=centroids.nbytes/1e6 | |
| 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)") | |
| 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") | |
| return | |
| log(f"ERROR: no manifest {manifest} and --dummy not set. Use --dummy for placeholder or provide --manifest") | |
| def cmd_test_unknown(args): | |
| # Quick test: known vs unknown images with current centroids | |
| centroids_path=Path(args.centroids) if args.centroids else CENTROIDS_PATH | |
| centroids, meta = load_centroids(centroids_path) | |
| thr_cos = args.thr_cos if args.thr_cos else (float(meta.get("thr_cos", DEFAULT_COS_THR)) if meta else DEFAULT_COS_THR) | |
| thr_conf = args.thr_conf if args.thr_conf else DEFAULT_CONF_THR | |
| device="cuda" if torch.cuda.is_available() and not args.cpu else "cpu" | |
| if not args.images or len(args.images)<2: | |
| log("need at least 2 images: known + unknown") | |
| sys.exit(1) | |
| for p in args.images: | |
| path=Path(p) | |
| if not path.exists(): | |
| log(f"not found {path}") | |
| continue | |
| meta_res, _, _, _, _ = predict_images([path], device=device, centroids_path=centroids_path, thr_cos=thr_cos, thr_conf=thr_conf) | |
| status="UNKNOWN" if meta_res["is_unknown"] else "KNOWN" | |
| 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%}") | |
| if meta_res["is_unknown"]: | |
| print(f" REASON: {meta_res['unknown_info']['reasons']}") | |
| def main(): | |
| parser=argparse.ArgumentParser(description="WA Plant Identifier — Built with DINOv3 | DSLR/phone seamless CLI", formatter_class=argparse.RawTextHelpFormatter) | |
| parser.add_argument("--cpu", action="store_true", help="force CPU") | |
| sub=parser.add_subparsers(dest="cmd", required=True) | |
| p_id=sub.add_parser("identify", help="identify 1-4 images (DLSR/phone JPEG)") | |
| p_id.add_argument("images", nargs="+", help="image path(s) 1-4 for multi-view") | |
| p_id.add_argument("--text", type=str, default=None, help='optional user text e.g. "yellow puff flower red loam" for +2.8 pct') | |
| p_id.add_argument("--lat", type=float, default=None, help="latitude for geo prior") | |
| p_id.add_argument("--lon", type=float, default=None, help="longitude for geo prior") | |
| p_id.add_argument("--topk", type=int, default=5, help="Top-K") | |
| p_id.add_argument("--size", type=int, default=336, help="image size") | |
| p_id.add_argument("--no-fusion", action="store_true", help="disable fusion even if text provided") | |
| p_id.add_argument("--fusion-thr", type=float, default=0.98, help="fusion gate conf thr 0.98") | |
| p_id.add_argument("--fusion-margin", type=float, default=0.70, help="fusion gate margin thr 0.70") | |
| p_id.add_argument("--json", type=str, default=None, help="save JSON") | |
| p_id.add_argument("--output", type=str, default=None, help="save CSV") | |
| p_id.add_argument("--vis", type=str, default=None, help="save visualization PNG") | |
| p_id.add_argument("--centroids", type=str, default=None, help="centroids npz path (default species_centroids.npz, ~2MB)") | |
| p_id.add_argument("--thr-cos", type=float, default=DEFAULT_COS_THR, help=f"unknown cos thr {DEFAULT_COS_THR}") | |
| p_id.add_argument("--thr-conf", type=float, default=DEFAULT_CONF_THR, help=f"unknown conf thr {DEFAULT_CONF_THR}") | |
| p_id.add_argument("--thr-margin", type=float, default=DEFAULT_MARGIN_THR, help=f"unknown margin thr {DEFAULT_MARGIN_THR}") | |
| p_id.add_argument("--no-centroids", action="store_true", help="disable centroid unknown check (use conf/margin only)") | |
| p_b=sub.add_parser("batch", help="batch folder of DSLR/phone images") | |
| p_b.add_argument("--dir", type=str, required=True, help="folder with images") | |
| p_b.add_argument("--pattern", type=str, default="*.jpg", help="glob e.g. star.jpg or star.JPG") | |
| p_b.add_argument("--recursive", action="store_true", help="rglob") | |
| p_b.add_argument("--group", type=int, default=0, help="group N images as multi-view (e.g. 4)") | |
| p_b.add_argument("--text", type=str, default=None, help="optional text for all") | |
| p_b.add_argument("--topk", type=int, default=5) | |
| p_b.add_argument("--out", type=str, default=None, help="out.csv or out.json") | |
| p_b.add_argument("--limit", type=int, default=0, help="limit N images for test") | |
| p_b.add_argument("--no-fusion", action="store_true") | |
| p_b.add_argument("--centroids", type=str, default=None, help="centroids path") | |
| p_b.add_argument("--thr-cos", type=float, default=DEFAULT_COS_THR) | |
| p_b.add_argument("--thr-conf", type=float, default=DEFAULT_CONF_THR) | |
| p_bench=sub.add_parser("info", help="show model info") | |
| p_build=sub.add_parser("build-centroids", help="build 999x512 centroid cache ~2MB") | |
| p_build.add_argument("--out", type=str, default=str(CENTROIDS_PATH), help="out npz") | |
| p_build.add_argument("--manifest", type=str, default=str(MANIFEST), help="manifest csv for true centroids") | |
| p_build.add_argument("--split", type=str, default="train", choices=["train","val","gold"]) | |
| p_build.add_argument("--per-class", type=int, default=10, help="images per class for mean") | |
| p_build.add_argument("--limit", type=int, default=0, help="limit total rows for quick test") | |
| p_build.add_argument("--thr-cos", type=float, default=DEFAULT_COS_THR, help="store thr") | |
| p_build.add_argument("--dummy", action="store_true", help="create dummy placeholder centroids (2MB) when no manifest") | |
| p_build.add_argument("--cpu", action="store_true", help="force CPU for build") | |
| p_test=sub.add_parser("test-unknown", help="test known vs unknown with centroids") | |
| p_test.add_argument("images", nargs="+", help="known.jpg unknown.jpg ...") | |
| p_test.add_argument("--centroids", type=str, default=None, help="centroids path") | |
| p_test.add_argument("--thr-cos", type=float, default=None) | |
| p_test.add_argument("--thr-conf", type=float, default=None) | |
| p_test.add_argument("--cpu", action="store_true") | |
| p_bench2=sub.add_parser("bench", help="bench on val/gold split") | |
| p_bench2.add_argument("--split", type=str, default="val", choices=["val","gold","train"]) | |
| p_bench2.add_argument("--manifest", type=str, default=str(MANIFEST)) | |
| args=parser.parse_args() | |
| if args.cmd=="identify": | |
| cmd_identify(args) | |
| elif args.cmd=="batch": | |
| cmd_batch(args) | |
| elif args.cmd=="info": | |
| cmd_info(args) | |
| elif args.cmd=="build-centroids": | |
| cmd_build_centroids(args) | |
| elif args.cmd=="test-unknown": | |
| cmd_test_unknown(args) | |
| elif args.cmd=="bench": | |
| # quick bench via bench_intense | |
| import subprocess | |
| ckpt = _resolve_ckpt(CKPT_VISUAL) | |
| 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")] | |
| print(" ".join(cmd)) | |
| subprocess.run(cmd) | |
| if __name__=="__main__": | |
| main() | |