Upload 3 files
Browse files- colorize_eval 7.py +357 -0
- colorize_image 3.py +176 -0
- colorize_train 14.py +786 -0
colorize_eval 7.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# /// script
|
| 2 |
+
# requires-python = ">=3.10"
|
| 3 |
+
# dependencies = [
|
| 4 |
+
# "torch>=2.3",
|
| 5 |
+
# "torchvision>=0.18",
|
| 6 |
+
# "datasets>=2.19",
|
| 7 |
+
# "huggingface_hub>=0.24",
|
| 8 |
+
# "safetensors>=0.4",
|
| 9 |
+
# "scikit-image>=0.22",
|
| 10 |
+
# "pillow>=10.0",
|
| 11 |
+
# "numpy",
|
| 12 |
+
# ]
|
| 13 |
+
# ///
|
| 14 |
+
"""
|
| 15 |
+
Evaluate a SmallUNetColorizer checkpoint with a large, class-stratified
|
| 16 |
+
sample grid -- unlike the small fixed grid saved during training (which,
|
| 17 |
+
before a fix, was drawn from a single class), this samples N images from
|
| 18 |
+
*every* class in the dataset, labeled, so weaknesses specific to one subject
|
| 19 |
+
category (e.g. reflective electronics) are visible instead of hidden.
|
| 20 |
+
|
| 21 |
+
Runs entirely on CPU -- inference-only on a ~3.6M param model, no GPU needed.
|
| 22 |
+
Designed for `hf jobs uv run --flavor cpu-basic`; since a Jobs container is
|
| 23 |
+
destroyed when it finishes, the resulting grid is pushed to a Hub repo
|
| 24 |
+
rather than saved somewhere you could copy it from afterward.
|
| 25 |
+
|
| 26 |
+
hf jobs uv run --flavor cpu-basic --timeout 1h -s HF_TOKEN \\
|
| 27 |
+
colorize_eval.py \\
|
| 28 |
+
--model User-2468/mini-unet-colorizer \\
|
| 29 |
+
--per-class 5 --temperature 0.38 \\
|
| 30 |
+
--push-to-hub --hub-model-id User-2468/mini-unet-colorizer
|
| 31 |
+
"""
|
| 32 |
+
import argparse
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
import numpy as np
|
| 36 |
+
import torch
|
| 37 |
+
import torch.nn as nn
|
| 38 |
+
import torch.nn.functional as F
|
| 39 |
+
from huggingface_hub import HfApi, PyTorchModelHubMixin
|
| 40 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 41 |
+
from skimage.color import lab2rgb, rgb2lab
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# --------------------------------------------------------------------------
|
| 45 |
+
# Model (identical to colorize_train.py -- kept self-contained since Jobs
|
| 46 |
+
# scripts don't have access to sibling files).
|
| 47 |
+
# --------------------------------------------------------------------------
|
| 48 |
+
def double_conv(in_ch, out_ch):
|
| 49 |
+
return nn.Sequential(
|
| 50 |
+
nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False),
|
| 51 |
+
nn.BatchNorm2d(out_ch),
|
| 52 |
+
nn.ReLU(inplace=True),
|
| 53 |
+
nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),
|
| 54 |
+
nn.BatchNorm2d(out_ch),
|
| 55 |
+
nn.ReLU(inplace=True),
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class DilatedContextBlock(nn.Module):
|
| 60 |
+
def __init__(self, channels, mid_ch=96, dilations=(2, 4, 8)):
|
| 61 |
+
super().__init__()
|
| 62 |
+
self.proj_in = nn.Sequential(
|
| 63 |
+
nn.Conv2d(channels, mid_ch, 1, bias=False),
|
| 64 |
+
nn.BatchNorm2d(mid_ch),
|
| 65 |
+
nn.ReLU(inplace=True),
|
| 66 |
+
)
|
| 67 |
+
layers = []
|
| 68 |
+
for d in dilations:
|
| 69 |
+
layers += [
|
| 70 |
+
nn.Conv2d(mid_ch, mid_ch, 3, padding=d, dilation=d, bias=False),
|
| 71 |
+
nn.BatchNorm2d(mid_ch),
|
| 72 |
+
nn.ReLU(inplace=True),
|
| 73 |
+
]
|
| 74 |
+
self.dilated = nn.Sequential(*layers)
|
| 75 |
+
self.proj_out = nn.Sequential(
|
| 76 |
+
nn.Conv2d(mid_ch, channels, 1, bias=False),
|
| 77 |
+
nn.BatchNorm2d(channels),
|
| 78 |
+
)
|
| 79 |
+
self.relu = nn.ReLU(inplace=True)
|
| 80 |
+
|
| 81 |
+
def forward(self, x):
|
| 82 |
+
y = self.proj_in(x)
|
| 83 |
+
y = self.dilated(y)
|
| 84 |
+
y = self.proj_out(y)
|
| 85 |
+
return self.relu(x + y)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class SmallUNetColorizer(
|
| 89 |
+
nn.Module,
|
| 90 |
+
PyTorchModelHubMixin,
|
| 91 |
+
pipeline_tag="image-to-image",
|
| 92 |
+
license="apache-2.0",
|
| 93 |
+
tags=["colorization", "unet", "image-to-image", "classification"],
|
| 94 |
+
):
|
| 95 |
+
def __init__(self, bin_centers, in_ch: int = 1, base: int = 44,
|
| 96 |
+
context_mid_ch: int = 96, context_dilations=(2, 4, 8)):
|
| 97 |
+
super().__init__()
|
| 98 |
+
self.in_ch, self.base = in_ch, base
|
| 99 |
+
num_bins = len(bin_centers)
|
| 100 |
+
self.num_bins = num_bins
|
| 101 |
+
self.register_buffer("bin_centers", torch.tensor(bin_centers, dtype=torch.float32))
|
| 102 |
+
|
| 103 |
+
self.enc1 = double_conv(in_ch, base)
|
| 104 |
+
self.enc2 = double_conv(base, base * 2)
|
| 105 |
+
self.enc3 = double_conv(base * 2, base * 4)
|
| 106 |
+
self.enc4 = double_conv(base * 4, base * 8)
|
| 107 |
+
self.pool = nn.MaxPool2d(2)
|
| 108 |
+
self.context = DilatedContextBlock(base * 8, mid_ch=context_mid_ch,
|
| 109 |
+
dilations=tuple(context_dilations))
|
| 110 |
+
self.up3 = nn.ConvTranspose2d(base * 8, base * 4, 2, stride=2)
|
| 111 |
+
self.dec3 = double_conv(base * 8, base * 4)
|
| 112 |
+
self.up2 = nn.ConvTranspose2d(base * 4, base * 2, 2, stride=2)
|
| 113 |
+
self.dec2 = double_conv(base * 4, base * 2)
|
| 114 |
+
self.up1 = nn.ConvTranspose2d(base * 2, base, 2, stride=2)
|
| 115 |
+
self.dec1 = double_conv(base * 2, base)
|
| 116 |
+
self.out_conv = nn.Conv2d(base, num_bins, 1)
|
| 117 |
+
|
| 118 |
+
def forward(self, x):
|
| 119 |
+
e1 = self.enc1(x)
|
| 120 |
+
e2 = self.enc2(self.pool(e1))
|
| 121 |
+
e3 = self.enc3(self.pool(e2))
|
| 122 |
+
e4 = self.context(self.enc4(self.pool(e3)))
|
| 123 |
+
d3 = self.dec3(torch.cat([self.up3(e4), e3], dim=1))
|
| 124 |
+
d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1))
|
| 125 |
+
d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1))
|
| 126 |
+
return self.out_conv(d1)
|
| 127 |
+
|
| 128 |
+
def decode(self, logits, temperature: float = 0.38):
|
| 129 |
+
logp = F.log_softmax(logits, dim=1)
|
| 130 |
+
probs_t = F.softmax(logp / temperature, dim=1)
|
| 131 |
+
return torch.einsum("bqhw,qc->bchw", probs_t, self.bin_centers)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# --------------------------------------------------------------------------
|
| 135 |
+
# Dataset loading (identical fallback logic to colorize_train.py, for
|
| 136 |
+
# datasets like frgfm/imagenette that ship a legacy loading script).
|
| 137 |
+
# --------------------------------------------------------------------------
|
| 138 |
+
# Some Hub repos for imagenette label classes by WordNet synset ID rather
|
| 139 |
+
# than a readable name (e.g. johnowhitaker/imagenette2-320). Standard,
|
| 140 |
+
# well-documented imagenette synset -> name mapping so captions stay
|
| 141 |
+
# readable regardless of which repo's labeling convention is in use.
|
| 142 |
+
SYNSET_TO_NAME = {
|
| 143 |
+
"n01440764": "tench", "n02102040": "English springer",
|
| 144 |
+
"n02979186": "cassette player", "n03000684": "chain saw",
|
| 145 |
+
"n03028079": "church", "n03394916": "French horn",
|
| 146 |
+
"n03417042": "garbage truck", "n03425413": "gas pump",
|
| 147 |
+
"n03445777": "golf ball", "n03888257": "parachute",
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def get_dataset_splits(dataset_name, dataset_config):
|
| 152 |
+
from datasets import Image as HFImage
|
| 153 |
+
from datasets import load_dataset
|
| 154 |
+
|
| 155 |
+
print(f"Loading {dataset_name}" + (f" ({dataset_config})" if dataset_config else "") + " ...")
|
| 156 |
+
try:
|
| 157 |
+
ds = load_dataset(dataset_name, dataset_config) if dataset_config else load_dataset(dataset_name)
|
| 158 |
+
except RuntimeError as e:
|
| 159 |
+
if "Dataset scripts are no longer supported" not in str(e):
|
| 160 |
+
raise
|
| 161 |
+
print("Dataset uses a legacy loading script; loading its Parquet "
|
| 162 |
+
f"files directly from refs/convert/parquet/{dataset_config}/ ...")
|
| 163 |
+
base = f"hf://datasets/{dataset_name}@refs%2Fconvert%2Fparquet/{dataset_config}"
|
| 164 |
+
try:
|
| 165 |
+
ds = load_dataset("parquet", data_files={
|
| 166 |
+
"train": f"{base}/train/*.parquet",
|
| 167 |
+
"validation": f"{base}/validation/*.parquet",
|
| 168 |
+
})
|
| 169 |
+
except ValueError as e2:
|
| 170 |
+
if "all the data_files are invalid" not in str(e2):
|
| 171 |
+
raise
|
| 172 |
+
raise RuntimeError(
|
| 173 |
+
f"No Parquet mirror found for {dataset_name} config '{dataset_config}' at "
|
| 174 |
+
f"refs/convert/parquet right now -- this can happen even for a config that "
|
| 175 |
+
f"worked before (transient Hub-side issue with this repo, not a bug here). "
|
| 176 |
+
f"Consider a dataset stored natively as Parquet instead (no legacy-script "
|
| 177 |
+
f"fallback needed at all), e.g. --dataset johnowhitaker/imagenette2-320."
|
| 178 |
+
) from e2
|
| 179 |
+
ds = ds.cast_column("image", HFImage())
|
| 180 |
+
train_split = ds["train"]
|
| 181 |
+
if "validation" in ds:
|
| 182 |
+
eval_split = ds["validation"]
|
| 183 |
+
elif "test" in ds:
|
| 184 |
+
eval_split = ds["test"]
|
| 185 |
+
else:
|
| 186 |
+
# Some repos (e.g. johnowhitaker/imagenette2-320) ship a single
|
| 187 |
+
# unsplit "train" -- fine for a qualitative visual check like this
|
| 188 |
+
# script does, just note it's not a strictly held-out set.
|
| 189 |
+
print(f"Note: {dataset_name} has no validation/test split -- sampling "
|
| 190 |
+
f"from 'train' instead. Fine for a visual check, but these may "
|
| 191 |
+
f"overlap with what the model was actually trained on.")
|
| 192 |
+
eval_split = train_split
|
| 193 |
+
return train_split, eval_split
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# --------------------------------------------------------------------------
|
| 197 |
+
# Evaluation
|
| 198 |
+
# --------------------------------------------------------------------------
|
| 199 |
+
def colorize_one(model, img, size, temperature, grayscale_chroma_threshold=3.0):
|
| 200 |
+
"""Returns (gray_rgb, pred_rgb, true_rgb, is_grayscale_source)."""
|
| 201 |
+
img = img.convert("RGB").resize((size, size))
|
| 202 |
+
arr = np.asarray(img).astype(np.float32) / 255.0
|
| 203 |
+
lab = rgb2lab(arr).astype(np.float32)
|
| 204 |
+
L = torch.from_numpy(lab[:, :, 0:1] / 50.0 - 1.0).permute(2, 0, 1)[None]
|
| 205 |
+
|
| 206 |
+
with torch.no_grad():
|
| 207 |
+
logits = model(L)
|
| 208 |
+
ab_pred = model.decode(logits, temperature=temperature)[0].permute(1, 2, 0).numpy()
|
| 209 |
+
|
| 210 |
+
L_raw = lab[:, :, 0]
|
| 211 |
+
ab_true = lab[:, :, 1:3]
|
| 212 |
+
is_grayscale = float(np.sqrt((ab_true ** 2).sum(-1)).mean()) < grayscale_chroma_threshold
|
| 213 |
+
|
| 214 |
+
def to_rgb(ab):
|
| 215 |
+
lab_full = np.concatenate([L_raw[:, :, None], ab], axis=-1)
|
| 216 |
+
return (np.clip(lab2rgb(lab_full), 0, 1) * 255).astype(np.uint8)
|
| 217 |
+
|
| 218 |
+
gray_rgb = (np.stack([L_raw] * 3, axis=-1) / 100.0 * 255).astype(np.uint8)
|
| 219 |
+
return gray_rgb, to_rgb(ab_pred), to_rgb(ab_true), is_grayscale
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def build_stratified_sample(val_split, per_class, seed):
|
| 223 |
+
"""Returns list of (index, class_name), `per_class` images from every
|
| 224 |
+
class in the dataset -- not just whichever class happens to sort first."""
|
| 225 |
+
labels = val_split["label"]
|
| 226 |
+
raw_names = val_split.features["label"].names
|
| 227 |
+
class_names = [SYNSET_TO_NAME.get(n, n) for n in raw_names] # readable, if known
|
| 228 |
+
rng = np.random.default_rng(seed)
|
| 229 |
+
|
| 230 |
+
by_class = {c: [] for c in range(len(class_names))}
|
| 231 |
+
for i, lbl in enumerate(labels):
|
| 232 |
+
by_class[lbl].append(i)
|
| 233 |
+
|
| 234 |
+
sample = []
|
| 235 |
+
for c, indices in by_class.items():
|
| 236 |
+
chosen = rng.choice(indices, size=min(per_class, len(indices)), replace=False)
|
| 237 |
+
sample.extend((int(i), class_names[c]) for i in chosen)
|
| 238 |
+
return sample
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def build_grid_image(rows_by_class, thumb_size, per_class):
|
| 242 |
+
"""rows_by_class: dict class_name -> list of (gray, pred, true, is_grayscale_source)."""
|
| 243 |
+
font = ImageFont.load_default()
|
| 244 |
+
header_h = 24
|
| 245 |
+
pad = 2
|
| 246 |
+
row_w = thumb_size * 3 + pad * 2
|
| 247 |
+
class_block_h = header_h + per_class * (thumb_size + pad)
|
| 248 |
+
|
| 249 |
+
class_names = list(rows_by_class.keys())
|
| 250 |
+
total_h = class_block_h * len(class_names)
|
| 251 |
+
canvas = Image.new("RGB", (row_w, total_h), "white")
|
| 252 |
+
draw = ImageDraw.Draw(canvas)
|
| 253 |
+
|
| 254 |
+
y = 0
|
| 255 |
+
for name in class_names:
|
| 256 |
+
rows = rows_by_class[name]
|
| 257 |
+
n_gray_source = sum(1 for r in rows if r[3])
|
| 258 |
+
header_text = f"{name} (gray | prediction | ground truth)"
|
| 259 |
+
if n_gray_source:
|
| 260 |
+
header_text += f" -- {n_gray_source}/{len(rows)} grayscale-source"
|
| 261 |
+
draw.rectangle([0, y, row_w, y + header_h], fill=(30, 30, 30))
|
| 262 |
+
draw.text((6, y + 5), header_text, fill="white", font=font)
|
| 263 |
+
y += header_h
|
| 264 |
+
for gray, pred, true, is_gray_source in rows:
|
| 265 |
+
x = 0
|
| 266 |
+
for arr in (gray, pred, true):
|
| 267 |
+
im = Image.fromarray(arr).resize((thumb_size, thumb_size))
|
| 268 |
+
canvas.paste(im, (x, y))
|
| 269 |
+
x += thumb_size + pad
|
| 270 |
+
if is_gray_source:
|
| 271 |
+
# Tag the ground-truth thumbnail so it's obvious *which* row
|
| 272 |
+
# had nothing to colorize, not just that the class has some.
|
| 273 |
+
tag_x = thumb_size * 2 + pad * 2
|
| 274 |
+
draw.rectangle([tag_x, y, tag_x + thumb_size, y + 14], fill=(200, 30, 30))
|
| 275 |
+
draw.text((tag_x + 3, y + 2), "B&W source", fill="white", font=font)
|
| 276 |
+
y += thumb_size + pad
|
| 277 |
+
return canvas
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def main():
|
| 281 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 282 |
+
p.add_argument("--model", required=True, help="Hub model id or local checkpoint path")
|
| 283 |
+
p.add_argument("--dataset", default="johnowhitaker/imagenette2-320",
|
| 284 |
+
help="Default is stored natively as Parquet (no legacy-script fallback "
|
| 285 |
+
"needed), which has been more reliable than frgfm/imagenette's "
|
| 286 |
+
"auto-converted mirror. It has no separate validation split (just "
|
| 287 |
+
"'train'), so this samples from the full set rather than a held-out "
|
| 288 |
+
"portion -- fine for a visual check. frgfm/imagenette is still an "
|
| 289 |
+
"option (--dataset-config 160px or 320px) if its mirror comes back.")
|
| 290 |
+
p.add_argument("--dataset-config", default=None,
|
| 291 |
+
help="Config name, if the dataset needs one (frgfm/imagenette does: "
|
| 292 |
+
"'160px' or '320px'). Leave unset for single-config datasets like "
|
| 293 |
+
"the johnowhitaker default.")
|
| 294 |
+
p.add_argument("--image-size", type=int, default=256, help="Resolution used for inference")
|
| 295 |
+
p.add_argument("--thumb-size", type=int, default=128, help="Display size in the grid")
|
| 296 |
+
p.add_argument("--per-class", type=int, default=5, help="Sampled images per class")
|
| 297 |
+
p.add_argument("--temperature", type=float, default=0.38)
|
| 298 |
+
p.add_argument("--grayscale-chroma-threshold", type=float, default=3.0,
|
| 299 |
+
help="Mean Lab chroma below which a ground-truth image is flagged as "
|
| 300 |
+
"genuinely grayscale-source and tagged in the grid, rather than a "
|
| 301 |
+
"real colorization miss.")
|
| 302 |
+
p.add_argument("--seed", type=int, default=0)
|
| 303 |
+
p.add_argument("--output-dir", default="./eval")
|
| 304 |
+
p.add_argument("--output-name", default="eval_grid.png")
|
| 305 |
+
p.add_argument("--push-to-hub", action="store_true")
|
| 306 |
+
p.add_argument("--hub-model-id", default=None,
|
| 307 |
+
help="Repo to push the grid to (defaults to --model). Since a Jobs "
|
| 308 |
+
"container is destroyed on completion, --push-to-hub is the only "
|
| 309 |
+
"way to get the result back out when running as a Job.")
|
| 310 |
+
args = p.parse_args()
|
| 311 |
+
|
| 312 |
+
print(f"Loading {args.model} ...")
|
| 313 |
+
model = SmallUNetColorizer.from_pretrained(args.model).eval()
|
| 314 |
+
print(f"({model.num_bins} color bins)")
|
| 315 |
+
|
| 316 |
+
_, val_split = get_dataset_splits(args.dataset, args.dataset_config)
|
| 317 |
+
sample = build_stratified_sample(val_split, args.per_class, args.seed)
|
| 318 |
+
class_names = [SYNSET_TO_NAME.get(n, n) for n in val_split.features["label"].names]
|
| 319 |
+
print(f"Evaluating {len(sample)} images across {len(class_names)} classes "
|
| 320 |
+
f"({args.per_class} per class) ...")
|
| 321 |
+
|
| 322 |
+
rows_by_class = {name: [] for name in class_names}
|
| 323 |
+
n_grayscale_total = 0
|
| 324 |
+
for n, (idx, class_name) in enumerate(sample):
|
| 325 |
+
img = val_split[idx]["image"]
|
| 326 |
+
gray, pred, true, is_gray_source = colorize_one(
|
| 327 |
+
model, img, args.image_size, args.temperature, args.grayscale_chroma_threshold)
|
| 328 |
+
rows_by_class[class_name].append((gray, pred, true, is_gray_source))
|
| 329 |
+
n_grayscale_total += is_gray_source
|
| 330 |
+
if (n + 1) % 10 == 0 or n + 1 == len(sample):
|
| 331 |
+
print(f" {n + 1}/{len(sample)}")
|
| 332 |
+
|
| 333 |
+
print(f"{n_grayscale_total}/{len(sample)} sampled images are grayscale-source "
|
| 334 |
+
f"(tagged in red in the grid) -- these have no color to recover, so weak-looking "
|
| 335 |
+
f"results there aren't a model failure.")
|
| 336 |
+
|
| 337 |
+
grid = build_grid_image(rows_by_class, args.thumb_size, args.per_class)
|
| 338 |
+
|
| 339 |
+
out_dir = Path(args.output_dir)
|
| 340 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 341 |
+
out_path = out_dir / args.output_name
|
| 342 |
+
grid.save(out_path)
|
| 343 |
+
print(f"Saved grid ({grid.width}x{grid.height}) to {out_path}")
|
| 344 |
+
|
| 345 |
+
if args.push_to_hub:
|
| 346 |
+
hub_model_id = args.hub_model_id or args.model
|
| 347 |
+
try:
|
| 348 |
+
HfApi().upload_file(path_or_fileobj=str(out_path), path_in_repo=args.output_name,
|
| 349 |
+
repo_id=hub_model_id)
|
| 350 |
+
print(f"Pushed to https://huggingface.co/{hub_model_id}/blob/main/{args.output_name}")
|
| 351 |
+
except Exception as e:
|
| 352 |
+
print(f"WARNING: push failed ({e}). Grid was still generated at {out_path}, "
|
| 353 |
+
f"but that path won't survive a Jobs container exiting.")
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
if __name__ == "__main__":
|
| 357 |
+
main()
|
colorize_image 3.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# /// script
|
| 2 |
+
# requires-python = ">=3.10"
|
| 3 |
+
# dependencies = [
|
| 4 |
+
# "torch>=2.3",
|
| 5 |
+
# "torchvision>=0.18",
|
| 6 |
+
# "huggingface_hub>=0.24",
|
| 7 |
+
# "safetensors>=0.4",
|
| 8 |
+
# "scikit-image>=0.22",
|
| 9 |
+
# "pillow>=10.0",
|
| 10 |
+
# "numpy",
|
| 11 |
+
# ]
|
| 12 |
+
# ///
|
| 13 |
+
"""
|
| 14 |
+
Colorize photos with a SmallUNetColorizer checkpoint trained by colorize_train.py
|
| 15 |
+
(classification-head version: predicts a distribution over quantized Lab ab
|
| 16 |
+
bins per pixel, decoded with an annealed mean).
|
| 17 |
+
|
| 18 |
+
Usage:
|
| 19 |
+
uv run colorize_image.py --model User-2468/mini-unet-colorizer photo.jpg
|
| 20 |
+
uv run colorize_image.py --model ./local_checkpoint --temperature 0.2 a.jpg b.jpg
|
| 21 |
+
|
| 22 |
+
--temperature is the main "vividness" knob: lower values weight the decode
|
| 23 |
+
toward the most likely color bin (more saturated, can be a bit blotchy);
|
| 24 |
+
higher values move toward the full expectation over the distribution
|
| 25 |
+
(smoother, but can drift back toward desaturated -- the same hedging effect
|
| 26 |
+
plain regression had). 0.38 (the default) is a reasonable middle ground.
|
| 27 |
+
--saturation-boost is an optional *additional* post-decode multiplier on top
|
| 28 |
+
of that, for further hand-tuning after picking a temperature.
|
| 29 |
+
"""
|
| 30 |
+
import argparse
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
|
| 33 |
+
import numpy as np
|
| 34 |
+
import torch
|
| 35 |
+
import torch.nn as nn
|
| 36 |
+
import torch.nn.functional as F
|
| 37 |
+
from huggingface_hub import PyTorchModelHubMixin
|
| 38 |
+
from PIL import Image
|
| 39 |
+
from skimage.color import lab2rgb, rgb2lab
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def double_conv(in_ch, out_ch):
|
| 43 |
+
return nn.Sequential(
|
| 44 |
+
nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False),
|
| 45 |
+
nn.BatchNorm2d(out_ch),
|
| 46 |
+
nn.ReLU(inplace=True),
|
| 47 |
+
nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),
|
| 48 |
+
nn.BatchNorm2d(out_ch),
|
| 49 |
+
nn.ReLU(inplace=True),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class DilatedContextBlock(nn.Module):
|
| 54 |
+
def __init__(self, channels, mid_ch=96, dilations=(2, 4, 8)):
|
| 55 |
+
super().__init__()
|
| 56 |
+
self.proj_in = nn.Sequential(
|
| 57 |
+
nn.Conv2d(channels, mid_ch, 1, bias=False),
|
| 58 |
+
nn.BatchNorm2d(mid_ch),
|
| 59 |
+
nn.ReLU(inplace=True),
|
| 60 |
+
)
|
| 61 |
+
layers = []
|
| 62 |
+
for d in dilations:
|
| 63 |
+
layers += [
|
| 64 |
+
nn.Conv2d(mid_ch, mid_ch, 3, padding=d, dilation=d, bias=False),
|
| 65 |
+
nn.BatchNorm2d(mid_ch),
|
| 66 |
+
nn.ReLU(inplace=True),
|
| 67 |
+
]
|
| 68 |
+
self.dilated = nn.Sequential(*layers)
|
| 69 |
+
self.proj_out = nn.Sequential(
|
| 70 |
+
nn.Conv2d(mid_ch, channels, 1, bias=False),
|
| 71 |
+
nn.BatchNorm2d(channels),
|
| 72 |
+
)
|
| 73 |
+
self.relu = nn.ReLU(inplace=True)
|
| 74 |
+
|
| 75 |
+
def forward(self, x):
|
| 76 |
+
y = self.proj_in(x)
|
| 77 |
+
y = self.dilated(y)
|
| 78 |
+
y = self.proj_out(y)
|
| 79 |
+
return self.relu(x + y)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class SmallUNetColorizer(
|
| 83 |
+
nn.Module,
|
| 84 |
+
PyTorchModelHubMixin,
|
| 85 |
+
pipeline_tag="image-to-image",
|
| 86 |
+
license="apache-2.0",
|
| 87 |
+
tags=["colorization", "unet", "image-to-image", "classification"],
|
| 88 |
+
):
|
| 89 |
+
def __init__(self, bin_centers, in_ch: int = 1, base: int = 44,
|
| 90 |
+
context_mid_ch: int = 96, context_dilations=(2, 4, 8)):
|
| 91 |
+
super().__init__()
|
| 92 |
+
self.in_ch, self.base = in_ch, base
|
| 93 |
+
num_bins = len(bin_centers)
|
| 94 |
+
self.num_bins = num_bins
|
| 95 |
+
self.register_buffer("bin_centers", torch.tensor(bin_centers, dtype=torch.float32))
|
| 96 |
+
|
| 97 |
+
self.enc1 = double_conv(in_ch, base)
|
| 98 |
+
self.enc2 = double_conv(base, base * 2)
|
| 99 |
+
self.enc3 = double_conv(base * 2, base * 4)
|
| 100 |
+
self.enc4 = double_conv(base * 4, base * 8)
|
| 101 |
+
self.pool = nn.MaxPool2d(2)
|
| 102 |
+
self.context = DilatedContextBlock(base * 8, mid_ch=context_mid_ch,
|
| 103 |
+
dilations=tuple(context_dilations))
|
| 104 |
+
self.up3 = nn.ConvTranspose2d(base * 8, base * 4, 2, stride=2)
|
| 105 |
+
self.dec3 = double_conv(base * 8, base * 4)
|
| 106 |
+
self.up2 = nn.ConvTranspose2d(base * 4, base * 2, 2, stride=2)
|
| 107 |
+
self.dec2 = double_conv(base * 4, base * 2)
|
| 108 |
+
self.up1 = nn.ConvTranspose2d(base * 2, base, 2, stride=2)
|
| 109 |
+
self.dec1 = double_conv(base * 2, base)
|
| 110 |
+
self.out_conv = nn.Conv2d(base, num_bins, 1)
|
| 111 |
+
|
| 112 |
+
def forward(self, x):
|
| 113 |
+
e1 = self.enc1(x)
|
| 114 |
+
e2 = self.enc2(self.pool(e1))
|
| 115 |
+
e3 = self.enc3(self.pool(e2))
|
| 116 |
+
e4 = self.context(self.enc4(self.pool(e3)))
|
| 117 |
+
d3 = self.dec3(torch.cat([self.up3(e4), e3], dim=1))
|
| 118 |
+
d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1))
|
| 119 |
+
d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1))
|
| 120 |
+
return self.out_conv(d1)
|
| 121 |
+
|
| 122 |
+
def decode(self, logits, temperature: float = 0.38):
|
| 123 |
+
logp = F.log_softmax(logits, dim=1)
|
| 124 |
+
probs_t = F.softmax(logp / temperature, dim=1)
|
| 125 |
+
return torch.einsum("bqhw,qc->bchw", probs_t, self.bin_centers)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def colorize(model, img, size, temperature, saturation_boost, device):
|
| 129 |
+
img = img.convert("RGB").resize((size, size))
|
| 130 |
+
arr = np.asarray(img).astype(np.float32) / 255.0
|
| 131 |
+
lab = rgb2lab(arr).astype(np.float32)
|
| 132 |
+
L = torch.from_numpy(lab[:, :, 0:1] / 50.0 - 1.0).permute(2, 0, 1)[None].to(device)
|
| 133 |
+
|
| 134 |
+
with torch.no_grad():
|
| 135 |
+
logits = model(L)
|
| 136 |
+
ab = model.decode(logits, temperature=temperature)[0].permute(1, 2, 0).cpu().numpy()
|
| 137 |
+
|
| 138 |
+
ab = np.clip(ab * saturation_boost, -128, 127)
|
| 139 |
+
L_out = (L[0, 0].cpu().numpy() + 1.0) * 50.0
|
| 140 |
+
lab_out = np.concatenate([L_out[:, :, None], ab], axis=-1)
|
| 141 |
+
rgb_out = np.clip(lab2rgb(lab_out), 0, 1)
|
| 142 |
+
return Image.fromarray((rgb_out * 255).astype(np.uint8))
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def main():
|
| 146 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 147 |
+
p.add_argument("images", nargs="+", help="Path(s) to input photo(s)")
|
| 148 |
+
p.add_argument("--model", required=True, help="Hub model id or local checkpoint path")
|
| 149 |
+
p.add_argument("--size", type=int, default=256, help="Resize input to this square size")
|
| 150 |
+
p.add_argument("--temperature", type=float, default=0.38,
|
| 151 |
+
help="Annealed-mean decode temperature. Lower = more vivid/mode-like, "
|
| 152 |
+
"higher = smoother but can desaturate again. Try 0.15-0.6.")
|
| 153 |
+
p.add_argument("--saturation-boost", type=float, default=1.0,
|
| 154 |
+
help="Extra multiplier on the decoded ab, applied after temperature. "
|
| 155 |
+
"1.0 = no extra boost.")
|
| 156 |
+
p.add_argument("--output-dir", default="./colorized")
|
| 157 |
+
args = p.parse_args()
|
| 158 |
+
|
| 159 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 160 |
+
print(f"Loading {args.model} on {device} ...")
|
| 161 |
+
model = SmallUNetColorizer.from_pretrained(args.model).to(device).eval()
|
| 162 |
+
print(f"({model.num_bins} color bins)")
|
| 163 |
+
|
| 164 |
+
out_dir = Path(args.output_dir)
|
| 165 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 166 |
+
|
| 167 |
+
for path in args.images:
|
| 168 |
+
img = Image.open(path)
|
| 169 |
+
result = colorize(model, img, args.size, args.temperature, args.saturation_boost, device)
|
| 170 |
+
out_path = out_dir / f"{Path(path).stem}_colorized.png"
|
| 171 |
+
result.save(out_path)
|
| 172 |
+
print(f"{path} -> {out_path}")
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
if __name__ == "__main__":
|
| 176 |
+
main()
|
colorize_train 14.py
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# /// script
|
| 2 |
+
# requires-python = ">=3.10"
|
| 3 |
+
# dependencies = [
|
| 4 |
+
# "torch>=2.3",
|
| 5 |
+
# "torchvision>=0.18",
|
| 6 |
+
# "datasets>=2.19",
|
| 7 |
+
# "huggingface_hub>=0.24",
|
| 8 |
+
# "safetensors>=0.4",
|
| 9 |
+
# "scikit-image>=0.22",
|
| 10 |
+
# "scipy>=1.11",
|
| 11 |
+
# "pillow>=10.0",
|
| 12 |
+
# "numpy",
|
| 13 |
+
# "tqdm",
|
| 14 |
+
# ]
|
| 15 |
+
# ///
|
| 16 |
+
"""
|
| 17 |
+
Train a tiny (<4M parameter) U-Net that colorizes grayscale photos.
|
| 18 |
+
|
| 19 |
+
Classification-style (Zhang et al., "Colorful Image Colorization"): predicts
|
| 20 |
+
a distribution over quantized Lab a/b color bins per pixel, rather than
|
| 21 |
+
regressing a single ab value directly. Bin classes are up-weighted by rarity
|
| 22 |
+
in the real training-data color distribution, so the model isn't rewarded
|
| 23 |
+
for hedging toward desaturated averages the way a plain L1/L2 regression
|
| 24 |
+
loss would. Decode with an annealed mean (see `SmallUNetColorizer.decode`).
|
| 25 |
+
|
| 26 |
+
Designed to run as a Hugging Face Job:
|
| 27 |
+
|
| 28 |
+
hf jobs uv run --flavor t4-small --timeout 1h \
|
| 29 |
+
-s HF_TOKEN colorize_train.py \
|
| 30 |
+
--push-to-hub --hub-model-id <your-username>/mini-unet-colorizer
|
| 31 |
+
|
| 32 |
+
Run locally the same way, just with `uv run colorize_train.py ...` or
|
| 33 |
+
`python colorize_train.py ...` after `pip install`-ing the deps above.
|
| 34 |
+
"""
|
| 35 |
+
import argparse
|
| 36 |
+
import os
|
| 37 |
+
import time
|
| 38 |
+
from pathlib import Path
|
| 39 |
+
|
| 40 |
+
import numpy as np
|
| 41 |
+
import torch
|
| 42 |
+
import torch.nn as nn
|
| 43 |
+
import torch.nn.functional as F
|
| 44 |
+
from huggingface_hub import HfApi, PyTorchModelHubMixin, hf_hub_download
|
| 45 |
+
from safetensors.torch import load_file as load_safetensors
|
| 46 |
+
from scipy.ndimage import gaussian_filter
|
| 47 |
+
from scipy.spatial import cKDTree
|
| 48 |
+
from skimage.color import lab2rgb, rgb2lab
|
| 49 |
+
from torch.utils.data import DataLoader, Dataset
|
| 50 |
+
from torchvision import transforms as T
|
| 51 |
+
from torchvision.utils import make_grid, save_image
|
| 52 |
+
from tqdm.auto import tqdm
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# --------------------------------------------------------------------------
|
| 56 |
+
# Classification-based colorization (Zhang et al., "Colorful Image
|
| 57 |
+
# Colorization" style): quantize ab space into bins, predict a distribution
|
| 58 |
+
# over bins per pixel instead of regressing a single ab value, and decode
|
| 59 |
+
# with an annealed mean. This avoids the "regress to the desaturated average
|
| 60 |
+
# of all plausible colors" failure mode of plain L1/L2 regression.
|
| 61 |
+
# --------------------------------------------------------------------------
|
| 62 |
+
class ColorBins:
|
| 63 |
+
"""The quantized ab bin grid, its empirical prior, and rebalancing weights.
|
| 64 |
+
|
| 65 |
+
`bin_centers`: (Q, 2) array of [a, b] centers for the Q bins that actually
|
| 66 |
+
occur in the sampled data (a data-driven stand-in for "in-gamut" bins,
|
| 67 |
+
rather than computing exact sRGB gamut geometry).
|
| 68 |
+
`weights`: (Q,) rebalancing weights, high for rare/saturated bins, low
|
| 69 |
+
for common/desaturated ones, E_p[weight] == 1.
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
def __init__(self, bin_centers: np.ndarray, weights: np.ndarray):
|
| 73 |
+
assert bin_centers.shape[0] == weights.shape[0]
|
| 74 |
+
self.bin_centers = bin_centers.astype(np.float32) # (Q, 2)
|
| 75 |
+
self.weights = weights.astype(np.float32) # (Q,)
|
| 76 |
+
self.num_bins = bin_centers.shape[0]
|
| 77 |
+
self._tree = cKDTree(self.bin_centers)
|
| 78 |
+
|
| 79 |
+
def soft_encode(self, ab: np.ndarray, k: int = 5, sigma: float = 5.0):
|
| 80 |
+
"""ab: (H, W, 2) continuous Lab a/b values.
|
| 81 |
+
Returns (top_idx, top_weight): each (H, W, k). top_weight sums to 1
|
| 82 |
+
over the last axis (a Gaussian-kernel soft target over the k nearest
|
| 83 |
+
bins, per Zhang et al.'s soft-encoding scheme)."""
|
| 84 |
+
h, w, _ = ab.shape
|
| 85 |
+
flat = ab.reshape(-1, 2)
|
| 86 |
+
dist, idx = self._tree.query(flat, k=k) # (N,k), (N,k)
|
| 87 |
+
if k == 1: # cKDTree squeezes the last dim when k==1
|
| 88 |
+
dist, idx = dist[:, None], idx[:, None]
|
| 89 |
+
weight = np.exp(-(dist ** 2) / (2 * sigma ** 2))
|
| 90 |
+
weight /= weight.sum(axis=1, keepdims=True)
|
| 91 |
+
return idx.reshape(h, w, k).astype(np.int64), weight.reshape(h, w, k).astype(np.float32)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def image_mean_chroma(lab):
|
| 95 |
+
"""Mean Lab chroma (sqrt(a^2+b^2)) -- near 0 for a genuinely grayscale-
|
| 96 |
+
source image (real color photos, even desaturated/overcast ones, have
|
| 97 |
+
meaningfully more residual chroma than a true B&W photo's compression
|
| 98 |
+
noise)."""
|
| 99 |
+
return float(np.sqrt(lab[:, :, 1] ** 2 + lab[:, :, 2] ** 2).mean())
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def build_color_bins(hf_split, num_samples=3000, grid_size=10, ab_range=110,
|
| 103 |
+
rebalance_lambda=0.5, smoothing_sigma=1.0, seed=0,
|
| 104 |
+
grayscale_chroma_threshold=3.0):
|
| 105 |
+
"""Derive the bin set + rebalancing weights from a sample of real training
|
| 106 |
+
images, by actually converting them to Lab and histogramming ab values on
|
| 107 |
+
a `grid_size`-spaced grid. Bins that never occur simply aren't included --
|
| 108 |
+
this is a cheap data-driven stand-in for restricting to in-gamut bins.
|
| 109 |
+
|
| 110 |
+
Images whose mean chroma is below `grayscale_chroma_threshold` are
|
| 111 |
+
excluded from the histogram: a genuinely black-and-white source photo's
|
| 112 |
+
correct ab really is ~0, which is a different phenomenon from the model
|
| 113 |
+
hedging toward gray on a photo that actually had color. Mixing the two
|
| 114 |
+
into one "near-zero is common" statistic would miscalibrate the
|
| 115 |
+
rebalancing weights -- overcounting how often gray is truly correct.
|
| 116 |
+
"""
|
| 117 |
+
edges = np.arange(-ab_range, ab_range + grid_size, grid_size)
|
| 118 |
+
centers_1d = (edges[:-1] + edges[1:]) / 2 # bin centers along one axis
|
| 119 |
+
n_grid = len(centers_1d)
|
| 120 |
+
|
| 121 |
+
rng = np.random.default_rng(seed)
|
| 122 |
+
n = len(hf_split)
|
| 123 |
+
sample_idx = rng.choice(n, size=min(num_samples, n), replace=False)
|
| 124 |
+
|
| 125 |
+
counts = np.zeros((n_grid, n_grid), dtype=np.int64)
|
| 126 |
+
n_grayscale_source = 0
|
| 127 |
+
for i in sample_idx:
|
| 128 |
+
img = hf_split[int(i)]["image"]
|
| 129 |
+
if img.mode != "RGB":
|
| 130 |
+
img = img.convert("RGB")
|
| 131 |
+
img = img.resize((64, 64)) # small is plenty for a color histogram
|
| 132 |
+
arr = np.asarray(img).astype(np.float32) / 255.0
|
| 133 |
+
lab = rgb2lab(arr)
|
| 134 |
+
if image_mean_chroma(lab) < grayscale_chroma_threshold:
|
| 135 |
+
n_grayscale_source += 1
|
| 136 |
+
continue
|
| 137 |
+
a_idx = np.clip(((lab[:, :, 1] + ab_range) // grid_size).astype(int), 0, n_grid - 1)
|
| 138 |
+
b_idx = np.clip(((lab[:, :, 2] + ab_range) // grid_size).astype(int), 0, n_grid - 1)
|
| 139 |
+
np.add.at(counts, (a_idx.ravel(), b_idx.ravel()), 1)
|
| 140 |
+
|
| 141 |
+
frac = n_grayscale_source / len(sample_idx)
|
| 142 |
+
print(f"{n_grayscale_source}/{len(sample_idx)} ({frac:.1%}) sampled images are "
|
| 143 |
+
f"grayscale-source (mean chroma < {grayscale_chroma_threshold}) -- excluded from "
|
| 144 |
+
f"the color-bin statistics below.")
|
| 145 |
+
|
| 146 |
+
smoothed = gaussian_filter(counts.astype(np.float32), sigma=smoothing_sigma)
|
| 147 |
+
|
| 148 |
+
keep_mask = counts > 0
|
| 149 |
+
keep_a, keep_b = np.nonzero(keep_mask)
|
| 150 |
+
bin_centers = np.stack([centers_1d[keep_a], centers_1d[keep_b]], axis=1) # (Q, 2)
|
| 151 |
+
|
| 152 |
+
prior = smoothed[keep_a, keep_b]
|
| 153 |
+
prior = prior / prior.sum()
|
| 154 |
+
q = len(prior)
|
| 155 |
+
w = 1.0 / ((1 - rebalance_lambda) * prior + rebalance_lambda / q)
|
| 156 |
+
w = w / (prior * w).sum() # normalize so E_prior[w] == 1
|
| 157 |
+
|
| 158 |
+
return ColorBins(bin_centers, w)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# --------------------------------------------------------------------------
|
| 162 |
+
# Model: small U-Net, L (1ch) in -> per-pixel logits over color bins out.
|
| 163 |
+
# ~3.6M trainable params at base=44 (bin_centers is a non-trainable buffer).
|
| 164 |
+
# --------------------------------------------------------------------------
|
| 165 |
+
def double_conv(in_ch, out_ch):
|
| 166 |
+
return nn.Sequential(
|
| 167 |
+
nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False),
|
| 168 |
+
nn.BatchNorm2d(out_ch),
|
| 169 |
+
nn.ReLU(inplace=True),
|
| 170 |
+
nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),
|
| 171 |
+
nn.BatchNorm2d(out_ch),
|
| 172 |
+
nn.ReLU(inplace=True),
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
class DilatedContextBlock(nn.Module):
|
| 177 |
+
"""Cheap receptive-field expansion at the bottleneck. The plain U-Net's
|
| 178 |
+
receptive field is fixed in absolute pixels regardless of input
|
| 179 |
+
resolution (~51-54px, empirically measured), so at higher --image-size
|
| 180 |
+
it covers a shrinking fraction of the image -- the likely cause of
|
| 181 |
+
color decisions that don't stay consistent across a whole object (e.g.
|
| 182 |
+
a parachute canopy getting inconsistent patches of color).
|
| 183 |
+
|
| 184 |
+
A naive fix (another U-Net level, channel-doubling as the existing ones
|
| 185 |
+
do) costs ~4.5M params just for one extra block -- unaffordable. This
|
| 186 |
+
instead projects down to a narrow `mid_ch` width, runs a few dilated 3x3
|
| 187 |
+
convs (same param cost as a normal 3x3 conv, much larger spatial reach:
|
| 188 |
+
empirically ~232x232px in isolation with dilations (2,4,8), verified via
|
| 189 |
+
gradient measurement), and projects back up. Residual connection, so it
|
| 190 |
+
starts as a small perturbation on top of the existing backbone (which
|
| 191 |
+
matters for warm-starting from a checkpoint that predates this block --
|
| 192 |
+
it won't disrupt what's already learned, and its influence grows through
|
| 193 |
+
training as it learns to use the wider context where it helps) rather
|
| 194 |
+
than a disruptive change.
|
| 195 |
+
"""
|
| 196 |
+
|
| 197 |
+
def __init__(self, channels, mid_ch=96, dilations=(2, 4, 8)):
|
| 198 |
+
super().__init__()
|
| 199 |
+
self.proj_in = nn.Sequential(
|
| 200 |
+
nn.Conv2d(channels, mid_ch, 1, bias=False),
|
| 201 |
+
nn.BatchNorm2d(mid_ch),
|
| 202 |
+
nn.ReLU(inplace=True),
|
| 203 |
+
)
|
| 204 |
+
layers = []
|
| 205 |
+
for d in dilations:
|
| 206 |
+
layers += [
|
| 207 |
+
nn.Conv2d(mid_ch, mid_ch, 3, padding=d, dilation=d, bias=False),
|
| 208 |
+
nn.BatchNorm2d(mid_ch),
|
| 209 |
+
nn.ReLU(inplace=True),
|
| 210 |
+
]
|
| 211 |
+
self.dilated = nn.Sequential(*layers)
|
| 212 |
+
self.proj_out = nn.Sequential(
|
| 213 |
+
nn.Conv2d(mid_ch, channels, 1, bias=False),
|
| 214 |
+
nn.BatchNorm2d(channels),
|
| 215 |
+
)
|
| 216 |
+
self.relu = nn.ReLU(inplace=True)
|
| 217 |
+
|
| 218 |
+
def forward(self, x):
|
| 219 |
+
y = self.proj_in(x)
|
| 220 |
+
y = self.dilated(y)
|
| 221 |
+
y = self.proj_out(y)
|
| 222 |
+
return self.relu(x + y)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class SmallUNetColorizer(
|
| 226 |
+
nn.Module,
|
| 227 |
+
PyTorchModelHubMixin,
|
| 228 |
+
pipeline_tag="image-to-image",
|
| 229 |
+
license="apache-2.0",
|
| 230 |
+
tags=["colorization", "unet", "image-to-image", "classification"],
|
| 231 |
+
):
|
| 232 |
+
"""U-Net colorizer, classification-style (Zhang et al.). In: L in [-1,1],
|
| 233 |
+
shape (B,1,H,W). Out: per-pixel logits over `len(bin_centers)` quantized
|
| 234 |
+
ab bins, shape (B,Q,H,W) -- decode with `.decode()` to get back ab.
|
| 235 |
+
|
| 236 |
+
`bin_centers` (a plain list of [a, b] pairs, JSON-serializable so it
|
| 237 |
+
round-trips through config.json) is stored as a buffer, so every
|
| 238 |
+
checkpoint carries its own bin grid -- no separate file to keep in sync.
|
| 239 |
+
"""
|
| 240 |
+
|
| 241 |
+
def __init__(self, bin_centers, in_ch: int = 1, base: int = 44,
|
| 242 |
+
context_mid_ch: int = 96, context_dilations=(2, 4, 8)):
|
| 243 |
+
super().__init__()
|
| 244 |
+
self.in_ch, self.base = in_ch, base
|
| 245 |
+
num_bins = len(bin_centers)
|
| 246 |
+
self.num_bins = num_bins
|
| 247 |
+
self.register_buffer("bin_centers", torch.tensor(bin_centers, dtype=torch.float32))
|
| 248 |
+
|
| 249 |
+
self.enc1 = double_conv(in_ch, base)
|
| 250 |
+
self.enc2 = double_conv(base, base * 2)
|
| 251 |
+
self.enc3 = double_conv(base * 2, base * 4)
|
| 252 |
+
self.enc4 = double_conv(base * 4, base * 8) # bottleneck
|
| 253 |
+
self.pool = nn.MaxPool2d(2)
|
| 254 |
+
self.context = DilatedContextBlock(base * 8, mid_ch=context_mid_ch,
|
| 255 |
+
dilations=tuple(context_dilations))
|
| 256 |
+
|
| 257 |
+
self.up3 = nn.ConvTranspose2d(base * 8, base * 4, 2, stride=2)
|
| 258 |
+
self.dec3 = double_conv(base * 8, base * 4)
|
| 259 |
+
self.up2 = nn.ConvTranspose2d(base * 4, base * 2, 2, stride=2)
|
| 260 |
+
self.dec2 = double_conv(base * 4, base * 2)
|
| 261 |
+
self.up1 = nn.ConvTranspose2d(base * 2, base, 2, stride=2)
|
| 262 |
+
self.dec1 = double_conv(base * 2, base)
|
| 263 |
+
|
| 264 |
+
self.out_conv = nn.Conv2d(base, num_bins, 1) # raw logits, no activation
|
| 265 |
+
|
| 266 |
+
def forward(self, x):
|
| 267 |
+
e1 = self.enc1(x)
|
| 268 |
+
e2 = self.enc2(self.pool(e1))
|
| 269 |
+
e3 = self.enc3(self.pool(e2))
|
| 270 |
+
e4 = self.context(self.enc4(self.pool(e3)))
|
| 271 |
+
|
| 272 |
+
d3 = self.dec3(torch.cat([self.up3(e4), e3], dim=1))
|
| 273 |
+
d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1))
|
| 274 |
+
d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1))
|
| 275 |
+
|
| 276 |
+
return self.out_conv(d1) # (B, Q, H, W) logits
|
| 277 |
+
|
| 278 |
+
def decode(self, logits, temperature: float = 0.38):
|
| 279 |
+
"""Annealed-mean decode: T=1 is the full expectation (smooth but
|
| 280 |
+
desaturated -- the same hedging problem as regression); T->0
|
| 281 |
+
approaches the argmax/mode (vivid but can be noisy/blotchy per
|
| 282 |
+
pixel). T=0.38 (Zhang et al.'s value) balances the two. Returns raw
|
| 283 |
+
ab in Lab units, shape (B, 2, H, W)."""
|
| 284 |
+
logp = F.log_softmax(logits, dim=1)
|
| 285 |
+
probs_t = F.softmax(logp / temperature, dim=1) # (B,Q,H,W)
|
| 286 |
+
return torch.einsum("bqhw,qc->bchw", probs_t, self.bin_centers)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# --------------------------------------------------------------------------
|
| 290 |
+
# Data: RGB photo -> (L, ab) in normalized Lab space
|
| 291 |
+
# --------------------------------------------------------------------------
|
| 292 |
+
class ColorizationDataset(Dataset):
|
| 293 |
+
"""Wraps a HF `datasets` split that has an 'image' column of PIL images.
|
| 294 |
+
Returns L (continuous, normalized), continuous ab (for visualization
|
| 295 |
+
ground truth only), and the soft-encoded bin targets used for the loss."""
|
| 296 |
+
|
| 297 |
+
def __init__(self, hf_split, color_bins, image_size=128, augment=True):
|
| 298 |
+
self.ds = hf_split
|
| 299 |
+
self.color_bins = color_bins
|
| 300 |
+
aug = [T.RandomHorizontalFlip()] if augment else []
|
| 301 |
+
self.resize = T.Compose([T.Resize((image_size, image_size)), *aug])
|
| 302 |
+
|
| 303 |
+
def __len__(self):
|
| 304 |
+
return len(self.ds)
|
| 305 |
+
|
| 306 |
+
def __getitem__(self, idx):
|
| 307 |
+
img = self.ds[idx]["image"]
|
| 308 |
+
if img.mode != "RGB":
|
| 309 |
+
img = img.convert("RGB")
|
| 310 |
+
img = self.resize(img)
|
| 311 |
+
arr = np.asarray(img).astype(np.float32) / 255.0
|
| 312 |
+
lab = rgb2lab(arr).astype(np.float32)
|
| 313 |
+
L = lab[:, :, 0:1] / 50.0 - 1.0
|
| 314 |
+
ab = lab[:, :, 1:3] # raw Lab units, kept continuous for viz ground truth
|
| 315 |
+
|
| 316 |
+
top_idx, top_weight = self.color_bins.soft_encode(ab) # (H,W,5) each
|
| 317 |
+
|
| 318 |
+
L = torch.from_numpy(np.ascontiguousarray(L.transpose(2, 0, 1)))
|
| 319 |
+
ab = torch.from_numpy(np.ascontiguousarray(ab.transpose(2, 0, 1)))
|
| 320 |
+
top_idx = torch.from_numpy(top_idx) # (H,W,5) int64
|
| 321 |
+
top_weight = torch.from_numpy(top_weight) # (H,W,5) float32
|
| 322 |
+
return L, ab, top_idx, top_weight
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def classification_loss(logits, top_idx, top_weight, class_weights):
|
| 326 |
+
"""Weighted soft multinomial cross-entropy (Zhang et al.).
|
| 327 |
+
|
| 328 |
+
logits: (B,Q,H,W) raw model output.
|
| 329 |
+
top_idx, top_weight: (B,H,W,5) from ColorBins.soft_encode -- the soft
|
| 330 |
+
target distribution over each pixel's 5 nearest bins.
|
| 331 |
+
class_weights: (Q,) rebalancing weights (high for rare/saturated bins),
|
| 332 |
+
applied per-pixel using that pixel's single nearest bin.
|
| 333 |
+
|
| 334 |
+
This is the classification analogue of the earlier `--color-weight`
|
| 335 |
+
trick, but built into the loss's class-rebalancing rather than bolted on
|
| 336 |
+
after the fact: rare, saturated bins get more weight *because* they're
|
| 337 |
+
rare in the data, computed directly from the empirical color
|
| 338 |
+
distribution instead of a hand-picked multiplier.
|
| 339 |
+
|
| 340 |
+
Deliberately avoids ever materializing a full (B,Q,H,W) log-softmax
|
| 341 |
+
tensor -- we only need log-probabilities at 5 of the Q bins per pixel,
|
| 342 |
+
not all of them, and with Q in the hundreds that full tensor is the
|
| 343 |
+
single biggest activation in the whole model (bigger than anything in
|
| 344 |
+
the backbone), so this matters a lot for fitting on a 16GB GPU.
|
| 345 |
+
"""
|
| 346 |
+
idx = top_idx.permute(0, 3, 1, 2) # (B,5,H,W)
|
| 347 |
+
w = top_weight.permute(0, 3, 1, 2) # (B,5,H,W)
|
| 348 |
+
gathered_logits = torch.gather(logits, 1, idx) # (B,5,H,W) -- small
|
| 349 |
+
logsumexp = torch.logsumexp(logits, dim=1, keepdim=True) # (B,1,H,W) -- small
|
| 350 |
+
gathered_logp = gathered_logits - logsumexp # (B,5,H,W)
|
| 351 |
+
per_pixel_nll = -(gathered_logp * w).sum(dim=1) # (B,H,W)
|
| 352 |
+
|
| 353 |
+
pixel_class_weight = class_weights[idx[:, 0]] # nearest bin per pixel, (B,H,W)
|
| 354 |
+
return (per_pixel_nll * pixel_class_weight).mean()
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def lab_to_rgb_batch(L, ab):
|
| 358 |
+
"""L:(N,1,H,W) normalized, ab:(N,2,H,W) raw Lab units -> (N,H,W,3) numpy RGB in [0,1]."""
|
| 359 |
+
L = (L.detach().cpu().numpy() + 1.0) * 50.0
|
| 360 |
+
ab = ab.detach().cpu().numpy()
|
| 361 |
+
lab = np.concatenate([L, ab], axis=1).transpose(0, 2, 3, 1)
|
| 362 |
+
return np.stack([np.clip(lab2rgb(x), 0, 1) for x in lab])
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def save_sample_grid(model, L, ab_true, path, device, temperature=0.38):
|
| 366 |
+
model.eval()
|
| 367 |
+
with torch.no_grad():
|
| 368 |
+
logits = model(L.to(device))
|
| 369 |
+
ab_pred = model.decode(logits, temperature=temperature).cpu()
|
| 370 |
+
rgb_true = lab_to_rgb_batch(L, ab_true)
|
| 371 |
+
rgb_pred = lab_to_rgb_batch(L, ab_pred)
|
| 372 |
+
gray = ((L + 1.0) * 0.5).repeat(1, 3, 1, 1).clamp(0, 1)
|
| 373 |
+
|
| 374 |
+
n = min(6, L.shape[0])
|
| 375 |
+
rows = []
|
| 376 |
+
for i in range(n):
|
| 377 |
+
rows.append(gray[i])
|
| 378 |
+
rows.append(torch.from_numpy(rgb_pred[i]).permute(2, 0, 1).float())
|
| 379 |
+
rows.append(torch.from_numpy(rgb_true[i]).permute(2, 0, 1).float())
|
| 380 |
+
save_image(make_grid(rows, nrow=3, padding=2), path)
|
| 381 |
+
model.train()
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# --------------------------------------------------------------------------
|
| 385 |
+
# Training
|
| 386 |
+
# --------------------------------------------------------------------------
|
| 387 |
+
def build_argparser():
|
| 388 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 389 |
+
p.add_argument("--dataset", default="frgfm/imagenette")
|
| 390 |
+
p.add_argument("--dataset-config", default=None,
|
| 391 |
+
help="Config name, if the dataset needs one (frgfm/imagenette does: "
|
| 392 |
+
"'160px', '320px', or 'full_size'). Leave unset for single-config "
|
| 393 |
+
"datasets, e.g. --dataset johnowhitaker/imagenette2-320.")
|
| 394 |
+
p.add_argument("--image-size", type=int, default=128)
|
| 395 |
+
p.add_argument("--base-channels", type=int, default=44,
|
| 396 |
+
help="Backbone width. If --init-from is set, only layers whose shape "
|
| 397 |
+
"matches are warm-started -- so changing this vs. the checkpoint "
|
| 398 |
+
"means starting the whole backbone fresh, not just the head.")
|
| 399 |
+
p.add_argument("--init-from", default=None,
|
| 400 |
+
help="Hub model id (or local path) to warm-start weights from. Layers "
|
| 401 |
+
"whose shape matches (the encoder/decoder backbone) are loaded; "
|
| 402 |
+
"layers that don't (e.g. the output head, if the bin grid or "
|
| 403 |
+
"--base-channels differs) are left randomly initialized. Works "
|
| 404 |
+
"across --image-size changes since the model is fully convolutional.")
|
| 405 |
+
p.add_argument("--bin-grid-size", type=int, default=10,
|
| 406 |
+
help="Spacing (in Lab ab units) of the color-bin grid.")
|
| 407 |
+
p.add_argument("--bin-samples", type=int, default=3000,
|
| 408 |
+
help="Number of training images to sample when deriving the color-bin "
|
| 409 |
+
"set and class-rebalancing weights from the real data distribution.")
|
| 410 |
+
p.add_argument("--rebalance-lambda", type=float, default=0.5,
|
| 411 |
+
help="Mix-in of uniform weighting when computing class-rebalancing "
|
| 412 |
+
"weights (Zhang et al.'s lambda). 0 = pure inverse-frequency "
|
| 413 |
+
"(aggressively up-weights rare colors); 1 = uniform (no "
|
| 414 |
+
"rebalancing, closer to plain classification).")
|
| 415 |
+
p.add_argument("--annealed-temp", type=float, default=0.38,
|
| 416 |
+
help="Temperature for the annealed-mean decode used for sample-grid "
|
| 417 |
+
"previews. Lower = closer to the mode (more vivid, can be "
|
| 418 |
+
"blotchy); higher = closer to the mean (smoother, can be "
|
| 419 |
+
"desaturated again). 0.38 is Zhang et al.'s value.")
|
| 420 |
+
p.add_argument("--grayscale-chroma-threshold", type=float, default=3.0,
|
| 421 |
+
help="Mean Lab chroma below which a sampled image is treated as "
|
| 422 |
+
"genuinely grayscale-source (vs. a real but desaturated color "
|
| 423 |
+
"photo). Always applied when building color-bin statistics. Only "
|
| 424 |
+
"affects actual training data if --exclude-grayscale-source is set.")
|
| 425 |
+
p.add_argument("--exclude-grayscale-source", action="store_true",
|
| 426 |
+
help="Also drop grayscale-source images from the train/val sets "
|
| 427 |
+
"themselves, not just from the bin-statistics sample. Off by "
|
| 428 |
+
"default since it changes what the model is trained on -- worth "
|
| 429 |
+
"trying if a meaningful fraction of the dataset turns out to be "
|
| 430 |
+
"grayscale-source, since those examples contribute pure "
|
| 431 |
+
"'predict zero' gradient that works against the rebalancing.")
|
| 432 |
+
p.add_argument("--batch-size", type=int, default=64)
|
| 433 |
+
p.add_argument("--no-amp", action="store_true",
|
| 434 |
+
help="Disable mixed-precision training. AMP is on by default on CUDA -- "
|
| 435 |
+
"the (B,num_bins,H,W) logits tensor is the single largest activation "
|
| 436 |
+
"in this model (much bigger than the old regression head's B,2,H,W), "
|
| 437 |
+
"so halving its memory footprint via fp16 matters more here than in "
|
| 438 |
+
"most small models.")
|
| 439 |
+
p.add_argument("--epochs", type=int, default=60)
|
| 440 |
+
p.add_argument("--lr", type=float, default=2e-4)
|
| 441 |
+
p.add_argument("--head-lr", type=float, default=None,
|
| 442 |
+
help="Separate learning rate for the output head (out_conv). Defaults "
|
| 443 |
+
"to --lr when unset. Worth raising above --lr when --init-from is "
|
| 444 |
+
"used: the warm-started backbone benefits from a gentle fine-tuning "
|
| 445 |
+
"rate, but the classification head is *not* warm-started (its shape "
|
| 446 |
+
"depends on the bin grid, which usually differs from the checkpoint "
|
| 447 |
+
"you're resuming from) -- it starts from random weights and can be "
|
| 448 |
+
"undertrained if it only ever sees the backbone's cautious LR.")
|
| 449 |
+
p.add_argument("--context-lr", type=float, default=None,
|
| 450 |
+
help="Separate learning rate for the dilated-conv context block. "
|
| 451 |
+
"Defaults to --lr when unset. Worth raising when warm-starting from "
|
| 452 |
+
"a checkpoint that predates this block: unlike the backbone, it "
|
| 453 |
+
"starts randomly initialized, and its residual connection means it "
|
| 454 |
+
"starts as a near no-op -- at the backbone's cautious LR it may not "
|
| 455 |
+
"get enough signal to develop meaningful behavior within a modest "
|
| 456 |
+
"number of epochs.")
|
| 457 |
+
p.add_argument("--num-workers", type=int, default=min(8, os.cpu_count() or 4))
|
| 458 |
+
p.add_argument("--output-dir", default="./output")
|
| 459 |
+
p.add_argument("--sample-every", type=int, default=5)
|
| 460 |
+
p.add_argument("--push-to-hub", action="store_true")
|
| 461 |
+
p.add_argument("--push-every", type=int, default=10,
|
| 462 |
+
help="Push a checkpoint to the Hub every N epochs, so a "
|
| 463 |
+
"job timeout only loses progress since the last "
|
| 464 |
+
"push instead of the whole run. Only used with "
|
| 465 |
+
"--push-to-hub.")
|
| 466 |
+
p.add_argument("--hub-model-id", default=None)
|
| 467 |
+
p.add_argument("--seed", type=int, default=0)
|
| 468 |
+
return p
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def get_dataset_splits(dataset_name, dataset_config, seed=0, val_fraction=0.05):
|
| 472 |
+
from datasets import Image as HFImage
|
| 473 |
+
from datasets import load_dataset
|
| 474 |
+
|
| 475 |
+
print(f"Loading {dataset_name}" + (f" ({dataset_config})" if dataset_config else "") + " ...")
|
| 476 |
+
try:
|
| 477 |
+
ds = load_dataset(dataset_name, dataset_config) if dataset_config else load_dataset(dataset_name)
|
| 478 |
+
except RuntimeError as e:
|
| 479 |
+
if "Dataset scripts are no longer supported" not in str(e):
|
| 480 |
+
raise
|
| 481 |
+
# This repo only ships a legacy loading script, which `datasets` now
|
| 482 |
+
# refuses to run. It still has an auto-converted Parquet mirror on
|
| 483 |
+
# `refs/convert/parquet`, but for repos with multiple size configs
|
| 484 |
+
# (like this one: full_size/160px/320px) that mirror doesn't expose
|
| 485 |
+
# them as separate named configs -- so instead of going through
|
| 486 |
+
# `datasets`' config auto-detection, load the underlying Parquet
|
| 487 |
+
# shards for this config directly.
|
| 488 |
+
print("Dataset uses a legacy loading script; loading its Parquet "
|
| 489 |
+
f"files directly from refs/convert/parquet/{dataset_config}/ ...")
|
| 490 |
+
base = f"hf://datasets/{dataset_name}@refs%2Fconvert%2Fparquet/{dataset_config}"
|
| 491 |
+
try:
|
| 492 |
+
ds = load_dataset("parquet", data_files={
|
| 493 |
+
"train": f"{base}/train/*.parquet",
|
| 494 |
+
"validation": f"{base}/validation/*.parquet",
|
| 495 |
+
})
|
| 496 |
+
except ValueError as e2:
|
| 497 |
+
if "all the data_files are invalid" not in str(e2):
|
| 498 |
+
raise
|
| 499 |
+
raise RuntimeError(
|
| 500 |
+
f"No Parquet mirror found for {dataset_name} config '{dataset_config}' at "
|
| 501 |
+
f"refs/convert/parquet right now -- this can happen even for a config that "
|
| 502 |
+
f"worked before (transient Hub-side issue with this repo, not a bug here). "
|
| 503 |
+
f"Consider a dataset stored natively as Parquet instead (no legacy-script "
|
| 504 |
+
f"fallback needed at all), e.g. --dataset johnowhitaker/imagenette2-320."
|
| 505 |
+
) from e2
|
| 506 |
+
# The generic "parquet" builder doesn't always restore the
|
| 507 |
+
# decode-on-access Image feature -- make sure it's actually an
|
| 508 |
+
# Image column and not raw {"bytes":..,"path":..} dicts.
|
| 509 |
+
ds = ds.cast_column("image", HFImage())
|
| 510 |
+
train_split = ds["train"]
|
| 511 |
+
if "validation" in ds:
|
| 512 |
+
val_split = ds["validation"]
|
| 513 |
+
elif "test" in ds:
|
| 514 |
+
val_split = ds["test"]
|
| 515 |
+
else:
|
| 516 |
+
# No separate held-out split at all (e.g. johnowhitaker/imagenette2-320
|
| 517 |
+
# ships everything as a single "train"). Carve out a genuine held-out
|
| 518 |
+
# set ourselves rather than reusing training data for validation --
|
| 519 |
+
# val_loss and the sample-grid preview should reflect unseen images.
|
| 520 |
+
print(f"{dataset_name} has no validation/test split -- creating a "
|
| 521 |
+
f"{val_fraction:.0%} held-out split via train_test_split(seed={seed}).")
|
| 522 |
+
split = train_split.train_test_split(test_size=val_fraction, seed=seed)
|
| 523 |
+
train_split, val_split = split["train"], split["test"]
|
| 524 |
+
return train_split, val_split
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def build_model_card(model, n_params, args, train_loss, val_loss, epoch, is_final):
|
| 528 |
+
status = (f"Final ({args.epochs} epochs complete)" if is_final
|
| 529 |
+
else f"Checkpoint at epoch {epoch}/{args.epochs} (training still in progress "
|
| 530 |
+
f"when this was pushed -- may be superseded by a later commit)")
|
| 531 |
+
return f"""---
|
| 532 |
+
tags:
|
| 533 |
+
- image-to-image
|
| 534 |
+
- colorization
|
| 535 |
+
- unet
|
| 536 |
+
- pytorch
|
| 537 |
+
license: apache-2.0
|
| 538 |
+
---
|
| 539 |
+
|
| 540 |
+
# Mini U-Net Colorizer
|
| 541 |
+
|
| 542 |
+
A {n_params:,}-parameter U-Net that colorizes grayscale photos. Classification-style
|
| 543 |
+
(Zhang et al., "Colorful Image Colorization"): it predicts a distribution over
|
| 544 |
+
{model.num_bins} quantized CIE Lab `a`/`b` bins per pixel rather than regressing a
|
| 545 |
+
single ab value directly, with color-bin loss weights derived from the real
|
| 546 |
+
training-data distribution (rare/saturated colors weighted higher) so it
|
| 547 |
+
doesn't just hedge toward desaturated averages. Decode with an annealed mean.
|
| 548 |
+
|
| 549 |
+
- **Status:** {status}
|
| 550 |
+
- **Input:** L channel normalized as `L/50 - 1` -> `[-1, 1]`, shape `(1, {args.image_size}, {args.image_size})`
|
| 551 |
+
- **Output:** logits over {model.num_bins} ab bins, shape `({model.num_bins}, {args.image_size}, {args.image_size})`
|
| 552 |
+
- **Trained on:** `{args.dataset}` ({args.dataset_config}){f", warm-started from `{args.init_from}`" if args.init_from else ""}
|
| 553 |
+
- **Loss so far (weighted soft cross-entropy):** train {train_loss:.4f}, val {val_loss:.4f}
|
| 554 |
+
|
| 555 |
+
## Usage
|
| 556 |
+
|
| 557 |
+
```python
|
| 558 |
+
import numpy as np, torch
|
| 559 |
+
from skimage.color import rgb2lab, lab2rgb
|
| 560 |
+
from PIL import Image
|
| 561 |
+
# paste the SmallUNetColorizer class definition from the training script, then:
|
| 562 |
+
model = SmallUNetColorizer.from_pretrained("{args.hub_model_id or 'USERNAME/REPO'}")
|
| 563 |
+
model.eval()
|
| 564 |
+
|
| 565 |
+
img = Image.open("photo.jpg").convert("RGB").resize(({args.image_size}, {args.image_size}))
|
| 566 |
+
lab = rgb2lab(np.asarray(img).astype("float32") / 255.0)
|
| 567 |
+
L = torch.from_numpy(lab[:, :, 0:1] / 50.0 - 1.0).permute(2, 0, 1)[None]
|
| 568 |
+
|
| 569 |
+
with torch.no_grad():
|
| 570 |
+
logits = model(L)
|
| 571 |
+
ab = model.decode(logits, temperature=0.38)[0].permute(1, 2, 0).numpy()
|
| 572 |
+
|
| 573 |
+
L_out = (L[0, 0].numpy() + 1) * 50.0
|
| 574 |
+
lab_out = np.concatenate([L_out[:, :, None], ab], axis=-1)
|
| 575 |
+
rgb_out = np.clip(lab2rgb(lab_out), 0, 1)
|
| 576 |
+
```
|
| 577 |
+
"""
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
def push_model(model, out_dir, args, n_params, train_loss, val_loss, epoch, is_final):
|
| 581 |
+
"""Push current weights + a model card to the Hub. Never raises -- a failed
|
| 582 |
+
push (e.g. transient network error) should not kill an otherwise-fine job,
|
| 583 |
+
especially mid-training where we'll just try again at the next checkpoint."""
|
| 584 |
+
try:
|
| 585 |
+
model.push_to_hub(args.hub_model_id)
|
| 586 |
+
api = HfApi()
|
| 587 |
+
card = build_model_card(model, n_params, args, train_loss, val_loss, epoch, is_final)
|
| 588 |
+
api.upload_file(path_or_fileobj=card.encode(), path_in_repo="README.md",
|
| 589 |
+
repo_id=args.hub_model_id)
|
| 590 |
+
sample_files = sorted((out_dir / "samples").glob("*.png"))
|
| 591 |
+
if sample_files:
|
| 592 |
+
api.upload_file(path_or_fileobj=str(sample_files[-1]), path_in_repo="sample.png",
|
| 593 |
+
repo_id=args.hub_model_id)
|
| 594 |
+
tag = "final" if is_final else f"epoch {epoch}"
|
| 595 |
+
print(f"Pushed ({tag}) to https://huggingface.co/{args.hub_model_id}")
|
| 596 |
+
except Exception as e:
|
| 597 |
+
print(f"WARNING: push_to_hub failed at epoch {epoch} ({e}). "
|
| 598 |
+
f"Continuing -- local copy is still at {out_dir / 'model'}.")
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
def load_raw_state_dict(model_id_or_path):
|
| 602 |
+
"""Load just the raw weight tensors from a checkpoint, without constructing
|
| 603 |
+
a model instance. This matters for warm-starting: the checkpoint we're
|
| 604 |
+
resuming from might be an older/incompatible architecture (e.g. a
|
| 605 |
+
previous regression-head version of this model) whose config.json can't
|
| 606 |
+
even be used to construct today's model class. We only need the tensors
|
| 607 |
+
themselves to warm-start by-name, shape-compatible layers -- not a
|
| 608 |
+
working instance of whatever architecture produced them."""
|
| 609 |
+
p = Path(model_id_or_path)
|
| 610 |
+
if p.is_dir():
|
| 611 |
+
safep = p / "model.safetensors"
|
| 612 |
+
if safep.exists():
|
| 613 |
+
return load_safetensors(str(safep))
|
| 614 |
+
return torch.load(p / "pytorch_model.bin", map_location="cpu")
|
| 615 |
+
try:
|
| 616 |
+
path = hf_hub_download(model_id_or_path, filename="model.safetensors")
|
| 617 |
+
return load_safetensors(path)
|
| 618 |
+
except Exception:
|
| 619 |
+
path = hf_hub_download(model_id_or_path, filename="pytorch_model.bin")
|
| 620 |
+
return torch.load(path, map_location="cpu")
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
def filter_grayscale_source(hf_split, threshold):
|
| 624 |
+
"""Drop images whose mean chroma is below `threshold` -- genuinely
|
| 625 |
+
grayscale-source photos, which contribute pure 'predict zero' gradient
|
| 626 |
+
with no offsetting colorful signal, working against the very thing the
|
| 627 |
+
rebalancing weights are trying to achieve."""
|
| 628 |
+
def is_colorful(example):
|
| 629 |
+
img = example["image"]
|
| 630 |
+
if img.mode != "RGB":
|
| 631 |
+
img = img.convert("RGB")
|
| 632 |
+
img = img.resize((64, 64))
|
| 633 |
+
arr = np.asarray(img).astype(np.float32) / 255.0
|
| 634 |
+
return image_mean_chroma(rgb2lab(arr)) >= threshold
|
| 635 |
+
|
| 636 |
+
n_before = len(hf_split)
|
| 637 |
+
filtered = hf_split.filter(is_colorful, num_proc=min(8, os.cpu_count() or 1))
|
| 638 |
+
n_after = len(filtered)
|
| 639 |
+
print(f"Excluded {n_before - n_after}/{n_before} grayscale-source images "
|
| 640 |
+
f"(mean chroma < {threshold}) from this split.")
|
| 641 |
+
return filtered
|
| 642 |
+
|
| 643 |
+
|
| 644 |
+
def main(argv=None, dataset_loader=get_dataset_splits):
|
| 645 |
+
args = build_argparser().parse_args(argv)
|
| 646 |
+
if args.push_to_hub and not args.hub_model_id:
|
| 647 |
+
raise SystemExit("--hub-model-id is required with --push-to-hub")
|
| 648 |
+
|
| 649 |
+
torch.manual_seed(args.seed)
|
| 650 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 651 |
+
print(f"Device: {device}")
|
| 652 |
+
out_dir = Path(args.output_dir)
|
| 653 |
+
(out_dir / "samples").mkdir(parents=True, exist_ok=True)
|
| 654 |
+
|
| 655 |
+
train_split, val_split = dataset_loader(args.dataset, args.dataset_config, seed=args.seed)
|
| 656 |
+
|
| 657 |
+
if args.exclude_grayscale_source:
|
| 658 |
+
print("Filtering grayscale-source images out of train/val (--exclude-grayscale-source)...")
|
| 659 |
+
train_split = filter_grayscale_source(train_split, args.grayscale_chroma_threshold)
|
| 660 |
+
val_split = filter_grayscale_source(val_split, args.grayscale_chroma_threshold)
|
| 661 |
+
|
| 662 |
+
print(f"Deriving color bins from {args.bin_samples} sampled training images "
|
| 663 |
+
f"(grid={args.bin_grid_size}, lambda={args.rebalance_lambda}) ...")
|
| 664 |
+
color_bins = build_color_bins(train_split, num_samples=args.bin_samples,
|
| 665 |
+
grid_size=args.bin_grid_size,
|
| 666 |
+
rebalance_lambda=args.rebalance_lambda, seed=args.seed,
|
| 667 |
+
grayscale_chroma_threshold=args.grayscale_chroma_threshold)
|
| 668 |
+
print(f"{color_bins.num_bins} color bins in use "
|
| 669 |
+
f"(weight range {color_bins.weights.min():.2f}-{color_bins.weights.max():.2f})")
|
| 670 |
+
|
| 671 |
+
model = SmallUNetColorizer(bin_centers=color_bins.bin_centers.tolist(),
|
| 672 |
+
base=args.base_channels).to(device)
|
| 673 |
+
n_params = sum(x.numel() for x in model.parameters())
|
| 674 |
+
print(f"Model parameters: {n_params:,}")
|
| 675 |
+
assert n_params < 4_000_000, f"Model has {n_params:,} params, over the 4M budget!"
|
| 676 |
+
|
| 677 |
+
if args.init_from:
|
| 678 |
+
print(f"Warm-starting from {args.init_from} ...")
|
| 679 |
+
ckpt_sd = load_raw_state_dict(args.init_from)
|
| 680 |
+
own_sd = model.state_dict()
|
| 681 |
+
compatible = {k: v for k, v in ckpt_sd.items()
|
| 682 |
+
if k in own_sd and own_sd[k].shape == v.shape}
|
| 683 |
+
skipped = sorted(set(own_sd) - set(compatible))
|
| 684 |
+
model.load_state_dict(compatible, strict=False)
|
| 685 |
+
print(f"Loaded {len(compatible)}/{len(own_sd)} tensors from the checkpoint "
|
| 686 |
+
f"(shape-compatible backbone layers). Randomly initialized: {skipped or 'none'}.")
|
| 687 |
+
|
| 688 |
+
class_weights = torch.from_numpy(color_bins.weights).to(device)
|
| 689 |
+
|
| 690 |
+
train_ds = ColorizationDataset(train_split, color_bins, args.image_size, augment=True)
|
| 691 |
+
val_ds = ColorizationDataset(val_split, color_bins, args.image_size, augment=False)
|
| 692 |
+
print(f"train: {len(train_ds)} images, val: {len(val_ds)} images")
|
| 693 |
+
|
| 694 |
+
common_kwargs = dict(num_workers=args.num_workers, pin_memory=(device == "cuda"))
|
| 695 |
+
if args.num_workers > 0:
|
| 696 |
+
common_kwargs["persistent_workers"] = True
|
| 697 |
+
train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True,
|
| 698 |
+
drop_last=True, **common_kwargs)
|
| 699 |
+
val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False, **common_kwargs)
|
| 700 |
+
|
| 701 |
+
# For the recurring sample-grid preview, draw a random (but seeded, so
|
| 702 |
+
# reproducible run-to-run) sample spanning the whole validation set --
|
| 703 |
+
# not just the literal first batch of an unshuffled loader, which for a
|
| 704 |
+
# class-grouped dataset like imagenette means a single class every time.
|
| 705 |
+
# (val_loss above is unaffected either way -- it already iterates the
|
| 706 |
+
# full val_loader, all classes, every epoch.)
|
| 707 |
+
sample_rng = np.random.default_rng(args.seed)
|
| 708 |
+
n_preview = min(args.batch_size, len(val_ds))
|
| 709 |
+
preview_indices = sample_rng.choice(len(val_ds), size=n_preview, replace=False).tolist()
|
| 710 |
+
preview_loader = DataLoader(torch.utils.data.Subset(val_ds, preview_indices),
|
| 711 |
+
batch_size=n_preview, shuffle=False)
|
| 712 |
+
fixed_val_L, fixed_val_ab, _, _ = next(iter(preview_loader))
|
| 713 |
+
|
| 714 |
+
head_lr = args.head_lr if args.head_lr is not None else args.lr
|
| 715 |
+
context_lr = args.context_lr if args.context_lr is not None else args.lr
|
| 716 |
+
backbone_params = [p for n, p in model.named_parameters()
|
| 717 |
+
if not n.startswith("out_conv") and not n.startswith("context")]
|
| 718 |
+
context_params = [p for n, p in model.named_parameters() if n.startswith("context")]
|
| 719 |
+
head_params = [p for n, p in model.named_parameters() if n.startswith("out_conv")]
|
| 720 |
+
opt = torch.optim.AdamW([
|
| 721 |
+
{"params": backbone_params, "lr": args.lr},
|
| 722 |
+
{"params": context_params, "lr": context_lr},
|
| 723 |
+
{"params": head_params, "lr": head_lr},
|
| 724 |
+
], weight_decay=1e-4)
|
| 725 |
+
print(f"Learning rates: backbone={args.lr} "
|
| 726 |
+
f"context={context_lr}{' (== --lr)' if args.context_lr is None else ''} "
|
| 727 |
+
f"head={head_lr}{' (== --lr)' if args.head_lr is None else ''}")
|
| 728 |
+
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=args.epochs)
|
| 729 |
+
amp_enabled = (device == "cuda") and not args.no_amp
|
| 730 |
+
scaler = torch.amp.GradScaler("cuda", enabled=amp_enabled)
|
| 731 |
+
print(f"Mixed precision (AMP): {'on' if amp_enabled else 'off'}")
|
| 732 |
+
|
| 733 |
+
def criterion(logits, top_idx, top_weight):
|
| 734 |
+
return classification_loss(logits, top_idx, top_weight, class_weights)
|
| 735 |
+
|
| 736 |
+
train_loss = val_loss = float("nan")
|
| 737 |
+
for epoch in range(1, args.epochs + 1):
|
| 738 |
+
epoch_start = time.time()
|
| 739 |
+
model.train()
|
| 740 |
+
running = 0.0
|
| 741 |
+
pbar = tqdm(train_loader, desc=f"epoch {epoch}/{args.epochs}")
|
| 742 |
+
for L, ab, top_idx, top_weight in pbar:
|
| 743 |
+
L = L.to(device, non_blocking=True)
|
| 744 |
+
top_idx = top_idx.to(device, non_blocking=True)
|
| 745 |
+
top_weight = top_weight.to(device, non_blocking=True)
|
| 746 |
+
with torch.amp.autocast("cuda", enabled=amp_enabled):
|
| 747 |
+
logits = model(L)
|
| 748 |
+
loss = criterion(logits, top_idx, top_weight)
|
| 749 |
+
opt.zero_grad(set_to_none=True)
|
| 750 |
+
scaler.scale(loss).backward()
|
| 751 |
+
scaler.step(opt)
|
| 752 |
+
scaler.update()
|
| 753 |
+
running += loss.item() * L.size(0)
|
| 754 |
+
pbar.set_postfix(loss=f"{loss.item():.4f}")
|
| 755 |
+
sched.step()
|
| 756 |
+
train_loss = running / len(train_ds)
|
| 757 |
+
|
| 758 |
+
model.eval()
|
| 759 |
+
val_running = 0.0
|
| 760 |
+
with torch.no_grad(), torch.amp.autocast("cuda", enabled=amp_enabled):
|
| 761 |
+
for L, ab, top_idx, top_weight in val_loader:
|
| 762 |
+
L = L.to(device)
|
| 763 |
+
top_idx, top_weight = top_idx.to(device), top_weight.to(device)
|
| 764 |
+
val_running += criterion(model(L), top_idx, top_weight).item() * L.size(0)
|
| 765 |
+
val_loss = val_running / len(val_ds)
|
| 766 |
+
print(f"epoch {epoch}: train_loss={train_loss:.4f} val_loss={val_loss:.4f} "
|
| 767 |
+
f"({time.time() - epoch_start:.1f}s)")
|
| 768 |
+
|
| 769 |
+
if epoch % args.sample_every == 0 or epoch == args.epochs:
|
| 770 |
+
save_sample_grid(model, fixed_val_L, fixed_val_ab,
|
| 771 |
+
out_dir / "samples" / f"epoch_{epoch:03d}.png", device,
|
| 772 |
+
temperature=args.annealed_temp)
|
| 773 |
+
|
| 774 |
+
if args.push_to_hub and epoch != args.epochs and epoch % args.push_every == 0:
|
| 775 |
+
push_model(model, out_dir, args, n_params, train_loss, val_loss, epoch, is_final=False)
|
| 776 |
+
|
| 777 |
+
model_dir = out_dir / "model"
|
| 778 |
+
model.save_pretrained(model_dir)
|
| 779 |
+
print(f"Saved model to {model_dir}")
|
| 780 |
+
|
| 781 |
+
if args.push_to_hub:
|
| 782 |
+
push_model(model, out_dir, args, n_params, train_loss, val_loss, args.epochs, is_final=True)
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
if __name__ == "__main__":
|
| 786 |
+
main()
|