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
2 changes: 1 addition & 1 deletion docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ These steps run in order. Each stage passes its buffer to the next.

* **Physical model**: the input is a **radiometric measurement**: pixel values are linear transmittance.
* **Source corrections** (linear domain, before the log conversion):
* **Flat-field** (`negpy.features.flatfield`): divides out illumination falloff with a blank reference frame. A per-channel gain map $\text{mean}(\text{blur})/\text{blur}$, computed on a 256 px copy and clamped to $[0.25, 4]$, multiplies the linear source. The Gaussian blur has $\sigma$ = 1/64 of the long side: small enough to follow the light source's own pattern (LED hot spots behind a diffuser), which repeats from shot to shot. A profile stores its baked gain, so a profile made with an earlier blur keeps that gain until a new profile is made from its reference. The blur and the mean cover only the lit area: pixels below 0.2 of the 95th-percentile luminance (a carrier edge in the reference) are masked out of a normalized convolution, so a dark border does not overcorrect the frame next to it. Pixels too far from the lit area take the nearest valid value. A profile also stores a 512 px copy of the reference (float16) and its self-check. Check Flat Field is a canvas view, not a stage: that copy times the gain, each channel over its lit-area median, ±10% to black..white; a profile without the copy shows 1/gain over ±15%. The gain is **baked once** into a profile (an `.npz` in `APP_CONFIG.flatfield_dir`, keyed by an opaque id), so moving or deleting the reference file is harmless. The edit stores only the profile id. The render resolves the gain through a provider (`set_gain_provider`, wired to `services/assets/flatfield.py`) and caches it; the profile id and a gain content token fold into the source hash.
* **Flat-field** (`negpy.features.flatfield`): divides out illumination falloff with a blank reference frame. A per-channel gain map $\text{mean}(\text{blur})/\text{blur}$, computed on a 256 px copy and clamped to $[0.25, 4]$, multiplies the linear source. The gain is stored in **sensor layout**: the bake undoes the reference's EXIF orientation, and each frame's decode orientation turns the gain before it is applied, so a camera on a copy stand that tags frames differently from its flat still gets the gain where the light fell. The Gaussian blur has $\sigma$ = 1/64 of the long side: small enough to follow the light source's own pattern (LED hot spots behind a diffuser), which repeats from shot to shot. A profile stores its baked gain, so a profile made with an earlier blur keeps that gain until a new profile is made from its reference. The blur and the mean cover only the lit area: pixels below 0.2 of the 95th-percentile luminance (a carrier edge in the reference) are masked out of a normalized convolution, so a dark border does not overcorrect the frame next to it. Pixels too far from the lit area take the nearest valid value. A profile also stores a 512 px copy of the reference (float16) and its self-check. Check Flat Field is a canvas view, not a stage: that copy times the gain, each channel over its lit-area median, ±10% to black..white; a profile without the copy shows 1/gain over ±15%. The gain is **baked once** into a profile (an `.npz` in `APP_CONFIG.flatfield_dir`, keyed by an opaque id), so moving or deleting the reference file is harmless. The edit stores only the profile id. The render resolves the gain through a provider (`set_gain_provider`, wired to `services/assets/flatfield.py`) and caches it; the profile id and a gain content token fold into the source hash.
* **Embedded lens correction** (`negpy.features.lens`): the scanning camera's own distortion and lateral CA profile (DNG `WarpRectilinear`, Sony ARW, and a radial distortion polynomial for Panasonic RW2), read from the source file and applied at decode after flat-field and before the unmix, so neither engine needs a shader. The RW2 model follows darktable's embedded-metadata reader and carries no lateral CA. It is skipped for composites and RGB+IR sources. A correction that reads past the frame edge is scaled about its center to fill (`fill_scale` ≤ 1); a CA-only correction is not scaled.
* **Sensor crosstalk unmix** (`sensor_matrix`, `features/process/sensor.py`): for single-shot narrowband camera scans, CFA passbands overlap the light's bands, so each channel leaks into the others. It is a property of the sensor and light, not the film. It is calibrated from three bare-light exposures (columns normalized to a unit diagonal), inverted, and applied as a 3×3 unmix of the **linear** capture, ahead of the log. Where the film passes almost none of a band's light, the subtraction leaves a value smaller than its grain and calibration error, and the log turns it into maximum density. `sensor_unmix` picks the handling (default `two_scale`). `linear` clips at zero. `density` applies $C = \mathrm{diag}(1/(M b))\,M\,\mathrm{diag}(b)$, the unmix linearized at the film base $b$ (rows sum to 1), to $\ln x - \ln b$; each channel is held at $10^{-3} b_c$ or above, so it never reaches zero. `two_scale` is the clipped linear unmix where its own-channel gain $M_{cc}\bar x_c / (M\bar x)_c$ stays below 1.15 $C_{cc}$, and blends by a smoothstep (full at 1.5 $C_{cc}$, one weight per pixel) toward $\mathrm{SF}(M\bar x)\,\exp\!\big(C(\ln x - \ln \bar x)\big)$. $\bar x$ is the capture blurred by $\sigma = $ long edge / 1200; $\mathrm{SF}$ is the smooth maximum $\tfrac12\big(u + f + \sqrt{(u - f)^2 + f^2}\big)$ of each unmixed channel and $f = 0.15\,M_{cc}\bar x_c$; a channel whose detail $\ln(x_c/\bar x_c)$ sits $\ln 64$ below both others' is a dead photosite, not an edge, and takes their mean. $b$ is the median color of the frame-interior cells thinnest in all three channels together; only its color matters. A channel whose 99.5th percentile sits at its maximum is clipped, and both methods fall back to `linear`. A stitch is unmixed once, assembled, and a half-frame after it is sliced, so preview and export read the same base. `unmix_block_reason` refuses it on a transparency (E-6 is not scanned narrowband) and on a camera-WB decode (`linear_raw` off), where a diagonal gain does not commute with the unmix. The **narrowband scan** toggle instead applies the bundled RGBScan *input* profile at the display and export boundary; an explicit Input ICC overrides it.
* **HDR merge** (`features/hdr`): combines a bracket of one frame into a single linear source at decode, next to the triplet merge. It has no shader and no GPU parity surface.
Expand Down
2 changes: 1 addition & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -879,7 +879,7 @@ Corrects uneven illumination (vignetting, falloff) from a copy-stand or scanner

