Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ A frame with no crop meters a **meter rect** (`process.meter_rect`, `resolve_met

Pixel values are linear transmittance. The source corrections run in that domain, before the log.

* **Shots of a multi-shot RAW** (`infrastructure/loaders/raw_shots.py`, `pixel_shift.py`): a TIFF-based RAW with several same-size CFA images holds several shots. LibRaw selects a shot at open, so `shot_select` is set before `open_file`. `raw_shot` 0 combines a Pentax Pixel Shift (4 shots, makernote Quality 7) and decodes the first shot of any other file. The combine follows RawTherapee's `rtengine/pixelshift.cc` without motion correction. Shot $k$ shows at $(i + dy_k, j + dx_k)$ what the first shot shows at $(i, j)$, with $(dy, dx) = (0,0), (1,0), (1,1), (0,1)$. So the 2×2 CFA block at $(i, j)$ gives the pixel one R, two G and one B sample, and the last row and column copy their neighbors. The counts above black are cached per file, G as the sum of its two samples. Each path scales them as LibRaw scales one shot: $\lfloor x \cdot 65535 / (n S) \rfloor$ in float32, with $n = 2$ for G and 1 for R and B. $S$ is `user_sat` for the render and the preview, and the white level minus black for Linear Output. The two G samples come from two shots. A pixel disagrees when their difference exceeds $6\sigma$, with $\sigma^2 = g L + f$ fitted on the flattest quarter of each level bin. Above `MISMATCH_WARNING` of the pixels, or `LIGHT_WARNING` of mean-level change from the first shot, the shots disagree.
* **Flat-field** (`negpy.features.flatfield`): a per-channel gain $\text{mean}(\text{blur})/\text{blur}$ from a blank reference, computed on a 256 px copy with $\sigma$ = 1/64 of the long side, clamped to $[0.25, 4]$. The blur and mean are a normalized convolution over the lit area (pixels above 0.35 of the 95th-percentile luminance), so a dark border does not overcorrect the frame beside it. The gain is stored in sensor layout and turned by each frame's decode orientation. It is baked once into a profile (an `.npz` in `APP_CONFIG.flatfield_dir`, keyed by an opaque id, with a 512 px float16 copy of the reference for the self-check: Check Flat Field shows that copy times the gain, each channel over its lit-area median, ±10% to black and white, or 1/gain over ±15% without the copy); the edit stores only the id. The render resolves it through `set_gain_provider` (wired to `services/assets/flatfield.py`); the profile id and a gain content token fold into the source hash.
* **Embedded lens correction** (`negpy.features.lens`): the camera's own distortion and lateral CA (DNG `WarpRectilinear`, Sony ARW; Panasonic RW2 distortion only, after darktable's reader), applied at decode after flat-field and before the unmix, so neither engine needs a shader. Composites and RGB+IR sources skip it. A correction that reads past the frame edge is scaled to fill (`fill_scale` ≤ 1).
* **Sensor crosstalk unmix** (`sensor_matrix`, `features/process/sensor.py`): for single-exposure narrowband camera scans, whose CFA passbands overlap the light's bands. The matrix is calibrated from three bare-light exposures (columns normalized to a unit diagonal), inverted, and applied to the linear capture. Where the film passes almost none of a band, the subtraction falls below grain and calibration error, which the log turns into maximum density. `sensor_unmix` picks the handling (default `two_scale`):
Expand Down
1 change: 1 addition & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@ A camera shows the Raw Decode and Optics cards. A camera with narrowband light a

How a camera RAW file is turned into pixels. Shown for a camera.

* **Shots**: a RAW file that holds several shots, such as an in-camera HDR, opens one of them. For a Pentax Pixel Shift file, **Pixel Shift** combines its four shots, so that every pixel has measured red, green and blue. The line below reports the result. A warning shows when the shots do not match. Shoot Pixel Shift with the self-timer or a remote, because the shutter button moves the camera.
* **Camera WB / Linear RAW**: Camera WB uses the camera's white balance. Linear RAW decodes neutral, for narrowband light. Setup sets it for you.
* **Preview** / **Export**: the demosaic method, which fills in the colors each sensor pixel cannot see. **Auto** is fast on screen and uses **AHD** for export. **VNG** is smooth and **PPG** fast. **DCB** and **DHT** keep more fine detail but can turn grain into patterns. **AAHD** softens edges. Only the methods in your LibRaw build are listed.
* **Recovery** (slides): rebuilds blown highlights in a camera RAW. **Blend** suits sun and sky. **Reconstruct** is stronger but can get the color wrong.
Expand Down
19 changes: 19 additions & 0 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from negpy.desktop.sticky import STICKY_CONFIG_KEY
from negpy.desktop.session import (
AppState,
raw_shots_warning,
DesktopSessionManager,
ToolMode,
REVERTIBLE_TOOLS,
Expand Down Expand Up @@ -240,6 +241,7 @@
from negpy.features.toning.models import ToningConfig
from negpy.infrastructure.capture.settings import WhiteCaptureMode
from negpy.infrastructure.display.color_spaces import ColorSpaceRegistry
from negpy.infrastructure.loaders.raw_shots import ShotsInfo
from negpy.infrastructure.filesystem.watcher import FolderWatchService
from negpy.infrastructure.gpu.device import GPUDevice
from negpy.infrastructure.gpu.resources import GPUTexture
Expand Down Expand Up @@ -747,6 +749,8 @@ def __init__(self, session_manager: DesktopSessionManager):
self._scan_as_roll = False
self._batch_frame_selected = False
self._gpu_fallback_notified = False
# Files whose Pixel Shift warning has shown this session: once per file, not per decode.
self._raw_shots_warned: Set[str] = set()
self._cleaned_up = False
self._active_batch: Optional[str] = None
self._active_batch_title = ""
Expand Down Expand Up @@ -1193,6 +1197,7 @@ def _connect_signals(self) -> None:
self.preview_load_worker.splash.connect(self._on_splash_preview)
self.preview_load_worker.finished.connect(self._on_preview_loaded)
self.preview_load_worker.vram_capped.connect(self._on_hq_preview_vram_capped)
self.preview_load_worker.raw_shots.connect(self._on_raw_shots_loaded)
self.preview_load_worker.error.connect(self._on_preview_load_error)
self.preview_load_worker.load_failed.connect(self._on_preview_load_failed)
self.prefetch_load_requested.connect(self.prefetch_load_worker.process)
Expand Down Expand Up @@ -3246,6 +3251,7 @@ def load_file(self, file_path: str, preserve_zoom: bool = False) -> None:
flatfield_profile_id=flatfield.profile_id if (stitch.stitch_enabled and flatfield.apply) else "",
half_slice=half_info,
demosaic=self.state.config.process.demosaic_preview,
raw_shot=self.state.config.process.raw_shot,
lens_corrections=metadata_lens_corrections(self.state.config),
lens_flatfield=self.state.config.flatfield,
)
Expand Down Expand Up @@ -3297,6 +3303,18 @@ def _on_preview_load_failed(self, file_path: str, message: str) -> None:
self.session.asset_model.refresh()
return

def _on_raw_shots_loaded(self, file_path: str, info: Optional[ShotsInfo]) -> None:
"""What a multi-shot RAW decoded to, ahead of the preview_loaded of the same load. The
Raw Decode card reads it; a Pixel Shift whose shots disagree warns once per file."""
if self._requested_file_path != file_path:
return
self.state.preview_raw_shots = info
self.state.preview_raw_shots_path = file_path
warning = raw_shots_warning(info)
if warning and file_path not in self._raw_shots_warned:
self._raw_shots_warned.add(file_path)
self.set_status(warning, 8000, kind="warning")

def _on_hq_preview_vram_capped(self, file_path: str, capped_long_edge: int) -> None:
"""An HQ load exceeded the GPU's VRAM budget and was downsampled instead of
crashing (see preview_manager._load_from_open_raw). Non-blocking — the user can
Expand Down Expand Up @@ -3443,6 +3461,7 @@ def _neighbor_prefetch_task(
protected_file_hashes=protected_file_hashes,
half_slice=half_slice,
demosaic=saved.process.demosaic_preview if saved else self.state.config.process.demosaic_preview,
raw_shot=(saved or self.state.config).process.raw_shot,
lens_corrections=metadata_lens_corrections(saved or self.state.config),
lens_flatfield=(saved or self.state.config).flatfield,
base_settings=base_settings,
Expand Down
47 changes: 47 additions & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
from negpy.features.stitch.models import StitchConfig
from negpy.features.lens.models import LensMetadata
from negpy.infrastructure.display.color_spaces import WORKING_COLOR_SPACE
from negpy.infrastructure.loaders.pixel_shift import MISMATCH_WARNING
from negpy.infrastructure.loaders.raw_shots import ShotsInfo
from negpy.infrastructure.storage.repository import StorageRepository
from negpy.kernel.system.config import APP_CONFIG, DEFAULT_WORKSPACE_CONFIG
from negpy.kernel.system.text import count_of
Expand Down Expand Up @@ -145,6 +147,9 @@ class AppState:
preview_lens: Optional[LensMetadata] = None
preview_lens_path: str = ""
preview_lens_token: str = ""
# What a multi-shot RAW decoded to, for preview_raw_shots_path only. None for a file with one shot.
preview_raw_shots: Optional[ShotsInfo] = None
preview_raw_shots_path: str = ""
# Preview-resolution stand-in for preview_raw while HQ is on. Interactive frames render
# against it. None when preview_raw is already small enough.
preview_proxy: Optional[Any] = None
Expand Down Expand Up @@ -469,6 +474,48 @@ def trichrome_merge_indices(files: List[Dict[str, Any]], indices: Collection[int
STITCH_TOOLTIP_PARTS = 8


def _percent(share: float) -> str:
return f"{100.0 * share:.2f}%"


def raw_shots_summary(info: Optional[ShotsInfo]) -> str:
"""One line naming what a multi-shot RAW decoded to. Empty for a file with one shot."""
if info is None:
return ""
if info.shot == 0:
return tr("Pixel Shift: {value} combined.", value=count_of(info.count, "shot"))
return tr("Shot {shot} of {count}.", shot=info.shot, count=info.count)


def raw_shots_details(info: Optional[ShotsInfo]) -> str:
"""The statistics of a Pixel Shift combine, for a tooltip. Empty when no shots were combined."""
stats = info.stats if info is not None else None
if stats is None:
return ""
return tr(
"The shots differ in {mismatch} of the picture. Shot brightness: {levels}. Time: {seconds} s.",
mismatch=_percent(stats.mismatch),
levels=" / ".join(f"{100.0 * level:.1f}%" for level in stats.levels),
seconds=f"{stats.seconds:.1f}",
)


def raw_shots_warning(info: Optional[ShotsInfo]) -> str:
"""What went wrong with the shots of a multi-shot RAW, or what to do about a Pixel Shift
whose shots disagree. Empty when the shots decoded and agree."""
if info is not None and info.failed:
return tr("NegPy could not decode the chosen shots, so the first shot shows.")
stats = info.stats if info is not None else None
if stats is None or not stats.shots_disagree:
return ""
if stats.mismatch > MISMATCH_WARNING:
return tr(
"The shots do not match in {value} of the picture. Use the self-timer or a remote, or pick one shot.",
value=_percent(stats.mismatch),
)
return tr("The light changed by {value} between the shots. Use a steady light, or pick one shot.", value=_percent(stats.light_change))


def composite_summary(asset: Dict[str, Any]) -> str:
"""Tooltip text naming what a frame is built from. Empty for a plain frame."""
kind = composite_kind(asset)
Expand Down
6 changes: 6 additions & 0 deletions negpy/desktop/settings_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ def _fmt_highlights(values: tuple) -> str:
return HIGHLIGHT_LEVELS[highlight_bucket(values[0])][1]


def _fmt_shots(values: tuple) -> str:
return tr("Shot {n}", n=values[0]) if values[0] else tr("Auto")


# fmt: off
CATALOG: list[tuple[str, tuple[SettingRow, ...]]] = [
(mark("Setup"), (
Expand Down Expand Up @@ -177,6 +181,7 @@ def _fmt_highlights(values: tuple) -> str:
_row(mark("Hue Trim"), "process", "hue_trim", sticky=True),
)),
(mark("Raw Decode"), (
_row(mark("Shots"), "process", "raw_shot", fmt=_fmt_shots),
_row(mark("White Balance"), "process", "linear_raw", fmt=_fmt_white_balance),
_row(mark("Demosaic Preview"), "process", "demosaic_preview"),
_row(mark("Demosaic Export"), "process", "demosaic_export"),
Expand Down Expand Up @@ -394,6 +399,7 @@ def _fmt_highlights(values: tuple) -> str:
"source_encoding",
"highlight_reconstruction",
"demosaic_preview",
"raw_shot",
"crop_from_auto",
"autocrop_offset",
"autocrop_rebate_trim",
Expand Down
11 changes: 9 additions & 2 deletions negpy/desktop/view/sidebar/controls_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
from negpy.desktop.view.sidebar.local import LocalSidebar
from negpy.desktop.view.sidebar.finish import FinishSidebar

_DEMOSAIC_FIELDS = ("highlight_reconstruction",)
_DEMOSAIC_FIELDS = ("highlight_reconstruction", "raw_shot")
# GeometryConfig is split across three cards: Crop and Geometry are this frame's own,
# and how the scanning lens bends is the roll's (Optics).
_LENS_FIELDS = (
Expand Down Expand Up @@ -1050,7 +1050,14 @@ def _sync_scan_setup(self) -> None:
(camera and light == "narrowband") or bool(self.controller.rgb_scan_mode_for_roll(state.active_roll_id))
)
self.sensor_section.setVisible(self.sensor_sidebar.has_controls())
self.demosaic_section.setVisible(camera or cfg.process.highlight_reconstruction != _DEFAULT_PROCESS.highlight_reconstruction)
# A multi-shot RAW offers its shots on any rig, and a stored shot choice stays in view.
multi_shot = state.preview_raw_shots is not None and state.preview_raw_shots_path == state.current_file_path
self.demosaic_section.setVisible(
camera
or multi_shot
or cfg.process.highlight_reconstruction != _DEFAULT_PROCESS.highlight_reconstruction
or cfg.process.raw_shot != _DEFAULT_PROCESS.raw_shot
)
optics_edited = any(getattr(cfg.geometry, f) != getattr(_DEFAULT_GEOMETRY, f) for f in _LENS_FIELDS) or cfg.flatfield.apply
self.optics_section.setVisible(camera or optics_edited)

Expand Down
Loading