diff --git a/CLAUDE.md b/CLAUDE.md index 8a9dca7..c99bddb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1520,6 +1520,12 @@ derivation both the encode and preview paths must use. - Deinterlacing enabled → `_FieldBased` comes from `pipeline.deinterlace.tff`. - Deinterlacing disabled → falls back to `job.detected_field_order` (zimg honours `_FieldBased` for chroma resampling too). +- **A filter that works vertically must not run on a woven interlaced frame**: + it blends the two fields on every moving edge. `ScriptGenerator::still_interlaced_tff` + (deinterlacing off + a detected order) is the one test for "still + interlaced"; Deblock uses it to run every method per field + (`{{#DEBLOCK_PER_FIELD}}`) — DCTFilter on the woven frame scored 1.5–3.7 dB + *below no filtering* (docs/reports/2026-11-deblock-order). ### Deinterlace Working Format (issue #49) diff --git a/app/test/integration_filter_parameters_test.dart b/app/test/integration_filter_parameters_test.dart index 6aa3bf5..d961387 100644 --- a/app/test/integration_filter_parameters_test.dart +++ b/app/test/integration_filter_parameters_test.dart @@ -1483,6 +1483,39 @@ void main() { print(' PASS'); }, timeout: const Timeout(Duration(minutes: 2))); + test('deblock: interlaced output is deblocked per field', () async { + // Deinterlacing off + a detected field order: every method runs between + // SeparateFields and DoubleWeave (docs/reports/2026-11-deblock-order). + loadSchema('deblock'); + for (final method in DeblockMethod.values) { + final job = buildJob( + testName: 'deblock_per_field_${method.name}', + deblock: DeblockParameters(enabled: true, method: method), + ).copyWith(detectedFieldOrder: FieldOrder.bottomFieldFirst); + final script = await generateScriptViaWorker(job); + final split = script.indexOf('core.std.SeparateFields(clip, tff=False)'); + final weave = script.indexOf('core.std.DoubleWeave(clip, tff=False)[::2]'); + expect(split, greaterThan(0), reason: '$method: no field split'); + expect(weave, greaterThan(split), reason: '$method: no weave after split'); + final call = switch (method) { + DeblockMethod.deblockQed => 'haf.Deblock_QED(', + DeblockMethod.deblock => 'core.deblock.Deblock(', + DeblockMethod.dctFilter => 'core.dctf.DCTFilter(', + }; + final at = script.indexOf(call); + expect(at > split && at < weave, isTrue, + reason: '$method: the filter must run on the separated fields'); + } + // Without a field order the source is treated as progressive. + final progressive = await generateScriptViaWorker(buildJob( + testName: 'deblock_whole_frame', + deblock: const DeblockParameters(enabled: true), + )); + expect(progressive, contains('haf.Deblock_QED(')); + expect(progressive, isNot(contains('_deblock_per_field'))); + print(' PASS'); + }, timeout: const Timeout(Duration(minutes: 2))); + test('grain: AddGrain params are not depth-scaled', () async { loadSchema('grain'); final job = buildJob( diff --git a/app/test/integration_new_passes_test.dart b/app/test/integration_new_passes_test.dart index 6490f39..19d8cdf 100644 --- a/app/test/integration_new_passes_test.dart +++ b/app/test/integration_new_passes_test.dart @@ -13,6 +13,7 @@ library; // ignore_for_file: avoid_print — these tests print diagnostics to the test log. import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; @@ -605,6 +606,57 @@ void main() { await _expectValidVideo(result); }, timeout: const Timeout(Duration(minutes: 6))); + // Deinterlacing off on an interlaced source: every Deblock method must work + // on each field, never the woven frame (docs/reports/2026-11-deblock-order). + // A job with no detected field order still takes the whole-frame path, so + // it is the in-test reference for what woven filtering does: DCTFilter on + // the woven frame attenuates the combing between fields and moves frame 45 + // ~2.2 levels from the source, against ~0.6 per field. + test('deblock: interlaced output is deblocked per field, every method', + () async { + VideoJob job(String name, DeblockParameters deblock, FieldOrder? order) => + _baseJob( + name, + pipeline: ProcessingPipeline( + deinterlace: const QTGMCParameters(enabled: false), + deblock: deblock, + ), + ).copyWith( + detectedFieldOrder: order, + inputWidth: 720, + inputHeight: 576, + inputPixelFormat: 'yuv422p', + inputFrameRate: 25, + totalFrames: 79, + ); + Future frame(VideoJob j, String label) async { + final r = await WorkerHarness.runPreview(j.toJson(), frame: 45, label: label); + expect(r.success, isTrue, reason: '$label preview failed: $r'); + return WorkerHarness.imageToRgb24(r.png!, label: label); + } + + for (final method in DeblockMethod.values) { + final params = DeblockParameters(enabled: true, method: method); + final name = 'deblock_per_field_${method.name}'; + final result = await WorkerHarness.runJob( + job(name, params, FieldOrder.topFieldFirst).toJson(), + label: name); + await _expectValidVideo(result); + } + + const off = DeblockParameters(enabled: false); + const dct = DeblockParameters(enabled: true, method: DeblockMethod.dctFilter); + final source = await frame(job('src', off, FieldOrder.topFieldFirst), 'dbf_src'); + final perField = WorkerHarness.meanAbsDiff( + await frame(job('pf', dct, FieldOrder.topFieldFirst), 'dbf_field'), source); + final woven = WorkerHarness.meanAbsDiff( + await frame(job('wv', dct, null), 'dbf_woven'), source); + print(' DCTFilter vs source, frame 45: per field $perField, woven $woven'); + expect(woven, greaterThan(perField * 2), + reason: 'per-field DCTFilter should change the picture far less than ' + 'woven-frame DCTFilter, which blends the fields'); + }, timeout: const Timeout(Duration(minutes: 10))); + test('grain: AddGrain and GrainFactory3 both run end-to-end', () async { for (final entry in { 'grain_add': const GrainParameters(enabled: true, var_: 6.0, uvar: 2.0), diff --git a/docs/ENGINEERING_NOTES.md b/docs/ENGINEERING_NOTES.md index f20d2b5..a6e7fbb 100644 --- a/docs/ENGINEERING_NOTES.md +++ b/docs/ENGINEERING_NOTES.md @@ -2775,6 +2775,38 @@ Recommendation, if built: an `advancedOnly` Deinterlace method with its own small control set, tuned as above, vs-jetpack and mvutensils pinned together. x86 speed and the v2 CPU tier are unmeasured. +### Deblock before or after deinterlacing: order kept, interlaced output deblocked per field (2026-11-03) + +Full report with screenshots and scripts: +[docs/reports/2026-11-deblock-order/](reports/2026-11-deblock-order/index.html). + +A user asked whether deblocking should precede deinterlacing (Gemini's +advice). It should in theory: `Deblock_QED` / `deblock.Deblock` assume an 8x8 +grid from (0,0), exact only on the decoded interlaced frame, and QTGMC moves +the horizontal block edges. Measured on two 720x576 TFF fixtures (film +ground truth; a real capture against QTGMC of its lossless original), each +MPEG-2 at 1000/1500/3000 kbps with `+ildct+ilme`: + +- **Deinterlacing on:** per-field deblocking before QTGMC beats today's + after-QTGMC order by ≤0.05 dB at the 24/26 default (0.05–0.14 dB at + 36/44), blockiness equal. Not worth reordering. +- **Deinterlacing off:** the deblocker ran on the woven frame; per field + (`SeparateFields → deblock → DoubleWeave[::2]`) won on PSNR and SSIM in + all 14 edge-deblocker comparisons, +0.03 to +0.30 dB. **DCTFilter on the + woven frame was destructive**: 1.5–3.7 dB *below no filtering*, because it + attenuates the field-to-field alternation on moving edges as high-frequency + detail; per field it is neutral to slightly positive. Built: every method + now runs per field when `ScriptGenerator::still_interlaced_tff` says the + picture is still interlaced (`{{#DEBLOCK_PER_FIELD}}`, both templates; + `test_183`). A 4:2:0 frame with an odd chroma field height (486 lines) + can't be split and keeps the woven path. The round trip is bit-exact with + frame props restored. +- At 1000 kbps a stronger Deblock_QED (36/44) gained ~3x what 24/26 did + (+0.48 against +0.16 dB): more than any order choice. One clip, one + strength; not a reason to change the default without its own probe. +- The film fixture's reference carries `small_clip.mp4`'s own (upscaled) + H.264 blocks; PSNR rewards deblocking's smoothing, and VMAF dips slightly. + ### Testing a deps change before publishing A PR that changes `deps/` has a chicken-and-egg problem: `ci-test.yml` and diff --git a/docs/reports/2026-11-deblock-order/img/01-vhs-input-clean-vs-1000k.png b/docs/reports/2026-11-deblock-order/img/01-vhs-input-clean-vs-1000k.png new file mode 100644 index 0000000..0869f98 Binary files /dev/null and b/docs/reports/2026-11-deblock-order/img/01-vhs-input-clean-vs-1000k.png differ diff --git a/docs/reports/2026-11-deblock-order/img/02-vhs-1000k-orders.png b/docs/reports/2026-11-deblock-order/img/02-vhs-1000k-orders.png new file mode 100644 index 0000000..bc51023 Binary files /dev/null and b/docs/reports/2026-11-deblock-order/img/02-vhs-1000k-orders.png differ diff --git a/docs/reports/2026-11-deblock-order/img/03-film-1000k-orders.png b/docs/reports/2026-11-deblock-order/img/03-film-1000k-orders.png new file mode 100644 index 0000000..744b569 Binary files /dev/null and b/docs/reports/2026-11-deblock-order/img/03-film-1000k-orders.png differ diff --git a/docs/reports/2026-11-deblock-order/img/04-vhs-frame91-error-x8.png b/docs/reports/2026-11-deblock-order/img/04-vhs-frame91-error-x8.png new file mode 100644 index 0000000..4f62ed5 Binary files /dev/null and b/docs/reports/2026-11-deblock-order/img/04-vhs-frame91-error-x8.png differ diff --git a/docs/reports/2026-11-deblock-order/img/05-vhs-interlaced-out-orders.png b/docs/reports/2026-11-deblock-order/img/05-vhs-interlaced-out-orders.png new file mode 100644 index 0000000..4adffee Binary files /dev/null and b/docs/reports/2026-11-deblock-order/img/05-vhs-interlaced-out-orders.png differ diff --git a/docs/reports/2026-11-deblock-order/img/06-vhs-dctfilter-error-x8.png b/docs/reports/2026-11-deblock-order/img/06-vhs-dctfilter-error-x8.png new file mode 100644 index 0000000..c5dd7f6 Binary files /dev/null and b/docs/reports/2026-11-deblock-order/img/06-vhs-dctfilter-error-x8.png differ diff --git a/docs/reports/2026-11-deblock-order/index.html b/docs/reports/2026-11-deblock-order/index.html new file mode 100644 index 0000000..2526d25 --- /dev/null +++ b/docs/reports/2026-11-deblock-order/index.html @@ -0,0 +1,277 @@ + + + + + +Deblock before or after deinterlacing? + + + +
+ +

Deblock before or after deinterlacing?

+

Probe, 2026-11-03. No issue: a user asked whether deblocking should run before deinterlacing, as a chat with Google Gemini had suggested. VapourBox runs it after.

+ +
+ Short answer: the advice is right in theory, but it makes almost no measurable difference to the deinterlaced result. +
    +
  • Deinterlacing on (the normal case). Per-field deblocking before QTGMC beats today's order by at most 0.05 dB at the app's default strength, and 0.05–0.14 dB at a much stronger setting. The visible blockiness left over is the same. Recommendation: don't reorder the pipeline.
  • +
  • Deinterlacing off on an interlaced source. Today the deblocker runs on the woven frame. Running it per field scored better in every case: +0.03 to +0.20 dB at default strength and +0.08 to +0.30 dB at strong, with SSIM agreeing. The gain is small but always points the same way, and the change is cheap. Recommendation: deblock per field when the output stays interlaced.
  • +
  • DCTFilter on a woven frame is damaging. This was added after the main run (§3, "DCTFilter"). The third Deblock method, run on the woven frame, scored 1.5–3.7 dB below not filtering at all, because it attenuates the combing between fields as if it were high-frequency detail. Per field, it's neutral to slightly positive. With deinterlacing off, today's app does the damaging version.
  • +
  • Side finding. At 1000 kbps, raising Deblock_QED from the default 24/26 to 36/44 gained more than any choice of order (about +0.5 dB against +0.2 dB). One clip is no reason to change a default, but it does put the order question in proportion.
  • +
+
+ +

1. Why order could matter

+

MPEG-2 and DV code each macroblock with either a frame DCT, where 8×8 blocks span both fields and edges fall every 8 frame lines, or a field DCT, where each block is 8 lines of one field and edges fall every 16 frame lines. Both deblockers in the app (haf.Deblock_QED and core.deblock.Deblock) assume an 8×8 grid starting at the frame's top-left corner. That grid is exact only on the decoded interlaced frame.

+

After QTGMC, the vertical block edges stay on the grid, because there's no horizontal resampling. The horizontal ones don't: each output frame is one field interpolated to full height, the bottom field sits a line lower, and QTGMC's motion-compensated smoothing moves and softens what remains. Deblocking the woven frame has the opposite problem. In moving areas its vertical filtering mixes the two fields. Deblocking per field avoids that mixing, but misses the edges of frame-DCT blocks that fall between field-grid lines.

+ +

2. Method

+

Two 720×576 TFF 4:2:0 fixtures, each encoded with ffmpeg's mpeg2video at 1000, 1500 and 3000 kbps. The encoder used interlaced DCT and interlaced motion estimation (-flags +ildct+ilme), GOP 12 with 2 B-frames, to approximate a DVD or a capture-card recording:

+
    +
  • film: frames 1400–1639 of small_clip.mp4, treated as 50 Hz field samples (as in the #117 report), upscaled to 720×576 and woven into 120 interlaced frames. The 50p frames are the ground truth.
  • +
  • vhs: interlaced_test.avi, a real 79-frame capture stored losslessly. It has no true 50p, so the deinterlaced reference is QTGMC Slower run on the lossless capture.
  • +
+

Each decoded clip went through scripts/probe.vpy, using the app's own pipe_source.py and the bundled macOS arm64 vspipe, with one of these orders:

+
    +
  • no deblock
  • +
  • after QTGMC: today's order
  • +
  • woven frame, before QTGMC
  • +
  • per field, before: SeparateFields → deblock → DoubleWeave[::2], then QTGMC
  • +
+

Every order was run with QTGMC Slower at double rate, and again with deinterlacing off. Deblock_QED and simple Deblock were both run at the app defaults (24/26 and 24). Scores against the reference are luma PSNR, luma SSIM, VMAF, and ffmpeg's blockdetect on the output (lower means fewer visible 8-pixel edges). The machine was an Apple M1 Pro. The full matrix takes about 2.5 minutes.

+ +
+ vhs fixture frame 51, clean vs 1000 kbps MPEG-2 +
Left: the lossless vhs capture. Right: the same frame after 1000 kbps MPEG-2. Frame 51 woven, sky crop at 2× nearest-neighbour. The flat sky has broken into visible blocks, which is the damage being measured. The striping is normal for a woven interlaced frame.
+
+ +
Limits of the fixtures, stated up front. +
    +
  • The film ground truth has blocks of its own. small_clip.mp4 is 450 kbps H.264 at 320×240. Its compression blocks were upscaled 2.25× into the reference, which shows in the dark crop of Figure 3. They sit on an 18-pixel grid, not the 8-pixel one, so a deblocker barely touches them. They still lower every film score by the same amount.
  • +
  • The vhs reference is QTGMC's own output, not the truth. It favours whatever looks most like QTGMC applied to a clean source.
  • +
  • PSNR rewards softness. Deblocking is a smoothing filter, so part of any PSNR gain is blur. VMAF, which rewards detail, falls slightly when deblocking film (Table 1). That's why the blockiness column and the images matter as much as the dB.
  • +
  • ffmpeg's encoder is not a DVD authoring encoder. Its choice between field and frame DCT for each macroblock may differ from a hardware encoder's.
  • +
+ +

3. Results

+

The green row in each group has the best PSNR. "Δ vs none" is the PSNR gain over not deblocking at all.

+ +

Deinterlacing on

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Deinterlaced (QTGMC Slower), Deblock_QED 24/26
FixturekbpsOrderPSNR-Y dBΔ vs noneSSIM-YVMAFBlockiness
film1000no deblock36.020.932161.544.98
film1000after QTGMC (today)36.18+0.160.936361.342.43
film1000woven frame, before36.17+0.150.936361.302.45
film1000per field, before36.22+0.200.937361.342.44
film1500no deblock38.890.956476.933.30
film1500after QTGMC (today)39.07+0.180.959176.661.88
film1500woven frame, before39.04+0.150.958876.581.91
film1500per field, before39.10+0.210.959576.531.91
film3000no deblock43.480.980692.232.12
film3000after QTGMC (today)43.62+0.140.981591.851.40
film3000woven frame, before43.53+0.050.981091.701.40
film3000per field, before43.61+0.130.981491.721.42
vhs1000no deblock33.180.923371.793.39
vhs1000after QTGMC (today)33.25+0.080.926071.831.82
vhs1000woven frame, before33.25+0.070.925871.811.93
vhs1000per field, before33.28+0.100.926671.921.87
vhs1500no deblock33.770.930875.023.04
vhs1500after QTGMC (today)33.85+0.080.933275.081.71
vhs1500woven frame, before33.85+0.070.933075.061.79
vhs1500per field, before33.87+0.100.933775.111.76
vhs3000no deblock36.950.955288.432.45
vhs3000after QTGMC (today)37.07+0.120.957288.471.45
vhs3000woven frame, before37.05+0.100.956888.461.52
vhs3000per field, before37.09+0.140.957488.501.50
+

The best PSNR alternates between per-field and after. Per-field leads at 1000 and 1500 kbps by 0.03–0.05 dB, and after leads at 3000 kbps by 0.01 dB on film. Blockiness agrees to within 0.1 across the three orders. Over individual frames of the 1000 kbps run, per-field beat after in 193 of 240 film frames and 93 of 158 vhs frames. The largest single-frame gap was +0.59 dB (vhs) and the worst −0.11 dB.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Deinterlaced, Deblock (simple) quant 24
FixturekbpsOrderPSNR-Y dBΔ vs noneSSIM-YVMAFBlockiness
film1000no deblock36.020.932161.544.98
film1000after QTGMC (today)36.14+0.120.936161.393.12
film1000woven frame, before36.12+0.100.935461.392.96
film1000per field, before36.15+0.130.936361.352.92
film1500no deblock38.890.956476.933.30
film1500after QTGMC (today)39.07+0.180.959776.672.21
film1500woven frame, before39.01+0.120.958876.632.10
film1500per field, before39.06+0.170.959476.592.09
film3000no deblock43.480.980692.232.12
film3000after QTGMC (today)43.71+0.230.982391.781.61
film3000woven frame, before43.54+0.060.981391.731.52
film3000per field, before43.62+0.140.981691.771.52
vhs1000no deblock33.180.923371.793.39
vhs1000after QTGMC (today)33.25+0.070.926671.812.24
vhs1000woven frame, before33.22+0.040.925671.772.13
vhs1000per field, before33.25+0.080.926671.812.08
vhs1500no deblock33.770.930875.023.04
vhs1500after QTGMC (today)33.85+0.080.933975.042.10
vhs1500woven frame, before33.83+0.060.933175.051.97
vhs1500per field, before33.86+0.080.933875.051.94
vhs3000no deblock36.950.955288.432.45
vhs3000after QTGMC (today)37.09+0.140.958288.451.82
vhs3000woven frame, before37.02+0.070.957188.411.67
vhs3000per field, before37.08+0.130.957788.461.64
+

Simple Deblock is much the same: the orders are within 0.04 dB of each other, except film at 3000 kbps, where after leads per-field by 0.09 dB.

+ +
+ vhs 1000k frame 103 crop, five panels +
referenceno deblockafter QTGMC (today)woven, beforeper field, before
+
vhs, 1000 kbps, output frame 103 (the blockiest output frame), sky crop at 3×. The large flat blocks are 16-pixel macroblocks that kept little more than their average colour. None of the deblocked panels looks clearly different from no deblock, and none removes the blocks: the default strength is tuned for 8-pixel edges, not for flat macroblocks. The orders are indistinguishable here.
+
+ +
+ film 1000k frame 187 crop, five panels, luma x4 +
referenceno deblockafter QTGMC (today)woven, beforeper field, before
+
film, 1000 kbps, output frame 187, a near-black coat at 3× with luma ×4 applied equally to every panel so the blocks show. This is the frame where order is most visible. "After QTGMC" leaves the 8-pixel steps almost as they were, while both before-deinterlacing orders smooth them. The reference has its own coarser blocks (see the limits above). This frame is not typical: averaged over the clip, the blockiness scores of the three orders are the same.
+
+ +
+ error maps after vs per field, vhs frame 91 +
|after QTGMC − reference| × 8|per field − reference| × 8
+
vhs, 1000 kbps, frame 91, where per-field beat after by the most (+0.57 dB). Luma error amplified 8×. The two maps are almost identical. The error is mostly detail that the compression destroyed, which no deblocker can bring back.
+
+ +

Deinterlacing off (output stays interlaced)

+ + + + + + + + + + + + + + + + + + + + +
Left interlaced, Deblock_QED 24/26
FixturekbpsOrderPSNR-Y dBΔ vs noneSSIM-YVMAFBlockiness
film1000no deblock35.430.931771.019.49
film1000woven frame, before35.58+0.150.935770.823.42
film1000per field, before35.66+0.230.937270.823.35
film1500no deblock38.360.957385.495.73
film1500woven frame, before38.55+0.180.959585.302.37
film1500per field, before38.66+0.290.961085.212.34
film3000no deblock44.110.983298.953.25
film3000woven frame, before44.22+0.110.983198.801.58
film3000per field, before44.41+0.310.984498.701.60
vhs1000no deblock32.440.901976.308.27
vhs1000woven frame, before32.50+0.070.904276.343.15
vhs1000per field, before32.53+0.090.905476.373.07
vhs1500no deblock33.010.910379.287.22
vhs1500woven frame, before33.08+0.070.912279.332.75
vhs1500per field, before33.11+0.100.913679.352.70
vhs3000no deblock36.000.937991.405.46
vhs3000woven frame, before36.10+0.100.938991.432.02
vhs3000per field, before36.15+0.150.940691.442.02
+

Scored against the clean interlaced frames. Per-field beats woven in PSNR and SSIM at every bitrate on both fixtures: by 0.08–0.20 dB on film and 0.03–0.05 dB on vhs. Blockiness is the same.

+ +
+ vhs interlaced output crop, four panels +
clean interlacedno deblockwoven deblockper-field deblock
+
vhs, 1000 kbps, woven frame 45, a moving street scene at 3×. At this strength the field-mixing that woven deblocking risks isn't visible. The difference shows up in the numbers, not to the eye.
+
+ +

A much stronger deblock

+ + + + + + + + +
Deinterlaced, Deblock_QED 36/44 (strong), 1000 kbps
FixturekbpsOrderPSNR-Y dBΔ vs noneSSIM-YBlockiness
film1000after QTGMC (today)36.50+0.480.94031.92
film1000woven frame, before36.46+0.440.93971.90
film1000per field, before36.64+0.620.94231.90
vhs1000after QTGMC (today)33.38+0.200.92771.63
vhs1000woven frame, before33.36+0.180.92721.67
vhs1000per field, before33.43+0.250.92861.63
+ + + + + + +
Left interlaced, Deblock_QED 36/44 (strong), 1000 kbps
FixturekbpsOrderPSNR-Y dBΔ vs noneSSIM-YBlockiness
film1000woven frame, before35.87+0.430.93752.23
film1000per field, before36.16+0.730.94162.67
vhs1000woven frame, before32.63+0.200.90512.06
vhs1000per field, before32.71+0.280.90782.24
+

At 36/44 the deblocker does about three times as much (Δ vs none: +0.48 dB against +0.16 on film), and the gap between orders grows with it. Deinterlaced, per-field leads after by 0.14 dB (film) and 0.05 dB (vhs). Left interlaced, per-field leads woven by 0.30 and 0.08 dB. Woven deblocking now scores lower on blockiness than per-field when left interlaced (2.23 against 2.67 on film). That matches the theory: on the frame grid, per-field filtering misses frame-DCT block edges.

+ +

DCTFilter (added the same day, while building the fix)

+

The main run covered only the two edge deblockers. Before applying the per-field change to all three Deblock methods, DCTFilter was run the same way at the app's default curve (cutoff 5, strength 0.6, luma only) on the 1000 kbps fixtures (scripts/dct.sh).

+ + + + + + +
Left interlaced, DCTFilter (app default curve), 1000 kbps
FixturekbpsOrderPSNR-Y dBΔ vs noneSSIM-YBlockiness
film1000woven frame, before31.71-3.720.91219.75
film1000per field, before35.51+0.080.932610.61
vhs1000woven frame, before30.89-1.540.88758.86
vhs1000per field, before32.49+0.050.90229.41
+ + + + + + + + +
Deinterlaced, DCTFilter (app default curve), 1000 kbps
FixturekbpsOrderPSNR-Y dBΔ vs noneSSIM-YBlockiness
film1000after QTGMC (today)36.09+0.070.93285.16
film1000woven frame, before31.75-4.270.91114.91
film1000per field, before36.10+0.080.93305.04
vhs1000after QTGMC (today)33.24+0.060.92413.55
vhs1000woven frame, before31.21-1.970.91023.60
vhs1000per field, before33.08-0.100.92333.42
+

On the woven frame it does real damage: −3.72 dB on film and −1.54 dB on vhs against no filtering, and −4.3 / −2.0 dB when QTGMC follows it. DCTFilter scales coefficients from 8×8 DCT blocks spanning both fields. On a moving edge, the line-to-line alternation between fields is the highest vertical frequency, so it gets attenuated, which blends the fields. Per field the same filter is neutral to slightly positive (+0.05 to +0.08 dB), the same as running it after QTGMC. Its blockiness score rises in every arrangement because DCTFilter flattens block interiors rather than smoothing edges, so it isn't a deblocker in that metric's sense.

+ +
+ DCTFilter error maps woven vs per field +
|DCTFilter on woven frame − clean| × 8|DCTFilter per field − clean| × 8
+
vhs, 1000 kbps, deinterlacing off, frame 45, luma error amplified 8×. The woven version has bright bands along every moving horizontal edge (the bonnet, the dashboard line, the kerb), where the fields have been blended. Per field, they're gone, and what's left is the compression error.
+
+ +

4. Decisions and why

+
    +
  • Keep Deblock after Deinterlace. At the defaults the gain from moving it is ≤0.05 dB, within what one clip can resolve, with no difference in blockiness. Reordering would also move it ahead of Noise Reduction and change every existing preset's output, for a gain nobody would see.
  • +
  • When deinterlacing is off and the source is interlaced, deblock per field, for all three methods. For the edge deblockers it beat woven deblocking on PSNR and SSIM in all 14 comparisons (both methods, both strengths). For DCTFilter it's the difference between a −3.7 dB loss and a small gain. It's a few lines in the Deblock block of both templates, gated on the same field-order decision that {{#SET_FIELD_BASED}} already uses (ScriptGenerator::field_based_for). Built in the same branch. A 4:2:0 frame whose field height is odd in chroma (486 lines) can't be split into fields; the script falls back to the woven frame there, as before.
  • +
  • Leave the default strength alone for now. 36/44 scored better at 1000 kbps, but only one strength was tried, VMAF wasn't run for it, and a stronger default would also soften sources that aren't blocky. That needs its own probe across bitrates.
  • +
+ +

5. Not tested

+
    +
  • NTSC (720×480), DV (2-4-8 DCT), and interlaced H.264 (PAFF/MBAFF). H.264's in-loop deblocking runs in the decoder, so there's less left for us to do.
  • +
  • Real DVD or broadcast MPEG-2 from a hardware encoder.
  • +
  • Any QTGMC preset other than Slower. Noise Reduction between QTGMC and Deblock, as the app's order puts it.
  • +
  • DCTFilter at any curve but the default, and x86. The deblock plugin is integer C on every platform, but QTGMC's interpolation differs.
  • +
  • More than two short clips.
  • +
+ +

6. Reproducing

+
export SCRATCH=/some/scratch/dir                  # large raw files go here (~1.3 GB)
+docs/reports/2026-11-deblock-order/scripts/make_fixtures.sh
+docs/reports/2026-11-deblock-order/scripts/run.sh     # → $SCRATCH/results.csv (copied here as results.csv)
+docs/reports/2026-11-deblock-order/scripts/strong.sh  # → $SCRATCH/results_strong.csv
+docs/reports/2026-11-deblock-order/scripts/dct.sh     # → $SCRATCH/results_dct.csv
+docs/reports/2026-11-deblock-order/scripts/imgs.sh    # → img/
+python3 -I docs/reports/2026-11-deblock-order/scripts/tables.py   # the tables above
+

Needs deps/macos-arm64. The scripts call that platform's ffmpeg and vspipe directly, and use BSD stat -f.

+ +
+ + diff --git a/docs/reports/2026-11-deblock-order/results.csv b/docs/reports/2026-11-deblock-order/results.csv new file mode 100644 index 0000000..842bebc --- /dev/null +++ b/docs/reports/2026-11-deblock-order/results.csv @@ -0,0 +1,75 @@ +fixture,bitrate,deint,order,method,psnr_y,ssim_y,vmaf,blockiness +film,clean,1,none,-,47.619619,0.993849,96.907711,1.849 +film,1000,1,none,-,36.020449,0.932119,61.540319,4.983 +film,1000,1,after,qed,36.177075,0.936303,61.344233,2.432 +film,1000,1,after,simple,36.141816,0.936055,61.386256,3.115 +film,1000,1,woven,qed,36.165892,0.936257,61.302056,2.454 +film,1000,1,woven,simple,36.116194,0.935359,61.393703,2.961 +film,1000,1,field,qed,36.220735,0.937343,61.340989,2.437 +film,1000,1,field,simple,36.153051,0.936279,61.349030,2.925 +film,1000,0,none,-,35.432414,0.931686,71.011149,9.494 +film,1000,0,woven,qed,35.583287,0.935723,70.820618,3.417 +film,1000,0,woven,simple,35.540970,0.935142,70.885676,4.469 +film,1000,0,field,qed,35.663103,0.937220,70.824354,3.346 +film,1000,0,field,simple,35.591458,0.936512,70.817297,4.456 +film,1500,1,none,-,38.890963,0.956409,76.929512,3.297 +film,1500,1,after,qed,39.074529,0.959144,76.661507,1.880 +film,1500,1,after,simple,39.072204,0.959694,76.667171,2.209 +film,1500,1,woven,qed,39.041313,0.958811,76.579870,1.906 +film,1500,1,woven,simple,39.013614,0.958789,76.625832,2.098 +film,1500,1,field,qed,39.099628,0.959545,76.533274,1.911 +film,1500,1,field,simple,39.057191,0.959381,76.585126,2.086 +film,1500,0,none,-,38.364379,0.957260,85.485327,5.731 +film,1500,0,woven,qed,38.545460,0.959518,85.303924,2.366 +film,1500,0,woven,simple,38.527124,0.959788,85.332913,2.772 +film,1500,0,field,qed,38.656889,0.960974,85.212086,2.340 +film,1500,0,field,simple,38.606581,0.961093,85.270816,2.771 +film,3000,1,none,-,43.481191,0.980611,92.226677,2.124 +film,3000,1,after,qed,43.624348,0.981548,91.848784,1.404 +film,3000,1,after,simple,43.709410,0.982291,91.782573,1.615 +film,3000,1,woven,qed,43.530415,0.980966,91.697203,1.398 +film,3000,1,woven,simple,43.544941,0.981294,91.731456,1.516 +film,3000,1,field,qed,43.610019,0.981400,91.724721,1.420 +film,3000,1,field,simple,43.621850,0.981579,91.766820,1.521 +film,3000,0,none,-,44.107331,0.983188,98.952003,3.255 +film,3000,0,woven,qed,44.216130,0.983084,98.799709,1.579 +film,3000,0,woven,simple,44.297249,0.983676,98.817364,1.835 +film,3000,0,field,qed,44.412358,0.984425,98.699420,1.598 +film,3000,0,field,simple,44.488091,0.984910,98.738581,1.847 +vhs,clean,1,none,-,inf,1.000000,99.977327,1.050 +vhs,1000,1,none,-,33.179018,0.923336,71.788958,3.391 +vhs,1000,1,after,qed,33.254284,0.925986,71.834457,1.824 +vhs,1000,1,after,simple,33.250550,0.926610,71.814874,2.238 +vhs,1000,1,woven,qed,33.246750,0.925835,71.814900,1.925 +vhs,1000,1,woven,simple,33.221127,0.925645,71.765754,2.132 +vhs,1000,1,field,qed,33.281953,0.926558,71.916596,1.874 +vhs,1000,1,field,simple,33.254389,0.926567,71.808365,2.084 +vhs,1000,0,none,-,32.435859,0.901936,76.295483,8.269 +vhs,1000,0,woven,qed,32.502238,0.904187,76.341939,3.149 +vhs,1000,0,woven,simple,32.488280,0.904079,76.313455,3.957 +vhs,1000,0,field,qed,32.527315,0.905441,76.370557,3.066 +vhs,1000,0,field,simple,32.513154,0.905760,76.304771,3.962 +vhs,1500,1,none,-,33.774489,0.930796,75.017175,3.043 +vhs,1500,1,after,qed,33.853030,0.933238,75.076991,1.711 +vhs,1500,1,after,simple,33.851846,0.933941,75.039426,2.105 +vhs,1500,1,woven,qed,33.846001,0.933049,75.058697,1.793 +vhs,1500,1,woven,simple,33.832491,0.933081,75.051887,1.965 +vhs,1500,1,field,qed,33.872058,0.933678,75.109753,1.764 +vhs,1500,1,field,simple,33.856723,0.933791,75.048185,1.936 +vhs,1500,0,none,-,33.011580,0.910276,79.283296,7.222 +vhs,1500,0,woven,qed,33.080259,0.912212,79.332981,2.749 +vhs,1500,0,woven,simple,33.068758,0.912253,79.296173,3.427 +vhs,1500,0,field,qed,33.109110,0.913569,79.349490,2.701 +vhs,1500,0,field,simple,33.095036,0.913872,79.295004,3.415 +vhs,3000,1,none,-,36.950600,0.955190,88.433029,2.448 +vhs,3000,1,after,qed,37.067426,0.957199,88.465138,1.446 +vhs,3000,1,after,simple,37.087383,0.958191,88.446071,1.819 +vhs,3000,1,woven,qed,37.046150,0.956794,88.457389,1.516 +vhs,3000,1,woven,simple,37.020243,0.957084,88.412584,1.673 +vhs,3000,1,field,qed,37.094309,0.957404,88.496608,1.498 +vhs,3000,1,field,simple,37.078244,0.957660,88.463401,1.643 +vhs,3000,0,none,-,35.999047,0.937871,91.400423,5.459 +vhs,3000,0,woven,qed,36.095035,0.938918,91.434689,2.018 +vhs,3000,0,woven,simple,36.092955,0.939220,91.418083,2.499 +vhs,3000,0,field,qed,36.145404,0.940569,91.438266,2.018 +vhs,3000,0,field,simple,36.140845,0.941025,91.423194,2.505 diff --git a/docs/reports/2026-11-deblock-order/results_dct.csv b/docs/reports/2026-11-deblock-order/results_dct.csv new file mode 100644 index 0000000..54c51f8 --- /dev/null +++ b/docs/reports/2026-11-deblock-order/results_dct.csv @@ -0,0 +1,11 @@ +fixture,bitrate,deint,order,method,psnr_y,ssim_y,vmaf,blockiness +film,1000,1,after,dct,36.087225,0.932834,,5.159 +film,1000,1,woven,dct,31.750095,0.911100,,4.905 +film,1000,1,field,dct,36.097127,0.932969,,5.037 +film,1000,0,woven,dct,31.711826,0.912127,,9.747 +film,1000,0,field,dct,35.513658,0.932561,,10.605 +vhs,1000,1,after,dct,33.242297,0.924122,,3.547 +vhs,1000,1,woven,dct,31.212136,0.910179,,3.598 +vhs,1000,1,field,dct,33.078823,0.923329,,3.418 +vhs,1000,0,woven,dct,30.894579,0.887540,,8.857 +vhs,1000,0,field,dct,32.487243,0.902224,,9.415 diff --git a/docs/reports/2026-11-deblock-order/results_strong.csv b/docs/reports/2026-11-deblock-order/results_strong.csv new file mode 100644 index 0000000..7893cb7 --- /dev/null +++ b/docs/reports/2026-11-deblock-order/results_strong.csv @@ -0,0 +1,11 @@ +fixture,bitrate,deint,order,method,psnr_y,ssim_y,vmaf,blockiness +film,1000,1,after,qed-36/44,36.498728,0.940270,,1.923 +film,1000,1,woven,qed-36/44,36.457369,0.939695,,1.898 +film,1000,1,field,qed-36/44,36.639402,0.942347,,1.899 +film,1000,0,woven,qed-36/44,35.865160,0.937543,,2.233 +film,1000,0,field,qed-36/44,36.163329,0.941622,,2.674 +vhs,1000,1,after,qed-36/44,33.377483,0.927705,,1.634 +vhs,1000,1,woven,qed-36/44,33.363029,0.927167,,1.673 +vhs,1000,1,field,qed-36/44,33.426799,0.928567,,1.630 +vhs,1000,0,woven,qed-36/44,32.632493,0.905075,,2.065 +vhs,1000,0,field,qed-36/44,32.713361,0.907754,,2.244 diff --git a/docs/reports/2026-11-deblock-order/scripts/dct.sh b/docs/reports/2026-11-deblock-order/scripts/dct.sh new file mode 100755 index 0000000..ad06bde --- /dev/null +++ b/docs/reports/2026-11-deblock-order/scripts/dct.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# The order comparison for DCTFilter (the third Deblock method, at the app's +# default curve), on the 1000 kbps fixtures. Added after the main run, when +# per-field deblocking was being built for all three methods. +# Writes $SCRATCH/results_dct.csv. Run run.sh first (it builds vhs_ref). +set -e +HERE="$(cd "$(dirname "$0")" && pwd)" +cd "$HERE/../../../.." +S="${SCRATCH:?set SCRATCH to your scratch directory}" +FF=deps/macos-arm64/ffmpeg/ffmpeg +VSPIPE=deps/macos-arm64/vapoursynth/vspipe +CSV="$S/results_dct.csv" +echo "fixture,bitrate,deint,order,method,psnr_y,ssim_y,vmaf,blockiness" > "$CSV" +for fx in film vhs; do + in="$S/fx/${fx}_1000.yuv"; nf=$(( $(stat -f %z "$in") / 622080 )) + for d in 1 0; do + if [ $d = 1 ]; then ref="$S/fx/${fx}_ref.yuv"; fps=50; orders="after woven field" + else ref="$S/fx/${fx}_clean.yuv"; fps=25; orders="woven field"; fi + for o in $orders; do + out="$S/out/${fx}_1000_${o}_dct_d${d}.y4m" + env TPL="$PWD/worker/templates" NF=$nf ORDER=$o METHOD=dct DEINT=$d \ + $VSPIPE -c y4m "$HERE/probe.vpy" "$out" < "$in" 2>"$out.log" + m=$($FF -v info -i "$out" -f rawvideo -pix_fmt yuv420p -s 720x576 -framerate $fps -i "$ref" -lavfi \ + "[0:v]split[a0][a1];[1:v]split[b0][b1];[a0]extractplanes=y[ay];[b0]extractplanes=y[by];[ay][by]psnr[p];[p]nullsink;\ + [a1]extractplanes=y[a2];[b1]extractplanes=y[b2];[a2][b2]ssim" -f null - 2>&1) + psnr=$(grep -oE 'PSNR y:[0-9.]+' <<<"$m" | head -1 | cut -d: -f2) + ssim=$(grep -oE 'SSIM Y:[0-9.]+' <<<"$m" | head -1 | cut -d: -f2) + blk=$($FF -v error -i "$out" -vf blockdetect,metadata=print:file=- -f null - 2>/dev/null \ + | grep 'block=' | awk -F= '{s+=$2;n++} END {printf "%.3f", s/n}') + echo "$fx,1000,$d,$o,dct,$psnr,$ssim,,$blk" | tee -a "$CSV" + done + done +done diff --git a/docs/reports/2026-11-deblock-order/scripts/imgs.sh b/docs/reports/2026-11-deblock-order/scripts/imgs.sh new file mode 100755 index 0000000..0386f75 --- /dev/null +++ b/docs/reports/2026-11-deblock-order/scripts/imgs.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Renders the report's images from run.sh's outputs into ../img. +# All crops are 3x nearest-neighbour, so every pixel shown is a real one. +set -e +HERE="$(cd "$(dirname "$0")" && pwd)" +cd "$HERE/../../../.." +S="${SCRATCH:?set SCRATCH to your scratch directory}" +FF="deps/macos-arm64/ffmpeg/ffmpeg -v error -y" +IMG="$HERE/../img" +mkdir -p "$IMG" +REFOPTS="-f rawvideo -pix_fmt yuv420p -s 720x576" + +# order_strip FIXTURE RATE FRAME CROP OUT [EXTRA_FILTER] +# Panels: reference | no deblock | after QTGMC | woven, before | per field, before +order_strip() { + local C="select=eq(n\,$3),crop=$4,scale=iw*3:ih*3:flags=neighbor${6:+,$6}" + $FF $REFOPTS -framerate 50 -i "$S/fx/$1_ref.yuv" \ + -i "$S/out/$1_$2_none_qed_d1.y4m" -i "$S/out/$1_$2_after_qed_d1.y4m" \ + -i "$S/out/$1_$2_woven_qed_d1.y4m" -i "$S/out/$1_$2_field_qed_d1.y4m" \ + -filter_complex "[0:v]$C[a];[1:v]$C[b];[2:v]$C[c];[3:v]$C[d];[4:v]$C[e];[a][b][c][d][e]hstack=5" \ + -frames:v 1 "$5" +} + +# 01: the decoded MPEG-2 input, woven, beside the clean interlaced frame +C1="select=eq(n\,51),crop=240:192:120:0,scale=iw*2:ih*2:flags=neighbor" +$FF $REFOPTS -framerate 25 -i "$S/fx/vhs_clean.yuv" $REFOPTS -framerate 25 -i "$S/fx/vhs_1000.yuv" \ + -filter_complex "[0:v]$C1[a];[1:v]$C1[b];[a][b]hstack" -frames:v 1 "$IMG/01-vhs-input-clean-vs-1000k.png" + +order_strip vhs 1000 103 120:96:120:0 "$IMG/02-vhs-1000k-orders.png" +# The film crop is a near-black coat: luma x4 (same for every panel) so the blocks show. +order_strip film 1000 187 120:96:400:330 "$IMG/03-film-1000k-orders.png" "lutyuv=y='min(val*4\\,255)'" + +# 04: |output - reference| x8, after vs per field, VHS frame 91 (field's best frame) +E="select=eq(n\,91),format=yuv420p" +$FF -i "$S/out/vhs_1000_after_qed_d1.y4m" -i "$S/out/vhs_1000_field_qed_d1.y4m" \ + $REFOPTS -framerate 50 -i "$S/fx/vhs_ref.yuv" \ + -filter_complex "[2:v]$E,split[r1][r2];[0:v]$E[a];[1:v]$E[f];\ +[a][r1]blend=all_mode=difference,extractplanes=y,lut=y='min(val*8\,255)'[ea];\ +[f][r2]blend=all_mode=difference,extractplanes=y,lut=y='min(val*8\,255)'[ef];[ea][ef]hstack" \ + -frames:v 1 "$IMG/04-vhs-frame91-error-x8.png" + +# 05: deinterlacing off, woven frame: no deblock | woven | per field (crop of moving area) +C5="select=eq(n\,45),crop=160:96:280:200,scale=iw*3:ih*3:flags=neighbor" +$FF $REFOPTS -framerate 25 -i "$S/fx/vhs_clean.yuv" -i "$S/out/vhs_1000_none_qed_d0.y4m" \ + -i "$S/out/vhs_1000_woven_qed_d0.y4m" -i "$S/out/vhs_1000_field_qed_d0.y4m" \ + -filter_complex "[0:v]$C5[a];[1:v]$C5[b];[2:v]$C5[c];[3:v]$C5[d];[a][b][c][d]hstack=4" \ + -frames:v 1 "$IMG/05-vhs-interlaced-out-orders.png" + + +# 06: DCTFilter, deinterlacing off: |woven - clean| x8 | |per field - clean| x8, +# frame 45, whole frame. Needs dct.sh's outputs. +if [ -f "$S/out/vhs_1000_field_dct_d0.y4m" ]; then + E6="select=eq(n\,45),format=yuv420p" + $FF -i "$S/out/vhs_1000_woven_dct_d0.y4m" -i "$S/out/vhs_1000_field_dct_d0.y4m" \ + $REFOPTS -framerate 25 -i "$S/fx/vhs_clean.yuv" \ + -filter_complex "[2:v]$E6,split[r1][r2];[0:v]$E6[w];[1:v]$E6[f];\ +[w][r1]blend=all_mode=difference,extractplanes=y,lut=y='min(val*8\,255)'[ew];\ +[f][r2]blend=all_mode=difference,extractplanes=y,lut=y='min(val*8\,255)'[ef];[ew][ef]hstack" \ + -frames:v 1 "$IMG/06-vhs-dctfilter-error-x8.png" +fi +ls -la "$IMG" diff --git a/docs/reports/2026-11-deblock-order/scripts/make_fixtures.sh b/docs/reports/2026-11-deblock-order/scripts/make_fixtures.sh new file mode 100755 index 0000000..e041686 --- /dev/null +++ b/docs/reports/2026-11-deblock-order/scripts/make_fixtures.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Builds the fixtures for the deblock-order probe into $SCRATCH/fx. +# +# film - 240 frames of Tests/TestResources/small_clip.mp4 (frames 1400-1639), +# treated as 50 Hz field-time samples, upscaled to 720x576 and woven +# into 120 TFF interlaced frames. ref.yuv is the 50p ground truth. +# vhs - Tests/TestResources/interlaced_test.avi, a real 720x576 TFF capture +# stored losslessly (Lagarith). No ground truth exists for it. +# +# Each clean interlaced clip is then encoded to MPEG-2 the way a DVD or a +# capture card would (interlaced DCT + interlaced motion estimation, TFF, 4:2:0) +# at each bitrate in $RATES, and decoded back to raw. Everything is yuv420p. +set -e +HERE="$(cd "$(dirname "$0")" && pwd)" +cd "$HERE/../../../.." +S="${SCRATCH:?set SCRATCH to your scratch directory}" +FF="deps/macos-arm64/ffmpeg/ffmpeg -v error -y" +RATES="${RATES:-1000 1500 3000}" +mkdir -p "$S/fx" + +# film: progressive 50p truth, then woven to 25i +$FF -i Tests/TestResources/small_clip.mp4 \ + -vf "select=between(n\,1400\,1639),setpts=N/50/TB,scale=720:576:flags=spline,format=yuv420p" \ + -fps_mode passthrough -f rawvideo "$S/fx/film_ref.yuv" +$FF -f rawvideo -pix_fmt yuv420p -s 720x576 -framerate 50 -i "$S/fx/film_ref.yuv" \ + -vf interlace=scan=tff:lowpass=0 -fps_mode passthrough -f rawvideo "$S/fx/film_clean.yuv" + +# vhs: 4:2:2 -> 4:2:0 resampled per field (interl=1), as a DVD authoring tool would +$FF -i Tests/TestResources/interlaced_test.avi \ + -vf "scale=interl=1,format=yuv420p" -f rawvideo "$S/fx/vhs_clean.yuv" + +for fx in film vhs; do + for r in $RATES; do + $FF -f rawvideo -pix_fmt yuv420p -s 720x576 -framerate 25 -i "$S/fx/${fx}_clean.yuv" \ + -vf setfield=tff -c:v mpeg2video -flags +ildct+ilme -g 12 -bf 2 \ + -b:v ${r}k -maxrate ${r}k -bufsize $((r * 2))k \ + "$S/fx/${fx}_${r}.m2v" + $FF -i "$S/fx/${fx}_${r}.m2v" -f rawvideo -pix_fmt yuv420p "$S/fx/${fx}_${r}.yuv" + done +done +ls -la "$S/fx" diff --git a/docs/reports/2026-11-deblock-order/scripts/probe.vpy b/docs/reports/2026-11-deblock-order/scripts/probe.vpy new file mode 100644 index 0000000..e788954 --- /dev/null +++ b/docs/reports/2026-11-deblock-order/scripts/probe.vpy @@ -0,0 +1,55 @@ +# One variant of the deblock-order probe. Reads raw 720x576 yuv420p TFF +# interlaced frames from stdin through the app's own pipe_source.py. +# +# Environment: +# TPL worker/templates (for pipe_source) +# NF frame count +# ORDER none | after | woven | field +# none - no deblocking +# after - deblock the deinterlaced output (VapourBox today) +# woven - deblock the woven interlaced frame, then deinterlace +# field - SeparateFields -> deblock -> Weave, then deinterlace +# METHOD qed | simple | dct (haf.Deblock_QED / core.deblock.Deblock / +# core.dctf.DCTFilter with the app's default curve: cutoff 5, +# strength 0.6, luma only) +# DEINT 1 = QTGMC Slower double rate (default), 0 = leave interlaced +# Q1, Q2 quant1 / quant2 (defaults 24 / 26, the app's defaults) +import os, sys +sys.path.insert(0, os.environ['TPL']) +from pipe_source import create_pipe_clip +import vapoursynth as vs +import havsfunc as haf +core = vs.core + +order = os.environ.get('ORDER', 'none') +method = os.environ.get('METHOD', 'qed') +deint = os.environ.get('DEINT', '1') == '1' +q1 = int(os.environ.get('Q1', '24')) +q2 = int(os.environ.get('Q2', '26')) + + +def deblock(c): + if method == 'qed': + return haf.Deblock_QED(c, quant1=q1, quant2=q2) + if method == 'dct': + return core.dctf.DCTFilter(c, factors=[1, 1, 1, 1, 1, 1, 0.7, 0.4], planes=[0]) + return core.deblock.Deblock(c, quant=q1) + + +clip = create_pipe_clip(720, 576, int(os.environ['NF']), 25, 1, 'yuv420p') +clip = core.std.SetFieldBased(clip, 2) + +if order == 'woven': + clip = deblock(clip) +elif order == 'field': + fields = core.std.SeparateFields(clip, tff=True) + clip = core.std.DoubleWeave(deblock(fields), tff=True)[::2] + clip = core.std.SetFieldBased(clip, 2) + +if deint: + clip = haf.QTGMC(clip, Preset='Slower', TFF=True) + +if order == 'after': + clip = deblock(clip) + +clip.set_output() diff --git a/docs/reports/2026-11-deblock-order/scripts/run.sh b/docs/reports/2026-11-deblock-order/scripts/run.sh new file mode 100755 index 0000000..d9155d7 --- /dev/null +++ b/docs/reports/2026-11-deblock-order/scripts/run.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Runs every variant of the deblock-order probe over the fixtures built by +# make_fixtures.sh and writes $SCRATCH/results.csv. +# +# Reference each output is scored against: +# deinterlaced, film - the true 50p frames (film_ref.yuv) +# deinterlaced, vhs - QTGMC Slower of the lossless capture (no true 50p exists) +# left interlaced - the clean interlaced frames before MPEG-2 (_clean.yuv) +# +# Metrics, all luma except VMAF: PSNR, SSIM, VMAF (libvmaf default model) and +# ffmpeg's blockdetect (mean per-frame blockiness of the output; lower = fewer +# visible 8x8 edges, measured on the output's own 8-pixel grid). +set -e +HERE="$(cd "$(dirname "$0")" && pwd)" +cd "$HERE/../../../.." +S="${SCRATCH:?set SCRATCH to your scratch directory}" +FF=deps/macos-arm64/ffmpeg/ffmpeg +VSPIPE=deps/macos-arm64/vapoursynth/vspipe +RATES="${RATES:-1000 1500 3000}" +TPL="$PWD/worker/templates" +mkdir -p "$S/out" + +frames() { echo $(( $(stat -f %z "$1") / 622080 )); } # 720*576*1.5 + +# score OUT.y4m REF.yuv FPS -> "psnr,ssim,vmaf,block" +score() { + local m + m=$($FF -v info -i "$1" -f rawvideo -pix_fmt yuv420p -s 720x576 -framerate "$3" -i "$2" -lavfi \ + "[0:v]split=3[a0][a1][a2];[1:v]split=3[b0][b1][b2];\ + [a0]extractplanes=y[ay];[b0]extractplanes=y[by];[ay][by]psnr[p];[p]nullsink;\ + [a1]extractplanes=y[ay2];[b1]extractplanes=y[by2];[ay2][by2]ssim[s];[s]nullsink;\ + [a2][b2]libvmaf=n_threads=8" -f null - 2>&1) + local psnr ssim vmaf blk + psnr=$(grep -oE 'PSNR y:[0-9.inf]+' <<<"$m" | head -1 | cut -d: -f2) + ssim=$(grep -oE 'SSIM Y:[0-9.]+' <<<"$m" | head -1 | cut -d: -f2) + vmaf=$(grep -oE 'VMAF score: [0-9.]+' <<<"$m" | head -1 | awk '{print $3}') + blk=$($FF -v error -i "$1" -vf blockdetect,metadata=print:file=- -f null - 2>/dev/null \ + | grep 'block=' | awk -F= '{s+=$2;n++} END {printf "%.3f", s/n}') + echo "$psnr,$ssim,$vmaf,$blk" +} + +# run IN.yuv OUT.y4m ORDER METHOD DEINT +run() { + env TPL="$TPL" NF=$(frames "$1") ORDER=$3 METHOD=$4 DEINT=$5 \ + $VSPIPE -c y4m "$HERE/probe.vpy" "$2" < "$1" 2>"$2.log" \ + || { echo "FAILED: $2" >&2; tail -5 "$2.log" >&2; exit 1; } +} + +# The vhs deinterlaced reference: QTGMC of the lossless capture. +run "$S/fx/vhs_clean.yuv" "$S/out/vhs_clean_ref.y4m" none qed 1 +$FF -v error -y -i "$S/out/vhs_clean_ref.y4m" -f rawvideo -pix_fmt yuv420p "$S/fx/vhs_ref.yuv" + +CSV="$S/results.csv" +echo "fixture,bitrate,deint,order,method,psnr_y,ssim_y,vmaf,blockiness" > "$CSV" + +for fx in film vhs; do + # Upper bound: the uncompressed interlaced source, deinterlaced. + run "$S/fx/${fx}_clean.yuv" "$S/out/${fx}_clean_none_d1.y4m" none qed 1 + echo "$fx,clean,1,none,-,$(score "$S/out/${fx}_clean_none_d1.y4m" "$S/fx/${fx}_ref.yuv" 50)" | tee -a "$CSV" + for r in $RATES; do + in="$S/fx/${fx}_${r}.yuv" + for d in 1 0; do + if [ $d = 1 ]; then ref="$S/fx/${fx}_ref.yuv"; fps=50; orders="none after woven field" + else ref="$S/fx/${fx}_clean.yuv"; fps=25; orders="none woven field"; fi + for o in $orders; do + for m in qed simple; do + [ $o = none ] && [ $m = simple ] && continue + out="$S/out/${fx}_${r}_${o}_${m}_d${d}.y4m" + run "$in" "$out" $o $m $d + echo "$fx,$r,$d,$o,$([ $o = none ] && echo - || echo $m),$(score "$out" "$ref" $fps)" | tee -a "$CSV" + done + done + done + done +done diff --git a/docs/reports/2026-11-deblock-order/scripts/strong.sh b/docs/reports/2026-11-deblock-order/scripts/strong.sh new file mode 100755 index 0000000..ff96b54 --- /dev/null +++ b/docs/reports/2026-11-deblock-order/scripts/strong.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# The order comparison again at a much stronger Deblock_QED setting +# (quant1=36, quant2=44, against the app's 24/26), on the 1000 kbps fixtures, +# to see whether order starts to matter once the deblocker is doing more. +# Appends to $SCRATCH/results_strong.csv. Run run.sh first (it builds vhs_ref). +set -e +HERE="$(cd "$(dirname "$0")" && pwd)" +cd "$HERE/../../../.." +S="${SCRATCH:?set SCRATCH to your scratch directory}" +FF=deps/macos-arm64/ffmpeg/ffmpeg +VSPIPE=deps/macos-arm64/vapoursynth/vspipe +CSV="$S/results_strong.csv" +echo "fixture,bitrate,deint,order,method,psnr_y,ssim_y,vmaf,blockiness" > "$CSV" +for fx in film vhs; do + in="$S/fx/${fx}_1000.yuv"; nf=$(( $(stat -f %z "$in") / 622080 )) + for d in 1 0; do + if [ $d = 1 ]; then ref="$S/fx/${fx}_ref.yuv"; fps=50; orders="after woven field" + else ref="$S/fx/${fx}_clean.yuv"; fps=25; orders="woven field"; fi + for o in $orders; do + out="$S/out/${fx}_1000_${o}_qedstrong_d${d}.y4m" + env TPL="$PWD/worker/templates" NF=$nf ORDER=$o METHOD=qed DEINT=$d Q1=36 Q2=44 \ + $VSPIPE -c y4m "$HERE/probe.vpy" "$out" < "$in" 2>"$out.log" + m=$($FF -v info -i "$out" -f rawvideo -pix_fmt yuv420p -s 720x576 -framerate $fps -i "$ref" -lavfi \ + "[0:v]split[a0][a1];[1:v]split[b0][b1];[a0]extractplanes=y[ay];[b0]extractplanes=y[by];[ay][by]psnr[p];[p]nullsink;\ + [a1]extractplanes=y[a2];[b1]extractplanes=y[b2];[a2][b2]ssim" -f null - 2>&1) + psnr=$(grep -oE 'PSNR y:[0-9.]+' <<<"$m" | head -1 | cut -d: -f2) + ssim=$(grep -oE 'SSIM Y:[0-9.]+' <<<"$m" | head -1 | cut -d: -f2) + blk=$($FF -v error -i "$out" -vf blockdetect,metadata=print:file=- -f null - 2>/dev/null \ + | grep 'block=' | awk -F= '{s+=$2;n++} END {printf "%.3f", s/n}') + echo "$fx,1000,$d,$o,qed-36/44,$psnr,$ssim,,$blk" | tee -a "$CSV" + done + done +done diff --git a/docs/reports/2026-11-deblock-order/scripts/tables.py b/docs/reports/2026-11-deblock-order/scripts/tables.py new file mode 100644 index 0000000..c8f7928 --- /dev/null +++ b/docs/reports/2026-11-deblock-order/scripts/tables.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Prints the report's result tables as HTML, from ../results.csv, +../results_strong.csv and ../results_dct.csv (copies of run.sh's, strong.sh's +and dct.sh's output). + +usage: python3 -I tables.py > tables.html +""" +import csv +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) + +ORDER_LABEL = { + 'none': 'no deblock', + 'after': 'after QTGMC (today)', + 'woven': 'woven frame, before', + 'field': 'per field, before', +} + + +def load(name): + with open(os.path.join(ROOT, name)) as f: + return list(csv.DictReader(f)) + + +def table(rows, deint, method, caption, with_vmaf=True, base_rows=None): + base_rows = [r for r in (base_rows or rows) if r['deint'] == deint] + rows = [r for r in rows if r['deint'] == deint and r['bitrate'] != 'clean' + and (r['method'] in (method, '-'))] + out = [f'', + '' + '' + ('' if with_vmaf else '') + + ''] + base = {} + for r in base_rows: + if r['order'] == 'none': + base[(r['fixture'], r['bitrate'])] = float(r['psnr_y']) + best = {} + for r in rows: + k = (r['fixture'], r['bitrate']) + if r['order'] != 'none': + best[k] = max(best.get(k, -1), float(r['psnr_y'])) + for r in rows: + k = (r['fixture'], r['bitrate']) + p = float(r['psnr_y']) + d = f'{p - base[k]:+.2f}' if k in base and r['order'] != 'none' else '' + cls = ' class="best"' if r['order'] != 'none' and p == best.get(k) else '' + out.append( + f'' + f'' + f'' + + (f'' if with_vmaf else '') + + f'') + out.append('
{caption}
FixturekbpsOrderPSNR-Y dBΔ vs noneSSIM-YVMAFBlockiness
{r["fixture"]}{r["bitrate"]}{ORDER_LABEL[r["order"]]}{p:.2f}{d}{float(r["ssim_y"]):.4f}{float(r["vmaf"]):.2f}{float(r["blockiness"]):.2f}
') + return '\n'.join(out) + + +main = load('results.csv') +strong = load('results_strong.csv') +print('') +print(table(main, '1', 'qed', 'Deinterlaced (QTGMC Slower), Deblock_QED 24/26')) +print('') +print(table(main, '0', 'qed', 'Left interlaced, Deblock_QED 24/26')) +print('') +print(table(main, '1', 'simple', 'Deinterlaced, Deblock (simple) quant 24')) +print('') +print(table(strong, '1', 'qed-36/44', 'Deinterlaced, Deblock_QED 36/44 (strong), 1000 kbps', + with_vmaf=False, base_rows=main)) +print(table(strong, '0', 'qed-36/44', 'Left interlaced, Deblock_QED 36/44 (strong), 1000 kbps', + with_vmaf=False, base_rows=main)) +dct = load('results_dct.csv') +print('') +print(table(dct, '0', 'dct', 'Left interlaced, DCTFilter (app default curve), 1000 kbps', + with_vmaf=False, base_rows=main)) +print(table(dct, '1', 'dct', 'Deinterlaced, DCTFilter (app default curve), 1000 kbps', + with_vmaf=False, base_rows=main)) diff --git a/docs/reports/README.md b/docs/reports/README.md index 6ac174a..252af0c 100644 --- a/docs/reports/README.md +++ b/docs/reports/README.md @@ -66,4 +66,5 @@ to it. | Date | Report | Outcome | |---|---|---| +| 2026-11-03 | [Deblock before or after deinterlacing?](2026-11-deblock-order/index.html) | Keep Deblock after Deinterlace (≤0.05 dB gain from moving it); when deinterlacing is off, deblock per field rather than on the woven frame (+0.03 to +0.30 dB; DCTFilter on the woven frame lost 1.5–3.7 dB); built | | 2026-11-03 | [Issue #117: QTempGaussMC (vs-jetpack) as an alternate deinterlacer](issue-117-qtempgaussmc/index.html) | Worth adding as an advanced-only method (not macOS x64); +0.5–0.8 dB over havsfunc QTGMC Slower at equal speed once tuned; not built yet | diff --git a/worker/src/script_generator.rs b/worker/src/script_generator.rs index 86bc4e0..c8276e7 100644 --- a/worker/src/script_generator.rs +++ b/worker/src/script_generator.rs @@ -364,6 +364,17 @@ impl ScriptGenerator { } } + /// Whether the picture is still interlaced when it reaches the passes after + /// Deinterlace, and if so whether it is top field first: deinterlacing is + /// off and a field order was detected. Derived from `field_based_for`, so + /// it agrees with the `_FieldBased` the script stamps. + pub fn still_interlaced_tff(job: &VideoJob, pipeline: &ProcessingPipeline) -> Option { + if pipeline.deinterlace.enabled { + return None; + } + Self::field_based_for(job, pipeline).map(|fb| fb == 2) + } + /// Substitute parameters in a script string. fn substitute_parameters(&self, template: &str, job: &VideoJob, pipeline: &ProcessingPipeline, _input_path: &str) -> String { let mut script = template.to_string(); @@ -1479,6 +1490,20 @@ impl ScriptGenerator { script = script.replace("{{#DEBLOCK}}", ""); script = script.replace("{{/DEBLOCK}}", ""); + // Still interlaced: deblock each field on its own, never the woven + // frame (docs/reports/2026-11-deblock-order). Deinterlaced output + // is progressive and keeps the whole-frame path. + match Self::still_interlaced_tff(job, pipeline) { + Some(tff) => { + script = script.replace("{{#DEBLOCK_PER_FIELD}}", ""); + script = script.replace("{{/DEBLOCK_PER_FIELD}}", ""); + script = script.replace("{{DEBLOCK_TFF}}", if tff { "True" } else { "False" }); + } + None => { + script = remove_block("{{#DEBLOCK_PER_FIELD}}", "{{/DEBLOCK_PER_FIELD}}", script); + } + } + match deblock.method { DeblockMethod::DeblockQed => { script = script.replace("{{#DEBLOCK_QED}}", ""); @@ -2329,11 +2354,9 @@ impl ScriptGenerator { // A picture that is still interlaced must move down by whole field // pairs. Conservative: it is only off-centre by a line or two if // something else (IVTC) already made it progressive. - let still_interlaced = !pipeline.deinterlace.enabled - && Self::field_based_for(job, pipeline).is_some(); script = script.replace( "{{BORDER_FIELD_ALIGN}}", - if still_interlaced { "2" } else { "1" }, + if Self::still_interlaced_tff(job, pipeline).is_some() { "2" } else { "1" }, ); if resize.pads_to_aspect() { diff --git a/worker/templates/pipeline_template.vpy b/worker/templates/pipeline_template.vpy index 12628e6..e5fd6a1 100644 --- a/worker/templates/pipeline_template.vpy +++ b/worker/templates/pipeline_template.vpy @@ -1244,6 +1244,17 @@ clip = haf.HQDeringmod( # PASS 5: DEBLOCK # ============================================================================ {{#DEBLOCK}} +{{#DEBLOCK_PER_FIELD}} +# The picture is still interlaced (deinterlacing is off), so each field is +# deblocked on its own. On the woven frame every method filters vertically +# across both fields, blending them on moving edges -- DCTFilter scored 1.5 to +# 3.7 dB below no filtering at all (docs/reports/2026-11-deblock-order). A +# 4:2:0 frame whose field height is odd in chroma (486 lines) cannot be split +# into fields and keeps the woven-frame path. +_deblock_per_field = (clip.height >> clip.format.subsampling_h) % 2 == 0 +if _deblock_per_field: + clip = core.std.SeparateFields(clip, tff={{DEBLOCK_TFF}}) +{{/DEBLOCK_PER_FIELD}} {{#DEBLOCK_QED}} # Deblock_QED - Quality Enhanced Deblocking @@ -1289,6 +1300,10 @@ clip = core.dctf.DCTFilter( planes={{DEBLOCK_DCT_PLANES}}, ) {{/DEBLOCK_DCTFILTER}} +{{#DEBLOCK_PER_FIELD}} +if _deblock_per_field: + clip = core.std.DoubleWeave(clip, tff={{DEBLOCK_TFF}})[::2] +{{/DEBLOCK_PER_FIELD}} {{/DEBLOCK}} # ============================================================================ diff --git a/worker/templates/preview_template.vpy b/worker/templates/preview_template.vpy index d4df1b2..f5fa04b 100644 --- a/worker/templates/preview_template.vpy +++ b/worker/templates/preview_template.vpy @@ -1187,6 +1187,17 @@ clip = haf.HQDeringmod( # PASS 5: DEBLOCK # ============================================================================ {{#DEBLOCK}} +{{#DEBLOCK_PER_FIELD}} +# The picture is still interlaced (deinterlacing is off), so each field is +# deblocked on its own. On the woven frame every method filters vertically +# across both fields, blending them on moving edges -- DCTFilter scored 1.5 to +# 3.7 dB below no filtering at all (docs/reports/2026-11-deblock-order). A +# 4:2:0 frame whose field height is odd in chroma (486 lines) cannot be split +# into fields and keeps the woven-frame path. +_deblock_per_field = (clip.height >> clip.format.subsampling_h) % 2 == 0 +if _deblock_per_field: + clip = core.std.SeparateFields(clip, tff={{DEBLOCK_TFF}}) +{{/DEBLOCK_PER_FIELD}} {{#DEBLOCK_QED}} # Deblock_QED - Quality Enhanced Deblocking @@ -1232,6 +1243,10 @@ clip = core.dctf.DCTFilter( planes={{DEBLOCK_DCT_PLANES}}, ) {{/DEBLOCK_DCTFILTER}} +{{#DEBLOCK_PER_FIELD}} +if _deblock_per_field: + clip = core.std.DoubleWeave(clip, tff={{DEBLOCK_TFF}})[::2] +{{/DEBLOCK_PER_FIELD}} {{/DEBLOCK}} # ============================================================================ diff --git a/worker/tests/filter_integration_test.rs b/worker/tests/filter_integration_test.rs index 8433112..1f763d7 100644 --- a/worker/tests/filter_integration_test.rs +++ b/worker/tests/filter_integration_test.rs @@ -6793,3 +6793,64 @@ fn test_182_preview_retime_padding() { assert!(pad < retime, "{method:?}: padding after the retime"); } } + +/// Deblocking a picture that is still interlaced works on each field, never +/// the woven frame, for every method (docs/reports/2026-11-deblock-order: +/// DCTFilter on the woven frame scored 1.5-3.7 dB below no filtering). +/// Deinterlaced output, and a source with no detected field order, keep the +/// whole-frame path. Both scripts, in the order split -> filter -> weave. +#[test] +fn test_183_deblock_per_field_when_still_interlaced() { + create_output_dir(); + let job_for = |method: &DeblockMethod, deinterlace: bool, detected: Option| { + let mut job = create_base_job("test_183_deblock_per_field"); + job.detected_field_order = detected; + job.qtgmc_parameters.enabled = deinterlace; + job.qtgmc_parameters.tff = Some(true); + job.processing_pipeline = Some(ProcessingPipeline { + deinterlace: job.qtgmc_parameters.clone(), + deblock: DeblockParameters { enabled: true, method: method.clone(), ..DeblockParameters::default() }, + ..ProcessingPipeline::default() + }); + job + }; + let call = |m: &DeblockMethod| match m { + DeblockMethod::DeblockQed => "haf.Deblock_QED(", + DeblockMethod::Deblock => "core.deblock.Deblock(", + DeblockMethod::DctFilter => "core.dctf.DCTFilter(", + }; + + for method in &[DeblockMethod::DeblockQed, DeblockMethod::Deblock, DeblockMethod::DctFilter] { + for (detected, tff) in [ + (FieldOrder::TopFieldFirst, "True"), + (FieldOrder::BottomFieldFirst, "False"), + ] { + let (encode, preview) = generate_both_scripts(&job_for(method, false, Some(detected))); + for (name, script) in [("encode", &encode), ("preview", &preview)] { + let split = script + .find(&format!("clip = core.std.SeparateFields(clip, tff={tff})")) + .unwrap_or_else(|| panic!("{method:?} {detected:?} {name}: no field split")); + let filter = script.find(call(method)).expect("deblock call"); + let weave = script + .find(&format!("clip = core.std.DoubleWeave(clip, tff={tff})[::2]")) + .unwrap_or_else(|| panic!("{method:?} {detected:?} {name}: no weave")); + assert!(split < filter && filter < weave, "{method:?} {name}: wrong order"); + assert!(script.contains("_deblock_per_field = (clip.height >> clip.format.subsampling_h) % 2 == 0")); + assert!(!script.contains("{{DEBLOCK"), "{method:?} {name}: placeholder left"); + } + } + + // Deinterlaced (progressive by then), or nothing says it is interlaced. + for job in [ + job_for(method, true, Some(FieldOrder::TopFieldFirst)), + job_for(method, false, None), + ] { + let (encode, preview) = generate_both_scripts(&job); + for script in [&encode, &preview] { + assert!(script.contains(call(method)), "{method:?}: deblock missing"); + assert!(!script.contains("_deblock_per_field"), "{method:?}: per-field on progressive"); + assert!(!script.contains("{{DEBLOCK"), "{method:?}: placeholder left"); + } + } + } +}