Connect the lens vignetting correction to the pipeline

`ops/vignetting.rs` has carried a complete descriptor, polynomial, helper
and test suite without an entry in `ops/`, so it was never in `chain()`.
It reached no photograph and no panel, and `Attribute::Optics` was an empty
category in consequence — filtered out of the tab strip for having no rows,
by a chain that had never been given its only member.

Declaring it needs the one thing the operation was written against and
which did not exist. `wgsl_body` reads `radius`, and the module claimed
"the composer publishes `radius` in the shader prologue for exactly this
reason". It did not. `sample_source` now does, in both sampling branches,
beside the `source_px` it already published for the same class of caller.

It is corner-normalised there, which is the part that is easy to leave out.
`p` spans ±0.5·aspect, so its length at the corner is 0.5·length(aspect) —
about 0.901 on a 3:2 frame, not 1. Lensfun's polynomials are fitted against
a corner radius of 1, so passing `length(p)` straight in evaluates every one
of them short of where it was measured, by a factor that changes with the
aspect ratio. It would have read as a correction that is simply too weak,
which is indistinguishable from a bad profile. Both `lens.rs` and
`framing.rs` asserted the normalisation `p` does not have; corrected.

`order: 5` puts the correction ahead of the tonal stages, and the ordering
is load-bearing rather than tidy. Recovering a corner means dividing by an
attenuation below one — about two stops for a fast prime wide open — so run
after the highlights have been rolled off and clipped, the lift has nowhere
to go and the corners posterise instead of brightening.

`layer_chain` now drops `Optics` as well as the neighbourhood operations.
A local vignetting slider would have worked, which is what makes it worth
excluding: `radius` measures from the centre of the whole photograph and a
mask cannot move the optical axis, so it would lay a frame-centred radial
ramp across the picture and multiply it by the mask. The existing exclusion
covers operations that move and do nothing; this one covers an operation
that moves and does something its name does not promise. The rule both
share is now written down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-05 14:34:24 +02:00
co-authored by Claude Opus 5
parent 8e7b1350bf
commit e17b909d41
9 changed files with 159 additions and 23 deletions
+40
View File
@@ -0,0 +1,40 @@
# A hand-written node, and the first thing in `Attribute::Optics`.
#
# Written, tested and unreferenced until now: `src/ops/vignetting.rs` has
# existed with a full descriptor and a working polynomial, and without an entry
# here it was never in `chain()` — so it reached no photograph and no panel.
# This file is the whole of what was missing, which is the point of `ops/`
# being the one place the pipeline's order is written down.
id: vignetting
order: 5
attributes: [optics]
rust: Vignetting
why_rust: |
It carries a lens profile's `pa` coefficients, which are not parameters: they
come from the body and lens that took the photograph, not from the
photographer, and no `uniforms:` expression could produce them. The one
parameter that *is* theirs — the manual trim — is composed with `k1` in Rust,
because the profile and the trim have to reach the shader as a single
polynomial rather than as two the fragment would have to add up.
placement: |
First in the chain, ahead of white balance and exposure.
Vignetting is what the lens did to the light before the sensor measured it,
so undoing it belongs with reading the file rather than with editing the
picture — everything downstream is then working on the frame the lens would
have delivered had it been even.
The ordering is load-bearing rather than tidy. Correcting a corner means
*dividing* by an attenuation below one, which pushes those pixels up: a fast
prime wide open needs about two stops there. Run after the tonal stages, that
recovery happens once the highlights have already been rolled off and
clipped, so it lifts values that no longer have anywhere to go and the
corners posterise instead of brightening. Run here, the headroom to hold them
still exists (ARCH §5.2).
Before distortion and chromatic aberration in intent, though those are
`lens::Warp`s rather than nodes and compose ahead of the fetch, so no `order:`
relates the two.