* **Profile**, with **+** and **trash** on the FLAT FIELD CORRECTION header: **+** bakes a reference image into a named profile in NegPy's `flatfield` folder, after which the image can be deleted. **Trash** asks first; every frame using the profile loses its correction.
* **Apply Flat Field** (bulb toggle beside the dropdown): apply the selected profile to this roll, enabled once a profile exists.
* **Check Flat Field** (eye toggle): shows how well the selected profile corrects its own reference, from any frame and without the reference file. A good profile shows an even gray; a band or patch shows where it is off, and the stronger it is, the larger the error: full white or black is 10% or more. The status line gives the spread. A profile saved before this check shows the light it corrects instead; save it again to check it. Saving a profile warns when its reference is clipped or does not correct itself evenly.
* **Check Flat Field** (eye toggle): shows how well the selected profile corrects its own reference, from any frame and without the reference file. A good profile shows an even gray; a band or patch shows where it is off, and the stronger it is, the larger the error: full white or black is 10% or more. It sits the way the open frame does, turned and flipped with it but without fine rotation, keystone or crop, so the edges stay in view. The status line gives the spread. A profile saved before this check shows the light it corrects instead; save it again to check it. Saving a profile warns when its reference is clipped or does not correct itself evenly.

A newly chosen profile becomes the rig's default for the next roll.

Expand Down
24 changes: 20 additions & 4 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from negpy.kernel.system.memory import available_system_memory_bytes
from negpy.kernel.system.text import count_of, plural
from negpy.kernel.image.logic import working_oetf_encode
from negpy.kernel.image.logic import apply_exif_orientation, working_oetf_encode
from negpy.desktop.auto_sliders import record_meters
from negpy.desktop.converters import ImageConverter
from negpy.desktop.render_memo import RenderMemo
Expand Down Expand Up @@ -2924,6 +2924,7 @@ def _on_preview_loaded(
self.state.preview_raw = raw
self.state.preview_cam_xyz, self.state.preview_camera_wb = cam_matrix[:2] if cam_matrix else (None, None)
self.state.preview_lens = cam_matrix[2] if cam_matrix and len(cam_matrix) > 2 else None
self.state.preview_orientation = int(cam_matrix[4] or 1) if cam_matrix and len(cam_matrix) > 4 else 1
self.state.preview_lens_path = file_path
self.state.preview_lens_token = decoded_lens_token
self._decoded_source_token = source_token(self.state.config)
Expand Down Expand Up @@ -3506,6 +3507,7 @@ def toggle_test_strip(self, force: Optional[bool] = None, kind: str = "tone") ->
detect_buffer=self.state.preview_detect,
cam_xyz=cam_xyz,
camera_wb=camera_wb,
source_orientation=self.state.preview_orientation,
)
)

