File size: 24,479 Bytes
cdcbe72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
> **Development log, kept verbatim.** This is the actual NERVE ablation log,
> including the local experiment paths and the dead ends, so you can see exactly
> what was tested and what the numbers were β€” not just the final claims.
> Section 15 is the capacity sweep that set the 1.80M default.

# NERVE β€” Ablation log & how we landed on the architecture

Research log, first pass: **2026-09-05**. Companion to `NERVE.md` (living spec)
and `nerve_review.md` (pre-training review). Documents the setup, the exact
protocol, results, and every decision β€” so future maintainers know why NERVE
looks the way it does.

## TL;DR (verdicts)

| Question | Result | Decision |
|---|---|---|
| Keep the 7 LayerNorms? | **No β€” removing them is +1.15 dB** and ~54% faster training | Norm-free is the canonical config |
| Dilated convs on blocks 3 & 5? | No gain on bicubic data (23.33 vs 23.39) | Drop for now; re-test under a real-degradation (OTF) recipe |
| Conv `bias=True`? | +0.08 dB **with** LN; **0.00 dB** without LN | Keep `bias=False` |
| Tanh gate (arm C)? | Not triggered | A already beats SPAN in PSNR |
| SiLU / StarReLU (NERVE.md F/G)? | Not run this pass | De-prioritized: marginal-dB chasers vs the simplicity thesis |

Headline: **norm-free NERVE (arm D) hit 24.54 dB / 0.724 SSIM on Urban100 x4
under the matched protocol vs 23.39 / 0.566 for the LayerNorm baseline** β€”
and it exports at **30 ONNX nodes instead of 100**.

## 1. Why an ablation at all

`NERVE.md` "Planned validation" assumed LayerNorm helped bf16 stability (an
educated guess during design, mirroring the fp32-statistics LN fix). The
`nerve_review.md` review flagged this as the single highest-value question:
LayerNorm is ~37-70% of the exported graph and pure memory-bandwidth cost. The
ablations exist to keep only what measurement justifies β€” the same discipline
that produced HEART.

## 2. Environment (this first pass ran on a Windows PC with an Intel Arc)

- **Machine:** Windows, Intel Core i3-12100F, 16 GB RAM, **Intel Arc A750 8 GB**
  (driver 32.0.101.8864). No NVIDIA/CUDA available.
- **PyTorch:** `2.14.0+xpu` from `https://download.pytorch.org/whl/xpu`
  (official Intel-GPU wheels), Python 3.13.1, bf16 autocast, **no
  torch.compile** (the triton-xpu backend needs a C compiler on PATH on this
  box).
- **Repo:** traiNNer-redux (`train.py` is CUDA-only; untouched). Ablations ran
  through a self-contained lab in `scripts/nerve/` (`ablation_train.py`,
  `nerve_variants.py`, `export_dynamic.py`, data + driver scripts). The
  canonical `traiNNer/archs/nerve_arch.py` stayed frozen during runs.
- All arms ran on the **same machine, same backend, same dtype, same seed**,
  so A-vs-* decisions are internally valid. These numbers are NOT directly
  comparable to RTX-3060/DIV2K numbers from other runs β€” record
  machine/backend/dtype with every run.

## 3. Data & protocol (matched across every arm)

- **Train:** LUCID CC0 v2 HC-512 (`Phips/lucid-cc0-v2-hc-512`, public,
  100,866 512Γ—512 CC0 tiles). HR kept in `datasets/train/lucid_hc512/hr`;
  bicubic `lr_x4` generated with **chainner CubicCatrom** (chaiNNer standard,
  via `chainner_ext`) to match the HEART release recipe.
- **Val:** Urban100 x4. LR regenerated locally with the same CubicCatrom
  kernel as training (downloaded LR packs use a different kernel and shift
  absolute numbers). Metric = **Y-from-YCbCr, crop_border 4, data range
  [0,1]**, using traiNNer-redux's `calculate_psnr_pt`/`calculate_ssim_pt`
  (Matlab-YCbCr convention). Pure-bicubic baseline measured: **23.26 dB /
  0.660 SSIM** (published 23.14/0.658 β€” sanity check passed).
