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
+152 -3
View File
@@ -33,9 +33,21 @@ struct DemosaicParams {
/// A demosaiced image living on the GPU.
///
/// Linear, scene-referred, camera colour space, RGBA16Float. This is the
/// input every adjustment operates on, and the reason the ops need no
/// knowledge of sensors or CFA patterns.
/// RGBA16Float, scene-referred, camera colour space. This is the input every
/// adjustment operates on, and the reason the ops need no knowledge of sensors
/// or CFA patterns.
///
/// **Two producers, not one.** [`Demosaicer::run`] builds it from CFA sensor
/// data; [`DemosaicedImage::from_rgba8`] builds it from an already-processed
/// RGB image such as a JPEG. Nothing about the type is CFA-specific — it is
/// simply "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 that
/// the source was not a RAW file.
///
/// The one thing that does differ is the transfer function: sensor data is
/// linear, a JPEG is gamma-encoded. That difference is carried by
/// [`Self::is_non_linear`] and resolved once, in the generated shader's
/// prologue, rather than being defended against by every operation.
pub struct DemosaicedImage {
texture: wgpu::Texture,
view: wgpu::TextureView,
@@ -45,6 +57,8 @@ pub struct DemosaicedImage {
color_matrix: [f32; 9],
/// As-shot white balance, the neutral starting point for the WB control.
as_shot_wb: [f32; 3],
/// Whether the texture holds gamma-encoded rather than linear values.
non_linear: bool,
}
impl DemosaicedImage {
@@ -75,6 +89,137 @@ impl DemosaicedImage {
pub fn as_shot_wb(&self) -> [f32; 3] {
self.as_shot_wb
}
/// The largest edge this device can hold in one texture.
///
/// Exposed because it is a *hardware* limit the caller has to plan around,
/// not a failure to report after the fact: a 13728×8928 film scan exceeds
/// the common 8192 limit, and the only way to develop it at all is to fit
/// it first. 8192 is still four times a 4K display's long edge, so nothing
/// visible is lost.
pub fn max_dimension(ctx: &GpuContext) -> u32 {
ctx.device.limits().max_texture_dimension_2d
}
/// Whether the texture is gamma-encoded rather than linear.
///
/// True for a source that arrived already display-encoded — a JPEG. The
/// adjust pass forwards this to the shader, which linearises before any
/// operation runs, so the ops themselves always see linear colour.
pub fn is_non_linear(&self) -> bool {
self.non_linear
}
/// Build one from an already-processed RGBA8 image, skipping demosaic.
///
/// The path a JPEG takes into develop mode (FR-RAW-4). There is no CFA to
/// interpolate and no sensor to normalise: the pixels are uploaded as they
/// arrived, gamma encoding intact, and flagged so the shader linearises
/// them.
///
/// The two sensor-derived transforms are deliberately neutral rather than
/// absent:
///
/// - **Colour matrix identity** — a JPEG is already in sRGB primaries, so
/// there is no camera space to convert out of. Applying a real camera
/// matrix here would be a second, unwanted colour transform.
/// - **White balance neutral** — the camera applied its own before writing
/// the file, and it cannot be undone from the encoded pixels. The WB
/// control still works; its neutral position is simply "as the camera
/// left it" rather than "as the sensor recorded it".
///
/// `rgba` must be tightly packed, 4 bytes per pixel, `width * height`
/// pixels, and must fit [`Self::max_dimension`] — a film scan can easily
/// exceed it, so callers downscale first rather than being refused here.
pub fn from_rgba8(
ctx: &GpuContext,
rgba: &[u8],
width: u32,
height: u32,
) -> Result<Self, GpuError> {
let (width, height) = (width.max(1), height.max(1));
let limits = ctx.device.limits();
if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d {
return Err(GpuError::TooLarge(format!(
"{width}×{height} exceeds the device limit of {}",
limits.max_texture_dimension_2d
)));
}
let expected = (width as usize) * (height as usize) * 4;
if rgba.len() < expected {
return Err(GpuError::TooLarge(format!(
"{} bytes is short of the {expected} needed for {width}×{height}",
rgba.len()
)));
}
// The staging texture is Rgba8Unorm because that is what the bytes
// are; the pass below converts into the Rgba16Float the rest of the
// pipeline expects. Writing f16 on the CPU instead would cost a
// full-image conversion before the upload rather than after it.
let half: Vec<u16> = rgba[..expected]
.iter()
.map(|&b| f32_to_f16_bits(f32::from(b) / 255.0))
.collect();
let texture = ctx.device.create_texture_with_data(
&ctx.queue,
&wgpu::TextureDescriptor {
label: Some("jpeg-source"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Self::FORMAT,
// No STORAGE_BINDING: nothing writes to this one. The adjust
// pass samples it, and tests copy from it.
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
bytemuck::cast_slice(&half),
);
let view = texture.create_view(&Default::default());
Ok(Self {
texture,
view,
width,
height,
color_matrix: IDENTITY_3X3,
as_shot_wb: [1.0, 1.0, 1.0],
non_linear: true,
})
}
}
/// Convert an f32 to IEEE 754 half-precision bits.
///
/// Written out rather than pulled in as a dependency: the inputs here are
/// `0.0..=1.0` from an 8-bit source, which is entirely inside the normal range
/// of f16, so the subnormal and overflow cases a general converter must handle
/// cannot arise. The clamp makes that assumption explicit rather than implicit.
fn f32_to_f16_bits(v: f32) -> u16 {
let v = v.clamp(0.0, 1.0);
if v == 0.0 {
return 0;
}
let bits = v.to_bits();
let exp = ((bits >> 23) & 0xFF) as i32 - 127 + 15;
let mantissa = (bits >> 13) & 0x3FF;
// v is in 0.0..=1.0, so the exponent cannot overflow f16's range; values
// below f16's smallest normal round to zero rather than to a subnormal,
// which at 8-bit source precision is a distinction without a difference.
if exp <= 0 {
return 0;
}
((exp as u16) << 10) | mantissa as u16
}
/// Runs the demosaic pass. Holds the pipeline so repeated images reuse it.
@@ -288,6 +433,10 @@ impl Demosaicer {
// no colour transform rather than not at all.
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
// Sensor data is linear by construction — the demosaic shader
// normalises against black and white levels and applies no
// transfer function.
non_linear: false,
})
}
}