"""Shared training code of the window networks (offsets, detector): canvas data from a Hugging Face dataset repo, the conv + transformer encoder, the LR schedule and resumable checkpoints.""" import json import math import os import random import tarfile import time from pathlib import Path import cv2 import numpy as np import torch from torch import nn from torch.utils.data import DataLoader GRID_SIZE = 7 EMBED_DIM = 192 NUM_HEADS = 6 NUM_LAYERS = 6 MLP_RATIO = 4 DROPOUT = 0.10 CONV_STEM_CHANNELS = 24 CONV_STAGE_CHANNELS = (32, 48, 64, 96, 128) GROUP_NORM_GROUPS = 8 BATCH_SIZE = 128 WARMUP_EPOCHS = 3 LEARNING_RATE = 6.0e-4 WEIGHT_DECAY = 1.0e-4 GRADIENT_CLIP_NORM = 1.0 SEED = int(os.environ.get("SEED", "12345")) WORK_DIR = Path("work") def set_seed(seed=SEED): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) def prepare_data(dataset_repo): """Download and unpack the canvas shards. -> {split: [indices]}, data directory.""" from huggingface_hub import snapshot_download name = dataset_repo.replace("/", "--") root = Path(snapshot_download(dataset_repo, repo_type="dataset", allow_patterns=["manifest.json", "shards/*"], local_dir=str(WORK_DIR / "download" / name))) data = WORK_DIR / "data" / name data.mkdir(parents=True, exist_ok=True) for shard in sorted((root / "shards").glob("*.tar")): marker = data / (shard.name + ".done") if not marker.is_file(): with tarfile.open(str(shard)) as archive: archive.extractall(str(data), filter="data") marker.touch() manifest = json.loads((root / "manifest.json").read_text()) splits = {name: [i for i in range(begin, end) if (data / "{:07d}.png".format(i)).is_file()] for name, (begin, end) in manifest["splits"].items()} return splits, data def make_loader(dataset, shuffle, device): workers = max(1, len(os.sched_getaffinity(0)) - 1) return DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=shuffle, drop_last=shuffle, num_workers=workers, pin_memory=device.type == "cuda", persistent_workers=True, worker_init_fn=lambda _: cv2.setNumThreads(1), prefetch_factor=4) def autocast(device): return torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=device.type == "cuda") def conv_block(in_channels, out_channels, stride): return nn.Sequential(nn.Conv2d(in_channels, out_channels, 3, stride, 1, bias=False), nn.GroupNorm(GROUP_NORM_GROUPS, out_channels), nn.GELU()) class ConvEncoder(nn.Module): """224 x 224 -> EMBED_DIM x 7 x 7: a full-resolution 3x3 stem (grid lines are 1-2 px wide), then five stages of a stride-2 and a stride-1 3x3 convolution.""" def __init__(self): super().__init__() layers = [conv_block(3, CONV_STEM_CHANNELS, 1)] channels = CONV_STEM_CHANNELS for stage_channels in CONV_STAGE_CHANNELS: layers += [conv_block(channels, stage_channels, 2), conv_block(stage_channels, stage_channels, 1)] channels = stage_channels layers.append(nn.Conv2d(channels, EMBED_DIM, 1)) self.layers = nn.Sequential(*layers) def forward(self, images): return self.layers(images) class WindowTransformer(nn.Module): """Conv encoder -> 7 x 7 tokens with learned row / column positions -> transformer -> [B, 49, EMBED_DIM]. Subclasses add their heads as attributes, so checkpoint keys stay flat.""" def __init__(self): super().__init__() self.patch_embedding = ConvEncoder() self.row_position = nn.Parameter(torch.zeros(1, GRID_SIZE, 1, EMBED_DIM)) self.column_position = nn.Parameter(torch.zeros(1, 1, GRID_SIZE, EMBED_DIM)) layer = nn.TransformerEncoderLayer(d_model=EMBED_DIM, nhead=NUM_HEADS, dim_feedforward=EMBED_DIM * MLP_RATIO, dropout=DROPOUT, activation="gelu", batch_first=True, norm_first=True) self.encoder = nn.TransformerEncoder(layer, NUM_LAYERS, enable_nested_tensor=False) self.output_norm = nn.LayerNorm(EMBED_DIM) def init_weights(self, *heads): """Called by subclasses after their heads exist (keeps the original initialisation order).""" nn.init.trunc_normal_(self.row_position, std=0.02) nn.init.trunc_normal_(self.column_position, std=0.02) for head in heads: for module in head: if isinstance(module, nn.Linear): nn.init.trunc_normal_(module.weight, std=0.02) nn.init.zeros_(module.bias) def tokens(self, images): tokens = self.patch_embedding(images).permute(0, 2, 3, 1) + self.row_position + self.column_position return self.output_norm(self.encoder(tokens.reshape(images.shape[0], GRID_SIZE * GRID_SIZE, EMBED_DIM))) def make_optimizer(model, steps_per_epoch, epochs): """AdamW, linear warm-up for WARMUP_EPOCHS, then a per-step cosine.""" optimizer = torch.optim.AdamW(model.parameters(), lr=LEARNING_RATE, weight_decay=WEIGHT_DECAY) warmup, total = WARMUP_EPOCHS * steps_per_epoch, epochs * steps_per_epoch def factor(step): if step < warmup: return (step + 1) / warmup return 0.5 * (1.0 + math.cos(math.pi * min(1.0, (step - warmup) / max(1, total - warmup)))) return optimizer, torch.optim.lr_scheduler.LambdaLR(optimizer, factor) class Run: """Checkpoints in work/runs/; with MODEL_REPO they are uploaded to runs/ and a stopped job resumes from the uploaded last.pt.""" def __init__(self, name, model_repo): self.name, self.repo = name, model_repo self.dir = WORK_DIR / "runs" / name self.dir.mkdir(parents=True, exist_ok=True) self.api = None if model_repo: from huggingface_hub import HfApi, hf_hub_download self.api = HfApi() self.api.create_repo(model_repo, repo_type="model", private=True, exist_ok=True) remote = set(self.api.list_repo_files(model_repo)) for file in ("last.pt", "best.pt"): path = "runs/{}/{}".format(name, file) if path in remote and not (self.dir / file).is_file(): local = hf_hub_download(model_repo, path, local_dir=str(WORK_DIR / "resume")) (self.dir / file).write_bytes(Path(local).read_bytes()) def resume(self, model, optimizer, scheduler, device): """-> (first epoch, best score) from last.pt, or (1, None).""" path = self.dir / "last.pt" if not path.is_file(): return 1, None checkpoint = torch.load(str(path), map_location=device, weights_only=False) model.load_state_dict(checkpoint["model"]) optimizer.load_state_dict(checkpoint["optimizer"]) scheduler.load_state_dict(checkpoint["scheduler"]) return int(checkpoint["epoch"]) + 1, checkpoint["best"] def save(self, file, model, optimizer, scheduler, epoch, best): payload = {"epoch": epoch, "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "best": best} torch.save(payload, str(self.dir / (file + ".tmp"))) os.replace(str(self.dir / (file + ".tmp")), str(self.dir / file)) def push(self, message): if self.api is None: return for attempt in range(5): try: self.api.upload_folder(folder_path=str(self.dir), path_in_repo="runs/" + self.name, repo_id=self.repo, repo_type="model", allow_patterns=["*.pt", "*.csv"], commit_message=message) return except Exception as error: print("upload failed ({}), retry {}".format(error, attempt + 1), flush=True) time.sleep(20 * (attempt + 1)) def log(run, row): """Print a row and append it to history.csv.""" path = run.dir / "history.csv" new = not path.is_file() with path.open("a", encoding="utf-8") as output: if new: output.write(",".join(row) + "\n") output.write(",".join("{:.6g}".format(v) if isinstance(v, float) else str(v) for v in row.values()) + "\n") print(" ".join("{}={}".format(k, "{:.4g}".format(v) if isinstance(v, float) else v) for k, v in row.items()), flush=True) def device_or_fail(): if not torch.cuda.is_available(): raise RuntimeError("CUDA is not available") return torch.device("cuda")