- **Per arm:** 20,000 iters, LQ64 crops (GT 256), batch 16, AdamW lr 2e-4
  (betas 0.9/0.99, wd 0), linear warmup 1k, MultiStepLR milestone 16k Γ—0.5,
  Charbonnier loss (eps 1e-12), EMA 0.999/0.75 (val on EMA net), hflip/rot90,
  seed 1024, input/GT range **[0,1]** (this matches what traiNNer-redux
  actually feeds models β€” SPAN archs assume it).
- ~100,866 tiles = ~3.2 epochs at this budget; 16 crops/iter.

## 4. Results (best Urban100 val PSNR @ iter, SSIM at that iter)

| Arm | Change vs A | Params | it/s | PSNR | @iter | SSIM | Note |
|---|---|---|---|---|---|---|---|
| A | baseline (LayerNorm) | 273,840 | 4.1 | 23.387 | 20000 | 0.566 | reference |
| D | **norm-free** | 273,168 | 6.4 | **24.541** | 20000 | **0.724** | **winner** |
| E | dilation=2 conv2 blocks 3&5 | 273,840 | 7.5 | 23.334 | 20000 | 0.565 | ~tie with A |
| F | bias=True (still has LN) | 274,512 | 7.5 | 23.465 | 20000 | 0.566 | +0.08 dB |
| B | SPAN (repo `span`, norm=False) | 2,621,964 | 5.5 | 22.526 | 20000 | 0.681 | see caveat |
| H | norm-free + bias (partial, 6k iters) | 273,840 | - | 23.76 @6k | - | 0.681 | **= D @6k** β†’ bias adds nothing on norm-free |

Reading:
- A/E/F all plateaued near the bicubic PSNR (23.3-23.5) with SSIM **below**
  the bicubic baseline (0.565-0.596 vs 0.660): the LN nets learned an
  over-smoothed residual under this bf16/EMA protocol. D does not have this
  problem and improves far faster.
- D @ 6k (23.77) already exceeds A's best at 20k (23.39).
- H β‰ˆ D exactly at the same iters β‡’ the +0.08 dB bias gain seen on the LN
  model (F) does not transfer to the norm-free model. Keep `bias=False`.

## 5. Why LayerNorm hurt (hypotheses, for future researchers)

Not fully root-caused β€” the win is empirical and decisive, and the protocol is
bf16-autocast + EMA. Candidate mechanisms:
1. LN computes channel statistics per spatial pixel; under bf16 AMP with
   fp32-cast stats this adds noise/numerical churn per forward.
2. LN re-centers each channel per pixel, which can suppress the small
   high-frequency residual signals these tiny nets need to emit, biasing the
   solution toward the bicubic baseline (matching the A/E/F PSNRβ‰ˆbicubic but
   SSIM<bicubic pattern).
3. The norm adds ~24 non-conv ops Γ— 7 = ~70/100 exported nodes and ~54%
   training slowdown (it/s 4.1 vs 6.4) β€” pure overhead in this regime.
This matches the classic EDSR-era result: small conv nets do not need
normalization when trained with modern hygiene.

## 6. Export / deployability findings (verified this pass)

- `pytest tests/test_archs -k nerve`: **16/16** (CPU; scales 1-4, both
  registries). ruff + pyright clean on the lab scripts.
- Dynamic-shape fp32 ONNX (opset 20) + `onnx.checker` pass; ORT-CPU vs
  PyTorch max abs diff **3-6e-6** at 48Γ—48 / 323Γ—711 / 720Γ—1280, scales 2 & 4.
  The bicubic `Resize` maps cleanly (unlike some fears) β€” parity is exact.
- **A vs D node count (the mobile cost of LayerNorm):** A = **100 nodes**
  (ReduceMeanΓ—14, MulΓ—14, SubΓ—7, PowΓ—7, Sqrt/DivΓ—7, ...), D = **30 nodes**
  (ConvΓ—14, AddΓ—8, ReluΓ—6, DepthToSpace, Resize). The 7 LayerNorms are ~70 of
  100 exported nodes β€” far more than the ~37% estimated from NERVE.md, because
  each norm is ~10 ops, not 1.
