| """Fine-tune a ResNet backbone for dog re-ID with triplet loss. Two interchangeable starting points |
| (pick with --init); no DINOv2 anywhere: |
| |
| --init resnet50 torchvision ResNet-50, ImageNet-1k pretrained (~25M params). The standard |
| person-re-ID backbone: generic features, no breed-invariance baggage, lighter/faster |
| at inference -- the baseline to beat. |
| --init breed your jhoppanne/Dogs-Breed-Image-Classification-V1 (ResNet-101, ~44M) with the |
| classifier head stripped. Dog-domain head start (matters at your scale), and its |
| training data (Stanford Dogs) is NOT your re-ID set, so validation stays clean -- |
| BUT it was trained toward breed-invariance (suppressing individual differences), so |
| we unfreeze deep stages to let triplet loss undo that collapse. NOTE: the shipped |
| checkpoint (best.pt) was trained with ``--unfreeze 1``, not the default below -- |
| diffing it against the pre-trained weights shows only the last stage changed, with |
| the earlier stages and the stem bit-identical. |
| |
| Both emit a 2048-d L2-normalized embedding (global-avg-pooled final conv features). |
| |
| Data (folder-per-identity), produced by scripts/ingest_reid_data.py: |
| FACE_ROOT/<dog_id>/*.jpg ~1400 face dogs (yours) |
| body_sources.json ~2800 body dogs (YT-BB-Dog + MPDD + your own) |
| sibetan_eval_manifest.json leakage-free cross-camera eval |
| |
| Online random-crop augmentation (random tighter crops labeled as the same identity) trains general |
| crop/scale invariance -- a cheap mitigation for the face/body framing gap; the Sibetan cross-camera |
| metric is what tells you whether any of this actually generalizes. |
| |
| Verify the HF ResNet stage attribute path (backbone.encoder.stages) against your transformers version, |
| and smoke-test on a tiny subset first. |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import random |
| import time |
| from dataclasses import dataclass |
| from pathlib import Path |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| import torchvision.transforms as T |
| from PIL import Image |
|
|
| |
| |
| |
| |
| import json |
| import os |
|
|
| REID_ROOT = Path(os.environ.get("REID_ROOT", Path(__file__).resolve().parents[2] / "reid_data")) |
| SPLIT_JSON = REID_ROOT / "split.json" |
| SIBETAN_MANIFEST = REID_ROOT / "sibetan_eval_manifest.json" |
| |
| |
| CKPT_DIR = Path(os.environ.get("CKPT_DIR", REID_ROOT / "checkpoints")) |
|
|
|
|
| def _resolve(p: str) -> Path: |
| q = Path(p) |
| return q if q.is_absolute() else (REID_ROOT / q) |
|
|
| |
| INIT = "breed" |
| BREED_CKPT = "jhoppanne/Dogs-Breed-Image-Classification-V1" |
| UNFREEZE_LAST_N_STAGES = 2 |
| TRIPLET_MARGIN = 0.45 |
| VAR_LOSS_WEIGHT = 0.5 |
| LR = 1e-4 |
| EPOCHS = 15 |
| STEPS_PER_EPOCH = 200 |
| IDENTITIES_PER_BATCH = 16 |
| PHOTOS_PER_IDENTITY = 4 |
| DOMAIN_RATIO = 0.5 |
| CROP_AUG_PROB = 0.5 |
| CROP_AUG_SCALE_RANGE = (0.4, 0.85) |
| DEVICE = "cuda" if torch.cuda.is_available() else "cpu" |
|
|
| IMAGENET_MEAN, IMAGENET_STD = [0.485, 0.456, 0.406], [0.229, 0.224, 0.225] |
| _preprocess = T.Compose([T.Resize((224, 224)), |
| T.ToTensor(), |
| T.Normalize(IMAGENET_MEAN, IMAGENET_STD)]) |
|
|
|
|
| |
| class ReIDModel(nn.Module): |
| """ResNet backbone -> 2048-d L2-normalized embedding, from one of two starting checkpoints.""" |
|
|
| def __init__(self, init: str = INIT): |
| super().__init__() |
| self.init = init |
| if init == "resnet50": |
| import torchvision |
| m = torchvision.models.resnet50(weights=torchvision.models.ResNet50_Weights.IMAGENET1K_V2) |
| m.fc = nn.Identity() |
| self.backbone = m |
| elif init == "breed": |
| from transformers import AutoModel |
| |
| self.backbone = AutoModel.from_pretrained(BREED_CKPT) |
| else: |
| raise ValueError(f"unknown init: {init!r}") |
|
|
| def forward(self, images: list[Image.Image]) -> torch.Tensor: |
| dev = next(self.parameters()).device |
| x = torch.stack([_preprocess(im.convert("RGB")) for im in images]).to(dev) |
| if self.init == "resnet50": |
| feats = self.backbone(x) |
| else: |
| feats = self.backbone(x).pooler_output.flatten(1) |
| return F.normalize(feats, dim=1) |
|
|
|
|
| def freeze_backbone(model: ReIDModel, n_stages: int = UNFREEZE_LAST_N_STAGES) -> None: |
| """Freeze early stages, unfreeze the last ``n_stages`` (+ their norms) so triplet loss reshapes |
| only the high-level semantics -- keeps generic edge/texture features, adapts identity.""" |
| for p in model.backbone.parameters(): |
| p.requires_grad = False |
| if model.init == "resnet50": |
| stages = [model.backbone.layer1, model.backbone.layer2, |
| model.backbone.layer3, model.backbone.layer4] |
| else: |
| |
| stages = list(model.backbone.encoder.stages) |
| for stage in stages[-n_stages:]: |
| for p in stage.parameters(): |
| p.requires_grad = True |
|
|
|
|
| |
| def load_split() -> dict[str, dict[str, list[Path]]]: |
| """Load the fixed 80/10/10 split (build_split.py). Keys are 'face:<id>' / 'body:<...>'; paths are |
| resolved (absolute local paths, or bundle-relative on Colab). Returns {train,val,test}: {key:[paths]}. |
| Accepts the legacy 'eval' split name and exposes it as 'val' (so old split.json still loads).""" |
| raw = json.loads(SPLIT_JSON.read_text()) |
| if "eval" in raw and "val" not in raw: |
| raw["val"] = raw.pop("eval") |
| return {s: {k: [_resolve(p) for p in paths] for k, paths in d.items()} for s, d in raw.items()} |
|
|
|
|
| def by_domain(ids: dict[str, list[Path]], domain: str) -> dict[str, list[Path]]: |
| return {k: v for k, v in ids.items() if k.startswith(f"{domain}:")} |
|
|
|
|
| def load_sibetan_split(holdout_n: int, seed: int = 0) -> tuple[set[str], dict[str, list[Path]]]: |
| """Split Sibetan into a held-out cross-camera EVAL set and a TRAIN set. Only multi-camera dogs make |
| a useful cross-camera eval, so we hold out `holdout_n` of them (seeded, deterministic) and NEVER |
| train on those. Everything else -- the remaining multi-camera dogs + all single-camera dogs -- is |
| returned as body-domain training identities (Sibetan is the ONLY source of same-dog/different-scene |
| positive pairs, exactly the cross-camera invariance we otherwise can't teach). |
| |
| Returns (holdout_identities, train_ids{ 'body:sibetan__<id>': [paths] }).""" |
| if not SIBETAN_MANIFEST.exists(): |
| return set(), {} |
| entries = json.loads(SIBETAN_MANIFEST.read_text()) |
| cams: dict[str, set[str]] = {} |
| imgs: dict[str, list[Path]] = {} |
| for e in entries: |
| cams.setdefault(e["identity"], set()).add(e["camera"]) |
| imgs.setdefault(e["identity"], []).append(_resolve(e["path"])) |
| multi = sorted(i for i, c in cams.items() if len(c) > 1) |
| holdout = set(random.Random(seed).sample(multi, min(holdout_n, len(multi)))) if holdout_n else set() |
| train_ids = {f"body:sibetan__{i}": ps for i, ps in imgs.items() if i not in holdout} |
| return holdout, train_ids |
|
|
|
|
| def random_view(img: Image.Image) -> Image.Image: |
| """With CROP_AUG_PROB, return a random tighter crop of the same photo (still the same identity); |
| otherwise the original. Applied to both domains -- for body photos this occasionally lands on |
| something close to a face crop by chance; always trains general scale/crop invariance.""" |
| if random.random() > CROP_AUG_PROB: |
| return img |
| w, h = img.size |
| scale = random.uniform(*CROP_AUG_SCALE_RANGE) |
| cw, ch = int(w * scale), int(h * scale) |
| x0 = random.randint(0, max(w - cw, 0)) |
| y0 = random.randint(0, max(h - ch, 0)) |
| return img.crop((x0, y0, x0 + cw, y0 + ch)) |
|
|
|
|
| @dataclass |
| class PKBatch: |
| images: list[Image.Image] |
| identity_ids: list[str] |
|
|
|
|
| class IdentityPool: |
| """An identity->photos map (keys already globally-unique), sampled P identities x K photos.""" |
|
|
| def __init__(self, ids: dict[str, list[Path]]): |
| self.ids = dict(ids) |
|
|
| def sample_batch(self, n_identities: int, k_photos: int) -> PKBatch: |
| keys = random.sample(list(self.ids), min(n_identities, len(self.ids))) |
| images, identity_ids = [], [] |
| for key in keys: |
| photos = self.ids[key] |
| chosen = random.sample(photos, min(k_photos, len(photos))) |
| while len(chosen) < k_photos: |
| chosen.append(random.choice(photos)) |
| for p in chosen: |
| images.append(random_view(Image.open(p).convert("RGB"))) |
| identity_ids.append(key) |
| return PKBatch(images, identity_ids) |
|
|
|
|
| def mixed_pk_batch(face_pool: IdentityPool, body_pool: IdentityPool, domain_ratio: float) -> PKBatch: |
| n_body = round(IDENTITIES_PER_BATCH * domain_ratio) |
| n_face = IDENTITIES_PER_BATCH - n_body |
| b = body_pool.sample_batch(n_body, PHOTOS_PER_IDENTITY) |
| f = face_pool.sample_batch(n_face, PHOTOS_PER_IDENTITY) |
| return PKBatch(b.images + f.images, b.identity_ids + f.identity_ids) |
|
|
|
|
| |
| def batch_hard_triplet_loss(embeddings: torch.Tensor, identity_ids: list[str], |
| margin: float = TRIPLET_MARGIN) -> torch.Tensor: |
| dist = torch.cdist(embeddings, embeddings, p=2) |
| index = {key: i for i, key in enumerate(dict.fromkeys(identity_ids))} |
| ids = torch.tensor([index[i] for i in identity_ids], device=embeddings.device) |
| same = ids.unsqueeze(0) == ids.unsqueeze(1) |
| diff = ~same |
| same.fill_diagonal_(False) |
|
|
| hardest_pos = (dist * same).max(dim=1).values |
| hardest_neg = dist.masked_fill(~diff, float("inf")).min(dim=1).values |
| return F.relu(hardest_pos - hardest_neg + margin).mean() |
|
|
|
|
| def intra_identity_variance_loss(embeddings: torch.Tensor, identity_ids: list[str]) -> torch.Tensor: |
| total = torch.tensor(0.0, device=embeddings.device) |
| seen = set(identity_ids) |
| for key in seen: |
| idx = [i for i, k in enumerate(identity_ids) if k == key] |
| if len(idx) < 2: |
| continue |
| group = embeddings[idx] |
| total = total + group.var(dim=0, unbiased=False).mean() |
| return total / max(len(seen), 1) |
|
|
|
|
| def reid_loss(embeddings: torch.Tensor, identity_ids: list[str]) -> torch.Tensor: |
| return (batch_hard_triplet_loss(embeddings, identity_ids) |
| + VAR_LOSS_WEIGHT * intra_identity_variance_loss(embeddings, identity_ids)) |
|
|
|
|
| |
| def retrieval_metrics(E_sub: torch.Tensor, lab_sub: torch.Tensor) -> dict[str, float]: |
| """Retrieval quality for a single modality. Every image is a query; the gallery is all OTHER images |
| in the group (self excluded). R@k = fraction of queries with a same-dog match in the top k. mAP = |
| mean average precision -- for each query it averages the precision at every rank where a true match |
| sits, so it rewards pushing ALL of a dog's photos up, not just the first. mAP is far stabler than |
| R@1 (it moves on any ranking gain), which is why it's the selection/early-stop metric. Only queries |
| that have >=1 same-dog match are scored.""" |
| sim = E_sub @ E_sub.t() |
| sim.fill_diagonal_(-2.0) |
| same = lab_sub.unsqueeze(0) == lab_sub.unsqueeze(1) |
| same.fill_diagonal_(False) |
| has_pos = same.any(dim=1) |
| n = int(has_pos.sum()) |
| if n == 0: |
| return {"r1": 0.0, "r5": 0.0, "r10": 0.0, "map": 0.0, "n": 0} |
| order = sim.argsort(dim=1, descending=True) |
| rel = torch.gather(same, 1, order).float() |
| rk = lambda k: round((rel[:, :k].sum(dim=1) > 0)[has_pos].float().mean().item(), 3) |
| prec = rel.cumsum(dim=1) / torch.arange(1, rel.shape[1] + 1).float() |
| ap = (prec * rel).sum(dim=1) / same.sum(dim=1).clamp(min=1) |
| return {"r1": rk(1), "r5": rk(5), "r10": rk(10), |
| "map": round(ap[has_pos].mean().item(), 3), "n": n} |
|
|
|
|
| @torch.no_grad() |
| def evaluate_indomain(model: ReIDModel, ids: dict[str, list[Path]], batch: int = 32, |
| with_loss: bool = False, loss_batches: int = 8) -> dict[str, float]: |
| """Held-out (val or test) retrieval, reported separately for faces and bodies: face queries |
| retrieve the face gallery, body queries the body gallery. Returns R@1/5/10 + mAP per modality. |
| with_loss=True also returns 'val_loss' -- the SAME reid_loss used in training, averaged over a |
| FIXED (seeded) set of PK samples drawn from the already-computed embeddings, so it's deterministic |
| and comparable epoch-to-epoch with no extra forward passes. Rising val_loss while train loss keeps |
| falling = overfitting.""" |
| model.eval() |
| paths, labels = [], [] |
| for k, ps in ids.items(): |
| for p in ps: |
| paths.append(p); labels.append(k) |
| embs = [] |
| for i in range(0, len(paths), batch): |
| imgs = [Image.open(p).convert("RGB") for p in paths[i:i + batch]] |
| embs.append(model(imgs)) |
| E = torch.cat(embs).cpu() |
| order = {k: i for i, k in enumerate(dict.fromkeys(labels))} |
| lab = torch.tensor([order[l] for l in labels]) |
| is_face = torch.tensor([l.startswith("face:") for l in labels]) |
|
|
| def domain(domain_face: bool) -> dict[str, float]: |
| sel = (is_face == domain_face).nonzero(as_tuple=True)[0] |
| if len(sel) < 2: |
| return {"r1": float("nan"), "r5": float("nan"), "r10": float("nan"), "map": float("nan"), "n": 0} |
| return retrieval_metrics(E[sel], lab[sel]) |
|
|
| val_loss = float("nan") |
| if with_loss: |
| by_id: dict[int, list[int]] = {} |
| for i, l in enumerate(lab.tolist()): |
| by_id.setdefault(l, []).append(i) |
| usable = sorted(k for k, v in by_id.items() if len(v) >= 2) |
| rng = random.Random(1234) |
| losses = [] |
| for _ in range(loss_batches): |
| keys = rng.sample(usable, min(IDENTITIES_PER_BATCH, len(usable))) |
| rows, idl = [], [] |
| for k in keys: |
| pick = rng.sample(by_id[k], min(PHOTOS_PER_IDENTITY, len(by_id[k]))) |
| rows += pick; idl += [str(k)] * len(pick) |
| losses.append(reid_loss(E[rows], idl).item()) |
| val_loss = round(sum(losses) / max(len(losses), 1), 4) |
|
|
| model.train() |
| f, b = domain(True), domain(False) |
| return {"face_rank1": f["r1"], "face_r5": f["r5"], "face_r10": f["r10"], "face_map": f["map"], "face_n": f["n"], |
| "body_rank1": b["r1"], "body_r5": b["r5"], "body_r10": b["r10"], "body_map": b["map"], "body_n": b["n"], |
| "val_loss": val_loss} |
|
|
|
|
| @torch.no_grad() |
| def evaluate_sibetan(model: ReIDModel, manifest_path: Path = SIBETAN_MANIFEST, |
| batch: int = 32, query_ids: set[str] | None = None) -> dict[str, float]: |
| """Leakage-free cross-camera re-ID metric on Sibetan (cross-camera, multi-day camera traps). |
| For each image, retrieve its nearest neighbor among images from a DIFFERENT track (stricter: a |
| different camera) and check identity. Excluding same-track candidates removes same-appearance |
| background cheating, so this measures whether the model learned THE DOG, not the scene. |
| |
| query_ids (optional): only score queries whose identity is in this set (the held-out cross-camera |
| dogs, which are never trained on). The gallery still spans ALL Sibetan images, so trained dogs act |
| as same-domain distractors -- keeps the metric hard AND leakage-free (held-out dogs' images are |
| never used as training queries/positives). query_ids=None scores every dog (old behavior).""" |
| if not manifest_path.exists(): |
| return {} |
| entries = json.loads(manifest_path.read_text()) |
| model.eval() |
| embs = [] |
| for i in range(0, len(entries), batch): |
| imgs = [Image.open(_resolve(e["path"])).convert("RGB") for e in entries[i:i + batch]] |
| embs.append(model(imgs)) |
| E = torch.cat(embs).cpu() |
| sim = E @ E.t() |
|
|
| def code(field: str) -> torch.Tensor: |
| order = {v: i for i, v in enumerate(dict.fromkeys(e[field] for e in entries))} |
| return torch.tensor([order[e[field]] for e in entries]) |
|
|
| ident, track, camera = code("identity"), code("track"), code("camera") |
| same_ident = ident.unsqueeze(0) == ident.unsqueeze(1) |
| |
| is_query = (torch.tensor([e["identity"] in query_ids for e in entries]) |
| if query_ids is not None else torch.ones(len(entries), dtype=torch.bool)) |
|
|
| def recall(group: torch.Tensor) -> tuple[float, float, float, float, int]: |
| |
| same_group = group.unsqueeze(0) == group.unsqueeze(1) |
| valid = ~same_group |
| same = same_ident & valid |
| has_pos = same.any(dim=1) & is_query |
| n = int(has_pos.sum()) |
| if n == 0: |
| return 0.0, 0.0, 0.0, 0.0, 0 |
| sim_v = sim.masked_fill(~valid, -2.0) |
| order = sim_v.argsort(dim=1, descending=True) |
| rel = torch.gather(same, 1, order).float() |
| rk = lambda k: round((rel[:, :k].sum(dim=1) > 0)[has_pos].float().mean().item(), 3) |
| prec = rel.cumsum(dim=1) / torch.arange(1, rel.shape[1] + 1).float() |
| ap = (prec * rel).sum(dim=1) / same.sum(dim=1).clamp(min=1) |
| return rk(1), rk(5), rk(10), round(ap[has_pos].mean().item(), 3), n |
|
|
| model.train() |
| xt1, xt5, xt10, xtm, nt = recall(track) |
| xc1, xc5, xc10, xcm, nc = recall(camera) |
| return {"xtrack_r1": xt1, "xtrack_r5": xt5, "xtrack_r10": xt10, "xtrack_map": xtm, "xtrack_n": nt, |
| "xcam_r1": xc1, "xcam_r5": xc5, "xcam_r10": xc10, "xcam_map": xcm, "xcam_n": nc} |
|
|
|
|
| |
| def _fmt_secs(s: float) -> str: |
| m, sec = divmod(int(s), 60); h, m = divmod(m, 60) |
| return f"{h}h{m:02d}m{sec:02d}s" if h else f"{m}m{sec:02d}s" |
|
|
|
|
| def _bucket_table(title: str, ev: dict, sib: dict) -> str: |
| """Aligned face/body/track/cam table: mAP + R@1/5/10 + n. (track/cam only if Sibetan present.)""" |
| rows = [("face", ev["face_map"], ev["face_rank1"], ev["face_r5"], ev["face_r10"], ev["face_n"]), |
| ("body", ev["body_map"], ev["body_rank1"], ev["body_r5"], ev["body_r10"], ev["body_n"])] |
| if sib: |
| rows += [("track", sib["xtrack_map"], sib["xtrack_r1"], sib["xtrack_r5"], sib["xtrack_r10"], sib["xtrack_n"]), |
| ("cam", sib["xcam_map"], sib["xcam_r1"], sib["xcam_r5"], sib["xcam_r10"], sib["xcam_n"])] |
| fnum = lambda v: f"{v:7.3f}" if (isinstance(v, float) and v == v) else f"{'n/a':>7}" |
| out = [f" {title}", f" {'bucket':<7}{'mAP':>7}{'R@1':>7}{'R@5':>7}{'R@10':>7}{'n':>8}"] |
| out += [f" {b:<7}{fnum(mp)}{fnum(r1)}{fnum(r5)}{fnum(r10)}{n:>8}" for b, mp, r1, r5, r10, n in rows] |
| return "\n".join(out) |
|
|
|
|
| def train(model: ReIDModel, face_pool: IdentityPool, body_pool: IdentityPool, val_set, |
| epochs: int, steps: int, eval_every: int, lr: float = LR, |
| patience: int = 0, min_delta: float = 0.0) -> None: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| opt = torch.optim.Adam((p for p in model.parameters() if p.requires_grad), lr=lr) |
| best_sel, best_epoch, stale, n_improved = -1.0, -1, 0, 0 |
| _mean = lambda *xs: sum(x for x in xs if x == x) / max(sum(x == x for x in xs), 1) |
| t_start = time.time() |
| for epoch in range(epochs): |
| t_ep = time.time() |
| model.train() |
| running = 0.0 |
| for _ in range(steps): |
| batch = mixed_pk_batch(face_pool, body_pool, DOMAIN_RATIO) |
| emb = model(batch.images) |
| loss = reid_loss(emb, batch.identity_ids) |
| opt.zero_grad(); loss.backward(); opt.step() |
| running += loss.item() |
| train_loss = running / steps |
| |
| if (epoch + 1) % eval_every == 0 or epoch == epochs - 1: |
| ev = evaluate_indomain(model, val_set, with_loss=True) |
| dt = (time.time() - t_ep) / 60.0 |
| sel = _mean(ev["face_map"], ev["body_map"]) |
| line = (f"epoch {epoch:03d} | {dt:.2f} min | " |
| f"loss tr={train_loss:.3f} val={ev['val_loss']:.3f} | sel(mAP)={sel:.3f} | " |
| f"body R@1={ev['body_rank1']:.3f} R@5={ev['body_r5']:.3f} | " |
| f"face R@1={ev['face_rank1']:.3f} R@5={ev['face_r5']:.3f}") |
| if sel > best_sel + min_delta: |
| best_sel, best_epoch, stale, n_improved = sel, epoch, 0, n_improved + 1 |
| torch.save(model.state_dict(), CKPT_DIR / "best.pt") |
| print(line + " *SAVED best") |
| print(_bucket_table(f"val @ new best (epoch {epoch})", ev, None)) |
| else: |
| stale += 1 |
| print(line + (f" (no improve {stale}/{patience})" if patience else "")) |
| if patience and stale >= patience: |
| print(f"\nEARLY STOP: {patience} evals with no improvement " |
| f"(best sel(mAP)={best_sel:.3f} @ epoch {best_epoch}).") |
| break |
| else: |
| print(f"epoch {epoch:03d} | {(time.time()-t_ep)/60.0:.2f} min | " |
| f"loss tr={train_loss:.3f} (eval skipped)") |
| print(f"\nBest sel(mAP)={best_sel:.3f} at epoch {best_epoch} ({n_improved} improvements, only best.pt kept). " |
| f"Total train time {_fmt_secs(time.time()-t_start)}.") |
|
|
|
|
| def run(init: str = INIT, epochs: int = EPOCHS, steps: int = STEPS_PER_EPOCH, |
| eval_every: int = 1, lr: float = LR, unfreeze: int = UNFREEZE_LAST_N_STAGES, |
| patience: int = 0, min_delta: float = 0.0, |
| sibetan_holdout: int = 3, sibetan_train: bool = False) -> None: |
| CKPT_DIR.mkdir(parents=True, exist_ok=True) |
| split = load_split() |
| train_set, val_set, test_set = split["train"], split["val"], split["test"] |
| nf = lambda s: sum(k.startswith("face:") for k in s) |
| |
| assert set(test_set).isdisjoint(train_set) and set(test_set).isdisjoint(val_set), "test set leaked!" |
|
|
| |
| |
| |
| if sibetan_train: |
| holdout, sib_train = load_sibetan_split(sibetan_holdout) |
| sib_query_ids = holdout |
| else: |
| holdout, sib_train, sib_query_ids = set(), {}, None |
|
|
| model = ReIDModel(init=init).to(DEVICE) |
| freeze_backbone(model, unfreeze) |
| trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) / 1e6 |
| total = sum(p.numel() for p in model.parameters()) / 1e6 |
| print(f"init={init} device={DEVICE} lr={lr} unfreeze={unfreeze}stages " |
| f"({trainable:.1f}M/{total:.1f}M trainable) | batch={IDENTITIES_PER_BATCH}ids x " |
| f"{PHOTOS_PER_IDENTITY} | epochs={epochs} steps={steps}") |
|
|
| face_pool = IdentityPool(by_domain(train_set, "face")) |
| body_ids = by_domain(train_set, "body") |
| body_ids.update(sib_train) |
| body_pool = IdentityPool(body_ids) |
| print(f"train {len(train_set)+len(sib_train)} " |
| f"({nf(train_set)}f/{len(train_set)-nf(train_set)}b + {len(sib_train)} sibetan) " |
| f"| val {len(val_set)} | test {len(test_set)} | sibetan held-out (xcam): " |
| f"{sorted(holdout) if holdout else 'none (eval on all)'}") |
|
|
| |
| |
| t_b = time.time() |
| bev = evaluate_indomain(model, val_set, with_loss=True) |
| bsib = evaluate_sibetan(model, query_ids=sib_query_ids) |
| bsel = (bev["face_map"] + bev["body_map"]) / 2 |
| print(f"\n=== BEFORE TRAINING (baseline, untrained) | eval took {(time.time()-t_b)/60.0:.2f} min ===") |
| print(f"baseline | val loss={bev['val_loss']:.3f} | sel(mAP)={bsel:.3f}") |
| print(_bucket_table("baseline (val + sibetan)", bev, bsib)) |
| print("=== training starts (Sibetan cam/track NOT evaluated again until the final TEST) ===") |
| train(model, face_pool, body_pool, val_set, epochs, steps, eval_every, lr, patience, min_delta) |
|
|
| |
| |
| model.load_state_dict(torch.load(CKPT_DIR / "best.pt")) |
| tev = evaluate_indomain(model, test_set) |
| tsib = evaluate_sibetan(model, query_ids=sib_query_ids) |
| print(_bucket_table("TEST (best ckpt, held out until now)", tev, tsib)) |
|
|
|
|
| if __name__ == "__main__": |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--init", choices=["breed", "resnet50"], default=INIT, |
| help="backbone starting checkpoint (default: breed)") |
| ap.add_argument("--epochs", type=int, default=1000, help="epoch CEILING; patience decides the real stop") |
| ap.add_argument("--steps", type=int, default=STEPS_PER_EPOCH, help="training steps per epoch") |
| ap.add_argument("--eval-every", type=int, default=1, help="run eval only every N epochs (CPU saver)") |
| ap.add_argument("--lr", type=float, default=LR, help="learning rate (lower = finer / less overfit)") |
| ap.add_argument("--unfreeze", type=int, default=UNFREEZE_LAST_N_STAGES, |
| help="ResNet stages to unfreeze, 1-4 (fewer = finer / less overfit)") |
| ap.add_argument("--batch-ids", type=int, default=IDENTITIES_PER_BATCH, help="P: identities per batch") |
| ap.add_argument("--batch-photos", type=int, default=PHOTOS_PER_IDENTITY, help="K: photos per identity") |
| ap.add_argument("--patience", type=int, default=30, |
| help="early-stop after this many evals with no improvement (0 = off, run all epochs)") |
| ap.add_argument("--min-delta", type=float, default=0.002, |
| help="minimum sel(mAP) gain to count as an improvement (ignores noise wiggle)") |
| ap.add_argument("--sibetan-train", action="store_true", |
| help="opt in: fold most of Sibetan into training, hold out --sibetan-holdout dogs " |
| "for xcam (default OFF: Sibetan is fully held out, xcam evaluated on all dogs)") |
| ap.add_argument("--sibetan-holdout", type=int, default=3, |
| help="with --sibetan-train: # of multi-camera dogs held out for the xcam check") |
| a = ap.parse_args() |
| IDENTITIES_PER_BATCH = a.batch_ids |
| PHOTOS_PER_IDENTITY = a.batch_photos |
| run(a.init, a.epochs, a.steps, a.eval_every, a.lr, a.unfreeze, a.patience, a.min_delta, |
| a.sibetan_holdout, a.sibetan_train) |
|
|