Straighten a portrait frame in the frame the user is looking at
The framing prologue built the centred position `p` by scaling with the source's aspect, then straightened, then permuted the quarter turns. On a landscape frame those are one space and it worked. Once a turn has swapped the axes — the rotate button, or a file whose EXIF tag says the camera was held sideways — they are not: `p` was measured with the source's ruler on a frame that is no longer that shape, stretching one axis against the other by (w/h)², which is 2.25 on a 3:2 photograph. The quarter-turn permutation happened to undo that stretch, so rotation alone looked right, which is how this survived. The straighten in between did not, and a rotation in a space whose axes carry different scales is a shear. `p` is now built in `frame_aspect` — the frame as the user sees it — and the permutation becomes the one place the two rulers meet: each axis divided by the aspect it is read from, multiplied by the aspect it is written to. Asserted on pixels rather than on the generated WGSL, because reading the shader and reasoning about which space `p` lives in is how the wrong formula got written in the first place: a disc, straightened by 20° on a turned 3:2 frame, must come back circular by every route to a swapped frame — the button, the tag, and the two composed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -674,18 +674,32 @@ impl Framing {
|
||||
",
|
||||
);
|
||||
|
||||
s.push_str(
|
||||
// The frame `p` is measured in is the one the *user* is looking at,
|
||||
// and a quarter turn — the file's or the user's — has already swapped
|
||||
// its axes. Measuring a portrait frame with the landscape aspect
|
||||
// stretches one axis against the other by `(w/h)²`, which the
|
||||
// permutation below silently undoes but the straightening above does
|
||||
// not: a rotation is only a rotation in a space whose axes carry the
|
||||
// same scale, so in the stretched one it comes out as a shear.
|
||||
let frame_aspect = if self.swaps_axes() {
|
||||
" let frame_aspect = vec2<f32>(f32(src_dims.y) / f32(src_dims.x), 1.0);\n"
|
||||
} else {
|
||||
" let frame_aspect = aspect;\n"
|
||||
};
|
||||
|
||||
let _ = write!(
|
||||
s,
|
||||
"
|
||||
// Into the crop rect.
|
||||
uv = u.crop_rect.xy + uv * u.crop_rect.zw;
|
||||
var p = (uv - vec2<f32>(0.5)) * aspect;
|
||||
",
|
||||
// Into the crop rect, then into the framed image's own centred space.
|
||||
{frame_aspect} uv = u.crop_rect.xy + uv * u.crop_rect.zw;
|
||||
var p = (uv - vec2<f32>(0.5)) * frame_aspect;
|
||||
"
|
||||
);
|
||||
|
||||
if self.angle != 0.0 {
|
||||
// Done in the aspect-corrected space, which is the whole reason
|
||||
// `p` is scaled by `aspect` above: a rotation applied to raw 0..1
|
||||
// coordinates on a non-square image shears it rather than
|
||||
// `p` is scaled by `frame_aspect` above: a rotation applied to raw
|
||||
// 0..1 coordinates on a non-square image shears it rather than
|
||||
// turning it, and that reads as a rendering fault.
|
||||
s.push_str(
|
||||
"
|
||||
@@ -706,12 +720,14 @@ impl Framing {
|
||||
if turns != 0 {
|
||||
// An exact coordinate permutation rather than a rotation through
|
||||
// the matrix above, which would resample a transform that has an
|
||||
// exact answer. Applied to `p`, so the aspect scaling has to be
|
||||
// undone and reapplied across the swap.
|
||||
// exact answer. It is also where the two aspects meet: `p` arrives
|
||||
// scaled by the framed image's and has to leave scaled by the
|
||||
// source's, so each axis is divided by the one it is read from and
|
||||
// multiplied by the one it is written to.
|
||||
let permutation = match turns {
|
||||
1 => " p = vec2<f32>(p.y * aspect.x, -p.x / aspect.x);",
|
||||
1 => " p = vec2<f32>(p.y * aspect.x, -p.x / frame_aspect.x);",
|
||||
2 => " p = -p;",
|
||||
_ => " p = vec2<f32>(-p.y * aspect.x, p.x / aspect.x);",
|
||||
_ => " p = vec2<f32>(-p.y * aspect.x, p.x / frame_aspect.x);",
|
||||
};
|
||||
let _ = write!(
|
||||
s,
|
||||
@@ -1283,6 +1299,53 @@ mod tests {
|
||||
assert!(f.wgsl_prologue().contains("u.framing_angle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turned_frame_is_measured_by_its_own_aspect() {
|
||||
// `p` is the space the straightening rotates in, so it has to be the
|
||||
// space the *user* sees. Once a turn has swapped the axes — the
|
||||
// button's or the file's — the source's aspect is the wrong ruler:
|
||||
// measuring a 2:3 frame with a 3:2 aspect stretches one axis against
|
||||
// the other, and the rotation that follows shears instead of turning.
|
||||
//
|
||||
// The pixels are asserted in `dr-gpu`'s
|
||||
// `straightening_a_turned_frame_keeps_a_circle_circular`; this is the
|
||||
// same claim at the level the code is written at.
|
||||
for turns in [1u8, 3] {
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(i32::from(turns));
|
||||
f.set_param(ANGLE, 5.0);
|
||||
let src = f.wgsl_prologue();
|
||||
assert!(
|
||||
src.contains(
|
||||
"let frame_aspect = vec2<f32>(f32(src_dims.y) / f32(src_dims.x), 1.0);"
|
||||
),
|
||||
"{turns} turns must measure the frame turned:\n{src}"
|
||||
);
|
||||
assert!(src.contains("* frame_aspect;"), "{src}");
|
||||
}
|
||||
|
||||
// An even turn leaves the axes where they were, so the two rulers are
|
||||
// the same one and nothing has to be recomputed.
|
||||
for turns in [0u8, 2] {
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(i32::from(turns));
|
||||
f.set_param(ANGLE, 5.0);
|
||||
assert!(
|
||||
f.wgsl_prologue().contains("let frame_aspect = aspect;"),
|
||||
"{turns} turns should reuse the source aspect"
|
||||
);
|
||||
}
|
||||
|
||||
// And the baseline reaches it the same way a button press does: a
|
||||
// file stored sideways is a turned frame whether or not it was edited.
|
||||
let mut sideways = Framing::new();
|
||||
sideways.set_baseline(dr_types::Orientation::from_exif(6));
|
||||
sideways.set_param(ANGLE, 5.0);
|
||||
assert!(sideways
|
||||
.wgsl_prologue()
|
||||
.contains("f32(src_dims.y) / f32(src_dims.x)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_turn_corrects_for_aspect_across_the_swap() {
|
||||
// `p` is scaled by the source aspect, so a permutation that exchanges
|
||||
|
||||
Reference in New Issue
Block a user