- Toolchain caveats recorded for future work:
  - torch 2.14 dynamo exporter has an **opset floor of 18** (an "opset 17"
    request emits 18; legacy tracer is broken in this build). Canonical export
    = opset 20.
  - torch.compile on XPU needs a C compiler on PATH (triton-xpu driver build).
  - XPU does not support fp64 β†’ metric code must run on CPU copies.

## 7. Pitfalls found while building the lab (worth knowing)

1. **Data range [0,1]**: traiNNer-redux's dataset returns float **[0,1]**.
   Feeding [0,255] silently changes the task. SPAN-family archs assume [0,1].
2. **Metric convention**: naive `0.299R+0.587G+0.114B` full-range Y reads
   ~1.3 dB lower than the standard Matlab-YCbCr numbers everyone quotes.
   Use the repo's `calculate_psnr_pt(..., test_y_channel=True)`.
3. **Val LR kernel**: downloaded SR-benchmark LR packs (eugenesiow, etc.) may
   use a different downscale kernel than your training LR. Regenerate val LR
   with the training kernel for internal consistency (baseline sanity check
   should reproduce the published bicubic numbers).
4. **SPAN `norm` default**: the repo `span` factory defaults to `norm=False`
   (no internal `(x-mean)*img_range` scaling). Our arm B used that default β€”
   protocol-fair and NERVE.md-faithful, but the resulting PSNR sits below
   bicubic while SSIM is healthy (scale/offset regime). A norm=True re-run is
   an optional follow-up for curiosity; it does not change the D-vs-A
   structural result.

## 8. What's next (not yet done)

- **Adopt the winner into `traiNNer/archs/nerve_arch.py`** (norm-free, single
  config, `bias=False`, ~273,168 params): remove LayerNorm2D/NerveBlock-norm,
  keep global residual + bicubic residual. Re-run the 16 arch tests, ruff,
  pyright, ONNX export + node-count, then update `NERVE.md`/`nerve_review.md`
  wording and this log.
- Delete the losing variant code once the canonical file is updated.
- Re-test arm E (dilated convs) **only under a Real-ESRGAN-style OTF
  degradation recipe** β€” dilation targets real-world blur, not bicubic data.
- Optional later: SiLU/StarReLU (NERVE.md F/G), real mobile latency
  (NCNN/CoreML), LPIPS/DISTS, multi-dataset eval, and an eventual SPAN
  norm=True re-run.

## 9. Canonical adoption + size sweep (2026-09-05, second pass)

All of section 8's first two items are **done**:

- `nerve_arch.py` is now the **norm-free canonical** (`dim=48`, `n_blocks=16`,
  `bias=False`, ~688K params, 70 exported ONNX nodes: Conv 34 / Add 18 / ReLU 16
  / DepthToSpace / Resize; ORT-CPU parity 3-6e-6 at 48x48/323x711/720x1280,
  scales 2 & 4; 16/16 arch tests, ruff+pyright clean).
- Losing variants were deleted; the lab (`scripts/nerve/`) now trains the
  canonical arch parameterized by `--dim/--n-blocks` (or `--arch span`).

**Size sweep** (same matched 20k clean-bicubic protocol, dim 48, Urban100 x4):

| depth | params | best PSNR | SSIM | RF (LR px) |
|---|---|---|---|---|
| 6 | 273K | 24.541 | 0.724 | ~29 |
| 12 | 522K | 24.849 | 0.740 | ~53 |
| 16 (chosen) | 688K | 24.936 | 0.743 | ~69 |

Decision rationale for n_blocks 16 as the **single** default: quality still
improves with depth (+0.31 dB for 6->12, +0.09 dB for 12->16 at equal budget),
the ~69px RF gives ~4x margin over the recipe's worst 17px blur kernel, and
688K stays in proven-mobile weight class (Real-ESRGAN `compact` is 621K). If a
future target phone needs a leaner profile it is a one-constant change, not a
variant.

NERVE training configs (for the 3060/Linux box) were added mirroring HEART:
- `options/train/RELEASE/4x_NERVE_release.yml` (clean bicubic fidelity)
- `options/train/RELEASE/4x_NERVE_OTF_fidelity.yml` (OTF fidelity pretrain)
- `options/train/RELEASE/4x_NERVE_OTF_gan.yml` (OTF GAN finetune, same losses
  as 4x_HEART_OTF_gan_lq96)

