# /// script # requires-python = ">=3.10" # dependencies = [ # "torch>=2.3", # "torchvision>=0.18", # "datasets>=2.19", # "huggingface_hub>=0.24", # "safetensors>=0.4", # "scikit-image>=0.22", # "scipy>=1.11", # "pillow>=10.0", # "numpy", # "tqdm", # ] # /// """ Train a tiny (<4M parameter) U-Net that colorizes grayscale photos. Classification-style (Zhang et al., "Colorful Image Colorization"): predicts a distribution over quantized Lab a/b color bins per pixel, rather than regressing a single ab value directly. Bin classes are up-weighted by rarity in the real training-data color distribution, so the model isn't rewarded for hedging toward desaturated averages the way a plain L1/L2 regression loss would. Decode with an annealed mean (see `SmallUNetColorizer.decode`). Designed to run as a Hugging Face Job: hf jobs uv run --flavor t4-small --timeout 1h \ -s HF_TOKEN colorize_train.py \ --push-to-hub --hub-model-id /mini-unet-colorizer Run locally the same way, just with `uv run colorize_train.py ...` or `python colorize_train.py ...` after `pip install`-ing the deps above. """ import argparse import os import time from pathlib import Path import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from huggingface_hub import HfApi, PyTorchModelHubMixin, hf_hub_download from safetensors.torch import load_file as load_safetensors from scipy.ndimage import gaussian_filter from scipy.spatial import cKDTree from skimage.color import lab2rgb, rgb2lab from torch.utils.data import DataLoader, Dataset from torchvision import transforms as T from torchvision.utils import make_grid, save_image from tqdm.auto import tqdm # -------------------------------------------------------------------------- # Classification-based colorization (Zhang et al., "Colorful Image # Colorization" style): quantize ab space into bins, predict a distribution # over bins per pixel instead of regressing a single ab value, and decode # with an annealed mean. This avoids the "regress to the desaturated average # of all plausible colors" failure mode of plain L1/L2 regression. # -------------------------------------------------------------------------- class ColorBins: """The quantized ab bin grid, its empirical prior, and rebalancing weights. `bin_centers`: (Q, 2) array of [a, b] centers for the Q bins that actually occur in the sampled data (a data-driven stand-in for "in-gamut" bins, rather than computing exact sRGB gamut geometry). `weights`: (Q,) rebalancing weights, high for rare/saturated bins, low for common/desaturated ones, E_p[weight] == 1. """ def __init__(self, bin_centers: np.ndarray, weights: np.ndarray): assert bin_centers.shape[0] == weights.shape[0] self.bin_centers = bin_centers.astype(np.float32) # (Q, 2) self.weights = weights.astype(np.float32) # (Q,) self.num_bins = bin_centers.shape[0] self._tree = cKDTree(self.bin_centers) def soft_encode(self, ab: np.ndarray, k: int = 5, sigma: float = 5.0): """ab: (H, W, 2) continuous Lab a/b values. Returns (top_idx, top_weight): each (H, W, k). top_weight sums to 1 over the last axis (a Gaussian-kernel soft target over the k nearest bins, per Zhang et al.'s soft-encoding scheme).""" h, w, _ = ab.shape flat = ab.reshape(-1, 2) dist, idx = self._tree.query(flat, k=k) # (N,k), (N,k) if k == 1: # cKDTree squeezes the last dim when k==1 dist, idx = dist[:, None], idx[:, None] weight = np.exp(-(dist ** 2) / (2 * sigma ** 2)) weight /= weight.sum(axis=1, keepdims=True) return idx.reshape(h, w, k).astype(np.int64), weight.reshape(h, w, k).astype(np.float32) def image_mean_chroma(lab): """Mean Lab chroma (sqrt(a^2+b^2)) -- near 0 for a genuinely grayscale- source image (real color photos, even desaturated/overcast ones, have meaningfully more residual chroma than a true B&W photo's compression noise).""" return float(np.sqrt(lab[:, :, 1] ** 2 + lab[:, :, 2] ** 2).mean()) def build_color_bins(hf_split, num_samples=3000, grid_size=10, ab_range=110, rebalance_lambda=0.5, smoothing_sigma=1.0, seed=0, grayscale_chroma_threshold=3.0): """Derive the bin set + rebalancing weights from a sample of real training images, by actually converting them to Lab and histogramming ab values on a `grid_size`-spaced grid. Bins that never occur simply aren't included -- this is a cheap data-driven stand-in for restricting to in-gamut bins. Images whose mean chroma is below `grayscale_chroma_threshold` are excluded from the histogram: a genuinely black-and-white source photo's correct ab really is ~0, which is a different phenomenon from the model hedging toward gray on a photo that actually had color. Mixing the two into one "near-zero is common" statistic would miscalibrate the rebalancing weights -- overcounting how often gray is truly correct. """ edges = np.arange(-ab_range, ab_range + grid_size, grid_size) centers_1d = (edges[:-1] + edges[1:]) / 2 # bin centers along one axis n_grid = len(centers_1d) rng = np.random.default_rng(seed) n = len(hf_split) sample_idx = rng.choice(n, size=min(num_samples, n), replace=False) counts = np.zeros((n_grid, n_grid), dtype=np.int64) n_grayscale_source = 0 for i in sample_idx: img = hf_split[int(i)]["image"] if img.mode != "RGB": img = img.convert("RGB") img = img.resize((64, 64)) # small is plenty for a color histogram arr = np.asarray(img).astype(np.float32) / 255.0 lab = rgb2lab(arr) if image_mean_chroma(lab) < grayscale_chroma_threshold: n_grayscale_source += 1 continue a_idx = np.clip(((lab[:, :, 1] + ab_range) // grid_size).astype(int), 0, n_grid - 1) b_idx = np.clip(((lab[:, :, 2] + ab_range) // grid_size).astype(int), 0, n_grid - 1) np.add.at(counts, (a_idx.ravel(), b_idx.ravel()), 1) frac = n_grayscale_source / len(sample_idx) print(f"{n_grayscale_source}/{len(sample_idx)} ({frac:.1%}) sampled images are " f"grayscale-source (mean chroma < {grayscale_chroma_threshold}) -- excluded from " f"the color-bin statistics below.") smoothed = gaussian_filter(counts.astype(np.float32), sigma=smoothing_sigma) keep_mask = counts > 0 keep_a, keep_b = np.nonzero(keep_mask) bin_centers = np.stack([centers_1d[keep_a], centers_1d[keep_b]], axis=1) # (Q, 2) prior = smoothed[keep_a, keep_b] prior = prior / prior.sum() q = len(prior) w = 1.0 / ((1 - rebalance_lambda) * prior + rebalance_lambda / q) w = w / (prior * w).sum() # normalize so E_prior[w] == 1 return ColorBins(bin_centers, w) # -------------------------------------------------------------------------- # Model: small U-Net, L (1ch) in -> per-pixel logits over color bins out. # ~3.6M trainable params at base=44 (bin_centers is a non-trainable buffer). # -------------------------------------------------------------------------- def double_conv(in_ch, out_ch): return nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) class DilatedContextBlock(nn.Module): """Cheap receptive-field expansion at the bottleneck. The plain U-Net's receptive field is fixed in absolute pixels regardless of input resolution (~51-54px, empirically measured), so at higher --image-size it covers a shrinking fraction of the image -- the likely cause of color decisions that don't stay consistent across a whole object (e.g. a parachute canopy getting inconsistent patches of color). A naive fix (another U-Net level, channel-doubling as the existing ones do) costs ~4.5M params just for one extra block -- unaffordable. This instead projects down to a narrow `mid_ch` width, runs a few dilated 3x3 convs (same param cost as a normal 3x3 conv, much larger spatial reach: empirically ~232x232px in isolation with dilations (2,4,8), verified via gradient measurement), and projects back up. Residual connection, so it starts as a small perturbation on top of the existing backbone (which matters for warm-starting from a checkpoint that predates this block -- it won't disrupt what's already learned, and its influence grows through training as it learns to use the wider context where it helps) rather than a disruptive change. """ def __init__(self, channels, mid_ch=96, dilations=(2, 4, 8)): super().__init__() self.proj_in = nn.Sequential( nn.Conv2d(channels, mid_ch, 1, bias=False), nn.BatchNorm2d(mid_ch), nn.ReLU(inplace=True), ) layers = [] for d in dilations: layers += [ nn.Conv2d(mid_ch, mid_ch, 3, padding=d, dilation=d, bias=False), nn.BatchNorm2d(mid_ch), nn.ReLU(inplace=True), ] self.dilated = nn.Sequential(*layers) self.proj_out = nn.Sequential( nn.Conv2d(mid_ch, channels, 1, bias=False), nn.BatchNorm2d(channels), ) self.relu = nn.ReLU(inplace=True) def forward(self, x): y = self.proj_in(x) y = self.dilated(y) y = self.proj_out(y) return self.relu(x + y) class SmallUNetColorizer( nn.Module, PyTorchModelHubMixin, pipeline_tag="image-to-image", license="apache-2.0", tags=["colorization", "unet", "image-to-image", "classification"], ): """U-Net colorizer, classification-style (Zhang et al.). In: L in [-1,1], shape (B,1,H,W). Out: per-pixel logits over `len(bin_centers)` quantized ab bins, shape (B,Q,H,W) -- decode with `.decode()` to get back ab. `bin_centers` (a plain list of [a, b] pairs, JSON-serializable so it round-trips through config.json) is stored as a buffer, so every checkpoint carries its own bin grid -- no separate file to keep in sync. """ def __init__(self, bin_centers, in_ch: int = 1, base: int = 44, context_mid_ch: int = 96, context_dilations=(2, 4, 8)): super().__init__() self.in_ch, self.base = in_ch, base num_bins = len(bin_centers) self.num_bins = num_bins self.register_buffer("bin_centers", torch.tensor(bin_centers, dtype=torch.float32)) self.enc1 = double_conv(in_ch, base) self.enc2 = double_conv(base, base * 2) self.enc3 = double_conv(base * 2, base * 4) self.enc4 = double_conv(base * 4, base * 8) # bottleneck self.pool = nn.MaxPool2d(2) self.context = DilatedContextBlock(base * 8, mid_ch=context_mid_ch, dilations=tuple(context_dilations)) self.up3 = nn.ConvTranspose2d(base * 8, base * 4, 2, stride=2) self.dec3 = double_conv(base * 8, base * 4) self.up2 = nn.ConvTranspose2d(base * 4, base * 2, 2, stride=2) self.dec2 = double_conv(base * 4, base * 2) self.up1 = nn.ConvTranspose2d(base * 2, base, 2, stride=2) self.dec1 = double_conv(base * 2, base) self.out_conv = nn.Conv2d(base, num_bins, 1) # raw logits, no activation def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) e4 = self.context(self.enc4(self.pool(e3))) d3 = self.dec3(torch.cat([self.up3(e4), e3], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return self.out_conv(d1) # (B, Q, H, W) logits def decode(self, logits, temperature: float = 0.38): """Annealed-mean decode: T=1 is the full expectation (smooth but desaturated -- the same hedging problem as regression); T->0 approaches the argmax/mode (vivid but can be noisy/blotchy per pixel). T=0.38 (Zhang et al.'s value) balances the two. Returns raw ab in Lab units, shape (B, 2, H, W).""" logp = F.log_softmax(logits, dim=1) probs_t = F.softmax(logp / temperature, dim=1) # (B,Q,H,W) return torch.einsum("bqhw,qc->bchw", probs_t, self.bin_centers) # -------------------------------------------------------------------------- # Data: RGB photo -> (L, ab) in normalized Lab space # -------------------------------------------------------------------------- class ColorizationDataset(Dataset): """Wraps a HF `datasets` split that has an 'image' column of PIL images. Returns L (continuous, normalized), continuous ab (for visualization ground truth only), and the soft-encoded bin targets used for the loss.""" def __init__(self, hf_split, color_bins, image_size=128, augment=True): self.ds = hf_split self.color_bins = color_bins aug = [T.RandomHorizontalFlip()] if augment else [] self.resize = T.Compose([T.Resize((image_size, image_size)), *aug]) def __len__(self): return len(self.ds) def __getitem__(self, idx): img = self.ds[idx]["image"] if img.mode != "RGB": img = img.convert("RGB") img = self.resize(img) arr = np.asarray(img).astype(np.float32) / 255.0 lab = rgb2lab(arr).astype(np.float32) L = lab[:, :, 0:1] / 50.0 - 1.0 ab = lab[:, :, 1:3] # raw Lab units, kept continuous for viz ground truth top_idx, top_weight = self.color_bins.soft_encode(ab) # (H,W,5) each L = torch.from_numpy(np.ascontiguousarray(L.transpose(2, 0, 1))) ab = torch.from_numpy(np.ascontiguousarray(ab.transpose(2, 0, 1))) top_idx = torch.from_numpy(top_idx) # (H,W,5) int64 top_weight = torch.from_numpy(top_weight) # (H,W,5) float32 return L, ab, top_idx, top_weight def classification_loss(logits, top_idx, top_weight, class_weights): """Weighted soft multinomial cross-entropy (Zhang et al.). logits: (B,Q,H,W) raw model output. top_idx, top_weight: (B,H,W,5) from ColorBins.soft_encode -- the soft target distribution over each pixel's 5 nearest bins. class_weights: (Q,) rebalancing weights (high for rare/saturated bins), applied per-pixel using that pixel's single nearest bin. This is the classification analogue of the earlier `--color-weight` trick, but built into the loss's class-rebalancing rather than bolted on after the fact: rare, saturated bins get more weight *because* they're rare in the data, computed directly from the empirical color distribution instead of a hand-picked multiplier. Deliberately avoids ever materializing a full (B,Q,H,W) log-softmax tensor -- we only need log-probabilities at 5 of the Q bins per pixel, not all of them, and with Q in the hundreds that full tensor is the single biggest activation in the whole model (bigger than anything in the backbone), so this matters a lot for fitting on a 16GB GPU. """ idx = top_idx.permute(0, 3, 1, 2) # (B,5,H,W) w = top_weight.permute(0, 3, 1, 2) # (B,5,H,W) gathered_logits = torch.gather(logits, 1, idx) # (B,5,H,W) -- small logsumexp = torch.logsumexp(logits, dim=1, keepdim=True) # (B,1,H,W) -- small gathered_logp = gathered_logits - logsumexp # (B,5,H,W) per_pixel_nll = -(gathered_logp * w).sum(dim=1) # (B,H,W) pixel_class_weight = class_weights[idx[:, 0]] # nearest bin per pixel, (B,H,W) return (per_pixel_nll * pixel_class_weight).mean() def lab_to_rgb_batch(L, ab): """L:(N,1,H,W) normalized, ab:(N,2,H,W) raw Lab units -> (N,H,W,3) numpy RGB in [0,1].""" L = (L.detach().cpu().numpy() + 1.0) * 50.0 ab = ab.detach().cpu().numpy() lab = np.concatenate([L, ab], axis=1).transpose(0, 2, 3, 1) return np.stack([np.clip(lab2rgb(x), 0, 1) for x in lab]) def save_sample_grid(model, L, ab_true, path, device, temperature=0.38): model.eval() with torch.no_grad(): logits = model(L.to(device)) ab_pred = model.decode(logits, temperature=temperature).cpu() rgb_true = lab_to_rgb_batch(L, ab_true) rgb_pred = lab_to_rgb_batch(L, ab_pred) gray = ((L + 1.0) * 0.5).repeat(1, 3, 1, 1).clamp(0, 1) n = min(6, L.shape[0]) rows = [] for i in range(n): rows.append(gray[i]) rows.append(torch.from_numpy(rgb_pred[i]).permute(2, 0, 1).float()) rows.append(torch.from_numpy(rgb_true[i]).permute(2, 0, 1).float()) save_image(make_grid(rows, nrow=3, padding=2), path) model.train() # -------------------------------------------------------------------------- # Training # -------------------------------------------------------------------------- def build_argparser(): p = argparse.ArgumentParser(description=__doc__) p.add_argument("--dataset", default="frgfm/imagenette") p.add_argument("--dataset-config", default=None, help="Config name, if the dataset needs one (frgfm/imagenette does: " "'160px', '320px', or 'full_size'). Leave unset for single-config " "datasets, e.g. --dataset johnowhitaker/imagenette2-320.") p.add_argument("--image-size", type=int, default=128) p.add_argument("--base-channels", type=int, default=44, help="Backbone width. If --init-from is set, only layers whose shape " "matches are warm-started -- so changing this vs. the checkpoint " "means starting the whole backbone fresh, not just the head.") p.add_argument("--init-from", default=None, help="Hub model id (or local path) to warm-start weights from. Layers " "whose shape matches (the encoder/decoder backbone) are loaded; " "layers that don't (e.g. the output head, if the bin grid or " "--base-channels differs) are left randomly initialized. Works " "across --image-size changes since the model is fully convolutional.") p.add_argument("--bin-grid-size", type=int, default=10, help="Spacing (in Lab ab units) of the color-bin grid.") p.add_argument("--bin-samples", type=int, default=3000, help="Number of training images to sample when deriving the color-bin " "set and class-rebalancing weights from the real data distribution.") p.add_argument("--rebalance-lambda", type=float, default=0.5, help="Mix-in of uniform weighting when computing class-rebalancing " "weights (Zhang et al.'s lambda). 0 = pure inverse-frequency " "(aggressively up-weights rare colors); 1 = uniform (no " "rebalancing, closer to plain classification).") p.add_argument("--annealed-temp", type=float, default=0.38, help="Temperature for the annealed-mean decode used for sample-grid " "previews. Lower = closer to the mode (more vivid, can be " "blotchy); higher = closer to the mean (smoother, can be " "desaturated again). 0.38 is Zhang et al.'s value.") p.add_argument("--grayscale-chroma-threshold", type=float, default=3.0, help="Mean Lab chroma below which a sampled image is treated as " "genuinely grayscale-source (vs. a real but desaturated color " "photo). Always applied when building color-bin statistics. Only " "affects actual training data if --exclude-grayscale-source is set.") p.add_argument("--exclude-grayscale-source", action="store_true", help="Also drop grayscale-source images from the train/val sets " "themselves, not just from the bin-statistics sample. Off by " "default since it changes what the model is trained on -- worth " "trying if a meaningful fraction of the dataset turns out to be " "grayscale-source, since those examples contribute pure " "'predict zero' gradient that works against the rebalancing.") p.add_argument("--batch-size", type=int, default=64) p.add_argument("--no-amp", action="store_true", help="Disable mixed-precision training. AMP is on by default on CUDA -- " "the (B,num_bins,H,W) logits tensor is the single largest activation " "in this model (much bigger than the old regression head's B,2,H,W), " "so halving its memory footprint via fp16 matters more here than in " "most small models.") p.add_argument("--epochs", type=int, default=60) p.add_argument("--lr", type=float, default=2e-4) p.add_argument("--head-lr", type=float, default=None, help="Separate learning rate for the output head (out_conv). Defaults " "to --lr when unset. Worth raising above --lr when --init-from is " "used: the warm-started backbone benefits from a gentle fine-tuning " "rate, but the classification head is *not* warm-started (its shape " "depends on the bin grid, which usually differs from the checkpoint " "you're resuming from) -- it starts from random weights and can be " "undertrained if it only ever sees the backbone's cautious LR.") p.add_argument("--context-lr", type=float, default=None, help="Separate learning rate for the dilated-conv context block. " "Defaults to --lr when unset. Worth raising when warm-starting from " "a checkpoint that predates this block: unlike the backbone, it " "starts randomly initialized, and its residual connection means it " "starts as a near no-op -- at the backbone's cautious LR it may not " "get enough signal to develop meaningful behavior within a modest " "number of epochs.") p.add_argument("--num-workers", type=int, default=min(8, os.cpu_count() or 4)) p.add_argument("--output-dir", default="./output") p.add_argument("--sample-every", type=int, default=5) p.add_argument("--push-to-hub", action="store_true") p.add_argument("--push-every", type=int, default=10, help="Push a checkpoint to the Hub every N epochs, so a " "job timeout only loses progress since the last " "push instead of the whole run. Only used with " "--push-to-hub.") p.add_argument("--hub-model-id", default=None) p.add_argument("--seed", type=int, default=0) return p def get_dataset_splits(dataset_name, dataset_config, seed=0, val_fraction=0.05): from datasets import Image as HFImage from datasets import load_dataset print(f"Loading {dataset_name}" + (f" ({dataset_config})" if dataset_config else "") + " ...") try: ds = load_dataset(dataset_name, dataset_config) if dataset_config else load_dataset(dataset_name) except RuntimeError as e: if "Dataset scripts are no longer supported" not in str(e): raise # This repo only ships a legacy loading script, which `datasets` now # refuses to run. It still has an auto-converted Parquet mirror on # `refs/convert/parquet`, but for repos with multiple size configs # (like this one: full_size/160px/320px) that mirror doesn't expose # them as separate named configs -- so instead of going through # `datasets`' config auto-detection, load the underlying Parquet # shards for this config directly. print("Dataset uses a legacy loading script; loading its Parquet " f"files directly from refs/convert/parquet/{dataset_config}/ ...") base = f"hf://datasets/{dataset_name}@refs%2Fconvert%2Fparquet/{dataset_config}" try: ds = load_dataset("parquet", data_files={ "train": f"{base}/train/*.parquet", "validation": f"{base}/validation/*.parquet", }) except ValueError as e2: if "all the data_files are invalid" not in str(e2): raise raise RuntimeError( f"No Parquet mirror found for {dataset_name} config '{dataset_config}' at " f"refs/convert/parquet right now -- this can happen even for a config that " f"worked before (transient Hub-side issue with this repo, not a bug here). " f"Consider a dataset stored natively as Parquet instead (no legacy-script " f"fallback needed at all), e.g. --dataset johnowhitaker/imagenette2-320." ) from e2 # The generic "parquet" builder doesn't always restore the # decode-on-access Image feature -- make sure it's actually an # Image column and not raw {"bytes":..,"path":..} dicts. ds = ds.cast_column("image", HFImage()) train_split = ds["train"] if "validation" in ds: val_split = ds["validation"] elif "test" in ds: val_split = ds["test"] else: # No separate held-out split at all (e.g. johnowhitaker/imagenette2-320 # ships everything as a single "train"). Carve out a genuine held-out # set ourselves rather than reusing training data for validation -- # val_loss and the sample-grid preview should reflect unseen images. print(f"{dataset_name} has no validation/test split -- creating a " f"{val_fraction:.0%} held-out split via train_test_split(seed={seed}).") split = train_split.train_test_split(test_size=val_fraction, seed=seed) train_split, val_split = split["train"], split["test"] return train_split, val_split def build_model_card(model, n_params, args, train_loss, val_loss, epoch, is_final): status = (f"Final ({args.epochs} epochs complete)" if is_final else f"Checkpoint at epoch {epoch}/{args.epochs} (training still in progress " f"when this was pushed -- may be superseded by a later commit)") return f"""--- tags: - image-to-image - colorization - unet - pytorch license: apache-2.0 --- # Mini U-Net Colorizer A {n_params:,}-parameter U-Net that colorizes grayscale photos. Classification-style (Zhang et al., "Colorful Image Colorization"): it predicts a distribution over {model.num_bins} quantized CIE Lab `a`/`b` bins per pixel rather than regressing a single ab value directly, with color-bin loss weights derived from the real training-data distribution (rare/saturated colors weighted higher) so it doesn't just hedge toward desaturated averages. Decode with an annealed mean. - **Status:** {status} - **Input:** L channel normalized as `L/50 - 1` -> `[-1, 1]`, shape `(1, {args.image_size}, {args.image_size})` - **Output:** logits over {model.num_bins} ab bins, shape `({model.num_bins}, {args.image_size}, {args.image_size})` - **Trained on:** `{args.dataset}` ({args.dataset_config}){f", warm-started from `{args.init_from}`" if args.init_from else ""} - **Loss so far (weighted soft cross-entropy):** train {train_loss:.4f}, val {val_loss:.4f} ## Usage ```python import numpy as np, torch from skimage.color import rgb2lab, lab2rgb from PIL import Image # paste the SmallUNetColorizer class definition from the training script, then: model = SmallUNetColorizer.from_pretrained("{args.hub_model_id or 'USERNAME/REPO'}") model.eval() img = Image.open("photo.jpg").convert("RGB").resize(({args.image_size}, {args.image_size})) lab = rgb2lab(np.asarray(img).astype("float32") / 255.0) L = torch.from_numpy(lab[:, :, 0:1] / 50.0 - 1.0).permute(2, 0, 1)[None] with torch.no_grad(): logits = model(L) ab = model.decode(logits, temperature=0.38)[0].permute(1, 2, 0).numpy() L_out = (L[0, 0].numpy() + 1) * 50.0 lab_out = np.concatenate([L_out[:, :, None], ab], axis=-1) rgb_out = np.clip(lab2rgb(lab_out), 0, 1) ``` """ def push_model(model, out_dir, args, n_params, train_loss, val_loss, epoch, is_final): """Push current weights + a model card to the Hub. Never raises -- a failed push (e.g. transient network error) should not kill an otherwise-fine job, especially mid-training where we'll just try again at the next checkpoint.""" try: model.push_to_hub(args.hub_model_id) api = HfApi() card = build_model_card(model, n_params, args, train_loss, val_loss, epoch, is_final) api.upload_file(path_or_fileobj=card.encode(), path_in_repo="README.md", repo_id=args.hub_model_id) sample_files = sorted((out_dir / "samples").glob("*.png")) if sample_files: api.upload_file(path_or_fileobj=str(sample_files[-1]), path_in_repo="sample.png", repo_id=args.hub_model_id) tag = "final" if is_final else f"epoch {epoch}" print(f"Pushed ({tag}) to https://huggingface.co/{args.hub_model_id}") except Exception as e: print(f"WARNING: push_to_hub failed at epoch {epoch} ({e}). " f"Continuing -- local copy is still at {out_dir / 'model'}.") def load_raw_state_dict(model_id_or_path): """Load just the raw weight tensors from a checkpoint, without constructing a model instance. This matters for warm-starting: the checkpoint we're resuming from might be an older/incompatible architecture (e.g. a previous regression-head version of this model) whose config.json can't even be used to construct today's model class. We only need the tensors themselves to warm-start by-name, shape-compatible layers -- not a working instance of whatever architecture produced them.""" p = Path(model_id_or_path) if p.is_dir(): safep = p / "model.safetensors" if safep.exists(): return load_safetensors(str(safep)) return torch.load(p / "pytorch_model.bin", map_location="cpu") try: path = hf_hub_download(model_id_or_path, filename="model.safetensors") return load_safetensors(path) except Exception: path = hf_hub_download(model_id_or_path, filename="pytorch_model.bin") return torch.load(path, map_location="cpu") def filter_grayscale_source(hf_split, threshold): """Drop images whose mean chroma is below `threshold` -- genuinely grayscale-source photos, which contribute pure 'predict zero' gradient with no offsetting colorful signal, working against the very thing the rebalancing weights are trying to achieve.""" def is_colorful(example): img = example["image"] if img.mode != "RGB": img = img.convert("RGB") img = img.resize((64, 64)) arr = np.asarray(img).astype(np.float32) / 255.0 return image_mean_chroma(rgb2lab(arr)) >= threshold n_before = len(hf_split) filtered = hf_split.filter(is_colorful, num_proc=min(8, os.cpu_count() or 1)) n_after = len(filtered) print(f"Excluded {n_before - n_after}/{n_before} grayscale-source images " f"(mean chroma < {threshold}) from this split.") return filtered def main(argv=None, dataset_loader=get_dataset_splits): args = build_argparser().parse_args(argv) if args.push_to_hub and not args.hub_model_id: raise SystemExit("--hub-model-id is required with --push-to-hub") torch.manual_seed(args.seed) device = "cuda" if torch.cuda.is_available() else "cpu" print(f"Device: {device}") out_dir = Path(args.output_dir) (out_dir / "samples").mkdir(parents=True, exist_ok=True) train_split, val_split = dataset_loader(args.dataset, args.dataset_config, seed=args.seed) if args.exclude_grayscale_source: print("Filtering grayscale-source images out of train/val (--exclude-grayscale-source)...") train_split = filter_grayscale_source(train_split, args.grayscale_chroma_threshold) val_split = filter_grayscale_source(val_split, args.grayscale_chroma_threshold) print(f"Deriving color bins from {args.bin_samples} sampled training images " f"(grid={args.bin_grid_size}, lambda={args.rebalance_lambda}) ...") color_bins = build_color_bins(train_split, num_samples=args.bin_samples, grid_size=args.bin_grid_size, rebalance_lambda=args.rebalance_lambda, seed=args.seed, grayscale_chroma_threshold=args.grayscale_chroma_threshold) print(f"{color_bins.num_bins} color bins in use " f"(weight range {color_bins.weights.min():.2f}-{color_bins.weights.max():.2f})") model = SmallUNetColorizer(bin_centers=color_bins.bin_centers.tolist(), base=args.base_channels).to(device) n_params = sum(x.numel() for x in model.parameters()) print(f"Model parameters: {n_params:,}") assert n_params < 4_000_000, f"Model has {n_params:,} params, over the 4M budget!" if args.init_from: print(f"Warm-starting from {args.init_from} ...") ckpt_sd = load_raw_state_dict(args.init_from) own_sd = model.state_dict() compatible = {k: v for k, v in ckpt_sd.items() if k in own_sd and own_sd[k].shape == v.shape} skipped = sorted(set(own_sd) - set(compatible)) model.load_state_dict(compatible, strict=False) print(f"Loaded {len(compatible)}/{len(own_sd)} tensors from the checkpoint " f"(shape-compatible backbone layers). Randomly initialized: {skipped or 'none'}.") class_weights = torch.from_numpy(color_bins.weights).to(device) train_ds = ColorizationDataset(train_split, color_bins, args.image_size, augment=True) val_ds = ColorizationDataset(val_split, color_bins, args.image_size, augment=False) print(f"train: {len(train_ds)} images, val: {len(val_ds)} images") common_kwargs = dict(num_workers=args.num_workers, pin_memory=(device == "cuda")) if args.num_workers > 0: common_kwargs["persistent_workers"] = True train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True, drop_last=True, **common_kwargs) val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False, **common_kwargs) # For the recurring sample-grid preview, draw a random (but seeded, so # reproducible run-to-run) sample spanning the whole validation set -- # not just the literal first batch of an unshuffled loader, which for a # class-grouped dataset like imagenette means a single class every time. # (val_loss above is unaffected either way -- it already iterates the # full val_loader, all classes, every epoch.) sample_rng = np.random.default_rng(args.seed) n_preview = min(args.batch_size, len(val_ds)) preview_indices = sample_rng.choice(len(val_ds), size=n_preview, replace=False).tolist() preview_loader = DataLoader(torch.utils.data.Subset(val_ds, preview_indices), batch_size=n_preview, shuffle=False) fixed_val_L, fixed_val_ab, _, _ = next(iter(preview_loader)) head_lr = args.head_lr if args.head_lr is not None else args.lr context_lr = args.context_lr if args.context_lr is not None else args.lr backbone_params = [p for n, p in model.named_parameters() if not n.startswith("out_conv") and not n.startswith("context")] context_params = [p for n, p in model.named_parameters() if n.startswith("context")] head_params = [p for n, p in model.named_parameters() if n.startswith("out_conv")] opt = torch.optim.AdamW([ {"params": backbone_params, "lr": args.lr}, {"params": context_params, "lr": context_lr}, {"params": head_params, "lr": head_lr}, ], weight_decay=1e-4) print(f"Learning rates: backbone={args.lr} " f"context={context_lr}{' (== --lr)' if args.context_lr is None else ''} " f"head={head_lr}{' (== --lr)' if args.head_lr is None else ''}") sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=args.epochs) amp_enabled = (device == "cuda") and not args.no_amp scaler = torch.amp.GradScaler("cuda", enabled=amp_enabled) print(f"Mixed precision (AMP): {'on' if amp_enabled else 'off'}") def criterion(logits, top_idx, top_weight): return classification_loss(logits, top_idx, top_weight, class_weights) train_loss = val_loss = float("nan") for epoch in range(1, args.epochs + 1): epoch_start = time.time() model.train() running = 0.0 pbar = tqdm(train_loader, desc=f"epoch {epoch}/{args.epochs}") for L, ab, top_idx, top_weight in pbar: L = L.to(device, non_blocking=True) top_idx = top_idx.to(device, non_blocking=True) top_weight = top_weight.to(device, non_blocking=True) with torch.amp.autocast("cuda", enabled=amp_enabled): logits = model(L) loss = criterion(logits, top_idx, top_weight) opt.zero_grad(set_to_none=True) scaler.scale(loss).backward() scaler.step(opt) scaler.update() running += loss.item() * L.size(0) pbar.set_postfix(loss=f"{loss.item():.4f}") sched.step() train_loss = running / len(train_ds) model.eval() val_running = 0.0 with torch.no_grad(), torch.amp.autocast("cuda", enabled=amp_enabled): for L, ab, top_idx, top_weight in val_loader: L = L.to(device) top_idx, top_weight = top_idx.to(device), top_weight.to(device) val_running += criterion(model(L), top_idx, top_weight).item() * L.size(0) val_loss = val_running / len(val_ds) print(f"epoch {epoch}: train_loss={train_loss:.4f} val_loss={val_loss:.4f} " f"({time.time() - epoch_start:.1f}s)") if epoch % args.sample_every == 0 or epoch == args.epochs: save_sample_grid(model, fixed_val_L, fixed_val_ab, out_dir / "samples" / f"epoch_{epoch:03d}.png", device, temperature=args.annealed_temp) if args.push_to_hub and epoch != args.epochs and epoch % args.push_every == 0: push_model(model, out_dir, args, n_params, train_loss, val_loss, epoch, is_final=False) model_dir = out_dir / "model" model.save_pretrained(model_dir) print(f"Saved model to {model_dir}") if args.push_to_hub: push_model(model, out_dir, args, n_params, train_loss, val_loss, args.epochs, is_final=True) if __name__ == "__main__": main()