Download train.py from bxding/RC1: direct link, hf CLI and curl.
- Browser
- Download file 44.7 kB
-
https://huggingface.co/bxding/RC1/resolve/main/train.py
- Command line
-
hf download hf://bxding/RC1/train.py
-
curl -L -o train.py https://huggingface.co/bxding/RC1/resolve/main/train.py
44.7 kB
"""Prepare, train, evaluate, and predict. See README.md for commands."""
import argparse
import json
import os
import random
import time
from pathlib import Path
# Keep downloaded weights with the project, not in a global cache.
os.environ.setdefault("HF_HOME", str(Path(".cache/huggingface").resolve()))
import numpy as np
import torch
from torch import nn
from torch.utils.data import ConcatDataset, DataLoader, RandomSampler, WeightedRandomSampler
from data import (
BACKGROUND,
IMAGE_NAMES,
FolderDataset,
GridDataset,
SupervisedGrids,
grid_boxes,
prepare,
prepare_jsonl,
prepare_folders,
read_rgb,
record_image_path,
target_name,
transform,
)
from models import BACKBONE, GridModel, select_target, transfer_backbone
def save_json(path, value):
Path(path).write_text(json.dumps(value, indent=2) + "\n")
def device_for(name):
if name == "auto":
name = (
"cuda"
if torch.cuda.is_available()
else "mps"
if torch.backends.mps.is_available()
else "cpu"
)
if name == "mps" and not torch.backends.mps.is_available():
raise RuntimeError(
"MPS is unavailable. Run in a terminal with Apple GPU access; use --device cpu only intentionally."
)
if name == "cuda" and not torch.cuda.is_available():
raise RuntimeError("CUDA is unavailable on this machine.")
return torch.device(name)
def empty_cache(device):
if device.type == "mps":
torch.mps.empty_cache()
elif device.type == "cuda":
torch.cuda.empty_cache()
def seed_everything(seed):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.set_num_threads(4)
def records_for(manifest, tiles, split):
return [r for r in manifest["records"] if r["tiles"] == tiles and r["split"] == split]
def auxiliary_records(manifest, targets, split):
return [r for r in records_for(manifest, 16, split) if r["target"] in targets]
def folder_records(folders, kind, split):
return (
[r for r in folders["records"] if r["kind"] == kind and r["split"] == split]
if folders
else []
)
def cell_logits(model, images):
"""Logits shaped (batch, classes, cells); a separate tile is one cell."""
output = model(images)
return output.unsqueeze(-1) if model.tiles == 9 else output.flatten(2)
def partial_loss(logits, labels, weights, any_labels, any_weights):
"""Weighted BCE on known cells, plus presence anywhere (max over cells) for image-level labels."""
cells = nn.functional.binary_cross_entropy_with_logits(
logits, labels, weight=weights, reduction="sum"
)
anywhere = nn.functional.binary_cross_entropy_with_logits(
logits.amax(-1), any_labels, weight=any_weights, reduction="sum"
)
total = weights.sum() + any_weights.sum()
return (cells + anywhere) / total.clamp(min=1), total
@torch.inference_mode()
def evaluate_folders(model, loader, device):
model.eval()
loss, total, probabilities, labels, weights = 0.0, 0.0, [], [], []
for images, *supervision in loader:
supervision = [x.to(device) for x in supervision]
logits = cell_logits(model, images.to(device)).float()
batch_loss, batch_total = partial_loss(logits, *supervision)
loss += batch_loss.item() * batch_total.item()
total += batch_total.item()
probabilities.append(logits.amax(-1).sigmoid().cpu())
labels.append(supervision[2].cpu())
weights.append(supervision[3].cpu())
return (
loss / max(total, 1),
torch.cat(probabilities),
torch.cat(labels).bool(),
torch.cat(weights),
)
def folder_report(probabilities, labels, weights, classes, threshold):
"""Single-label folder images: recall per class, and how often each class fires on other classes' images."""
predicted = probabilities >= threshold
background = (weights == 1).all(1) & ~labels.any(1)
report = {
"threshold": threshold,
"images": len(labels),
"per_class": {},
"background_images": int(background.sum()),
"background_any_selected": float(predicted[background].any(1).float().mean())
if background.any()
else None,
}
for i, name in enumerate(classes):
positive, others = labels[:, i], ~labels[:, i] & labels.any(1)
report["per_class"][name] = {
"positives": int(positive.sum()),
"recall": float(predicted[positive, i].float().mean()) if positive.any() else None,
"fires_on_other_classes": float(predicted[others, i].float().mean())
if others.any()
else None,
}
return report
def thresholds_for(checkpoint, targets):
return torch.tensor(
[
checkpoint.get("thresholds", {}).get(
checkpoint["classes"][int(i)], checkpoint["threshold"]
)
for i in targets
]
).unsqueeze(1)
def grid_logits(model, images, targets):
if model.tiles == 9:
batch, cells = images.shape[:2]
logits = model(images.flatten(0, 1)).reshape(batch, cells, -1).transpose(1, 2)
else:
logits = model(images).flatten(2)
selected = select_target(logits, targets)
# Presence in any enlarged subregion counts as presence in the tile.
for target_id in getattr(model, "crop_target_ids", []):
mask = targets == target_id
if not mask.any():
continue
tile_images = images[mask].flatten(0, 1)
height, width = tile_images.shape[-2:]
crop_h, crop_w = height // 2, width // 2
positions = [
(0, 0),
(0, width - crop_w),
(height - crop_h, 0),
(height - crop_h, width - crop_w),
((height - crop_h) // 2, (width - crop_w) // 2),
]
scores = selected[mask]
for top, left in positions:
crop = tile_images[:, :, top : top + crop_h, left : left + crop_w]
crop = nn.functional.interpolate(
crop, size=(height, width), mode="bilinear", align_corners=False
)
enlarged = model(crop)[:, target_id].reshape_as(scores)
scores = torch.maximum(scores, enlarged)
selected[mask] = scores
return selected
@torch.inference_mode()
def evaluate_loader(model, loader, device):
model.eval()
predictions, labels, targets = [], [], []
loss, total = 0.0, 0
for images, target, truth in loader:
logits = grid_logits(model, images.to(device), target.to(device))
loss += nn.functional.binary_cross_entropy_with_logits(
logits, truth.to(device), reduction="sum"
).item()
total += truth.numel()
predictions.append(logits.sigmoid().cpu())
labels.append(truth)
targets.append(target)
if not total:
raise ValueError("Evaluation split is empty")
return loss / total, torch.cat(predictions), torch.cat(labels).bool(), torch.cat(targets)
def metrics(probabilities, labels, threshold):
predicted = probabilities >= threshold
tp = int((predicted & labels).sum())
fp = int((predicted & ~labels).sum())
fn = int((~predicted & labels).sum())
return {
"grids": len(labels),
"exact_grid_accuracy": float((predicted == labels).all(1).float().mean()),
"cell_accuracy": float((predicted == labels).float().mean()),
"cell_precision": tp / max(tp + fp, 1),
"cell_recall": tp / max(tp + fn, 1),
"cell_f1": 2 * tp / max(2 * tp + fp + fn, 1),
"tp": tp,
"fp": fp,
"fn": fn,
}
def choose_threshold(probabilities, labels):
candidates = [round(0.1 + 0.025 * i, 3) for i in range(33)]
def rank(threshold):
result = metrics(probabilities, labels, threshold)
return result["exact_grid_accuracy"], result["cell_f1"], -abs(threshold - 0.5)
return max(candidates, key=rank)
def load_checkpoint(path, device):
checkpoint = torch.load(path, map_location="cpu", weights_only=True)
model = GridModel(
checkpoint["tiles"],
len(checkpoint["classes"]),
pretrained=False,
backbone=checkpoint.get("backbone", BACKBONE),
)
model.load_state_dict(checkpoint["model"])
model.crop_target_ids = [
checkpoint["classes"].index(name) for name in checkpoint.get("crop_targets", [])
]
if model.crop_target_ids and checkpoint["tiles"] != 9:
raise ValueError("Crop views are supported only by tile classifiers")
return model.to(device).eval(), checkpoint
def train_one(args, tiles, manifest, folders=None):
seed_everything(args.seed)
device = device_for(args.device)
output = Path(args.output) / f"grid{tiles}"
if (output / "best.pt").exists():
raise ValueError(
f"{output}/best.pt already exists. Choose a new --output to preserve this run."
)
output.mkdir(parents=True, exist_ok=True)
cells = 1 if tiles == 9 else 16
kind = "tile" if tiles == 9 else "full"
classes = sorted(
{r["target"] for r in manifest["records"] if r["tiles"] == tiles}
| {t for r in folder_records(folders, kind, "train") for t in r["targets"]} - {BACKGROUND}
)
aux_targets = sorted({target_name(name) for name in args.aux_targets})
if aux_targets and (tiles != 9 or not set(aux_targets) <= set(classes)):
raise ValueError("Auxiliary targets must be existing classes in a 9-tile model")
# 4x4 photos are 450 px; 448 keeps small slivers at a size divisible by the stride.
size = args.size or (224 if tiles == 9 else 448)
backbone_lr = args.backbone_lr if args.backbone_lr is not None else 0.0001
finetune_head_lr = args.finetune_head_lr if args.finetune_head_lr is not None else 0.0003
config = {
"tiles": tiles,
"classes": classes,
"size": size,
"backbone": args.backbone,
"seed": args.seed,
"batch_size": args.batch_size,
"device": str(device),
"precision": "bfloat16 autocast" if args.amp else "float32",
"frozen_epochs": args.frozen_epochs,
"finetune_epochs": args.epochs,
"patience": args.patience,
"optimizer": "AdamW",
"weight_decay": 0.0001,
"head_lr": args.head_lr,
"finetune_head_lr": finetune_head_lr,
"finetune_backbone_lr": backbone_lr,
"warm_start": args.warm_start,
"init_backbone": args.init_backbone,
"aux_targets": aux_targets,
"folders": args.folders,
"folder_kind": kind if folders else None,
"folder_negative_weight": args.negative_weight if folders else None,
"old_fraction": args.old_fraction if folders else None,
"epoch_size": args.epoch_size,
"keep_uncertain": args.keep_uncertain,
"train_batchnorm": not args.freeze_bn,
"amp": args.amp,
"selection": (
"mean of answer-key and folder validation loss"
if folders
else "mean of main and auxiliary validation loss"
if aux_targets
else "main validation loss"
),
"torch": str(torch.__version__),
}
if args.amp and device.type != "cuda":
raise ValueError("--amp is supported only with --device cuda")
if device.type == "cuda":
torch.backends.cudnn.benchmark = True
torch.set_float32_matmul_precision("high")
save_json(output / "config.json", config)
save_json(output / "classes.json", classes)
save_json(output / "split.json", manifest)
training_records = records_for(manifest, tiles, "train") + auxiliary_records(
manifest, aux_targets, "train"
)
training = SupervisedGrids(
GridDataset(
manifest["root"],
training_records,
classes,
size,
training=True,
tiles=tiles == 9,
include_full_images=bool(aux_targets),
),
cells,
keep_uncertain=args.keep_uncertain,
flip=not args.no_flip,
)
validation = GridDataset(manifest["root"], records_for(manifest, tiles, "val"), classes, size)
workers = {
"num_workers": args.workers,
"persistent_workers": args.workers > 0,
"pin_memory": device.type == "cuda",
}
sampler, folder_loader = None, None
if folders:
folder_training = FolderDataset(
folders["root"],
folder_records(folders, kind, "train"),
classes,
size,
cells,
training=True,
negative_weight=args.negative_weight,
)
folder_validation = FolderDataset(
folders["root"],
folder_records(folders, kind, "val"),
classes,
size,
cells,
negative_weight=args.negative_weight,
)
# Square-root class balancing: rare classes are seen more often without memorizing a few images.
counts = {}
for record in folder_training.records:
counts[record["targets"][0]] = counts.get(record["targets"][0], 0) + 1
folder_weights = torch.tensor(
[counts[r["targets"][0]] ** -0.5 for r in folder_training.records]
)
folder_weights *= (1 - args.old_fraction) / folder_weights.sum()
old_weights = torch.full((len(training),), args.old_fraction / len(training))
sampler = WeightedRandomSampler(
torch.cat([old_weights, folder_weights]).double(),
args.epoch_size or len(training) + len(folder_training),
generator=torch.Generator().manual_seed(args.seed),
)
training = ConcatDataset([training, folder_training])
folder_loader = DataLoader(folder_validation, batch_size=64, **workers)
elif args.epoch_size:
sampler = RandomSampler(
training,
replacement=True,
num_samples=args.epoch_size,
generator=torch.Generator().manual_seed(args.seed),
)
train_loader = DataLoader(
training, batch_size=args.batch_size, shuffle=sampler is None, sampler=sampler, **workers
)
val_loader = DataLoader(validation, batch_size=16, **workers)
aux_loader = None
if aux_targets:
aux_validation = GridDataset(
manifest["root"],
auxiliary_records(manifest, aux_targets, "val"),
classes,
size,
tile_grids=True,
)
if not len(aux_validation):
raise ValueError("Auxiliary targets need validation examples")
aux_loader = DataLoader(aux_validation, batch_size=2)
if args.warm_start:
model, initial = load_checkpoint(args.warm_start, device)
config["backbone"] = initial.get("backbone", BACKBONE)
save_json(output / "config.json", config)
if initial["tiles"] != tiles or initial["classes"] != classes or initial["size"] != size:
raise ValueError("Warm-start model must match task, classes, and image size")
else:
model = GridModel(tiles, len(classes), backbone=args.backbone).to(device)
if args.init_backbone:
source = torch.load(args.init_backbone, map_location="cpu", weights_only=True)
print(
f"grid{tiles}: copied {transfer_backbone(model, source['model'])} backbone tensors "
f"from {args.init_backbone}",
flush=True,
)
model.freeze_backbone(True)
optimizer = torch.optim.AdamW(model.head.parameters(), lr=args.head_lr, weight_decay=0.0001)
history, best, stale = [], float("inf"), 0
start = time.perf_counter()
print(
f"grid{tiles}: {len(train_loader.dataset)} training items ({len(train_loader.sampler)} per epoch), "
f"{len(validation)} validation grids, device={device}",
flush=True,
)
for epoch in range(args.frozen_epochs + args.epochs):
frozen = epoch < args.frozen_epochs
if epoch == args.frozen_epochs:
model.freeze_backbone(False)
optimizer = torch.optim.AdamW(
[
{"params": model.backbone.parameters(), "lr": backbone_lr},
{"params": model.head.parameters(), "lr": finetune_head_lr},
],
weight_decay=0.0001,
)
stale = 0
model.train()
if args.freeze_bn:
# Keep pretrained batch-normalization statistics (for small datasets).
model.backbone.eval()
total_loss, supervised = 0.0, 0.0
epoch_start = time.perf_counter()
for images, *supervision in train_loader:
images = images.to(device, non_blocking=True)
supervision = [x.to(device, non_blocking=True) for x in supervision]
optimizer.zero_grad(set_to_none=True)
with torch.autocast("cuda", dtype=torch.bfloat16, enabled=args.amp):
logits = cell_logits(model, images)
loss, count = partial_loss(logits.float(), *supervision)
loss.backward()
optimizer.step()
total_loss += loss.item() * count.item()
supervised += count.item()
val_loss, probabilities, truth, _ = evaluate_loader(model, val_loader, device)
aux_loss = evaluate_loader(model, aux_loader, device)[0] if aux_loader is not None else None
folder_loss = (
evaluate_folders(model, folder_loader, device)[0] if folder_loader is not None else None
)
extra = [x for x in (aux_loss, folder_loss) if x is not None]
selection_loss = sum([val_loss] + extra) / (1 + len(extra))
row = {
"epoch": epoch + 1,
"phase": "head" if frozen else "finetune",
"train_loss": total_loss / max(supervised, 1),
"val_loss": val_loss,
"aux_val_loss": aux_loss,
"folder_val_loss": folder_loss,
"selection_loss": selection_loss,
"val_exact_at_0.5": metrics(probabilities, truth, 0.5)["exact_grid_accuracy"],
"seconds": time.perf_counter() - epoch_start,
}
history.append(row)
save_json(output / "history.json", history)
print(json.dumps({"grid": tiles, **row}), flush=True)
if selection_loss < best:
best, stale = selection_loss, 0
torch.save(
{
**config,
"epoch": epoch + 1,
"val_loss": val_loss,
"selection_loss": selection_loss,
"model": {k: v.detach().cpu().clone() for k, v in model.state_dict().items()},
},
output / "best.pt",
)
elif not frozen:
stale += 1
if not frozen and stale >= args.patience:
break
elapsed = time.perf_counter() - start
del optimizer, model
empty_cache(device)
model, checkpoint = load_checkpoint(output / "best.pt", device)
_, probabilities, truth, val_targets = evaluate_loader(model, val_loader, device)
threshold = choose_threshold(probabilities, truth)
checkpoint["threshold"] = threshold
checkpoint["thresholds"] = {}
if aux_loader is not None:
_, aux_probabilities, aux_truth, aux_ids = evaluate_loader(model, aux_loader, device)
for name in aux_targets:
mask = aux_ids == classes.index(name)
if mask.sum() >= 3:
checkpoint["thresholds"][name] = choose_threshold(
aux_probabilities[mask], aux_truth[mask]
)
save_json(
output / "aux_validation.json",
{
"source": "4x4 crops; proxy only for 3x3 performance",
"thresholds": checkpoint["thresholds"],
**metrics(aux_probabilities, aux_truth, thresholds_for(checkpoint, aux_ids)),
},
)
if folder_loader is not None:
_, folder_probabilities, folder_truth, folder_weights = evaluate_folders(
model, folder_loader, device
)
save_json(
output / "folder_validation.json",
{
"source": f"held-out {kind} images from {args.folders}; single-label, so 'fires_on_other_classes' "
"includes images where that target is genuinely visible",
**folder_report(
folder_probabilities, folder_truth, folder_weights, classes, threshold
),
},
)
checkpoint["training_seconds"] = elapsed
torch.save(checkpoint, output / "best.pt")
save_json(
output / "validation.json",
{
"threshold": threshold,
"thresholds": checkpoint["thresholds"],
**metrics(probabilities, truth, thresholds_for(checkpoint, val_targets)),
},
)
print(
f"grid{tiles}: saved best epoch {checkpoint['epoch']}; threshold={threshold}; training={elapsed:.1f}s",
flush=True,
)
def synchronize(device):
if device.type == "mps":
torch.mps.synchronize()
elif device.type == "cuda":
torch.cuda.synchronize()
def calibrate_crops(args):
"""Enable enlarged views only after a validation-only comparison."""
device = device_for(args.device)
seed_everything(42)
model, checkpoint = load_checkpoint(args.checkpoint, device)
name = target_name(args.target)
if checkpoint["tiles"] != 9 or name not in checkpoint["classes"]:
raise ValueError("Choose a supported target in a tile classifier")
if name in checkpoint.get("crop_targets", []):
raise ValueError("Crop views are already enabled for this target")
directory = Path(args.checkpoint).parent
manifest = json.loads((directory / "split.json").read_text())
records = auxiliary_records(manifest, [name], "val")
if len(records) < 3:
raise ValueError("Need at least three auxiliary validation grids")
dataset = GridDataset(
manifest["root"], records, checkpoint["classes"], checkpoint["size"], tile_grids=True
)
loader = DataLoader(dataset, batch_size=2)
_, probabilities, truth, _ = evaluate_loader(model, loader, device)
old_threshold = checkpoint.get("thresholds", {}).get(name, checkpoint["threshold"])
before = metrics(probabilities, truth, old_threshold)
model.crop_target_ids.append(checkpoint["classes"].index(name))
_, probabilities, truth, _ = evaluate_loader(model, loader, device)
cutoff = choose_threshold(probabilities, truth)
after = metrics(probabilities, truth, cutoff)
accepted = (
after["exact_grid_accuracy"] >= before["exact_grid_accuracy"]
and after["cell_f1"] > before["cell_f1"]
)
report = {
"target": name,
"source": "4x4 validation crops; not 3x3 test data",
"views": "full tile plus center and four corner half-size crops",
"before": {"threshold": old_threshold, **before},
"after": {"threshold": cutoff, **after},
"accepted": accepted,
}
if accepted:
backup = directory / "pre_crop_best.pt"
if backup.exists():
raise ValueError(f"Refusing to overwrite {backup}")
torch.save(checkpoint, backup)
checkpoint.setdefault("crop_targets", []).append(name)
checkpoint.setdefault("thresholds", {})[name] = cutoff
torch.save(checkpoint, args.checkpoint)
save_json(directory / "crop_calibration.json", report)
print(json.dumps(report, indent=2))
@torch.inference_mode()
def benchmark(model, dataset, device):
images, target, _ = dataset[0]
images, target = images.unsqueeze(0).to(device), torch.tensor([target], device=device)
for _ in range(5):
grid_logits(model, images, target)
synchronize(device)
timings = []
for _ in range(30):
start = time.perf_counter()
grid_logits(model, images, target).sigmoid().cpu()
synchronize(device)
timings.append((time.perf_counter() - start) * 1000)
return {
"median_ms": float(np.median(timings)),
"p95_ms": float(np.percentile(timings, 95)),
"scope": "one grid, warm model, includes output transfer; excludes image loading and preprocessing",
"device": str(device),
"iterations": len(timings),
}
def evaluate(args):
device = device_for(args.device)
seed_everything(42)
model, checkpoint = load_checkpoint(args.checkpoint, device)
directory = Path(args.checkpoint).parent
manifest = json.loads((directory / "split.json").read_text())
records = records_for(manifest, checkpoint["tiles"], args.split)
if not records:
raise ValueError(
f"No {args.split} grids in this manifest"
+ (" (run prepare with --review)" if args.split == "review" else "")
)
dataset = GridDataset(
args.data or manifest["root"], records, checkpoint["classes"], checkpoint["size"]
)
loss, probabilities, truth, targets = evaluate_loader(
model, DataLoader(dataset, batch_size=4), device
)
threshold = thresholds_for(checkpoint, targets)
report = {
"split": args.split,
"epoch": checkpoint["epoch"],
"threshold": checkpoint["threshold"],
"thresholds": checkpoint.get("thresholds", {}),
"loss": loss,
"training_seconds": checkpoint["training_seconds"],
"overall": metrics(probabilities, truth, threshold),
"per_class": {},
}
if args.split == "review":
# Same grids, answered by the labeler whose output the model was trained on.
right = [r["labeler_answers"] == r["answers"] for r in records if "labeler_answers" in r]
report["labeler_exact_grid_accuracy"] = sum(right) / len(right) if right else None
for i, name in enumerate(checkpoint["classes"]):
mask = targets == i
support = {
split: len(
[
r
for r in records_for(manifest, checkpoint["tiles"], split)
if r["target"] == name
]
)
for split in ("train", "val", "test", "review")
}
report["per_class"][name] = {
"support": support,
"metrics": metrics(probabilities[mask], truth[mask], threshold[mask])
if mask.any()
else None,
}
report["latency"] = benchmark(model, dataset, device)
save_json(directory / f"{args.split}_metrics.json", report)
save_json(
directory / f"{args.split}_predictions.json",
[
{
"id": r["id"],
"target": r["target"],
"answers": r["answers"],
"selected_cells": (p >= cutoff).nonzero().flatten().tolist(),
"probabilities": p.tolist(),
}
for r, p, cutoff in zip(records, probabilities, threshold)
],
)
print(json.dumps(report, indent=2))
@torch.inference_mode()
def predict(args):
device = device_for(args.device)
model, checkpoint = load_checkpoint(args.checkpoint, device)
name = target_name(args.target)
if name not in checkpoint["classes"]:
raise ValueError(f"Unknown target {name!r}; supported: {checkpoint['classes']}")
preprocess = transform(checkpoint["size"])
image = read_rgb(image_file(args.image))
if checkpoint["tiles"] == 9:
images = torch.stack([preprocess(image.crop(box)) for box in grid_boxes(*image.size, 9)])
else:
images = preprocess(image)
target = torch.tensor([checkpoint["classes"].index(name)], device=device)
probabilities = grid_logits(model, images.unsqueeze(0).to(device), target).sigmoid().cpu()[0]
cutoff = checkpoint.get("thresholds", {}).get(name, checkpoint["threshold"])
print(
json.dumps(
{
"target": name,
"threshold": cutoff,
"selected_cells": (probabilities >= cutoff).nonzero().flatten().tolist(),
"probabilities": probabilities.tolist(),
},
indent=2,
)
)
def export(args):
"""ONNX fp32 plus calibrated int8, checked against PyTorch on the old test grids."""
import onnx
from onnxruntime.quantization import (
CalibrationDataReader,
CalibrationMethod,
QuantFormat,
QuantType,
quantize_static,
)
from onnxruntime.quantization.shape_inference import quant_pre_process
from predict_onnx import GridPredictor
seed_everything(42)
model, checkpoint = load_checkpoint(args.checkpoint, torch.device("cpu"))
if model.crop_target_ids:
raise ValueError("Enlarged crop views are not supported by the ONNX export")
tiles, size = checkpoint["tiles"], checkpoint["size"]
directory = Path(args.checkpoint).parent
output = Path(args.output or directory / "onnx")
output.mkdir(parents=True, exist_ok=True)
manifest = json.loads((directory / "split.json").read_text())
root = Path(args.data or manifest["root"])
class Probabilities(nn.Module):
def __init__(self):
super().__init__()
self.model = model
def forward(self, images):
logits = self.model(images)
return (logits if tiles == 9 else logits.flatten(2)).sigmoid()
info = {
"tiles": tiles,
"size": size,
"classes": checkpoint["classes"],
"threshold": checkpoint["threshold"],
"thresholds": checkpoint.get("thresholds", {}),
"epoch": checkpoint["epoch"],
}
def with_metadata(path):
exported = onnx.load(path)
del exported.metadata_props[:]
exported.metadata_props.add(key="grid_vision", value=json.dumps(info))
onnx.save(exported, path)
fp32, prepared, int8 = (
output / f"grid{tiles}.onnx",
output / f"grid{tiles}.pre.onnx",
output / f"grid{tiles}.int8.onnx",
)
for path in (fp32, int8):
if path.exists():
raise ValueError(f"{path} already exists. Choose a new --output.")
torch.onnx.export(
Probabilities().eval(),
torch.zeros(9 if tiles == 9 else 1, 3, size, size),
fp32,
input_names=["images"],
output_names=["probabilities"],
opset_version=17,
dynamic_axes={"images": {0: "batch"}, "probabilities": {0: "batch"}},
dynamo=False,
)
with_metadata(fp32)
reference = GridPredictor(fp32)
calibration = random.Random(0).sample(
records_for(manifest, tiles, "train"),
min(args.calibration_grids, len(records_for(manifest, tiles, "train"))),
)
class Calibration(CalibrationDataReader):
def __init__(self):
self.batches = iter(reference.inputs(record_image_path(root, r)) for r in calibration)
def get_next(self):
batch = next(self.batches, None)
return None if batch is None else {"images": batch}
quant_pre_process(str(fp32), str(prepared))
# Percentile ranges and full-precision first/last layers changed the fewest baseline decisions.
layers = [
n.name for n in onnx.load(prepared).graph.node if n.op_type in ("Conv", "Gemm", "MatMul")
]
quantize_static(
str(prepared),
str(int8),
Calibration(),
quant_format=QuantFormat.QDQ,
per_channel=True,
activation_type=QuantType.QUInt8,
weight_type=QuantType.QInt8,
calibrate_method=CalibrationMethod.Percentile,
nodes_to_exclude=[layers[0], layers[-1]],
)
prepared.unlink()
with_metadata(int8)
records = records_for(manifest, tiles, "test")
variants = {"fp32": GridPredictor(fp32), "int8": GridPredictor(int8)}
probabilities = {name: [] for name in ["torch", *variants]}
labels, cutoffs = [], []
with torch.inference_mode():
for record in records:
inputs = reference.inputs(record_image_path(root, record))
target = checkpoint["classes"].index(record["target"])
scores = Probabilities()(torch.from_numpy(inputs)).numpy()
probabilities["torch"].append(scores.T[target] if tiles == 9 else scores[0, target])
for name, predictor in variants.items():
probabilities[name].append(predictor.probabilities(inputs)[target])
labels.append([i in record["answers"] for i in range(record["tiles"])])
cutoffs.append(info["thresholds"].get(record["target"], info["threshold"]))
labels, cutoffs = torch.tensor(labels), torch.tensor(cutoffs).unsqueeze(1)
tensors = {name: torch.tensor(np.array(values)) for name, values in probabilities.items()}
selected = {name: values >= cutoffs for name, values in tensors.items()}
report = {
"checkpoint": str(args.checkpoint),
"test_grids": len(records),
"preprocessing": "predict_onnx.preprocess (Pillow + numpy) for every variant",
}
for name, path in [("fp32", fp32), ("int8", int8)]:
timings = {}
sample = reference.inputs(record_image_path(root, records[0]))
for threads in (1, 4):
predictor = GridPredictor(path, threads)
for _ in range(5):
predictor.probabilities(sample)
runs = []
for _ in range(50):
start = time.perf_counter()
predictor.probabilities(sample)
runs.append((time.perf_counter() - start) * 1000)
timings[f"{threads}_thread_median_ms"] = float(np.median(runs))
report[name] = {
"file": str(path),
"megabytes": path.stat().st_size / 1e6,
"max_abs_difference_from_torch": float((tensors[name] - tensors["torch"]).abs().max()),
"grids_with_different_selection": int(
(selected[name] != selected["torch"]).any(1).sum()
),
"test": metrics(tensors[name], labels, cutoffs),
"cpu_latency_per_grid": {
**timings,
"scope": "one grid on this machine's CPU, excludes image loading",
},
}
report["torch_test"] = metrics(tensors["torch"], labels, cutoffs)
save_json(output / "export.json", report)
print(json.dumps(report, indent=2))
def seam_ratio(image, parts=3):
"""Separate tiles have sharp seams at the 1/parts lines; one continuous photo usually does not."""
pixels = np.asarray(image.convert("L"), dtype=np.float32)
height, width = pixels.shape
dx, dy = np.abs(np.diff(pixels, axis=1)).mean(0), np.abs(np.diff(pixels, axis=0)).mean(1)
def seams(differences, length):
return max(
differences[max(0, round(length * j / parts) - 2) : round(length * j / parts) + 1].max()
for j in range(1, parts)
)
return min(seams(dx, width), seams(dy, height)) / ((np.median(dx) + np.median(dy)) / 2 + 1e-6)
def image_file(source):
"""A challenge image, or the image inside a dataset item folder."""
source = Path(source)
if source.is_dir():
found = next((source / name for name in IMAGE_NAMES if (source / name).exists()), None)
if found is None:
raise ValueError(f"No image in {source}")
return found
return source
def layout(source):
"""9 or 16 tiles for a challenge image or item folder (300 px = 3x3, 450 px = 4x4)."""
metadata = Path(source) / "metadata.json"
if Path(source).is_dir() and metadata.is_file():
grid = json.loads(metadata.read_text()).get("grid")
if grid in ("3x3", "4x4"):
return 9 if grid == "3x3" else 16
image = read_rgb(image_file(source))
side = max(image.size)
if abs(side - 300) <= 30:
return 9
if abs(side - 450) <= 45:
return 16
return 9 if seam_ratio(image) >= 3 else 16
def solve(args):
"""Detect the layout and use the matching grid model."""
args.checkpoint = args.grid9 if layout(args.image) == 9 else args.grid16
predict(args)
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
prep = commands.add_parser("prepare")
prep.add_argument(
"--data",
required=True,
help="Folder of labeled <item>/{image.jpg, metadata.json} (or answer-key) folders",
)
prep.add_argument("--output", default="runs")
prep.add_argument("--seed", type=int, default=42)
prep.add_argument(
"--workers", type=int, default=os.cpu_count() or 1, help="Processes for scanning images"
)
prep.add_argument(
"--sam",
action="store_true",
help="Take 4x4 answers from <item>/sam.json (SAM 3) instead of metadata.json",
)
prep.add_argument(
"--review",
nargs="*",
default=[],
help="Reference answer files held out as a 'review' split",
)
prep_folders = commands.add_parser("prepare-folders", help="Ingest class-folder image datasets")
prep_folders.add_argument("--data", nargs="+", required=True, help="Class-folder datasets")
prep_folders.add_argument(
"--manifest", default="runs/split.json", help="Old split; its held-out images are excluded"
)
prep_folders.add_argument("--output", default="runs")
prep_folders.add_argument("--seed", type=int, default=42)
train = commands.add_parser("train")
train.add_argument("--manifest", default="runs/split.json")
train.add_argument("--output", default="runs/baseline")
train.add_argument("--tiles", choices=["9", "16", "both"], default="both")
train.add_argument("--device", choices=["auto", "cuda", "mps", "cpu"], default="auto")
train.add_argument("--batch-size", type=int, default=16)
train.add_argument("--frozen-epochs", type=int, default=3)
train.add_argument(
"--epochs", type=int, default=20, help="Maximum fine-tuning epochs after head training"
)
train.add_argument("--patience", type=int, default=5)
train.add_argument("--backbone-lr", type=float, help="Default: 1e-4")
train.add_argument("--finetune-head-lr", type=float, help="Default: 3e-4")
train.add_argument("--head-lr", type=float, default=0.001)
train.add_argument("--warm-start", help="Initialize from a compatible trained checkpoint")
train.add_argument(
"--aux-targets",
nargs="*",
default=[],
help="Use these targets' 4x4 crops to train a 9-tile classifier",
)
train.add_argument("--seed", type=int, default=42)
train.add_argument("--size", type=int, help="Input size. Default: 224 for 9 tiles, 448 for 16")
train.add_argument(
"--folders", help="Folder manifest from prepare-folders, e.g. runs/folders.json"
)
train.add_argument(
"--negative-weight",
type=float,
default=0.2,
help="Weight for assuming other targets are absent in a single-label folder image",
)
train.add_argument(
"--old-fraction", type=float, default=0.25, help="Share of samples from answer-key grids"
)
train.add_argument("--epoch-size", type=int, help="Training samples (tiles or grids) per epoch")
train.add_argument(
"--keep-uncertain",
action="store_true",
help="Also train on cells the labeler marked uncertain (ignored by default)",
)
train.add_argument(
"--no-flip", action="store_true", help="Turn off random left-right mirroring"
)
train.add_argument(
"--backbone",
default=BACKBONE,
help="Any timm model with a stride-16 stage, e.g. convnext_tiny.dinov3_lvd1689m",
)
train.add_argument(
"--init-backbone", help="Copy backbone weights from another checkpoint, e.g. the 3x3 model"
)
train.add_argument(
"--freeze-bn", action="store_true", help="Keep pretrained batch-normalization statistics"
)
train.add_argument(
"--workers",
type=int,
default=min(16, os.cpu_count() or 1),
help="DataLoader worker processes",
)
train.add_argument("--amp", action="store_true", help="bfloat16 autocast (CUDA only)")
test = commands.add_parser("evaluate")
test.add_argument("--checkpoint", required=True)
test.add_argument(
"--split",
choices=["test", "review"],
default="test",
help="review: the reference grids held out by prepare --review",
)
test.add_argument("--data", help="Override dataset root if moved")
test.add_argument("--device", choices=["auto", "cuda", "mps", "cpu"], default="auto")
prediction = commands.add_parser("predict")
prediction.add_argument("--checkpoint", required=True)
prediction.add_argument("--image", required=True, help="Image file or example folder")
prediction.add_argument("--target", required=True)
prediction.add_argument("--device", choices=["auto", "cuda", "mps", "cpu"], default="auto")
onnx_export = commands.add_parser(
"export", help="ONNX fp32 and int8 for CPU inference without PyTorch"
)
onnx_export.add_argument("--checkpoint", required=True)
onnx_export.add_argument("--output", help="Default: <checkpoint folder>/onnx")
onnx_export.add_argument("--data", help="Override dataset root if moved")
onnx_export.add_argument("--calibration-grids", type=int, default=100)
solver = commands.add_parser("solve", help="Detect the layout and use the matching model")
solver.add_argument("--image", required=True, help="Image file or example folder")
solver.add_argument("--target", required=True)
solver.add_argument("--grid9", default="runs/baseline/grid9/best.pt")
solver.add_argument("--grid16", default="runs/baseline/grid16/best.pt")
solver.add_argument("--device", choices=["auto", "cuda", "mps", "cpu"], default="cpu")
crops = commands.add_parser("calibrate-crops")
crops.add_argument("--checkpoint", required=True)
crops.add_argument("--target", required=True)
crops.add_argument("--device", choices=["auto", "cuda", "mps", "cpu"], default="auto")
args = parser.parse_args()
if args.command == "prepare":
source = Path(args.data)
if (source / "data" / "3x3").is_dir() or (source / "3x3").is_dir():
if args.sam or args.review:
parser.error("--sam and --review apply only to raw labeled folders")
_, counts = prepare_jsonl(source, args.output)
else:
_, counts = prepare(args.data, args.output, args.seed, args.workers, args.sam, args.review)
print(json.dumps(counts, indent=2))
elif args.command == "prepare-folders":
_, summary = prepare_folders(args.data, args.manifest, args.output, args.seed)
print(json.dumps(summary, indent=2))
elif args.command == "train":
if min(args.batch_size, args.epochs, args.patience) < 1 or args.frozen_epochs < 0:
parser.error(
"Batch size, epochs, and patience must be positive; frozen epochs cannot be negative"
)
if any(
lr is not None and (not np.isfinite(lr) or lr <= 0)
for lr in (args.backbone_lr, args.finetune_head_lr, args.head_lr)
):
parser.error("Learning rates must be finite and positive")
if not 0 < args.old_fraction < 1 or args.negative_weight < 0 or args.workers < 0:
parser.error("--old-fraction must be in (0, 1); weights and workers cannot be negative")
if not np.isfinite(args.negative_weight):
parser.error("--negative-weight must be finite")
if (args.epoch_size is not None and args.epoch_size < 1) or (
args.size is not None and args.size < 1
):
parser.error("--epoch-size and --size must be positive")
manifest = json.loads(Path(args.manifest).read_text())
folders = json.loads(Path(args.folders).read_text()) if args.folders else None
for tiles in [9, 16] if args.tiles == "both" else [int(args.tiles)]:
train_one(args, tiles, manifest, folders)
elif args.command == "evaluate":
evaluate(args)
elif args.command == "export":
if args.calibration_grids < 1:
parser.error("--calibration-grids must be positive")
export(args)
elif args.command == "solve":
solve(args)
elif args.command == "calibrate-crops":
calibrate_crops(args)
else:
predict(args)
if __name__ == "__main__":
main()
"""Prepare, train, evaluate, and predict. See README.md for commands."""
import argparse
import json
import os
import random
import time
from pathlib import Path
# Keep downloaded weights with the project, not in a global cache.
os.environ.setdefault("HF_HOME", str(Path(".cache/huggingface").resolve()))
import numpy as np
import torch
from torch import nn
from torch.utils.data import ConcatDataset, DataLoader, RandomSampler, WeightedRandomSampler
from data import (
BACKGROUND,
IMAGE_NAMES,
FolderDataset,
GridDataset,
SupervisedGrids,
grid_boxes,
prepare,
prepare_jsonl,
prepare_folders,
read_rgb,
record_image_path,
target_name,
transform,
)
from models import BACKBONE, GridModel, select_target, transfer_backbone
def save_json(path, value):
Path(path).write_text(json.dumps(value, indent=2) + "\n")
def device_for(name):
if name == "auto":
name = (
"cuda"
if torch.cuda.is_available()
else "mps"
if torch.backends.mps.is_available()
else "cpu"
)
if name == "mps" and not torch.backends.mps.is_available():
raise RuntimeError(
"MPS is unavailable. Run in a terminal with Apple GPU access; use --device cpu only intentionally."
)
if name == "cuda" and not torch.cuda.is_available():
raise RuntimeError("CUDA is unavailable on this machine.")
return torch.device(name)
def empty_cache(device):
if device.type == "mps":
torch.mps.empty_cache()
elif device.type == "cuda":
torch.cuda.empty_cache()
def seed_everything(seed):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.set_num_threads(4)
def records_for(manifest, tiles, split):
return [r for r in manifest["records"] if r["tiles"] == tiles and r["split"] == split]
def auxiliary_records(manifest, targets, split):
return [r for r in records_for(manifest, 16, split) if r["target"] in targets]
def folder_records(folders, kind, split):
return (
[r for r in folders["records"] if r["kind"] == kind and r["split"] == split]
if folders
else []
)
def cell_logits(model, images):
"""Logits shaped (batch, classes, cells); a separate tile is one cell."""
output = model(images)
return output.unsqueeze(-1) if model.tiles == 9 else output.flatten(2)
def partial_loss(logits, labels, weights, any_labels, any_weights):
"""Weighted BCE on known cells, plus presence anywhere (max over cells) for image-level labels."""
cells = nn.functional.binary_cross_entropy_with_logits(
logits, labels, weight=weights, reduction="sum"
)
anywhere = nn.functional.binary_cross_entropy_with_logits(
logits.amax(-1), any_labels, weight=any_weights, reduction="sum"