Remaining for the GAN tier (later, on the GPU box): run release -> OTF
fidelity -> OTF GAN, validate on Urban100_OTF x4 with TopIQ, then benchmark
mobile latency (NCNN/CoreML) and optionally re-test dilation under OTF.

## 10. Multi-scale release chain (2x then 4x-from-2x) β€” 2026-09-06

EDSR-style cross-scale warm start, validated on this machine (same matched lab
protocol; clean bicubic LUCID data; Urban100 val, YCbCr crop_border=scale):

| Run | setup | params | iters | best PSNR | SSIM |
|---|---|---|---|---|---|
| from-scratch 4x (earlier) | random init | 688K | 20k | 24.94 | 0.743 |
| **release 2x** | random init | 672K | 60k | **31.19** (x2) | 0.918 |
| **release 4x (from 2x)** | 2x body warm start | 688K | 100k | **25.71** | 0.774 |

Findings:
- The 2x->4x transfer loads 33/34 state-dict keys; only the scale-specific
  PixelShuffle head (upsampler.0, different channel counts) resets. Warm start
  is real: 4x-from-2x hit 25.49 dB at only 24k iters vs 24.94 dB at 20k for
  the random-init run.
- Best EMA checkpoints export to dynamic-shape ONNX cleanly
  (ORT-CPU vs PyTorch max diff ~1e-5 at 48x48/323x711/720x1280).

Recipe notes:
- Train order: `2x fidelity` -> `4x fidelity (warm body)` -> `OTF fidelity`
  -> `OTF GAN` (the last two run with traiNNer-redux release configs on the
  GPU box).
- This lab run used lq64/bs16 60-100k iters; the Linux release configs
  (options/train/RELEASE/2x_NERVE_release.yml, 4x_NERVE_release.yml with the
  strict_load_g:false warm start) train longer at lq96 and are the canonical
  release runs.

## 11. Review-2 follow-ups (2026-09-06)

Independent review (nerve_review2.md) confirmed the frozen architecture and
flagged three blind spots; all cheap ones are now closed with data:

### Fair (param-matched) SPAN baseline β€” NERVE wins by ~2.1 dB
Repo `span` with `feature_channels=26` -> 664,762 params (NERVE is 687,888),
identical 20k matched protocol, Urban100 x4:

| model | params | best PSNR | SSIM |
|---|---|---|---|
| SPAN feat26 | 664,762 | 22.798 | 0.663 |
| NERVE nb16 (seed 1024) | 687,888 | **24.936** | **0.743** |

Even with the parameter budget controlled, repo-SPAN under the identical
[0,1]/norm=False protocol sits below the bicubic baseline (23.26 dB) β€” NERVE
leads by ~2.1 dB. Caveat that remains: SPAN's native `norm=True` regime and
the GAN/texture tier are still untested; the clean-bicubic claim is now solid,
the GAN-tier claim stays queued.

### Seed robustness β€” size decision is safe
Canonical nb16 re-run with seed 2048: 24.917 dB / 0.742 vs seed 1024
24.936 / 0.743 -> run-to-run noise is ~Β±0.02 dB, far below the +0.09 dB
(12->16 blocks) decision margin.

### Multi-dataset eval (release 4x model, standard metric convention)
| set | bicubic | NERVE-4x (from 2x, 100k) | from-scratch (20k) |
|---|---|---|---|
| Set5 | 28.79 | 32.11 | 31.25 |
| Set14 | 25.78 | 27.36 | 27.04 |
| B100 | 25.69 | 26.39 | 26.28 |
| Urban100 | 23.26 | 25.71 | 24.94 |

Still queued (need the GPU box / a device): OTF-GAN smoke with LPIPS + eyes on
images, dilation-under-OTF, NCNN/CoreML latency, and CoreML dynamic-shape
conversion. Toolchain versions should be pinned next to release checkpoints.

## 12. OTF/GAN capability smoke (2026-09-06/07) β€” the GAN regime now has data

Closes the biggest blind spot from `nerve_review2.md` (Β§4.4/Β§7.2): every prior
NERVE measurement was clean-bicubic fidelity; the Real-ESRGAN OTF + GAN regime
had zero measurements. Run on the RTX 3060 box via traiNNer-redux RELEASE
configs, **aggressive smoke scale** (lq64, hard caps) β€” a network test, NOT a
release run.

