Develop a JPEG through the same pipeline as a RAW

DemosaicedImage gains a second producer, from_rgba8, alongside the CFA path.
Nothing about the type is CFA-specific — it is "an image on the GPU, ready to
adjust" — which is what lets develop mode work on a JPEG without the edit
graph or any operation knowing the source was not a RAW file.

The one real difference is the transfer function: sensor data is linear, a
JPEG is gamma-encoded. Every operation assumes linear scene-referred colour
(exposure is a multiply, and doubling a gamma-encoded value is not a stop), so
the shader prologue linearises once, at the only point where the two source
kinds still differ. The flag rides in as_shot_wb.w, which was padding. For a
JPEG the white balance uniform is neutral and the colour matrix is identity,
so both stay unconditional multiplies rather than becoming branches.

max_dimension is exposed because it is a hardware limit the caller must plan
around, not a failure to report afterwards: a 13728x8928 film scan exceeds the
common 8192 texture limit, and fitting it first is the only way to develop it
at all.

Assisted-by: LLM
This commit is contained in:
2026-08-09 21:07:20 +02:00
parent 050dcff5bb
commit 5786977a51
4 changed files with 566 additions and 6 deletions
+210 -1
View File
@@ -223,7 +223,11 @@ impl AdjustPass {
uniforms[0..4].copy_from_slice(&[m[0], m[1], m[2], 0.0]);
uniforms[4..8].copy_from_slice(&[m[3], m[4], m[5], 0.0]);
uniforms[8..12].copy_from_slice(&[m[6], m[7], m[8], 0.0]);
uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], 0.0]);
// The fourth slot is the non-linear flag, not padding: it tells the
// shader whether to linearise the sampled texel before any operation
// runs. See `DemosaicedImage::is_non_linear`.
let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 };
uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]);
let params_buf = self
.ctx
@@ -615,6 +619,74 @@ mod tests {
);
}
#[test]
fn zooming_shows_only_the_region_looked_at() {
// Zoom is a coordinate map, and a map that type-checks can still
// sample the wrong place. Checked against content: zoomed into the
// bright half the frame must be bright edge to edge, and into the
// dark half, dark — which a wrong origin or extent would break.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.framing_mut().set_view(dr_pipeline::CropRect {
x: 0.0,
y: 0.4,
width: 0.2,
height: 0.2,
});
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
let left_near = read_pixel(&ctx, tex, 4, 16)[0];
let left_far = read_pixel(&ctx, tex, 28, 16)[0];
g.framing_mut().set_view(dr_pipeline::CropRect {
x: 0.8,
y: 0.4,
width: 0.2,
height: 0.2,
});
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
let right_near = read_pixel(&ctx, tex, 4, 16)[0];
assert!(
left_far > 100 && left_near > 100,
"zoomed into the bright half, both edges should be bright: \
near={left_near} far={left_far}"
);
assert!(
left_near > right_near + 40,
"zooming to the far side should show the dark half: \
left={left_near} right={right_near}"
);
}
#[test]
fn zooming_does_not_recompile() {
// The property that makes scroll-wheel zoom smooth: a new zoom level
// is a uniform upload, never a pipeline build. If zoom reached the
// structure hash, every wheel notch would stall on a shader compile.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
for (i, extent) in [1.0f32, 0.5, 0.25, 0.125].iter().enumerate() {
g.framing_mut().set_view(dr_pipeline::CropRect {
x: 0.0,
y: 0.0,
width: *extent,
height: *extent,
});
pass.render(&img, &g.compose(), 32, 32).expect("render");
assert_eq!(
pass.cached_pipelines(),
1,
"zoom step {i} compiled a second pipeline"
);
}
}
#[test]
fn cropping_to_one_half_shows_only_that_half() {
// The property a crop exists for, checked against content rather than
@@ -946,4 +1018,141 @@ mod tests {
let t = pass.render(&img, &shader, 32, 96).expect("render");
assert_eq!((t.width(), t.height()), (32, 96));
}
/// A flat RGBA8 image on the JPEG path — already gamma-encoded, as a
/// decoded JPEG is.
fn jpeg_image(ctx: &GpuContext, rgb: [u8; 3]) -> DemosaicedImage {
let size = 16u32;
let mut data = Vec::with_capacity((size * size) as usize * 4);
for _ in 0..size * size {
data.extend_from_slice(&[rgb[0], rgb[1], rgb[2], 255]);
}
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
}
#[test]
fn a_jpeg_survives_a_neutral_graph_unchanged() {
// The property the whole JPEG path rests on: decoding the transfer
// function on the way in and re-encoding on the way out must be exact
// inverses. If they are not, merely *opening* a JPEG in develop mode
// shifts its tones — the file would be altered by being looked at,
// which is far worse than the panel being disabled.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
// Several levels: a transfer-function error is smallest in the
// mid-tones and largest near the ends, so one sample could miss it.
for level in [16u8, 64, 128, 200, 240] {
let img = jpeg_image(&ctx, [level, level, level]);
let t = pass.render(&img, &shader, 16, 16).expect("render");
let got = read_centre(&ctx, t);
for (i, c) in got[..3].iter().enumerate() {
let delta = (i32::from(*c) - i32::from(level)).abs();
assert!(
delta <= 2,
"channel {i} at level {level} came back {c} (delta {delta}) \
— the transfer functions are not inverses"
);
}
}
}
#[test]
fn a_jpeg_keeps_its_colour_through_a_neutral_graph() {
// Identity colour matrix and neutral white balance, specifically: a
// camera matrix applied to an image already in sRGB primaries would
// skew colour, and this is what catches it. A grey patch cannot —
// every matrix maps neutral to neutral.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
let img = jpeg_image(&ctx, [200, 90, 40]);
let t = pass.render(&img, &shader, 16, 16).expect("render");
let got = read_centre(&ctx, t);
for (i, expected) in [200u8, 90, 40].iter().enumerate() {
let delta = (i32::from(got[i]) - i32::from(*expected)).abs();
assert!(
delta <= 2,
"channel {i} expected ~{expected}, got {} — colour is being \
transformed on a source that needs no transform",
got[i]
);
}
}
#[test]
fn exposure_brightens_a_jpeg() {
// Proves the operations reach the JPEG path at all, and that they act
// on linearised values: an exposure stop is a multiply, which is only
// meaningful once the gamma encoding is undone.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = jpeg_image(&ctx, [110, 110, 110]);
let neutral = EditGraph::default_chain().compose();
let before = {
let t = pass.render(&img, &neutral, 16, 16).expect("render");
read_centre(&ctx, t)
};
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
let brighter = g.compose();
let after = {
let t = pass.render(&img, &brighter, 16, 16).expect("render");
read_centre(&ctx, t)
};
assert!(
after[0] > before[0],
"+1 stop should brighten a JPEG: {before:?} -> {after:?}"
);
// One stop on a linear value is a doubling, which after re-encoding
// lands near 1.5x the encoded value rather than 2x. Checking the
// magnitude is what distinguishes "linearised correctly" from
// "doubled the gamma-encoded value", which would blow straight to
// white — the exact bug a brightness-only assertion would miss.
assert!(
after[0] < 255,
"a stop from mid-grey must not clip: {} — the encoding was \
probably not undone before the multiply",
after[0]
);
}
#[test]
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() {
// The two producers must be interchangeable. A mid-grey that is
// linearly 0.216 (sRGB 128) arriving as sensor data and as a JPEG
// must render the same, or an edit would mean different things
// depending on which decoder opened the file.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
// sRGB 128 linearises to ~0.2159; against a 16383 white level that is
// sample ~3537.
let sensor = grey_image(&ctx, 3537);
let jpeg = jpeg_image(&ctx, [128, 128, 128]);
let from_sensor = {
let t = pass.render(&sensor, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
let from_jpeg = {
let t = pass.render(&jpeg, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
let delta = (i32::from(from_sensor[0]) - i32::from(from_jpeg[0])).abs();
assert!(
delta <= 3,
"the same scene value rendered {from_sensor:?} from sensor data \
and {from_jpeg:?} from a JPEG"
);
}
}