#!/usr/bin/env python3 """Train CellQuant on CVAT-annotated images. Examples -------- Final model on every annotated image: python train.py --data-dir /path/to/images_and_zips --out weights/cellquant.pt Leave-one-image-out fold (held-out image is only used for monitoring): python train.py --data-dir ... --holdout C110 --out runs/fold_C110.pt """ from __future__ import annotations import argparse import json import math import os import sys import time import numpy as np import torch sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from cellquant.annotations import discover, load_image, rasterize # noqa: E402 from cellquant.data import AugConfig, Prefetcher, make_targets, negative_nucleus_points # noqa: E402 from cellquant.infer import Predictor, pick_device # noqa: E402 from cellquant.losses import multitask_loss # noqa: E402 from cellquant.metrics import evaluate_cells, evaluate_nostrils # noqa: E402 from cellquant.model import CellUNet, save_checkpoint # noqa: E402 from cellquant.postprocess import PostConfig, analyze # noqa: E402 def parse_args(argv=None): ap = argparse.ArgumentParser() ap.add_argument("--data-dir", required=True) ap.add_argument("--out", required=True) ap.add_argument("--holdout", default="", help="image key to leave out (e.g. C110); empty = train on all") ap.add_argument("--iters", type=int, default=3000) ap.add_argument("--batch", type=int, default=4) ap.add_argument("--crop", type=int, default=512) ap.add_argument("--lr", type=float, default=3e-4) ap.add_argument("--weight-decay", type=float, default=1e-4) ap.add_argument("--warmup", type=int, default=100) ap.add_argument("--encoder", default="resnet34") ap.add_argument("--no-pretrained", action="store_true") ap.add_argument("--seed", type=int, default=0) ap.add_argument("--device", default="auto") ap.add_argument("--log-every", type=int, default=50) ap.add_argument("--val-every", type=int, default=500) ap.add_argument("--workers", type=int, default=3, help="augmentation threads") ap.add_argument("--p-focus", type=float, default=AugConfig.p_focus, help="share of crops forced to contain an antibody-negative nucleus (0 = off)") ap.add_argument("--ab-neg-weight", default="auto", help="loss weight of antibody-negative nucleus pixels: 'auto' = sqrt(pos px / neg px) clipped to [1, 5], or a number") return ap.parse_args(argv) def antibody_negative_weight(targets: list[dict], spec: str = "auto") -> float: """Class-balancing weight for the antibody head (see losses.masked_bce).""" if spec != "auto": return float(spec) pos = sum(float(((t["antibody"] > 0.5) & (t["cellmask"] > 0)).sum()) for t in targets) neg = sum(float(((t["antibody"] < 0.5) & (t["cellmask"] > 0)).sum()) for t in targets) if neg == 0: return 1.0 return float(np.clip(np.sqrt(pos / neg), 1.0, 5.0)) def lr_at(it: int, total: int, base: float, warmup: int) -> float: if it < warmup: return base * (it + 1) / warmup t = (it - warmup) / max(1, total - warmup) return base * (0.02 + 0.98 * 0.5 * (1 + math.cos(math.pi * t))) def quick_val(model: CellUNet, img: np.ndarray, R, device) -> dict: pred = Predictor(model, device=str(device), tta=1) res, _ = pred.predict(img, PostConfig()) ce = evaluate_cells(R.cell_inst, R.cell_labels, res.cell_inst, res.cell_labels) ne = evaluate_nostrils(R.nostril_inst, R.nostril_labels, res.nostril_inst, res.nostril_labels) return { "cell_det_f1": ce["detection"]["f1"], "cell_attr_both": ce["attribute_accuracy"]["both"], "nos_det_f1": ne["detection"]["f1"], "nos_type_acc": ne["type_accuracy"], "n_pred_cells": ce["n_pred"], "n_gt_cells": ce["n_gt"], } def main(argv=None): a = parse_args(argv) torch.manual_seed(a.seed) np.random.seed(a.seed) device = pick_device(a.device) samples, _ = discover(a.data_dir) if not samples: raise SystemExit("no annotated images found") train = [s for s in samples if s.key != a.holdout] held = [s for s in samples if s.key == a.holdout] if a.holdout and not held: raise SystemExit(f"holdout {a.holdout!r} not among {[s.key for s in samples]}") print(f"device={device} train={[s.key for s in train]} holdout={[s.key for s in held]}") images, targets, focus = [], [], [] for s in train: img = load_image(s.image_path) R = rasterize(s.annotation, img.shape[0], img.shape[1]) images.append(img) targets.append(make_targets(R)) focus.append(negative_nucleus_points(R)) ab_neg_weight = antibody_negative_weight(targets, a.ab_neg_weight) print(f"antibody-negative nuclei in training set: {sum(len(f) for f in focus)} " f"(loss weight {ab_neg_weight:.2f}, focus share {a.p_focus:.2f})") sampler = Prefetcher(images, targets, AugConfig(crop=a.crop, p_focus=a.p_focus), batch=a.batch, seed=a.seed, workers=a.workers, focus=focus if a.p_focus > 0 else None) held_img = held_R = None if held: held_img = load_image(held[0].image_path) held_R = rasterize(held[0].annotation, held_img.shape[0], held_img.shape[1]) model = CellUNet(a.encoder, pretrained=not a.no_pretrained).to(device) opt = torch.optim.AdamW(model.parameters(), lr=a.lr, weight_decay=a.weight_decay) history = {"train": [], "val": []} t0 = time.time() run = {} for it in range(a.iters): lr = lr_at(it, a.iters, a.lr, a.warmup) for g in opt.param_groups: g["lr"] = lr model.train() x, t = sampler.next() out = model(x.to(device)) loss, parts = multitask_loss(out, t, ab_neg_weight=ab_neg_weight) opt.zero_grad(set_to_none=True) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) opt.step() for k, v in parts.items(): run[k] = run.get(k, 0.0) + v if (it + 1) % a.log_every == 0: avg = {k: v / a.log_every for k, v in run.items()} run = {} avg["iter"] = it + 1 avg["lr"] = lr avg["elapsed_s"] = time.time() - t0 history["train"].append(avg) print(f"it {it+1:5d} loss {avg['total']:.3f} cell {avg['ce_cell']:.3f}/{avg['dice_cell']:.3f} " f"nos {avg['ce_nos']:.3f}/{avg['dice_nos']:.3f} ab {avg['bce_ab']:.3f} cn {avg['bce_cn']:.3f} " f"dist {avg.get('mse_dist', 0.0):.4f} lr {lr:.2e} {avg['elapsed_s']:.0f}s", flush=True) if held and ((it + 1) % a.val_every == 0 or it + 1 == a.iters): model.eval() v = quick_val(model, held_img, held_R, device) v["iter"] = it + 1 history["val"].append(v) print(f" val[{a.holdout}] cell F1 {v['cell_det_f1']:.3f} attr {v['cell_attr_both']:.3f} " f"nos F1 {v['nos_det_f1']:.3f} type {v['nos_type_acc']:.3f} (pred {v['n_pred_cells']} / gt {v['n_gt_cells']} cells)", flush=True) sampler.close() model.eval().cpu() os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True) save_checkpoint(model, a.out, { "config": {**vars(a), "ab_neg_weight_used": ab_neg_weight}, "train_keys": [s.key for s in train], "holdout": a.holdout, "history": history, "labels": list(__import__("cellquant.labels", fromlist=["ALL_LABELS"]).ALL_LABELS), }) with open(os.path.splitext(a.out)[0] + "_history.json", "w") as f: json.dump(history, f, indent=1) print(f"saved {a.out} ({time.time()-t0:.0f}s)") if __name__ == "__main__": main()