From 113f37fe4b898cfa687a47ce1c8a143504f09b59 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:50:09 +0100 Subject: [PATCH] fix(positive): convert a Positive source's primaries after the density corrections A Positive JPEG or profiled TIFF converted its primaries to Adobe RGB in the decode, before the crosstalk unmix, fade and Remove Stain offset. The conversion mixes about 28% of sRGB green into red, and Fade Restoration then scaled that mix with the cyan dye. On a red-faded slide (Red Survival 0.19) the river went from reddish brown (0.25, 0.22, 0.21) to cyan-grey (0.17, 0.22, 0.20), and red and green lost separation. A source whose gamut fits Adobe RGB now keeps its own primaries through the decode and carries the matrix in the camera-matrix slot (source_cam_xyz), marked by a 4th row (is_source_profile). On the Positive path the engines apply that matrix after the density matrix when one is active: CPU through the new negpy_core.relog_matrix kernel, GPU through post rows in the normalization shader. With no density matrix it applies before the log as before. A wider source (ProPhoto) still converts in the decode, with the gamut pass. Estimate and the Remove Stain white read the source channels. A camera RAW keeps its matrix before the log on every path, so its render and Estimate do not change. Its matrix folds the as-shot white balance on a Linear RAW decode, and moving it after the stain offset gave the white border a magenta cast. The UI reads the same marker, so a Positive JPEG does not show as a camera RAW. Bumps THUMBNAIL_RENDER_VERSION to 31 and BOUNDS_VERSION to 3. --- docs/PIPELINE.md | 6 +- negpy/desktop/session.py | 9 +- negpy/desktop/view/sidebar/demosaic.py | 5 +- negpy/desktop/view/sidebar/scan_setup.py | 3 +- negpy/features/exposure/normalization.py | 11 +- .../exposure/shaders/normalization.wgsl | 14 ++ negpy/features/process/capture_color.py | 20 +++ negpy/features/transparency/logic.py | 20 +++ negpy/features/transparency/processor.py | 22 ++- negpy/infrastructure/loaders/helpers.py | 7 +- negpy/infrastructure/loaders/jpeg_loader.py | 8 +- .../infrastructure/loaders/source_encoding.py | 18 ++- negpy/infrastructure/loaders/tiff_loader.py | 9 +- .../services/assets/thumbnail_fingerprint.py | 2 +- negpy/services/rendering/gpu_engine.py | 51 +++++-- negpy_core/src/analysis.rs | 16 ++ negpy_core/src/py/analysis.rs | 25 ++++ tests/test_bounds_migration.py | 2 +- tests/test_core_relog.py | 56 +++++++ tests/test_core_same_bits.py | 5 + tests/test_pipeline_parity.py | 23 +++ tests/test_positive_source_primaries.py | 138 ++++++++++++++++++ tests/test_source_encoding.py | 21 ++- tests/thumbnail_fingerprint_defaults.json | 2 +- 24 files changed, 450 insertions(+), 43 deletions(-) create mode 100644 tests/test_core_relog.py create mode 100644 tests/test_positive_source_primaries.py diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index d3bddea00..36cd247cb 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -61,7 +61,7 @@ Pixel values are linear transmittance. The source corrections run in that domain * **Channel unmix / crosstalk** (`crosstalk_strength` / `crosstalk_matrix`, docs/CROSSTALK.md): a 3×3 unmix of the negative log densities before bounds and stretch, where dye absorptions, light spectrum and CFA are linear, so a profile describes a scanning setup. It is blended with identity by strength and row-normalized, so grays stay. Every meter reads the unmixed film, so a change needs a new Roll Analysis. * **Fade restoration** (`fade_ratio_r` / `fade_ratio_g` / `fade_ratio_b`, slides only: TRANSFER and POSITIVE): composes a diagonal restoration matrix $F^{-1}$ after the crosstalk unmix, $M = F^{-1} M_{\text{unmix}}$ (`features/process/fade.py::unmix_with_fade`). It runs before Cast Removal, which then meters the restored film. $F = \mathrm{diag}(a_r,\, a_r a_g,\, a_r a_b)$ acts on densities. $a_g$ and $a_b$ are survival ratios to red, which a neutral can constrain. $a_r$ is red's own survival and is set by eye. $F$ is the identity at unit survival, and it is not row-normalized. - Estimate (`features/process/fade.py`) runs on request, never in a render. It renders the frame on the CPU with $F$ at identity and White Anchor off, and reads the transparency meter grid (`transfer_grid`: bakes, geometry, crop, camera matrix and unmix). It meters the grays with `measure_neutral_axis_from_log` against the frame's own bounds: first with the foliage and sky sectors excluded, against bounds from each channel's extreme cells with decoded zeros dropped, at confidence `MIN_CONFIDENCE` ($0.2$) or more. Else it reads against bounds from the `ROBUST_EXTREME_PCT` ($0.1$) percentiles, with the sectors excluded, then not, at `ROBUST_MIN_CONFIDENCE` ($0.4$). MIDTONE reads the midtone densities and also sets $a_r$; SPREAD reads each channel's midtone-to-shadow spread. Ratios clamp to $[0.2, 5]$, and the hint shows the unclamped value. The hint also shows the other method's reading: a large gap is a cast that a fade does not explain. A near-monochrome slide meters as faded. + Estimate (`features/process/fade.py`) runs on request, never in a render. It renders the frame on the CPU with $F$ at identity and White Anchor off, and reads the transparency meter grid (`transfer_grid`: bakes, geometry, crop, camera matrix and unmix; a Positive source's grid stays in its own primaries). It meters the grays with `measure_neutral_axis_from_log` against the frame's own bounds: first with the foliage and sky sectors excluded, against bounds from each channel's extreme cells with decoded zeros dropped, at confidence `MIN_CONFIDENCE` ($0.2$) or more. Else it reads against bounds from the `ROBUST_EXTREME_PCT` ($0.1$) percentiles, with the sectors excluded, then not, at `ROBUST_MIN_CONFIDENCE` ($0.4$). MIDTONE reads the midtone densities and also sets $a_r$; SPREAD reads each channel's midtone-to-shadow spread. Ratios clamp to $[0.2, 5]$, and the hint shows the unclamped value. The hint also shows the other method's reading: a large gap is a cast that a fade does not explain. A near-monochrome slide meters as faded. White Anchor (`fade_white_anchor`, UI "Remove Stain") makes the matrix affine, $M(L - w) + t$. The stain $w$ is each channel's `WHITE_PCT` cell of the meter grid in log transmittance, without decoded zeros and cells at full scale in every channel; Estimate stores it as `fade_white_r/g/b`. It maps to the neutral $t = \min(\max_c w_c, -0.1)$ (`FADE_WHITE_HEADROOM`): the cleanest channel's own level, never within $0.1$ D of full scale. $F^{-1}$ acts on $L - w$ before $t$ is added, so the Estimate reads the grays on $\min(L - w, 0)$. The offset rides in the w lanes of the unmix rows on the GPU. * **Density above base**: `density_above_base` is (Dmin, Dmed, Dmax) above the clear base, luminance-weighted: the thin and dense bounds (before trims; the roll's under Roll Analysis) and the metered median, each minus the base. The base is the picked one, else the rebate reader's, unmixed like the meters. A pick is the open roll's (`rolls.roll_film_base`), or the frame's own with no roll open (`process.film_base`, unhashed); in an open roll the roll's wins, with none open the frame's. The preview carries it as `film_base`; export never reads it. The reader takes the flat 8-px blocks (std over mean under 0.03, unclipped) in the outer 15% of the film box (`film_and_picture` at `AUTOCROP_DETECT_RES`), groups them by brightness with a 0.04-decade gap, and takes the largest group of at least five. It finds none without a film box, with a base darker than the picture, or on the transfer path. The preview reads it once per source, on the flat-fielded, unmixed source before the defect repairs, and passes the read to both engines. @@ -172,9 +172,9 @@ with `flat_log_gain` $= 0.65$ and `flat_log_lift` $= 0.10$: shadow → $0.10$, m For Transparency and Positive a transfer curve replaces the Print stage, exactly the identity at default settings. `process.path.render_path` (`TRANSFER`, `POSITIVE`, `PRINT`) routes the base and exposure stages; the negative processors hold no slide branch. `TransparencyBaseProcessor` meters only for Auto Density, Auto Grade and Cast Removal. -* **Neutral decode**: this path always decodes without the as-shot white balance (`effective_linear_raw`), because the camera matrix folds it back in. Decode, both matrices and every cache token ask that helper; it also makes a TIFF load as literal linear data. Positive is exempt: a TIFF decodes by Source Encoding (`path.positive_decode`, `loaders/source_encoding.py`), whatever Linear RAW says; Linear and 2.2 override Auto, and Input ICC applies a LUT profile. Auto skips a device profile (class `scnr`, `is_device_profile`), which describes the scanner rather than the file's encoding. Otherwise it applies the embedded profile's tone curves (`curv`, `para`) and then its colorants to Adobe RGB, relative to the D50 PCS; a LUT-only profile falls back to its name (sRGB, Adobe RGB). A JPEG decodes by Auto, as it has no Encoding control. A JXL loads as linear. An untagged TIFF is linear above 8 bits and sRGB at 8 bits. Out-of-gamut pixels after the primaries matrix move toward their own luminance until they fit, which keeps hue. An identity decode of 16-bit samples passes them through exactly. The encoding joins `linear_raw_token` and `source_token`. A Slide that is not a camera RAW (`LoaderFactory.is_camera_raw`: TIFF, JPEG, JXL, Pakon, Noritsu, Coolscan NEF, Flextight FFF) starts Positive on a first open and on a switch to Slide, unless its roll's Film card holds a Positive value (`loaders/source_kind.py`). Positive off sends it down the as-captured transfer. +* **Neutral decode**: this path always decodes without the as-shot white balance (`effective_linear_raw`), because the camera matrix folds it back in. Decode, both matrices and every cache token ask that helper; it also makes a TIFF load as literal linear data. Positive is exempt: a TIFF decodes by Source Encoding (`path.positive_decode`, `loaders/source_encoding.py`), whatever Linear RAW says; Linear and 2.2 override Auto, and Input ICC applies a LUT profile. Auto skips a device profile (class `scnr`, `is_device_profile`), which describes the scanner rather than the file's encoding. Otherwise it applies the embedded profile's tone curves (`curv`, `para`); a LUT-only profile falls back to its name (sRGB, Adobe RGB). The colorants to Adobe RGB are relative to the D50 PCS. A source whose gamut fits Adobe RGB (no negative matrix entry) keeps its own primaries, and the matrix becomes the frame's camera matrix (`decode_positive`, `source_cam_xyz`). A wider source converts in the decode, where out-of-gamut pixels move toward their own luminance until they fit, which keeps hue. A JPEG decodes by Auto, as it has no Encoding control. A JXL loads as linear. An untagged TIFF is linear above 8 bits and sRGB at 8 bits. An identity decode of 16-bit samples passes them through exactly. The encoding joins `linear_raw_token` and `source_token`. A Slide that is not a camera RAW (`LoaderFactory.is_camera_raw`: TIFF, JPEG, JXL, Pakon, Noritsu, Coolscan NEF, Flextight FFF) starts Positive on a first open and on a switch to Slide, unless its roll's Film card holds a Positive value (`loaders/source_kind.py`). Positive off sends it down the as-captured transfer. * **Highlight reconstruction** (`highlight_reconstruction`, `effective_highlight_reconstruction`): libraw `highlight_mode` `0` Clip, `2` Blend, `3` to `9` Reconstruct (the sidebar uses `5`). It is forced to `0` off Transparency, under Narrowband and on a merged bracket, whose weighting needs pixels near the sensor ceiling (`WorkspaceConfig.__post_init__`; `HdrWorker.run` zeroes it per frame). Active reconstruction bakes the real white balance and skips the matrix fold (`highlight_reconstruction_bakes_wb`, `should_fold_camera_wb`), because libraw finds clipping from its own gains; `highlight_reconstruction_bright_gain` passes the darkening ratio back as libraw's `bright`. -* **Camera matrix**: libraw's `rgb_xyz_matrix` (`PipelineContext.cam_xyz`) becomes the working→camera matrix, row-normalized, then inverted (dcraw's `cam_xyz_coeff` order); normalizing after inversion gives a magenta cast. It applies in linear before the log. An explicit Input ICC swaps in `wb_only_cam_xyz` (`AppController._effective_cam_xyz`), which reduces to the white-balance fold. +* **Camera matrix**: libraw's `rgb_xyz_matrix` (`PipelineContext.cam_xyz`) becomes the working→camera matrix, row-normalized, then inverted (dcraw's `cam_xyz_coeff` order); normalizing after inversion gives a magenta cast. It applies in linear before the log. A Positive source profile's matrix (`source_cam_xyz`, marked by its 4th row, `is_source_profile`) applies after the density matrix (crosstalk, fade or stain offset) instead, in linear light (`density_stage_matrices`, `relog_matrix_image`, the shader's `post` rows): the density corrections act on the source's own channels, which the primaries matrix mixes. A camera's matrix applies before the log on every path. An explicit Input ICC swaps in `wb_only_cam_xyz` (`AppController._effective_cam_xyz`), which reduces to the white-balance fold. * **Fixed bounds**: a constant window from the decoder's white level ($D = 0$) to `TRANSFER_DENSITY_RANGE` $= 3.0$ decades below. White and Black Point (`per_channel_point_offsets`) shift it. No dye-crosstalk unmix. * **Cast Removal bands**: the neutral axis (§3, Automatic helpers) takes its highlight, midtone and shadow bands from the fixed window, before White and Black Point. A Positive file has no sensor white, so its bands span the 1st to 99th percentile of its luma-weighted log density, one offset on all channels (`transfer_axis_bounds`, `POSITIVE_AXIS_PERCENTILES`). When that finds no axis, the dark end is taken again over the cells more than `POSITIVE_AXIS_FLOOR_MARGIN` (0.1 D) above it, so a black border does not fill the shadow band (`measure_transfer_neutral_axis`). The bands then select the same pixels at any exposure or encoding of the file, and the refs stay raw log densities. diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index c24d40878..2f6b05cbc 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -31,6 +31,7 @@ from negpy.desktop.view.canvas.crop_guides import CropGuide from negpy.domain.models import PROOF_INTENT_LABELS, ExportPreset, ProofIntent, WorkspaceConfig from negpy.features.exposure.models import apply_targets +from negpy.features.process.capture_color import is_source_profile from negpy.features.geometry.logic import flip_geometry_and_analysis, has_manual_crop, rotate_geometry_and_analysis from negpy.features.process.models import invalidate_local_bounds, mode_aware_exposure_reset, with_process_mode from negpy.infrastructure.loaders.source_kind import with_scanner_positive_default @@ -137,7 +138,8 @@ class AppState: auto_meters: Dict[str, Dict[str, Any]] = field(default_factory=dict) preview_raw: Optional[Any] = None # Decoder XYZ->camera matrix for preview_raw. Only the transparency transfer reads it. - # None for sources that carry no camera matrix (scanner TIFF, JPEG). + # None for sources that carry no camera matrix (scanner TIFF, JPEG). A Positive decode carries + # its profile's primaries here instead (source_cam_xyz), so a camera is camera_raw_preview(). preview_cam_xyz: Optional[list] = None preview_camera_wb: Optional[list] = None # EXIF orientation the decode baked into preview_raw; the flat-field gain takes it. @@ -410,6 +412,11 @@ def canvas_value(self, key: str, default: Any = None) -> Any: return self.last_metrics.get(key, default) +def camera_raw_preview(state: Any) -> bool: + """The open frame carries a camera's matrix, not a Positive decode's source primaries.""" + return state.preview_cam_xyz is not None and not is_source_profile(state.preview_cam_xyz) + + def _asset_key(asset: Dict[str, Any]) -> tuple: """A file's stable identity: its content hash, plus which half for a half-frame pair sharing that hash. Survives `uploaded_files` gaining or losing rows, unlike its position.""" diff --git a/negpy/desktop/view/sidebar/demosaic.py b/negpy/desktop/view/sidebar/demosaic.py index d468c9db1..17b9de792 100644 --- a/negpy/desktop/view/sidebar/demosaic.py +++ b/negpy/desktop/view/sidebar/demosaic.py @@ -6,6 +6,7 @@ from negpy.desktop.view.widgets.segmented_choice import SegmentedChoice from negpy.desktop.view.widgets.step_combo import StepCombo from negpy.features.hdr.models import hdr_active +from negpy.desktop.session import camera_raw_preview from negpy.features.process.models import DemosaicMode, ProcessMode from negpy.features.rgbscan.models import is_rgb_triplet from negpy.infrastructure.loaders.helpers import supported_demosaic_modes @@ -145,11 +146,11 @@ def sync_ui(self) -> None: is_e6 = conf.process_mode == ProcessMode.E6 merged = hdr_active(self.state.config.hdr) # No camera matrix: not a camera RAW decode, so nothing here is demosaiced. - self.hint.setVisible(bool(self.state.current_file_hash) and self.state.preview_cam_xyz is None) + self.hint.setVisible(bool(self.state.current_file_hash) and not camera_raw_preview(self.state)) self.highlight_header.setVisible(is_e6) self.highlight_label.setVisible(is_e6) self.highlight_combo.setVisible(is_e6) - self.highlight_combo.setEnabled(self.state.preview_cam_xyz is not None and not merged) + self.highlight_combo.setEnabled(camera_raw_preview(self.state) and not merged) self.highlight_combo.setCurrentIndex(highlight_bucket(conf.highlight_reconstruction)) self.highlight_merged_hint.setVisible(is_e6 and merged) finally: diff --git a/negpy/desktop/view/sidebar/scan_setup.py b/negpy/desktop/view/sidebar/scan_setup.py index a9a508bef..71c716e8b 100644 --- a/negpy/desktop/view/sidebar/scan_setup.py +++ b/negpy/desktop/view/sidebar/scan_setup.py @@ -5,6 +5,7 @@ from negpy.desktop.view.sidebar.base import BaseSidebar from negpy.desktop.view.styles.templates import hint_label from negpy.desktop.view.widgets.segmented_choice import SegmentedChoice +from negpy.desktop.session import camera_raw_preview from negpy.features.exposure.papers import resolve_paper from negpy.features.process.models import SCAN_SETUP_PAPERS, ProcessMode, scan_setup_rig, scan_setup_values @@ -36,7 +37,7 @@ def detected_capture(state) -> str: """A camera RAW (it has a camera matrix) reads as "camera", anything else as "scanner"; with no frame open, "camera".""" if not state.current_file_hash: return "camera" - return "camera" if state.preview_cam_xyz is not None else "scanner" + return "camera" if camera_raw_preview(state) else "scanner" def resolved_rig(repo, state) -> tuple[str, str]: diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 9b2a810c6..ab06d912c 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -15,7 +15,7 @@ # Bump in any change that alters the bounds a frame measures for unchanged settings. Saved # per-frame bounds from an older version are dropped at startup (migrations/bounds.py). -BOUNDS_VERSION = 2 +BOUNDS_VERSION = 3 # Contrast Mask blur sigma, per-cent of the analysis grid's short side. Of the grid, not of # the render, so preview and export mask alike. Near the Frontier HyperTone blur's spread. @@ -201,6 +201,15 @@ def unmix_log_image(img_log: ImageBuffer, matrix: Optional[np.ndarray]) -> Image return out + m[:, 3] if m.shape[1] == 4 else out +def relog_matrix_image(img_log: ImageBuffer, matrix: Optional[np.ndarray]) -> ImageBuffer: + """Apply a linear-light 3x3 to a (H, W, 3) log-density image: 10^v, the matrix, then the log + clamped at 1e-6 as `log_density_open` clamps it. Identity when None.""" + if matrix is None: + return img_log + m = np.ascontiguousarray(np.asarray(matrix, dtype=np.float32)[:, :3]) + return negpy_core.relog_matrix(np.ascontiguousarray(img_log, dtype=np.float32), m) + + def sorted_channel_grid(img_log: ImageBuffer) -> np.ndarray: """ Per-channel sorted values of a prefiltered grid, shape (n, 3). The clip percentiles diff --git a/negpy/features/exposure/shaders/normalization.wgsl b/negpy/features/exposure/shaders/normalization.wgsl index 13d23c6ad..18e6a5f8b 100644 --- a/negpy/features/exposure/shaders/normalization.wgsl +++ b/negpy/features/exposure/shaders/normalization.wgsl @@ -20,6 +20,12 @@ struct NormUniforms { cam0: vec4, cam1: vec4, cam2: vec4, + // A Positive source's primaries, applied in LINEAR after the density matrix so the unmix, + // fade and stain offset act on its own channels (density_stage_matrices). post0.w is 1.0 + // when it applies; the cam rows are then identity. + post0: vec4, + post1: vec4, + post2: vec4, }; @group(0) @binding(0) var input_tex: texture_2d; @@ -63,6 +69,14 @@ fn main(@builtin(global_invocation_id) gid: vec3) { dot(params.unmix1.xyz, log_color) + params.unmix1.w, dot(params.unmix2.xyz, log_color) + params.unmix2.w, ); + if (params.post0.w > 0.5) { + let unmixed = pow(vec3(10.0), log_color); + log_color = log10_vec(max(vec3( + dot(params.post0.xyz, unmixed), + dot(params.post1.xyz, unmixed), + dot(params.post2.xyz, unmixed), + ), vec3(epsilon))); + } var res: vec3; diff --git a/negpy/features/process/capture_color.py b/negpy/features/process/capture_color.py index d49816fbd..d3a98c937 100644 --- a/negpy/features/process/capture_color.py +++ b/negpy/features/process/capture_color.py @@ -98,6 +98,26 @@ def wb_only_cam_xyz(cam_xyz: Optional[Sequence[Sequence[float]]]) -> Optional[li return _XYZ_TO_WORKING.tolist() +# The 4th row of a source profile's `cam_xyz`. A camera's reaches the pipeline as 3 rows +# (helpers.camera_xyz_matrix), and `camera_to_working_matrix` reads only the first 3. +_SOURCE_PROFILE_ROW = (-1.0, -1.0, -1.0) + + +def source_cam_xyz(to_working: Optional[np.ndarray]) -> Optional[list]: + """The `cam_xyz` of a Positive source decoded in its own primaries: the one that + `camera_to_working_matrix` turns into *to_working*, the profile's source-to-working 3x3. The + engines apply it like a camera's, so the density stage can act on the source channels first.""" + if to_working is None: + return None + rows = (np.linalg.inv(np.asarray(to_working, dtype=np.float64)) @ _XYZ_TO_WORKING).tolist() + return rows + [list(_SOURCE_PROFILE_ROW)] + + +def is_source_profile(cam_xyz: Optional[Sequence[Sequence[float]]]) -> bool: + """True when *cam_xyz* came from a source profile (`source_cam_xyz`), not from a camera.""" + return cam_xyz is not None and len(cam_xyz) > 3 and tuple(float(v) for v in cam_xyz[3]) == _SOURCE_PROFILE_ROW + + def apply_camera_matrix(img: np.ndarray, matrix: Optional[np.ndarray]) -> np.ndarray: """ Camera primaries -> working space. A None matrix passes the buffer through. diff --git a/negpy/features/transparency/logic.py b/negpy/features/transparency/logic.py index fb142c76c..752ed69f7 100644 --- a/negpy/features/transparency/logic.py +++ b/negpy/features/transparency/logic.py @@ -483,6 +483,26 @@ def transfer_point_offsets(process: ProcessConfig) -> Tuple[Tuple[float, float, return (-wp3[0], -wp3[1], -wp3[2]), (-bp3[0], -bp3[1], -bp3[2]) +def source_channels_first(process: ProcessConfig, cam_xyz: Optional[list]) -> bool: + """True when the density stage acts on a Positive source profile's own channels, so its + primaries convert after it. A camera matrix always applies before the log.""" + from negpy.features.process.capture_color import is_source_profile + from negpy.features.process.path import RenderPath, render_path + + return is_source_profile(cam_xyz) and render_path(process) is RenderPath.POSITIVE + + +def density_stage_matrices( + process: ProcessConfig, cam_xyz: Optional[list], cam: Optional[np.ndarray], unmix: Optional[np.ndarray] +) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: + """(before the log, after the density matrix): where the source-to-working 3x3 *cam* applies. + A Positive source's primaries convert after the crosstalk unmix, fade and stain offset, which + act on the source's own channels; the conversion mixes them. Else it applies before the log.""" + if cam is not None and unmix is not None and source_channels_first(process, cam_xyz): + return None, cam + return cam, None + + def transfer_bounds(density_range: float = TRANSFER_DENSITY_RANGE) -> Tuple[Tuple[float, ...], Tuple[float, ...]]: """ The fixed (floors, ceils) the transparency path normalizes with — the decoder's diff --git a/negpy/features/transparency/processor.py b/negpy/features/transparency/processor.py index 48fdbb0b0..e69e78f74 100644 --- a/negpy/features/transparency/processor.py +++ b/negpy/features/transparency/processor.py @@ -21,6 +21,7 @@ measure_textural_range_from_log, normalize_log_image, prefilter_log_grid, + relog_matrix_image, resolve_analysis_region, unmix_log_image, ) @@ -32,6 +33,8 @@ from negpy.features.transparency.logic import ( TRANSFER_DENSITY_RANGE, apply_transfer_curve, + density_stage_matrices, + source_channels_first, measure_transfer_neutral_axis, transfer_assumed_anchor, transfer_auto_terms, @@ -54,7 +57,7 @@ def __init__(self, config: ProcessConfig, cast_strength: float = 0.0): def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: """ - Camera primaries -> working space, then a FIXED log-density window. + Camera or source primaries -> working space, then a FIXED log-density window. No meter shapes the window itself. Measured bounds are exactly what makes two exposures of one slide render alike, and a raw slide was exposed deliberately @@ -72,13 +75,14 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: # should_fold_camera_wb). matrix = camera_to_working_matrix(context.cam_xyz, context.camera_wb if should_fold_camera_wb(self.config) else None) image = np.ascontiguousarray(image, dtype=np.float32) - linear = image if matrix is None else apply_camera_matrix(negpy_core.nan_to_num(image, epsilon, 1.0, epsilon), matrix) - img_log = negpy_core.log_density_open(np.ascontiguousarray(linear, dtype=np.float32)) # Honoured here too: a rig-calibrated matrix is a capture correction like Hue Trim, and # the mode gate keeps a negative's profile from touching a slide. Inert at the shipped # default, so the as-captured render is unperturbed. unmix = unmix_with_fade(self.config, context.process_mode) - img_log = unmix_log_image(img_log, unmix) + pre, post = density_stage_matrices(self.config, context.cam_xyz, matrix, unmix) + linear = image if pre is None else apply_camera_matrix(negpy_core.nan_to_num(image, epsilon, 1.0, epsilon), pre) + img_log = negpy_core.log_density_open(np.ascontiguousarray(linear, dtype=np.float32)) + img_log = relog_matrix_image(unmix_log_image(img_log, unmix), post) floors, ceils = transfer_bounds() pre_trim_bounds = LogNegativeBounds(floors=floors, ceils=ceils) # White/Black Point manually deviate the fixed window, same technique as the measured @@ -97,10 +101,14 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: an_roi, an_buffer = resolve_analysis_region( linear.shape, context.active_roi, self.config.analysis_buffer, self.config.analysis_rect, self.config.meter_rect ) - prefiltered = unmix_log_image(prefilter_log_grid(linear, an_roi, an_buffer), unmix) + source_grid = unmix_log_image(prefilter_log_grid(linear, an_roi, an_buffer), unmix) + prefiltered = relog_matrix_image(source_grid, post) if context.readback_metrics: - # Fade Estimate meters this grid, rendered with the fade at identity. - context.metrics["transfer_grid"] = prefiltered + # Fade Estimate meters this grid, rendered with the fade at identity, in the channels + # the fade acts on: a Positive source's own primaries. + if post is None and pre is not None and source_channels_first(self.config, context.cam_xyz): + source_grid = unmix_log_image(prefilter_log_grid(image, an_roi, an_buffer), unmix) + context.metrics["transfer_grid"] = source_grid # Cast Removal's neutral axis is metered on the working-space log image the curve # consumes, since the camera matrix above would leave a meter reading a different diff --git a/negpy/infrastructure/loaders/helpers.py b/negpy/infrastructure/loaders/helpers.py index 3a186a012..6cb7e84f8 100644 --- a/negpy/infrastructure/loaders/helpers.py +++ b/negpy/infrastructure/loaders/helpers.py @@ -10,6 +10,7 @@ from PIL import Image, ImageCms from negpy.domain.models import ColorSpace +from negpy.features.process.capture_color import is_source_profile from negpy.features.process.models import DemosaicMode from negpy.infrastructure.loaders.constants import SUPPORTED_RAW_EXTENSIONS from negpy.kernel.image.logic import apply_exif_orientation, ensure_rgb, uint16_to_float32 @@ -665,6 +666,9 @@ def __init__( # for camera WB. None means the source has no WB to offer, as with NegPy's own scanner # DNGs, which are always neutral. postprocess() then leaves the data untouched. self.wb_gains: Optional[Tuple[float, float, float]] = wb_gains + # A Positive source decoded in its own primaries carries them here (source_cam_xyz), read + # by camera_xyz_matrix like a camera's. None for every other source. + self.rgb_xyz_matrix: Optional[list] = None @property def data(self) -> np.ndarray: @@ -793,7 +797,8 @@ def camera_xyz_matrix(raw: Any) -> Optional[list]: # All-zero is libraw's "no color data" sentinel, not a valid transform. if float(np.abs(m[:3]).max()) < 1e-12: return None - return [[float(v) for v in row] for row in m[:3]] + rows = [[float(v) for v in row] for row in m[:3]] + return rows + [[float(v) for v in m[3]]] if is_source_profile(raw.rgb_xyz_matrix) else rows def camera_wb_multipliers(raw: Any) -> Optional[list]: diff --git a/negpy/infrastructure/loaders/jpeg_loader.py b/negpy/infrastructure/loaders/jpeg_loader.py index 9e6dc1766..9ca79f54d 100644 --- a/negpy/infrastructure/loaders/jpeg_loader.py +++ b/negpy/infrastructure/loaders/jpeg_loader.py @@ -14,7 +14,8 @@ identify_color_space_from_icc, read_orientation, ) -from negpy.infrastructure.loaders.source_encoding import PROFILE, apply_decode, resolve_decode +from negpy.features.process.capture_color import source_cam_xyz +from negpy.infrastructure.loaders.source_encoding import PROFILE, decode_positive, resolve_decode from negpy.kernel.image.logic import srgb_to_linear, uint8_to_float32 @@ -52,7 +53,10 @@ def load(self, file_path: str, positive_decode: str = "") -> Tuple[ContextManage "ir": None, "source_encoding": ran, } - return NonStandardFileWrapper(apply_decode(np.ascontiguousarray(img), decode)), metadata + linear, to_working = decode_positive(np.ascontiguousarray(img), decode) + wrapper = NonStandardFileWrapper(linear) + wrapper.rgb_xyz_matrix = source_cam_xyz(to_working) + return wrapper, metadata if img.dtype == np.uint8: f32 = uint8_to_float32(np.ascontiguousarray(img)) diff --git a/negpy/infrastructure/loaders/source_encoding.py b/negpy/infrastructure/loaders/source_encoding.py index 3d7ad368c..87dccd7c3 100644 --- a/negpy/infrastructure/loaders/source_encoding.py +++ b/negpy/infrastructure/loaders/source_encoding.py @@ -1,5 +1,5 @@ -"""Positive decode of a TIFF by Source Encoding: the samples' tone curve to linear, -then the embedded profile's primaries to the working space (Adobe RGB 1998).""" +"""Positive decode of a TIFF by Source Encoding: the samples' tone curve to linear, and the +embedded profile's source-to-working primaries (Adobe RGB 1998), which the engines apply.""" import struct from dataclasses import dataclass @@ -215,6 +215,20 @@ def _into_gamut(rgb: np.ndarray) -> np.ndarray: return np.clip(y + t * (rgb - y), 0.0, 1.0) +# A source matrix with no entry below this maps the source's cube into the working space's: the +# source gamut fits, so the engines can convert it without a gamut pass. +_INSIDE_TOLERANCE = -1e-3 + + +def decode_positive(img: np.ndarray, decode: ProfileDecode) -> tuple[np.ndarray, Optional[np.ndarray]]: + """(linear float32 RGB, source-to-working 3x3 or None) for a Positive source. A source whose + gamut fits Adobe RGB keeps its own primaries and returns the matrix for the engines, which + apply it after the density corrections. A wider source converts here, through the gamut pass.""" + if decode.to_working is not None and float(np.min(decode.to_working)) >= _INSIDE_TOLERANCE: + return np.ascontiguousarray(_apply_curves(img, decode.curves), dtype=np.float32), decode.to_working + return apply_decode(img, decode), None + + def apply_decode(img: np.ndarray, decode: ProfileDecode) -> np.ndarray: """Linear working-space float32 RGB in [0, 1] from a Positive source's samples.""" linear = _apply_curves(img, decode.curves) diff --git a/negpy/infrastructure/loaders/tiff_loader.py b/negpy/infrastructure/loaders/tiff_loader.py index bd56008d9..2d13e53d2 100644 --- a/negpy/infrastructure/loaders/tiff_loader.py +++ b/negpy/infrastructure/loaders/tiff_loader.py @@ -17,7 +17,8 @@ identify_color_space_from_icc, read_orientation, ) -from negpy.infrastructure.loaders.source_encoding import PROFILE, apply_decode, resolve_decode +from negpy.features.process.capture_color import source_cam_xyz +from negpy.infrastructure.loaders.source_encoding import PROFILE, decode_positive, resolve_decode from negpy.infrastructure.loaders.ir_planes import find_ir_plane, normalize_ir_to_float32 from negpy.kernel.system.logging import get_logger @@ -220,12 +221,14 @@ def load( color_space = None if positive_decode: ran, decode = resolve_decode(positive_decode, icc_bytes, img.dtype) - # Decoded to the working space, so Same as Source names it only for a profile. + # The engines take it to the working space, so Same as Source names it only for a profile. color_space = identify_color_space_from_icc(icc_bytes) if ran == PROFILE else None if decode.identity and img.dtype == np.uint16: wrapper = NonStandardFileWrapper(source_u16=np.ascontiguousarray(img)) else: - wrapper = NonStandardFileWrapper(apply_decode(img, decode)) + linear, to_working = decode_positive(img, decode) + wrapper = NonStandardFileWrapper(linear) + wrapper.rgb_xyz_matrix = source_cam_xyz(to_working) metadata = { "orientation": read_orientation(file_path), "color_space": color_space, diff --git a/negpy/services/assets/thumbnail_fingerprint.py b/negpy/services/assets/thumbnail_fingerprint.py index 10bfcdb64..7f279af1f 100644 --- a/negpy/services/assets/thumbnail_fingerprint.py +++ b/negpy/services/assets/thumbnail_fingerprint.py @@ -12,7 +12,7 @@ from negpy.domain.models import WorkspaceConfig # Bump in any change that alters rendered pixels for unchanged settings, a changed default included. -THUMBNAIL_RENDER_VERSION = 30 +THUMBNAIL_RENDER_VERSION = 31 # Marks a thumbnail made from the source preview, which runs none of the frame's settings. QUICK = "quick" diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index da39d364b..c38027ce7 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -38,6 +38,7 @@ measure_clip_fractions, measure_neutral_axis_from_log, unmix_log_image, + relog_matrix_image, measure_highlight_point_from_log, measure_shadow_point_from_log, measure_textural_range_from_log, @@ -78,6 +79,8 @@ from negpy.features.local.logic import compute_local_maps, limited_masks from negpy.features.local.models import MAX_KEYED_MASKS from negpy.features.transparency.logic import ( + density_stage_matrices, + source_channels_first, TRANSFER_CONSTANTS, ZONE_BLACK_TAPER, TRANSFER_DENSITY_RANGE, @@ -394,7 +397,7 @@ def __init__(self) -> None: # alignment (exposure) occupies multiple aligned slots. self._uniform_sizes = { "geometry": 64, - "normalization": 160, + "normalization": 208, "exposure": 480, "transfer": 224, "clahe_u": 32, @@ -869,6 +872,8 @@ def process_to_texture( p.fade_white_g, p.fade_white_b, p.process_mode, + p.positive_source, + source_channels_first(p, cam_xyz), None if cam_m is None else tuple(np.asarray(cam_m).ravel().tolist()), ) if analysis_source_hash is not None and not tiling_mode @@ -897,11 +902,15 @@ def process_to_texture( if transfer: # No camera matrix (a scanner TIFF) means the buffer is already in # working space, so the shared grid is the working-space grid. - cam_prefiltered = ( - prefiltered - if cam_m is None - else unmix_log_image(prefilter_log_grid(apply_camera_matrix(analysis_source, cam_m), None, an_buffer), unmix_m) - ) + pre_m, post_m = density_stage_matrices(settings.process, cam_xyz, cam_m, unmix_m) + if cam_m is None: + cam_prefiltered = prefiltered + elif post_m is not None: + cam_prefiltered = relog_matrix_image(prefiltered, post_m) + else: + cam_prefiltered = unmix_log_image( + prefilter_log_grid(apply_camera_matrix(analysis_source, pre_m), None, an_buffer), unmix_m + ) if prefilter_key is not None: self._prefilter_cache = (prefilter_key, prefiltered, scan_clip_fractions, None, cam_prefiltered) if analysis_key is not None: @@ -1906,6 +1915,7 @@ def _upload_unified_uniforms( # Capture-side dye-unmix rows, resolved once per frame by the caller and shared # with NormalizationProcessor. Identity when off; a (3, 4) affine packs its offset in w. + unmix_or_none = unmix if unmix is None: unmix = np.eye(3) unmix_w = unmix[:, 3] if unmix.shape[1] == 4 else (0.0, 0.0, 0.0) @@ -1916,9 +1926,19 @@ def _upload_unified_uniforms( # and the render stops depending on which decode produced the buffer — unless the # capture is narrowband, where an as-shot WB estimate has no scene to describe and # never folds (see should_fold_camera_wb). - cam = camera_to_working_matrix(cam_xyz, camera_wb if should_fold_camera_wb(settings.process) else None) + cam, post = density_stage_matrices( + settings.process, + cam_xyz, + camera_to_working_matrix(cam_xyz, camera_wb if should_fold_camera_wb(settings.process) else None), + unmix_or_none, + ) if cam is None: cam = np.eye(3, dtype=np.float32) + # After the density matrix, a Positive source's primaries (density_stage_matrices); the + # w lane of the first row turns the conversion on. + post_on = 1.0 if post is not None else 0.0 + if post is None: + post = np.eye(3, dtype=np.float32) n_data = ( struct.pack("ffff", adj_floors[0], adj_floors[1], adj_floors[2], 0.0) @@ -1936,6 +1956,9 @@ def _upload_unified_uniforms( + struct.pack("ffff", float(cam[0, 0]), float(cam[0, 1]), float(cam[0, 2]), 0.0) + struct.pack("ffff", float(cam[1, 0]), float(cam[1, 1]), float(cam[1, 2]), 0.0) + struct.pack("ffff", float(cam[2, 0]), float(cam[2, 1]), float(cam[2, 2]), 0.0) + + struct.pack("ffff", float(post[0, 0]), float(post[0, 1]), float(post[0, 2]), post_on) + + struct.pack("ffff", float(post[1, 0]), float(post[1, 1]), float(post[1, 2]), 0.0) + + struct.pack("ffff", float(post[2, 0]), float(post[2, 1]), float(post[2, 2]), 0.0) ) from negpy.features.exposure.logic import ( @@ -2999,11 +3022,15 @@ def _meter_grid() -> np.ndarray: return _prefiltered() if transfer_grid is None: cam_m = camera_to_working_matrix(cam_xyz, camera_wb if should_fold_camera_wb(settings.process) else None) - transfer_grid = ( - _prefiltered() - if cam_m is None - else unmix_log_image(prefilter_log_grid(apply_camera_matrix(_analysis_img(), cam_m), meter_roi, meter_buffer), unmix_m) - ) + pre_m, post_m = density_stage_matrices(settings.process, cam_xyz, cam_m, unmix_m) + if cam_m is None: + transfer_grid = _prefiltered() + elif post_m is not None: + transfer_grid = relog_matrix_image(_prefiltered(), post_m) + else: + transfer_grid = unmix_log_image( + prefilter_log_grid(apply_camera_matrix(_analysis_img(), pre_m), meter_roi, meter_buffer), unmix_m + ) return transfer_grid meter_bounds = LogNegativeBounds(*transfer_bounds()) if transfer else global_anchor_bounds diff --git a/negpy_core/src/analysis.rs b/negpy_core/src/analysis.rs index 28ff3b4cb..fcea83bdd 100644 --- a/negpy_core/src/analysis.rs +++ b/negpy_core/src/analysis.rs @@ -154,6 +154,22 @@ pub fn unmix_log(img: &[f32], m: [[f32; 3]; 3], dst: &mut [f32]) { ); } +/// `relog_matrix_image`: a 3x3 matrix on each pixel of an HxWx3 log image, in linear light. Each +/// value goes to 10^v, each row is float32 products summed in channel order with no `mul_add`, and +/// the log clamps below at 1e-6 as `log_density_open` does. +pub fn relog_matrix(img: &[f32], m: [[f32; 3]; 3], dst: &mut [f32]) { + crate::par_zip( + dst.as_chunks_mut::<3>().0, + img.as_chunks::<3>().0, + move |d, p| { + let lin = p.map(|v| crate::mathf::exp10_f64(v as f64) as f32); + *d = m.map(|r| { + crate::mathf::log10f((lin[0] * r[0] + lin[1] * r[1] + lin[2] * r[2]).max(1e-6)) + }); + }, + ); +} + /// `normalize_log_image`: `(v - floor) / (ceil - floor)` per channel of an HxWx3 log image, /// unclamped. A span under 1e-6 divides by +-1e-6, by the span's sign. pub fn normalize_log_image(img: &[f32], floors: [f32; 3], ceils: [f32; 3], dst: &mut [f32]) { diff --git a/negpy_core/src/py/analysis.rs b/negpy_core/src/py/analysis.rs index e0f53f26e..8078c11c4 100644 --- a/negpy_core/src/py/analysis.rs +++ b/negpy_core/src/py/analysis.rs @@ -467,6 +467,30 @@ fn unmix_log<'py>( Ok(out) } +/// `relog_matrix_image(img_log, matrix)` of an HxWx3 float32 log image. +#[pyfunction] +fn relog_matrix<'py>( + py: Python<'py>, + img: PyReadonlyArrayDyn<'py, f32>, + m: numpy::PyReadonlyArray2<'py, f32>, +) -> PyResult>> { + let shape = img.shape().to_vec(); + if shape.len() != 3 || shape[2] != 3 { + return Err(PyValueError::new_err("relog_matrix needs an HxWx3 array")); + } + let m = super::sensor::matrix(&m)?; + let data = slice(&img)?; + let out = empty::(py, &shape, "float32")?; + { + let mut rw = out.readwrite(); + let dst = rw + .as_slice_mut() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + super::detach(py, dst.len(), || analysis::relog_matrix(data, m, dst)); + } + Ok(out) +} + /// `_block_median_grid` of an HxWxC float32 image in `b` x `b` blocks. #[pyfunction] fn block_median_grid<'py>( @@ -807,6 +831,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(to_log_density, m)?)?; m.add_function(wrap_pyfunction!(log_density_open, m)?)?; m.add_function(wrap_pyfunction!(unmix_log, m)?)?; + m.add_function(wrap_pyfunction!(relog_matrix, m)?)?; m.add_function(wrap_pyfunction!(normalize_to_uint8, m)?)?; m.add_function(wrap_pyfunction!(detection_luma, m)?)?; m.add_function(wrap_pyfunction!(base_blocks, m)?)?; diff --git a/tests/test_bounds_migration.py b/tests/test_bounds_migration.py index c7341e6e7..ae7d9e02c 100644 --- a/tests/test_bounds_migration.py +++ b/tests/test_bounds_migration.py @@ -15,7 +15,7 @@ # Bounds the analysis measures on _synthetic_negative() at each (luma_range_clip, color_range_clip), # under BOUNDS_VERSION. A change to them needs a bump; then record the new values here. -_RECORDED_VERSION = 2 +_RECORDED_VERSION = 3 _RECORDED = { (0.0, 1.0): ([-1.135058, -1.168505, -1.038996], [-0.247402, -0.451715, -0.607022]), (-0.3, 0.5): ([-1.512205, -1.48816, -1.244211], [0.135708, -0.13491, -0.408953]), diff --git a/tests/test_core_relog.py b/tests/test_core_relog.py new file mode 100644 index 000000000..2d6e135e5 --- /dev/null +++ b/tests/test_core_relog.py @@ -0,0 +1,56 @@ +"""The linear-light matrix on a log image (`relog_matrix_image`): a float64 oracle, the identity, +the log floor, and a hash pin.""" + +import negpy_core +import numpy as np +import pytest +from core_pin import digest, linux_pin + +from negpy.features.exposure.normalization import relog_matrix_image + +_M = np.array([[0.7152, 0.2848, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0411, 0.9589]], dtype=np.float64) + + +def _log(seed, shape=(301, 457)): + return -np.random.default_rng(seed).uniform(0.0, 4.0, (*shape, 3)).astype(np.float32) + + +def _oracle(img, m): + lin = 10.0 ** img.astype(np.float64) + return np.log10(np.maximum(lin @ m.astype(np.float32).astype(np.float64).T, 1e-6)) + + +@pytest.mark.parametrize("shape", [(1, 1), (7, 5), (301, 457)]) +def test_matches_a_float64_oracle(shape): + img = _log(shape[0], shape) + np.testing.assert_allclose(relog_matrix_image(img, _M), _oracle(img, _M), atol=2e-6) + + +def test_the_identity_keeps_the_densities(): + img = _log(2) + np.testing.assert_allclose(relog_matrix_image(img, np.eye(3)), img, atol=1e-6) + + +def test_a_mix_below_zero_stops_at_the_log_floor(): + img = np.full((2, 2, 3), -1.0, np.float32) + out = relog_matrix_image(img, np.array([[1.0, -2.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])) + np.testing.assert_allclose(out[..., 0], -6.0, atol=1e-6) + + +def test_no_matrix_is_the_identity(): + img = _log(4) + assert relog_matrix_image(img, None) is img + + +def test_rejects_a_non_rgb_image(): + with pytest.raises(ValueError): + negpy_core.relog_matrix(np.zeros((4, 4, 2), np.float32), np.eye(3, dtype=np.float32)) + + +@linux_pin +def test_relog_matrix_is_pinned(): + img = _log(5) + assert ( + digest(relog_matrix_image(img, _M), relog_matrix_image(img[:, ::-1], _M.T)) + == "7bf89a974d8dea2088c65273e29b163cdd8682559743571044c0c9b6367fca1d" + ) diff --git a/tests/test_core_same_bits.py b/tests/test_core_same_bits.py index 65a57b87e..d8885b06e 100644 --- a/tests/test_core_same_bits.py +++ b/tests/test_core_same_bits.py @@ -58,6 +58,10 @@ def _filled(fill): np.ascontiguousarray(_IMG3 * np.float32(-3.0)), np.array([[1.3, -0.2, -0.1], [-0.05, 1.4, -0.35], [0.0, -0.3, 1.3]], np.float32), ), + "relog_matrix": lambda: negpy_core.relog_matrix( + np.ascontiguousarray(_IMG3 * np.float32(-3.0)), + np.array([[0.7152, 0.2848, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0411, 0.9589]], np.float32), + ), "oetf_encode": lambda: negpy_core.oetf_encode(_IMG3, np.float32(256.0 / 563.0)), "rgb_to_lab": lambda: negpy_core.rgb_to_lab( _IMG3, _M, np.array([0.95047, 1.0, 1.08883], np.float32), np.float32(0.008856), np.float32(7.787) @@ -159,6 +163,7 @@ def _filled(fill): "morph_close_u8": "4be9a2976881ff83eb0b92a8ec484e048c61612324afbde73b6e0c89aa92865e", "to_log_density": "cc4d2086edf051826619f024a4f5d28c80d28528291d9df4610c1d3854144038", "unmix_log": "73edc03deb7e068718e77264608858efaec4b64776f64645da9f4aef54ec23b4", + "relog_matrix": "e79e7bbb62aa68e9248c9b92d9875cbb5779b640a93b8f8e45a648b5009547be", "oetf_encode": "44508d28673c678fc0ec7b5262a06d0053ea6b4bed4bead7bc49556bc60eb070", "rgb_to_lab": "02c583bf1f5fe9723e2cb0a3e1f34759d6b0778d3644e8e2cff2251e756b68f2", "lab_to_rgb": "34016e7f9aebcfdf780b67b96b128041c6ba1ac2a427f4e87b5413dcc7345eef", diff --git a/tests/test_pipeline_parity.py b/tests/test_pipeline_parity.py index 5463fc5a7..000fa3351 100644 --- a/tests/test_pipeline_parity.py +++ b/tests/test_pipeline_parity.py @@ -365,6 +365,29 @@ def test_slide_fade_restoration(self, positive): assert float(np.abs(cpu - plain).mean()) > 0.02 _assert_mostly_close(cpu, gpu, atol=2e-2, rtol=2e-2, max_violation_frac=0.01) + @pytest.mark.parametrize("fade", [False, True]) + def test_positive_source_primaries(self, fade): + # A Positive source's primaries ride the camera-matrix slot; with a fade both engines + # convert them after the density matrix, the GPU in the normalization shader. + from negpy.domain.interfaces import PipelineContext + from negpy.features.process.capture_color import source_cam_xyz + from negpy.infrastructure.loaders.source_encoding import _SRGB_COLORANTS_D50, _to_working + from negpy.kernel.system.config import render_scale_factor + + cam_xyz = source_cam_xyz(_to_working(_SRGB_COLORANTS_D50)) + base = _make_base_settings() + ratios = dict(fade_ratio_r=0.5, fade_ratio_g=1.6, fade_ratio_b=1.3) if fade else {} + s = replace(base, process=replace(base.process, process_mode=ProcessMode.E6, positive_source=True, **ratios)) + h, w = self.img.shape[:2] + ctx = PipelineContext( + original_size=(h, w), scale_factor=render_scale_factor(self.img.shape), process_mode=ProcessMode.E6, cam_xyz=cam_xyz + ) + cpu = self.cpu.process(self.img, s, f"parity_positive_primaries_{fade}", ctx) + tex, _ = self.gpu.process_to_texture( + self.img, s, scale_factor=max(h, w) / 1024.0, apply_layout=False, readback_metrics=False, cam_xyz=cam_xyz + ) + _assert_mostly_close(cpu, self.gpu._readback_downsampled(tex), atol=2e-2, rtol=2e-2, max_violation_frac=0.01) + class TestLabParity: """CPU vs GPU parity for the lab color/sharpening shader.""" diff --git a/tests/test_positive_source_primaries.py b/tests/test_positive_source_primaries.py new file mode 100644 index 000000000..6f9d92efc --- /dev/null +++ b/tests/test_positive_source_primaries.py @@ -0,0 +1,138 @@ +"""A Positive source keeps its own primaries through the decode. The engines convert them after the +crosstalk unmix, fade and stain offset, which act on the source's channels.""" + +from types import SimpleNamespace + +import numpy as np +import pytest +from PIL import Image + +from negpy.desktop.session import camera_raw_preview +from negpy.domain.interfaces import PipelineContext +from negpy.features.exposure.normalization import relog_matrix_image, unmix_log_image +from negpy.features.process.capture_color import camera_to_working_matrix, is_source_profile, source_cam_xyz +from negpy.features.process.fade import unmix_with_fade +from negpy.features.process.models import ProcessConfig, ProcessMode +from negpy.features.transparency.logic import density_stage_matrices +from negpy.features.transparency.processor import TransparencyBaseProcessor +from negpy.infrastructure.loaders.helpers import camera_xyz_matrix +from negpy.infrastructure.loaders.jpeg_loader import JpegLoader +from negpy.infrastructure.loaders.source_encoding import _SRGB_COLORANTS_D50, ProfileDecode, _to_working, apply_decode, decode_positive + +_P = _to_working(_SRGB_COLORANTS_D50) +_SOURCE = source_cam_xyz(_P) +# A camera's XYZ->cam rows, as libraw gives them (3 rows after camera_xyz_matrix). +_CAMERA = [[0.6722, -0.0635, -0.0963], [-0.4287, 1.246, 0.2046], [-0.0908, 0.2268, 0.5762]] +_FADE = dict(fade_ratio_r=0.4, fade_ratio_g=2.0, fade_ratio_b=1.5) + + +def _slide(**kw) -> ProcessConfig: + return ProcessConfig(process_mode=ProcessMode.E6, **kw) + + +def _img(seed: int = 0) -> np.ndarray: + return np.random.default_rng(seed).uniform(0.02, 1.0, (40, 60, 3)).astype(np.float32) + + +def test_source_cam_xyz_gives_back_the_profile_matrix(): + np.testing.assert_allclose(camera_to_working_matrix(_SOURCE), _P, atol=2e-4) + assert source_cam_xyz(None) is None + assert is_source_profile(_SOURCE) and not is_source_profile(_CAMERA) and not is_source_profile(None) + + +@pytest.mark.parametrize( + ("positive", "unmix", "cam_xyz", "expected"), + [(True, True, _SOURCE, "post"), (True, False, _SOURCE, "pre"), (False, True, _SOURCE, "pre"), (True, True, _CAMERA, "pre")], +) +def test_the_primaries_follow_the_density_matrix_only_for_a_positive_source_profile(positive, unmix, cam_xyz, expected): + m = np.eye(3) * 2.0 if unmix else None + pre, post = density_stage_matrices(_slide(positive_source=positive), cam_xyz, _P, m) + assert (pre is _P, post is _P) == ((expected == "pre"), (expected == "post")) + + +def test_no_source_matrix_has_nothing_to_move(): + assert density_stage_matrices(_slide(positive_source=True), _SOURCE, None, np.eye(3)) == (None, None) + + +def _render(config: ProcessConfig, img: np.ndarray, cam_xyz=_SOURCE, camera_wb=None): + ctx = PipelineContext(original_size=img.shape[:2], scale_factor=1.0, process_mode=ProcessMode.E6, cam_xyz=cam_xyz, camera_wb=camera_wb) + return TransparencyBaseProcessor(config).process(img, ctx), ctx + + +def _restored_log(ctx) -> np.ndarray: + floors, ceils = ctx.metrics["log_bounds"].floors, ctx.metrics["log_bounds"].ceils + return ctx.metrics["normalized_log"] * (np.asarray(ceils) - np.asarray(floors)) + np.asarray(floors) + + +def test_a_positive_fade_acts_on_the_source_channels_then_converts(): + config = _slide(positive_source=True, **_FADE) + img = _img() + _, ctx = _render(config, img) + p = camera_to_working_matrix(_SOURCE) + expected = relog_matrix_image(unmix_log_image(np.log10(img), unmix_with_fade(config, ProcessMode.E6)), p) + converted_first = unmix_log_image(np.log10(img @ p.T), unmix_with_fade(config, ProcessMode.E6)) + restored = _restored_log(ctx) + np.testing.assert_allclose(restored, expected, atol=1e-4) + assert float(np.abs(restored - converted_first).max()) > 0.05 + + +def test_a_positive_at_neutral_renders_as_the_conversion_first(): + img = _img(1) + res, _ = _render(_slide(positive_source=True), img) + ctx = PipelineContext(original_size=img.shape[:2], scale_factor=1.0, process_mode=ProcessMode.E6) + p = camera_to_working_matrix(_SOURCE) + ref = TransparencyBaseProcessor(_slide(positive_source=True)).process(img @ p.T, ctx) + np.testing.assert_allclose(res, ref, atol=1e-5) + + +def test_estimate_reads_the_source_channels(): + """Estimate renders with the fade at identity, so its grid must not take the conversion.""" + img = _img(2) + _, ctx = _render(_slide(positive_source=True), img) + grid = ctx.metrics["transfer_grid"] + assert grid.min() >= np.log10(img.min()) - 1e-4 + flat = np.full((40, 60, 3), (0.5, 0.2, 0.1), np.float32) + _, ctx = _render(_slide(positive_source=True), flat) + np.testing.assert_allclose(ctx.metrics["transfer_grid"].reshape(-1, 3)[0], np.log10((0.5, 0.2, 0.1)), atol=1e-4) + + +def test_a_positive_jpeg_keeps_its_primaries_and_carries_the_matrix(tmp_path): + path = str(tmp_path / "srgb.jpg") + Image.new("RGB", (8, 8), (255, 0, 0)).save(path, quality=100) + ctx, metadata = JpegLoader().load(path, positive_decode="auto") + with ctx as raw: + assert raw.data[0, 0, 0] > 0.95 and raw.data[0, 0, 1] < 0.02 + np.testing.assert_allclose(camera_to_working_matrix(camera_xyz_matrix(raw)), _P, atol=2e-4) + assert metadata["source_encoding"] + + +def test_a_wider_source_converts_in_the_decode(): + """ProPhoto reaches past Adobe RGB, so it takes the decode's gamut pass and carries no matrix.""" + prophoto = _to_working(np.array([[0.7977, 0.1352, 0.0313], [0.2880, 0.7119, 0.0001], [0.0, 0.0, 0.8249]])) + curves = (np.linspace(0.0, 1.0, 256, dtype=np.float32),) * 3 + img = np.random.default_rng(3).integers(0, 256, (6, 8, 3), dtype=np.uint8) + linear, matrix = decode_positive(img, ProfileDecode(curves, prophoto)) + assert matrix is None + np.testing.assert_array_equal(linear, apply_decode(img, ProfileDecode(curves, prophoto))) + _, srgb = decode_positive(img, ProfileDecode(curves, _P)) + assert srgb is _P + + +def test_a_positive_decode_is_not_a_camera_raw(): + assert camera_raw_preview(SimpleNamespace(preview_cam_xyz=_CAMERA)) + assert not camera_raw_preview(SimpleNamespace(preview_cam_xyz=_SOURCE)) + assert not camera_raw_preview(SimpleNamespace(preview_cam_xyz=None)) + + +def test_a_camera_raw_on_positive_converts_before_the_density_stage(): + """A Linear RAW folds the as-shot white balance into the camera matrix, so it applies before the + fade and the stain offset, which then act on balanced channels.""" + wb = (2.0, 1.0, 1.5) + img = np.full((40, 60, 3), 0.8, np.float32) / np.asarray(wb, np.float32) + config = _slide( + positive_source=True, linear_raw=True, fade_white_anchor=True, fade_white_r=-0.3, fade_white_g=-0.1, fade_white_b=-0.2, **_FADE + ) + _, ctx = _render(config, img, cam_xyz=_CAMERA, camera_wb=list(wb)) + cam = camera_to_working_matrix(_CAMERA, wb) + expected = unmix_log_image(np.log10(img @ cam.T), unmix_with_fade(config, ProcessMode.E6)) + np.testing.assert_allclose(_restored_log(ctx), expected, atol=1e-4) diff --git a/tests/test_source_encoding.py b/tests/test_source_encoding.py index fb8c9032b..d45aedb45 100644 --- a/tests/test_source_encoding.py +++ b/tests/test_source_encoding.py @@ -10,10 +10,12 @@ from PIL import Image from negpy.domain.models import ColorSpace, ExportFormat, WorkspaceConfig +from negpy.features.process.capture_color import apply_camera_matrix, camera_to_working_matrix from negpy.features.process.logic import linear_raw_token from negpy.features.process.models import ProcessConfig, ProcessMode, SourceEncoding, with_positive_source from negpy.features.process.path import positive_decode from negpy.infrastructure.loaders import source_encoding as se +from negpy.infrastructure.loaders.helpers import camera_xyz_matrix from negpy.infrastructure.loaders.source_kind import is_scanned_positive_source, with_scanner_positive_default from negpy.infrastructure.loaders.tiff_loader import TiffLoader from negpy.kernel.image.logic import working_oetf_decode @@ -62,16 +64,25 @@ def test_auto_decodes_an_adobe_rgb_profile_even_with_linear_raw(tmp_path, linear assert metadata["color_space"] == ColorSpace.ADOBE_RGB.value +def _load_working(path: str, encoding: str) -> np.ndarray: + """The decode as the engines see it: the source primaries the loader keeps, then the + source-to-working matrix it carries in the camera-matrix slot.""" + ctx, _ = TiffLoader().load(path, linear_raw=True, positive_decode=encoding) + with ctx as raw: + return apply_camera_matrix(raw.data, camera_to_working_matrix(camera_xyz_matrix(raw))) + + def test_an_srgb_profile_converts_to_adobe_primaries(tmp_path) -> None: """Pure sRGB red is inside Adobe RGB, so it lands at a smaller red and a nonzero green.""" data = np.zeros((4, 4, 3), dtype=np.uint16) data[..., 0] = 65535 - f32, _ = _load(_write(tmp_path, data, _icc("sRGB-v4.icc")), SourceEncoding.AUTO) - r, g, b = f32[0, 0] - assert 0.70 < r < 0.73 and 0.0 <= g < 0.01 and b < 0.01 + path = _write(tmp_path, data, _icc("sRGB-v4.icc")) + source, _ = _load(path, SourceEncoding.AUTO) + np.testing.assert_allclose(source[0, 0], (1.0, 0.0, 0.0), atol=1e-6) + r, g, b = _load_working(path, SourceEncoding.AUTO)[0, 0] + assert 0.70 < r < 0.73 and abs(g) < 0.01 and abs(b) < 0.01 data[..., :] = 65535 - f32, _ = _load(_write(tmp_path, data, _icc("sRGB-v4.icc")), SourceEncoding.AUTO) - np.testing.assert_allclose(f32[0, 0], 1.0, atol=2e-3) + np.testing.assert_allclose(_load_working(_write(tmp_path, data, _icc("sRGB-v4.icc")), SourceEncoding.AUTO)[0, 0], 1.0, atol=2e-3) def test_linear_and_gamma_override_the_profile(tmp_path) -> None: diff --git a/tests/thumbnail_fingerprint_defaults.json b/tests/thumbnail_fingerprint_defaults.json index 62fc41321..c3a0dde20 100644 --- a/tests/thumbnail_fingerprint_defaults.json +++ b/tests/thumbnail_fingerprint_defaults.json @@ -211,5 +211,5 @@ "vanadium_strength": "0.0" } }, - "render_version": 30 + "render_version": 31 }