User-2468 commited on
Commit
6efbc33
·
verified ·
1 Parent(s): 8245f3f

Upload 3 files

Browse files
Files changed (3) hide show
  1. colorize_eval 7.py +357 -0
  2. colorize_image 3.py +176 -0
  3. 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()