Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
9 changes: 8 additions & 1 deletion negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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."""
Expand Down
5 changes: 3 additions & 2 deletions negpy/desktop/view/sidebar/demosaic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion negpy/desktop/view/sidebar/scan_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]:
Expand Down
11 changes: 10 additions & 1 deletion negpy/features/exposure/normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions negpy/features/exposure/shaders/normalization.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ struct NormUniforms {
cam0: vec4<f32>,
cam1: vec4<f32>,
cam2: vec4<f32>,
// 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<f32>,
post1: vec4<f32>,
post2: vec4<f32>,
};

@group(0) @binding(0) var input_tex: texture_2d<f32>;
Expand Down Expand Up @@ -63,6 +69,14 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
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<f32>(10.0), log_color);
log_color = log10_vec(max(vec3<f32>(
dot(params.post0.xyz, unmixed),
dot(params.post1.xyz, unmixed),
dot(params.post2.xyz, unmixed),
), vec3<f32>(epsilon)));
}

var res: vec3<f32>;

Expand Down
20 changes: 20 additions & 0 deletions negpy/features/process/capture_color.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading