Skip to content

Stabilize pass: gate weave and vertical pitch correction (#150) - #152

Merged
StuartCameronCode merged 3 commits into
mainfrom
agentbob/8-stabiliser
Nov 10, 2026
Merged

StuartCameronCode merged 3 commits into
mainfrom
agentbob/8-stabiliser

Conversation

@StuartCameronCode

@StuartCameronCode StuartCameronCode commented Nov 10, 2026 •

Copy link
Copy Markdown
Owner

Rebased onto main (2026-11-10). This branch had been cut from a stale main. Main has since gained its own Stabilize pass (haf.Stab with dxmax/dymax/mirror, f5a29ee). Rather than add a second pass, this PR now extends main's pass: main's haf.Stab weave correction stays, with a new weave toggle (default on). This PR's vertical pitch correction (off/auto/file, per-job pitch file, frame offsets, declared radius) is added alongside it. Old presets and jobs generate a byte-identical Stabilize block (test_212). Rust tests are renumbered test_211–test_218. The original description below covers the earlier standalone version, so read its file and test names against the new commit.

Closes #150.

Summary

Adds a Stabilize pass for film gate stabilisation of 8mm / Super 8 / 16mm scans. It runs after Deinterlace and before DeScratch. The logic lives in worker/templates/stabilize.py, which both templates import, so the preview and the encode can't drift apart.

It makes two independent corrections:

  • Gate weave: havsfunc's Stab technique (mv.DepanEstimate / DepanCompensate against a 15-frame average). RGB is carried through 4:4:4 YUV and float through 16-bit, because Depan rejects RGB and rgvs.Repair rejects float.
  • Vertical pitch ("frame height breathing"): resamples each frame sub-pixel about its centre at a constant frame size (src_top/src_height). The approach in the issue rounded to even pixels and padded back, which turns any real breathing correction into a no-op.
    • auto: measures scale and shift between adjacent frames by grid search on a luma strip, then corrects each frame to the mean of its ±radius window. Pairs that still differ by more than 3/255 after the best match are treated as cuts.
    • file: one factor per source frame. It's per job (VideoJob.pitchFactorsPath), never per pipeline, so it can't leak into presets. <video>.pitch.txt, <video ext>.pitch.txt and <stem>_pitch.txt sidecars are picked up automatically. A missing or malformed file is an error that names the bad line.

It also wires up every step of the CLAUDE.md "Adding a New Filter" checklist: schema and manifest, Rust and Dart models, converter, the four UI sites, both templates and both test suites. stabilize.py is added to all five packaging and debug scripts, and CLAUDE.md and README are updated.

Testing

  • cargo test: all pass, including the new test_92…test_98 (script generation, frame offsets, errors, cache size).
  • flutter test --exclude-tags heavy: 273 pass.
  • flutter test --tags heavy test/integration_stabilize_test.dart: 5 pass. The test injects known breathing using file mode, then measures the error against the undistorted original:
    • File mode: 0.329 → 0.077
    • Auto mode: 0.329 → 0.168
    • Preview: 0.611 → 0.130
  • flutter analyze reports no new problems. The one existing error, in noise_reduction_settings_panel.dart, is in a file this branch doesn't touch.

Review closely

  • Frame offsets. Each per-frame lookup adds the clip's source-frame offset: the preview window's first, a trimmed encode's start_frame, and the rate change from double-rate QTGMC or IVTC (rate_before_stabilize). If any of these is wrong, every frame silently gets another frame's factor.
  • Declared radius (StabilizeParameters::radius()). The review measured the frames actually requested under vspipe: translation reads n−8…n+9, so TRANSLATE_RADIUS is now 9 (commit 9ef6b61). This value sizes both the preview decode window and pipe_source's cache (pipe_cache_frames). If it's too small, pipe_source silently returns the wrong frame.
  • Auto mode removes about half of the injected breathing, and file mode about three quarters. That's expected for a method that compares each frame with its neighbours, and worth stating in the release notes.
  • In file mode, a queued video with no factor file fails only when its job runs. The settings panel warns about it, but only for the selected video.
  • Minor: QueueItem.copyWith(pitchFactorsPath: null) can't clear the path. Nothing relies on it.

@StuartCameronCode
StuartCameronCode marked this pull request as ready for review November 10, 2026 07:14
…ave (#149)

The comparison drew both sides at the source's display aspect, so any pass
that reshaped the frame (resize, borders, crop, rotate, square pixels, a
forced DAR) previewed distorted. A 720x576 PAL 4:3 capture resized to
1440x1080 and bordered out to 1920x1080 came out squashed into 4:3.

- Worker: ProcessingPipeline::output_aspect is the single aspect decision,
  used by the encode's setsar/setdar and reported by the preview on its
  PREVIEW_INFO line as sar=N/D or dar=N/D.
- App: PreviewFrameInfo parses it. The processed aspect is stored with the
  processed image on the QueueItem. BeforeAfterComparisonWidget takes a
  separate before/after aspect, and 1:1 is measured against the processed one.
- The preview job now carries inputSar (via the shared
  FieldOrderDetector.normalizeSar). Without it {{SOURCE_SAR}} fell back to 1
  and square-pixel fitting previewed a 1350-wide picture where the encode
  makes 1440.

Tests: worker unit tests, test_185, preview_frame_info_test,
preview_aspect_test, before_after_comparison_test, and a heavy preview group
in integration_aspect_ratio_test comparing the preview's picture extent
with the encode's.
… previews

- Move the comparison's per-side aspect choice into MainViewModel
  (sourceDisplayAspect, comparisonAfterAspect) and the preview-arrival
  storage into _acceptProcessedPreview, so both are testable without a worker.
- preview_aspect_test: an anamorphic source only denoised keeps 16:9 on both
  sides; the processed aspect survives a scrubber move (fails if computed from
  previewFrameInfo); falls back to the source's before a processed frame;
  cleared with a failed preview.
- integration_aspect_ratio_test (heavy): denoise-only 16:11 source and a
  rotated 16:15 source, preview aspect against the encode's.
…150)

Brings #152's frame-height "breathing" correction for pull-through film
scans into main's existing Stabilize pass instead of adding a second one.

The pass keeps its id, its position (last before framing) and its havsfunc
Stab weave correction (dxmax/dymax/mirror). Weave stays on main's haf.Stab
rather than #152's own copy of the Stab technique: #152 carried RGB through
4:4:4 and float through 16-bit for Depan/Repair, but at this pass's position
the clip is YUV integer, and Stab is already verified at 8-16 bit there.

New, all defaulting to today's behaviour:
- weave (default true) so pitch-only is possible
- pitchMode off (default) / auto / file, maxPitchDeviation, smoothingRadius,
  invertFactors, pitchEdgeFill
- VideoJob.pitchFactorsPath: per-job factor file (never per preset), with
  <video>.pitch.txt / <stem>.pitch.txt / <stem>_pitch.txt sidecar pickup in
  the app and a Choose/Clear row in the pass settings

Pitch logic lives in templates/stabilize.py (correct_pitch), imported by
both templates. A pipeline saved before this change deserialises to
weave-only and produces the same Stabilize block byte for byte (inner tags
are removed with their lines); test_212 pins it.

Frame bookkeeping:
- per-source-frame lookups add the preview window start
  (PreviewParams::source_frame_offset), the trim start, and the rate change
  of the passes ahead of Stabilize (rate_before_stabilize, composed from
  their FrameMaps)
- Stab's measured reach (n-8..n+9) is now declared as the pass's radius (it
  was treated as spatial), plus radius+1 for auto pitch; this sizes the
  preview window and pipe_source's cache (pipe_cache_frames, floor 40)
- file mode without a usable file is an error naming the bad line
- pitch on a still-interlaced clip clears _FieldBased; weave alone does not

Tests: Rust test_211-test_218 plus unit tests; Flutter converter, pitch file,
script-generation and high-bit-depth parity cases, and the heavy
integration_stabilize_test.dart ported from #152.
@brandoncornellwilliams

Copy link
Copy Markdown

Hello. I probably missed it. What does the .txt file content look like? Or is that auto-populated if the file exists alongside the video file? Thanks

@StuartCameronCode

Copy link
Copy Markdown
Owner Author

Hi, you didn't miss much. The PR description never shows the format, so here it is.

VapourBox doesn't create the file. You supply it, for example from your own analysis script. "Auto-populated" only means VapourBox finds it for you: when you add reel1.mp4 to the queue, it looks in the same folder for a file named reel1.pitch.txt (also accepted: reel1.mp4.pitch.txt or reel1_pitch.txt). If it finds one, it uses that file for that video. You can also pick a file by hand in the Stabilize panel, with Pitch Correction set to From File.

Format: the format from your proposal works as is: plain text, one factor per line, in frame order starting at frame 0.

# reel1 - comments like this are ignored
1.0000
1.0021
0.9987
1.0004

Or give the frame number with each factor. Any frame you don't list gets no correction:

0,1.0000
1,1.0021
2,0.9987
120,1.0035
  • The frame number and factor can be separated by a comma, semicolon, space or tab.
  • Blank lines and # comments are ignored.
  • Pick one form per file. Mixing bare factors and frame,factor lines is an error.
  • Every factor must be a positive number. A bad line fails the job with an error that names the line number, so one typo can't shift every later frame.
  • Frame numbers are source frames, so trimming or deinterlacing doesn't change them.

One thing to check: what the number means. By default, VapourBox reads each factor as the frame's measured height. 1.004 means the frame came out 0.4% too tall, so it gets shrunk back. In the script in your proposal, the factor was the stretch to apply (target_height = height * factor). If your log works that way, tick Factors Are Corrections. Otherwise every frame is corrected in the wrong direction and the breathing doubles. Each correction is capped by Max Pitch Correction (%), which defaults to 1%.

Two small differences from the proposal's script:

  • The resize is sub-pixel. Rounding the new height to an even number of pixels would throw away any correction under about 0.2% on 1080p, which is most real breathing.
  • The edges a shrunk frame exposes are filled from the picture's own edge rather than with black. Set Pitch Edge Fill to Black if you want black instead.

If you don't have a factor file, set Pitch Correction to Auto. VapourBox then measures the breathing itself by comparing each frame with its neighbours. It doesn't look at the frame borders the way you suggested. In our tests Auto removed about half of the breathing, and a good factor file about three quarters.

I haven't done the two-zone curl correction yet. That one needs some thought about how to keep a seam from showing at the split line.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Film frame size stabilizer

2 participants