Expand Down Expand Up @@ -3661,7 +3663,9 @@ def auto_skew_frame(self) -> None:
config = self.state.config
geo = config.geometry
# Every detection path reads the flat-fielded source.
source = raw if metadata_lens_corrections(config) else apply_flatfield(raw, config.flatfield)
source = (
raw if metadata_lens_corrections(config) else apply_flatfield(raw, config.flatfield, orientation=self.state.preview_orientation)
)
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
fit = squared_geometry(source, geo, config.process.process_mode)
Expand Down Expand Up @@ -6760,6 +6764,7 @@ def request_render(
gutter_thickness=dip[0]["gutter_thickness"] if dip is not None else 0.0,
split_axis=str(dip[0].get("split_axis") or "x") if dip is not None else "x",
gain_slices=self._preview_gain_slices(dip),
source_orientation=self.state.preview_orientation,
)

self._cancel_neighbor_prefetch()
Expand Down Expand Up @@ -7125,8 +7130,9 @@ def _paint_flatfield_peek(self) -> None:

The profile's stored reference copy times its gain, each channel over its lit-area
median, ±EVENNESS_RANGE as black..white, so a good profile shows gray. A profile saved
before the copy was stored shows the light its gain corrects instead. Sensor layout:
the frame's geometry is not the reference's.
before the copy was stored shows the light its gain corrects instead. It sits the way the
frame does: a sensor-layout profile takes the frame's EXIF orientation, then the frame's
quarter turns and flips. Fine rotation, keystone and crop are left out, so the edges show.
"""
import cv2

Expand Down Expand Up @@ -7154,6 +7160,16 @@ def _paint_flatfield_peek(self) -> None:
f"Flat Field '{name}' was saved before the check: showing the light it corrects, "
"darker where there is less light. Save the profile again to check it"
)
if stored.sensor_layout:
view = apply_exif_orientation(view, self.state.preview_orientation)
# GeometryProcessor's order.
geometry = self.state.config.geometry
view = np.rot90(view, k=geometry.rotation)
if geometry.flip_horizontal:
view = np.fliplr(view)
if geometry.flip_vertical:
view = np.flipud(view)
view = np.ascontiguousarray(view)
h, w = view.shape[:2]
scale = APP_CONFIG.preview_render_size / max(h, w)
view = cv2.resize(view, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_LINEAR)
Expand Down
2 changes: 2 additions & 0 deletions negpy/desktop/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from negpy.services.assets.crosstalk import CrosstalkProfiles
from negpy.services.assets.flatfield import FlatFieldProfiles
from negpy.services.assets.migrations.flatfield import migrate_legacy_flatfield_profiles
from negpy.services.assets.migrations.flatfield_layout import migrate_flatfield_sensor_layout
from negpy.services.assets.gear import GearProfiles
from negpy.services.assets.migrations.gear_presets import migrate_gear_presets
from negpy.services.assets.migrations.normalization_roll import migrate_legacy_normalization_rolls
Expand Down Expand Up @@ -296,6 +297,7 @@ def main() -> None:
# into that store.
set_gain_provider(FlatFieldProfiles.load_gain)
migrate_legacy_flatfield_profiles(repo)
migrate_flatfield_sensor_layout()
migrate_gear_presets(repo)
migrate_legacy_normalization_rolls(repo)
migrate_auto_meter_for_positive_frames(repo)
Expand Down
2 changes: 2 additions & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ class AppState:
# None for sources that carry no camera matrix (scanner TIFF, JPEG).
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.
preview_orientation: int = 1
preview_lens: Optional[LensMetadata] = None
preview_lens_path: str = ""
preview_lens_token: str = ""
Expand Down
Loading
Loading