**Setup:** stage 1 = 4x OTF fidelity from scratch (no clean checkpoint on this
box), lq64, bs8, 20k iters, const lr 1e-4 (`4x_NERVE_OTF_fidelity_lq64.yml`).
Stage 2 = 4x OTF GAN, warm start from the stage-1 20k EMA, lq64, bs4, 30k iters
(`4x_NERVE_OTF_gan_lq64.yml`) β€” identical degradation + losses to the proven
HEART OTF GAN (mssim 0.5 / perceptual 0.01 / hsluv 1.0 / gan 0.1, dunet D), so
any failure would isolate to the generator. Val = Urban100 OTF x4.

**Stage 1 (base), PSNR / SSIM / LPIPS:**

| iters | PSNR | SSIM | LPIPS |
|---|---|---|---|
| bicubic ref | 20.55 | 0.533 | 0.737 |
| 5k | 21.21 | 0.587 | 0.564 |
| 10k | 21.38 | 0.602 | 0.526 |
| 15k | 21.43 | 0.608 | 0.505 |
| 20k (cut, plateau) | 21.45 | 0.610 | 0.495 |

**Stage 2 (GAN), TopIQ / LPIPS:**

| iters | TopIQ | LPIPS |
|---|---|---|
| GAN 5k | 0.350 | 0.432 |
| GAN 10k | 0.353 | 0.415 |
| GAN 15k | 0.357 | 0.393 |
| GAN 20k | 0.360 | 0.383 |
| GAN 25k | 0.364 | 0.375 (best LPIPS) |
| GAN 30k | 0.365 (best TopIQ) | 0.376 |

**Verdict: NERVE is GAN-capable at 688K. No parameter increase needed.**
- No collapse/wax signature: TopIQ climbed monotonically to the cap; LPIPS fell
  0.12 (βˆ’24%) vs the fidelity base. GAN loss crept up slowly (G pressing), D
  outputs oscillated in a healthy band, structure losses held the base. No NaN.
- Visual check (vision model, 8 Urban100 montages, LR/GT/fidelity/GAN): real
  crisp texture (window grids, planks, perforation holes, rib lines), no grid,
  no halos, no moirΓ© on regular texture, no color speckle, cleaner than fidelity
  and close to GT. Trouble cases (dense regular perforation, glass lattice)
  held up β€” where small SR nets usually fail.
- Absolute TopIQ (0.365) is far below HEART-GAN (0.513): expected β€” this is a
  30k smoke from a 20k scratch base, not a 125k+ warm-started release run.
- Implication: the architecture and the shared HEART GAN recipe both hold for
  the tiny pure-conv member. The remaining work is release-scale training
  (lq96, warm-started, long schedule), not architecture.

**Arch cleanup before official training (2026-09-07):** both temporary test
hooks from this pass β€” the `use_checkpoint` compat param and the `dilate_blocks`
knob β€” were removed from `nerve_arch.py` before any official release run. The
release arch is exactly: `NerveBlock` (conv-ReLU-conv), `NERVE(dim=48,
n_blocks=16)` with the `nerve()` factory (scale/in_ch/out_ch/dim/n_blocks).
687,888 params at 4x / 672,336 at 2x; 16/16 arch tests; 71-node dynamic ONNX.
The lq64 smoke configs were deleted after the test; this log is the record.

## 13. Dilation-under-OTF A/B (arm E re-test, 2026-09-07) β€” result: see below

Arm E was a no-op on clean-bicubic data; the queued follow-up was to re-test
`dilation=2` under the OTF recipe, where real blur lives. Protocol is IDENTICAL
to the Β§12 stage-1 baseline (from scratch, lq64, bs8, 20k, const lr 1e-4);
dilated arm adds `dilate_blocks=[7, 11]` (dilation=2 on conv2 of blocks 8 & 12,
one-indexed) β€” zero extra params. Decision rule: adopt only on a clear win
(>~0.1 dB OTF-val PSNR, seed noise β‰ˆ Β±0.02); otherwise freeze plain.
Expectation was null (RF ~71 px is ~4x the worst 17 px blur kernel).

**Exportability (verified before the run):** the dilated variant exports to
dynamic-shape ONNX identically to plain β€” same 71 nodes, same op set
(Conv/Relu/Add/DepthToSpace/Resize), ORT-CPU parity ~3.4e-6 at 64x64 and 77x83.
Dilation appears only as the standard Conv `dilations=[2,2]` attribute, which
NCNN supports natively. If dilation ever wins, deployability is unaffected.

**Result (2026-09-07): NULL β€” freeze the plain architecture.**

| arm | 20k PSNR | SSIM | LPIPS |
|---|---|---|---|
| plain baseline (Β§12) | 21.454 | 0.6102 | 0.4954 |
| dilated [7,11] | 21.462 | 0.6111 | 0.4958 |

Dilated trails/ties plain at every checkpoint (5k +0.007, 10k +0.006, 20k
+0.008 dB PSNR; LPIPS 0.4958 vs 0.4954) β€” well inside the Β±0.02 seed band.
The RF (~71 px β‰ˆ 4x the worst 17 px blur kernel) already covers OTF blur, so
extra dilated reach has nothing to add. **Architecture decision: NERVE is
frozen as canonical plain norm-free (dim 48, n_blocks 16, bias=False), no
dilation.** Dilation was removed from the arch file before official training
(see the Β§12 cleanup note); the A/B table above is the record of why.


## 14. Checkerboard / phase-lock fix (2026-09-10) β€” ICNR + degradation phase jitter

**Problem (visual):** the OTF **fidelity** stage output (and, to a lesser
degree, the OTF **GAN**) shows a fine 2x2 checkerboard + period-4 column
banding on smooth dark regions (anime hair, flat sky). The clean `*_release`
models are free of it (const-gray 2x2 phase spread 0.003/255 vs fidelity
0.169/255 at 135k). It is baked into the head weights, not a sampling glitch:
constant-gray input produces a periodic output signature.

**Root causes identified:**

1. **Phase-locked degradation.** The final resize-back in `RealESRGANModel`
   resamples to the fixed lq grid, so the LR always sits at the same sub-pixel
   phase relative to the GT. The model exploits that lock, and the PixelShuffle
   head learns a phase-dependent DC gain -> checkerboard.
2. **`nearest-exact` in the final resize-back** (`resize_mode_list3`, 25%
   probability in the release OTF configs). Nearest is phase-locked *and*
   aliasing β€” the single worst case for the grid.
3. **No ICNR init.** The PixelShuffle conv was randomly initialized, so each
   sub-pixel phase starts with an independent bias.

**Fixes (merged, permanent):**

- `traiNNer/utils/icnr.py`: ICNR init + a `icnr_reinit(model)` walker that
  re-initializes every Conv2d directly feeding a PixelShuffle.
- `nerve_arch.py` / `heart_arch.py`: **ICNR is now unconditional** β€” applied
  automatically at construction in both arches, no flag. It is init-only
  (zero graph/runtime cost, same dynamic-ONNX output) and is the same
  practice `temporal_span_arch.py` (SPAN) already uses.
- `redux_options.py`: `path.icnr_reinit_g` (re-scrub the head after loading a
  warm-start, keeping the body) and `lq_phase_jitter` / `lq_phase_jitter_range`
  (random sub-pixel shift of the final LQ, train-only). **`lq_phase_jitter`
  was briefly defaulted to true, then reverted to false in Β§15** β€” jitter
  destabilized the GAN stage and aliased LR is the intended teacher.
- `realesrgan_model.py`: `_lq_phase_jitter` β€” random (dx,dy) in Β±0.5 lq px via
  grid_sample (reflection padding), applied after the final sinc filter.
- `misc.py`: `icnr_reinit` excluded from `make_exp_dirs` path iteration.
- New configs `4x_NERVE_OTF_fidelity_v2.yml` / `4x_NERVE_OTF_gan_v2.yml`:
  `icnr_reinit_g: true` (fidelity only) and anti-aliased
  `resize_mode_list3` (bilinear/bicubic/lanczos, no nearest-exact).
  (Arch `icnr` key is no longer a config option β€” the arch just does it.)
- `4x_NERVE_L_OTF_fidelity.yml` / `4x_NERVE_L_OTF_gan.yml`: same recipe at
  dim 64 / n_blocks 24 (1.80M params) for the mottling re-test, from scratch.

**Baseline grid metrics (const-gray, /255; ani-hair 2x2 chkr in parens):**

| model | checkerboard 2x2 | x period-4 |
|---|---|---|
| 4x release 485k | 0.003 | 0.014 |
| 4x OTF fidelity v1 135k | 0.169 | 0.127 |
| 4x OTF GAN v1 35k | 0.045 | 0.127 |

Target for v2 fidelity: const-gray 2x2 phase spread << 0.10 (near release
levels). Validation in progress (`scripts/nerve/check_grid.py`).

**Eval note:** v2 fidelity warms up from the clean release weights with an
ICNR-scrubbed head, so its head starts phase-balanced. **Result: the
degradation changes that accompanied this (phase jitter + anti-aliased final
resize) made the GAN visibly worse β€” see Β§15.**

## 15. Capacity scaling + the aliasing lesson (2026-09-12/13)

**What went wrong in v2 (result).** The v2 chain (weaker base + sub-pixel
`lq_phase_jitter` + anti-aliased final resize, no nearest-exact) did reduce the
smooth-hair grid mid-training (const-gray 0.169 -> 0.058 @95k) but at a real
cost:

- fidelity PSNR 21.95 (v1) -> 21.23 (v2) on the phase-locked val (~-0.3 dB on a
  fair sub-pixel-shifted eval, where v1 loses 2.4 dB under shift vs v2's 1.0).
- the GAN v2 output is visibly **worse**: thin lines break into beading/dots
  and flat areas stipple. Line high-freq (right-hair LapStd): v1 GAN 35k =
  19.31, v2 GAN 45k = 24.23 (+25%), v2 60k = 25.53. lpips 0.3174 -> 0.4020,
  topiq 0.4065 -> 0.3485.
- the fidelity grid crept back late (0.149 @200k).

**The aliasing lesson (the fix).** *The LR must stay aliased.* `nearest-exact`
downscaling gives jagged LR; training on it teaches the aliased->smooth
(de-alias) mapping. Removing aliasing from the LR (anti-aliased final resize)
plus sub-pixel jitter left the generator phase-uncertain and unable to
de-alias, so adversarial pressure turned thin lines into beading. Actions:

- `resize_mode_list3` restored to include `nearest-exact` in all NERVE OTF
  configs.
- `lq_phase_jitter` default reverted to **false**.
- ICNR (init-only) kept β€” harmless and correct.

**Capacity scaling (exact v1 GAN recipe; aliased LR; no jitter).** The bigger
model is warm-started from the v1 dim48 fidelity via a width+block expansion
(`scripts/nerve/expand_nerve.py`: old weights top-left, new channels small
random, new blocks near no-op) β€” verified ~equivalent to v1 fidelity (81.85 dB).

| model | params | topiq @35k | lpips @35k | line HF right/bangs |
|---|---|---|---|---|
| v1 GAN dim48/b16 | 0.69M | 0.4065 | 0.3174 | 19.31 / 15.54 |
| dim64/b24 | 1.80M | **0.4124** | **0.3136** | **18.64 / 14.82** |
| dim64/b24 @40k | 1.80M | 0.4121 | 0.3122 | 19.01 / 15.13 |

Capacity **helps both metrics and line cleanliness** (lower HF = fewer
artifacts); the 1.80M model had not plateaued at 40k (lpips still falling)
while v1 plateaued at 35k.

**Reference parameter counts:** SRVGGNetCompact default 0.62M, x4v3 1.21M,
ultracompact 0.33M; SPAN-S 2.24M, SPAN base 2.62M, SPAN-F64 3.96M. NERVE
1.80M sits in the SPAN class while staying conv-only.

**Protocol (running):** train 1.80M to 150k, then dim64/b32 (2.39M) to 150k
with the same recipe/start/iters -> scaling curve. Next point if needed:
dim96/b24 (4.03M). Deferred: a dedicated aliasing augmentation in the OTF
pipeline; WebP compression (jpeg already generalizes to webp/screenshot DCT).