diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md
index 0710038b0..0b94ec679 100644
--- a/docs/PIPELINE.md
+++ b/docs/PIPELINE.md
@@ -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`):
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 11e9fc0a3..d99237ca8 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -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.
diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py
index 0785e31b5..f3359af16 100644
--- a/negpy/desktop/controller.py
+++ b/negpy/desktop/controller.py
@@ -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,
@@ -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
@@ -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 = ""
@@ -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)
@@ -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,
)
@@ -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
@@ -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,
diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py
index c24d40878..e8e0b8a78 100644
--- a/negpy/desktop/session.py
+++ b/negpy/desktop/session.py
@@ -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
@@ -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
@@ -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)
diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py
index fb5c76fe3..034a51206 100644
--- a/negpy/desktop/settings_catalog.py
+++ b/negpy/desktop/settings_catalog.py
@@ -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"), (
@@ -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"),
@@ -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",
diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py
index 156dcda08..ec0cfa50c 100644
--- a/negpy/desktop/view/sidebar/controls_panel.py
+++ b/negpy/desktop/view/sidebar/controls_panel.py
@@ -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 = (
@@ -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)
diff --git a/negpy/desktop/view/sidebar/demosaic.py b/negpy/desktop/view/sidebar/demosaic.py
index d468c9db1..8c73692d5 100644
--- a/negpy/desktop/view/sidebar/demosaic.py
+++ b/negpy/desktop/view/sidebar/demosaic.py
@@ -1,30 +1,59 @@
+from typing import Optional
+
from negpy.kernel.system.i18n import tr
from negpy.desktop.settings_catalog import HIGHLIGHT_LEVELS, highlight_bucket
from negpy.desktop.view.sidebar.base import BaseSidebar
-from negpy.desktop.view.styles.templates import field_row, hint_label, section_subheader, wrap_tooltip
+from negpy.desktop.session import raw_shots_details, raw_shots_summary, raw_shots_warning
+from negpy.desktop.view.styles.templates import field_row, hint_label, section_subheader, set_hint_kind, wrap_tooltip
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.features.process.models import DemosaicMode, ProcessMode
from negpy.features.rgbscan.models import is_rgb_triplet
from negpy.infrastructure.loaders.helpers import supported_demosaic_modes
+from negpy.infrastructure.loaders.raw_shots import ShotKind, ShotsInfo
_TIP = tr("How the camera's raw data becomes pixels: Auto is fast on screen and uses AHD for export.")
+_SHOTS_TIP = tr("A RAW that holds several shots opens one of them, or Pixel Shift combines four shots into measured colors at every pixel.")
+
_HIGHLIGHT_TIP = tr(
"Rebuilds blown highlights in a camera RAW: Blend suits sun and sky, and Reconstruct is stronger but can get the color wrong."
)
+def _shot_choices(info: ShotsInfo) -> tuple[list[str], list[int]]:
+ """Menu labels and stored raw_shot values: Pixel Shift (Auto, 0) where the shots combine,
+ then each shot by number."""
+ labels = [tr("Shot {n}", n=n) for n in range(1, info.count + 1)]
+ values = list(range(1, info.count + 1))
+ if info.kind is ShotKind.PIXEL_SHIFT:
+ labels.insert(0, tr("Pixel Shift"))
+ values.insert(0, 0)
+ return labels, values
+
+
class DemosaicSidebar(BaseSidebar):
- """How a camera RAW decodes: its white balance, CFA interpolation chosen separately for
- what you see and what you get, and a slide's highlight reconstruction."""
+ """How a camera RAW decodes: the shot of a multi-shot RAW, its white balance, CFA
+ interpolation chosen separately for what you see and what you get, and a slide's
+ highlight reconstruction."""
def _init_ui(self) -> None:
conf = self.state.config.process
modes = [str(m) for m in supported_demosaic_modes()]
+ # Shown only for a RAW that holds several shots; the choices come from that file.
+ self.shots_header = section_subheader(tr("SHOTS"))
+ self.shot_combo = StepCombo.of([], _SHOTS_TIP)
+ shots_row = field_row(tr("Shots"), self.shot_combo)
+ self.shots_label = shots_row.itemAt(0).widget()
+ self.shots_hint = hint_label("")
+ self.layout.addWidget(self.shots_header)
+ self.layout.addLayout(shots_row)
+ self.layout.addWidget(self.shots_hint)
+ self._shot_choices_for: Optional[tuple[int, ShotKind]] = None
+
self.preview_combo = StepCombo()
self.preview_combo.addItems(modes)
self.preview_combo.setToolTip(_TIP)
@@ -57,6 +86,9 @@ def _init_ui(self) -> None:
self.hint = hint_label(tr("This frame is not a Bayer or X-Trans RAW, so it arrives already de-mosaiced."))
self.hint.setVisible(False)
self.layout.addWidget(self.hint)
+ self.combined_hint = hint_label(tr("Not used for Pixel Shift: every pixel has measured colors."))
+ self.combined_hint.setVisible(False)
+ self.layout.addWidget(self.combined_hint)
self.preview_combo.setCurrentText(str(DemosaicMode(conf.demosaic_preview)))
self.export_combo.setCurrentText(str(DemosaicMode(conf.demosaic_export)))
@@ -77,12 +109,47 @@ def _init_ui(self) -> None:
)
self.highlight_merged_hint.setVisible(False)
self.layout.addWidget(self.highlight_merged_hint)
+ self.highlight_combined_hint = hint_label(tr("Not applied to Pixel Shift."))
+ self.highlight_combined_hint.setVisible(False)
+ self.layout.addWidget(self.highlight_combined_hint)
def _connect_signals(self) -> None:
self.preview_combo.currentTextChanged.connect(lambda name: self._on_changed("demosaic_preview", name))
self.export_combo.currentTextChanged.connect(lambda name: self._on_changed("demosaic_export", name))
self.highlight_combo.currentIndexChanged.connect(self._on_highlight_reconstruction_changed)
self.linear_raw_btn.currentChanged.connect(self._on_linear_raw_changed)
+ self.shot_combo.currentIndexChanged.connect(self._on_shot_changed)
+
+ def _on_shot_changed(self, _index: int) -> None:
+ # raw_shot is a meter input and part of source_token: the edit decodes again and meters again.
+ value = self.shot_combo.currentData()
+ if value is not None:
+ self.controller.set_roll_default("demosaic", raw_shot=int(value))
+
+ def _current_shots(self) -> Optional[ShotsInfo]:
+ info = self.state.preview_raw_shots
+ if info is None or self.state.preview_raw_shots_path != self.state.current_file_path:
+ return None
+ return info
+
+ def _sync_shots(self, raw_shot: int) -> Optional[ShotsInfo]:
+ info = self._current_shots()
+ for widget in (self.shots_header, self.shots_label, self.shot_combo, self.shots_hint):
+ widget.setVisible(info is not None)
+ if info is None:
+ return None
+ if self._shot_choices_for != (info.count, info.kind):
+ labels, values = _shot_choices(info)
+ self.shot_combo.set_choices(labels, values)
+ self._shot_choices_for = (info.count, info.kind)
+ index = self.shot_combo.findData(raw_shot)
+ self.shot_combo.setCurrentIndex(index if index >= 0 else self.shot_combo.findData(1 if info.kind is ShotKind.SHOTS else 0))
+ warning = raw_shots_warning(info)
+ self.shots_hint.setText(warning or raw_shots_summary(info))
+ set_hint_kind(self.shots_hint, "warning" if warning else "muted")
+ details = raw_shots_details(info)
+ self.shots_hint.setToolTip(wrap_tooltip(details) if details else "")
+ return info
def _on_linear_raw_changed(self, _index: int) -> None:
# linear_raw switches use_camera_wb, so it is a source change: set_roll_default's
@@ -138,6 +205,14 @@ def sync_ui(self) -> None:
self.preview_combo.setCurrentText(str(DemosaicMode(conf.demosaic_preview)))
self.export_combo.setCurrentText(str(DemosaicMode(conf.demosaic_export)))
+ # The combined shots carry no mosaic, so the demosaic choices and LibRaw's
+ # reconstruction have nothing to act on there.
+ shots = self._sync_shots(conf.raw_shot)
+ combined = shots is not None and shots.shot == 0
+ self.preview_combo.setEnabled(not combined)
+ self.export_combo.setEnabled(not combined)
+ self.combined_hint.setVisible(combined)
+
# Reconstruction only means anything against a slide's own blown highlights (see
# effective_highlight_reconstruction), so it hides off Slide. Greyed instead of hidden
# when the source has no camera matrix (a scanner TIFF, JPEG, or other
@@ -149,12 +224,13 @@ def sync_ui(self) -> None:
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(self.state.preview_cam_xyz is not None and not merged and not combined)
self.highlight_combo.setCurrentIndex(highlight_bucket(conf.highlight_reconstruction))
self.highlight_merged_hint.setVisible(is_e6 and merged)
+ self.highlight_combined_hint.setVisible(is_e6 and combined and not merged)
finally:
self.block_signals(False)
def block_signals(self, blocked: bool) -> None:
- for w in (self.linear_raw_btn, self.preview_combo, self.export_combo, self.highlight_combo):
+ for w in (self.linear_raw_btn, self.preview_combo, self.export_combo, self.highlight_combo, self.shot_combo):
w.blockSignals(blocked)
diff --git a/negpy/desktop/view/widgets/sliders.py b/negpy/desktop/view/widgets/sliders.py
index e8b9ca24a..b9d60f6a0 100644
--- a/negpy/desktop/view/widgets/sliders.py
+++ b/negpy/desktop/view/widgets/sliders.py
@@ -322,7 +322,8 @@ def eventFilter(self, obj, event) -> bool:
if event.type() == QEvent.Type.MouseButtonDblClick:
self.mouseDoubleClickEvent(event)
return True
- if obj is self.slider and event.type() in (QEvent.Type.FocusIn, QEvent.Type.FocusOut):
+ # getattr: the cycle GC clears this wrapper before Qt deletes its children, which still send it events.
+ if obj is getattr(self, "slider", None) and event.type() in (QEvent.Type.FocusIn, QEvent.Type.FocusOut):
# A window regaining focus keeps the edge it had.
if event.type() == QEvent.Type.FocusIn and event.reason() != Qt.FocusReason.ActiveWindowFocusReason:
self._tab_focus = event.reason() in (Qt.FocusReason.TabFocusReason, Qt.FocusReason.BacktabFocusReason)
@@ -493,7 +494,7 @@ def _update_edited_state(self) -> None:
self._edited_dot.setVisible(abs(self.spin.value() - self._default) > 1e-6)
def eventFilter(self, obj, event) -> bool:
- if obj is self.label:
+ if obj is getattr(self, "label", None): # getattr: see BaseSlider.eventFilter
et = event.type()
if et == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton:
if not self.isEnabled():
diff --git a/negpy/desktop/workers/render.py b/negpy/desktop/workers/render.py
index dc403304a..4f89dd747 100644
--- a/negpy/desktop/workers/render.py
+++ b/negpy/desktop/workers/render.py
@@ -36,6 +36,7 @@
from negpy.features.process.models import DemosaicMode
from negpy.infrastructure.loaders.factory import loader_factory
from negpy.infrastructure.loaders.helpers import unsupported_raw_reason
+from negpy.infrastructure.loaders.raw_shots import thumbnail_shot
from negpy.features.rgbscan.models import RgbScanConfig, is_rgb_triplet
from negpy.features.stitch.models import StitchConfig, stitch_active
from negpy.features.geometry.models import squares_on_arm
@@ -337,6 +338,7 @@ class PreviewLoadTask:
lens_corrections: LensCorrections = LensCorrections()
lens_flatfield: FlatFieldConfig = FlatFieldConfig()
demosaic: str = DemosaicMode.AUTO # CFA interpolation for the preview decode
+ raw_shot: int = 0 # shot of a multi-shot RAW, 0 Auto (see loaders/raw_shots)
half_slice: tuple[int, float, tuple[float, float, float, float] | None, float, str] | None = (
None # (half, split_x, crop_rect, gutter_thickness, split_axis)
)
@@ -1282,6 +1284,8 @@ class PreviewLoadWorker(QObject):
# (file_path, applied long-edge cap px): an HQ load exceeded the GPU's VRAM budget
# and was downsampled instead of crashing. Emitted alongside `finished`.
vram_capped = pyqtSignal(str, int)
+ # (file_path, ShotsInfo or None): what a multi-shot RAW decoded to, before finished for the same load.
+ raw_shots = pyqtSignal(str, object)
# (file_path, message): the error carries no path, so badge attribution needs this
load_failed = pyqtSignal(str, str)
prefetch_finished = pyqtSignal(int, str)
@@ -1322,6 +1326,7 @@ def _process_prefetch(self, task: PreviewLoadTask) -> None:
file_hash=task.file_hash,
half_slice=task.half_slice,
demosaic=task.demosaic,
+ raw_shot=task.raw_shot,
positive_decode=task.positive_decode,
integrated_gpu=task.integrated_gpu,
protected_file_hashes=task.protected_file_hashes,
@@ -1340,6 +1345,7 @@ def _process_prefetch(self, task: PreviewLoadTask) -> None:
use_camera_wb=task.use_camera_wb,
file_hash=task.file_hash,
demosaic=task.demosaic,
+ raw_shot=task.raw_shot,
positive_decode=task.positive_decode,
highlight_mode=task.highlight_mode,
bake_camera_wb=task.bake_camera_wb,
@@ -1377,6 +1383,7 @@ def cancelled() -> bool:
file_hash=task.file_hash,
flatfield_profile_id=task.flatfield_profile_id,
demosaic=task.demosaic,
+ raw_shot=task.raw_shot,
should_cancel=cancelled,
highlight_mode=task.highlight_mode,
bake_camera_wb=task.bake_camera_wb,
@@ -1394,6 +1401,7 @@ def cancelled() -> bool:
capped = metadata.get("vram_capped_long_edge")
if capped:
self.vram_capped.emit(task.file_path, int(capped))
+ self.raw_shots.emit(task.file_path, metadata.get("raw_shots"))
self.finished.emit(
task.file_path,
raw,
@@ -1424,6 +1432,7 @@ def cancelled() -> bool:
full_resolution=task.full_resolution,
file_hash=task.file_hash,
demosaic=task.demosaic,
+ raw_shot=task.raw_shot,
should_cancel=cancelled,
highlight_mode=task.highlight_mode,
bake_camera_wb=task.bake_camera_wb,
@@ -1441,6 +1450,7 @@ def cancelled() -> bool:
capped = metadata.get("vram_capped_long_edge")
if capped:
self.vram_capped.emit(task.file_path, int(capped))
+ self.raw_shots.emit(task.file_path, metadata.get("raw_shots"))
self.finished.emit(
task.file_path,
raw,
@@ -1471,6 +1481,7 @@ def cancelled() -> bool:
full_resolution=task.full_resolution,
file_hash=task.file_hash,
demosaic=task.demosaic,
+ raw_shot=task.raw_shot,
should_cancel=cancelled,
)
if not self._is_current(task):
@@ -1486,6 +1497,7 @@ def cancelled() -> bool:
capped = metadata.get("vram_capped_long_edge")
if capped:
self.vram_capped.emit(task.file_path, int(capped))
+ self.raw_shots.emit(task.file_path, metadata.get("raw_shots"))
self.finished.emit(
task.file_path,
raw,
@@ -1516,6 +1528,7 @@ def cancelled() -> bool:
log_timings=True,
half_slice=task.half_slice,
demosaic=task.demosaic,
+ raw_shot=task.raw_shot,
positive_decode=task.positive_decode,
should_cancel=cancelled,
highlight_mode=task.highlight_mode,
@@ -1538,6 +1551,7 @@ def cancelled() -> bool:
log_timings=True,
half_slice=task.half_slice,
demosaic=task.demosaic,
+ raw_shot=task.raw_shot,
positive_decode=task.positive_decode,
should_cancel=cancelled,
highlight_mode=task.highlight_mode,
@@ -1558,6 +1572,7 @@ def cancelled() -> bool:
capped = metadata.get("vram_capped_long_edge")
if capped:
self.vram_capped.emit(task.file_path, int(capped))
+ self.raw_shots.emit(task.file_path, metadata.get("raw_shots"))
self.finished.emit(
task.file_path,
raw,
@@ -1641,11 +1656,16 @@ def _decode_asset_preview_with_meta(
file_info: dict,
config: WorkspaceConfig,
workspace_color_space: str,
+ *,
+ raw_shot: Optional[int] = None,
) -> tuple[np.ndarray, dict]:
"""Decode one asset the way the render path does, with its loader metadata: a stitch
through its stored registration, a composite through the merge that assembles it, a
plain frame direct — the same branch order `PreviewLoadWorker.process` uses.
+ `raw_shot` overrides the edit's own shot choice; a thumbnail passes `thumbnail_shot`
+ so that it never combines, while meters keep the shots the render combines.
+
A batch that decodes only ``file_info["path"]`` sees one member of the composite. For a
triplet that member holds real signal in the red channel alone, so anything measured off
it is invalid for the assembled three-band source.
@@ -1659,6 +1679,7 @@ def _decode_asset_preview_with_meta(
"full_resolution": False,
"file_hash": base_hash(file_info.get("hash")), # halves share one decode
"demosaic": config.process.demosaic_preview,
+ "raw_shot": config.process.raw_shot if raw_shot is None else raw_shot,
}
hdr = config.hdr
if stitch_active(stitch):
@@ -1953,6 +1974,7 @@ def _peek_live_preview(self, frame: ThumbnailRenderInput, workspace_color_space:
use_camera_wb=not effective_linear_raw(config.process),
file_hash=base_hash(frame.file_info.get("hash")),
demosaic=config.process.demosaic_preview,
+ raw_shot=thumbnail_shot(frame.file_info["path"], config.process.raw_shot),
positive_decode=positive_decode(config.process),
highlight_mode=effective_highlight_reconstruction(config.process),
bake_camera_wb=highlight_reconstruction_bakes_wb(config.process),
@@ -1971,7 +1993,13 @@ def _decode(self, frame: ThumbnailRenderInput, workspace_color_space: str) -> tu
hit = self._peek_live_preview(frame, workspace_color_space)
if hit is not None:
return hit
- return _decode_asset_preview_with_meta(self._preview_service, frame.file_info, frame.config, workspace_color_space)
+ return _decode_asset_preview_with_meta(
+ self._preview_service,
+ frame.file_info,
+ frame.config,
+ workspace_color_space,
+ raw_shot=thumbnail_shot(frame.file_info["path"], frame.config.process.raw_shot),
+ )
def _emit_finished_unless_cancelled(self, generation: int, rendered_count: int) -> None:
"""Atomically choose the terminal signal for a generation, so a `cancel()` racing
diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py
index e32f8a38a..4ecb12397 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.
diff --git a/negpy/features/process/logic.py b/negpy/features/process/logic.py
index e9fb8239a..eb91f1e88 100644
--- a/negpy/features/process/logic.py
+++ b/negpy/features/process/logic.py
@@ -60,6 +60,11 @@ def demosaic_token(mode: str) -> str:
return f"|dm:{mode}"
+def raw_shot_token(raw_shot: int) -> str:
+ """Shot-choice identity for a cache key. One choice serves preview and export alike."""
+ return f"|rs:{raw_shot}"
+
+
def should_fold_camera_wb(process: ProcessConfig) -> bool:
"""Whether `camera_to_working_matrix` should fold the as-shot multipliers back in.
diff --git a/negpy/features/process/models.py b/negpy/features/process/models.py
index 9c736c479..4dd059ee7 100644
--- a/negpy/features/process/models.py
+++ b/negpy/features/process/models.py
@@ -149,6 +149,9 @@ class ProcessConfig:
# See loaders/helpers.get_best_demosaic_algorithm for what AUTO resolves to on each path.
demosaic_preview: DemosaicMode = DemosaicMode.AUTO
demosaic_export: DemosaicMode = DemosaicMode.AUTO
+ # Shot of a RAW that holds several (loaders/raw_shots): 0 is Auto, which combines a Pentax
+ # Pixel Shift and takes the first shot of any other file; N is shot N.
+ raw_shot: int = 0
# libraw HighlightMode: 0=Clip (current behaviour), 2=Blend, 3-9=Reconstruct(level).
# 1 (Ignore) is reserved for a separate decode-correctness fix and is never valid here.
# Only meaningful on the positive path; see effective_highlight_reconstruction.
@@ -241,6 +244,13 @@ def __post_init__(self) -> None:
object.__setattr__(self, "process_mode", ProcessMode(self.process_mode))
object.__setattr__(self, "source_encoding", SourceEncoding(self.source_encoding))
object.__setattr__(self, "sensor_unmix", SensorUnmix(self.sensor_unmix))
+ # Roll defaults and sticky values overlay raw JSON through replace, so a bad value
+ # lands here and must read as Auto.
+ try:
+ shot = int(self.raw_shot)
+ except (TypeError, ValueError):
+ shot = 0
+ object.__setattr__(self, "raw_shot", max(shot, 0))
# Same reach for renamed crosstalk profiles: roll defaults overlay this field
# raw through dataclasses.replace, so the rename must run on every build too.
if isinstance(self.crosstalk_profile, str) and self.crosstalk_profile in CROSSTALK_PROFILE_RENAMES:
diff --git a/negpy/infrastructure/loaders/factory.py b/negpy/infrastructure/loaders/factory.py
index 996e85b87..592fdbb2a 100644
--- a/negpy/infrastructure/loaders/factory.py
+++ b/negpy/infrastructure/loaders/factory.py
@@ -71,7 +71,10 @@ def get_loader(
preview_max_edge: Optional[int] = None,
should_cancel: Optional[Callable[[], bool]] = None,
load_ir: bool = True,
+ raw_shot: Optional[int] = None,
) -> Tuple[ContextManager[Any], dict]:
+ """`raw_shot` reaches the camera RAW loader only: the shot choice of a multi-shot RAW
+ (0 Auto, N shot N). None decodes the first shot, for callers with no edit to follow."""
loader = self._select_loader(file_path)
if loader is self._tiff:
@@ -79,7 +82,7 @@ def get_loader(
if loader is self._nef or loader is self._fff:
return loader.load(file_path, linear_raw=linear_raw)
if loader is self._rawpy:
- return loader.load(file_path, preview_max_edge=preview_max_edge, should_cancel=should_cancel)
+ return loader.load(file_path, preview_max_edge=preview_max_edge, should_cancel=should_cancel, raw_shot=raw_shot)
if loader is self._jpeg:
return loader.load(file_path, positive_decode=positive_decode)
return loader.load(file_path)
diff --git a/negpy/infrastructure/loaders/helpers.py b/negpy/infrastructure/loaders/helpers.py
index 3a186a012..9f31df1d1 100644
--- a/negpy/infrastructure/loaders/helpers.py
+++ b/negpy/infrastructure/loaders/helpers.py
@@ -642,6 +642,22 @@ def embedded_preview(raw: Any, file_path: str) -> Optional[Image.Image]:
return None
+def open_shot(file_path: str, index: int) -> rawpy.RawPy:
+ """Open shot `index` (0 is the first) of a camera RAW that holds several shots.
+
+ LibRaw selects the shot while it opens the file, so shot_select is set before open_file.
+ rawpy.imread sets it after the open, which gives the first shot for every index.
+ """
+ raw = rawpy.RawPy()
+ try:
+ raw.set_unpack_params(shot_select=index)
+ raw.open_file(file_path)
+ except Exception:
+ raw.close()
+ raise
+ return raw
+
+
class NonStandardFileWrapper:
"""
numpy -> rawpy-like interface.
diff --git a/negpy/infrastructure/loaders/pixel_shift.py b/negpy/infrastructure/loaders/pixel_shift.py
new file mode 100644
index 000000000..6cca9d500
--- /dev/null
+++ b/negpy/infrastructure/loaders/pixel_shift.py
@@ -0,0 +1,381 @@
+"""Pentax Pixel Shift: the four shots of one RAW combined into measured red, green and blue at
+every pixel, and a check that the shots agree.
+
+The shot mapping follows RawTherapee (rtengine/pixelshift.cc) without its motion correction:
+shot k shows at (i + dy, j + dx) what the first shot shows at (i, j), with (dy, dx) = (0, 0),
+(1, 0), (1, 1), (0, 1). The four samples of pixel (i, j) come from the 2x2 CFA block at (i, j),
+so each pixel gets one red, two green and one blue sample, and no demosaic runs.
+"""
+
+import os
+import threading
+import time
+from collections import OrderedDict
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any, Optional, Sequence
+
+import numpy as np
+
+from negpy.infrastructure.capture.raw_demosaic import _user_sat
+from negpy.infrastructure.loaders.helpers import NonStandardFileWrapper, open_shot
+from negpy.kernel.image.logic import uint16_to_float32
+from negpy.kernel.system.logging import get_logger
+
+logger = get_logger(__name__)
+
+SHOT_SHIFT: tuple[tuple[int, int], ...] = ((0, 0), (1, 0), (1, 1), (0, 1))
+
+# A green difference above this many noise sigmas counts as a mismatch.
+_MISMATCH_SIGMA = 6.0
+# Above this share of mismatched pixels, the camera or the subject moved between the shots.
+MISMATCH_WARNING = 0.005
+# Above this change of the mean level from the first shot, the light changed during the sequence.
+LIGHT_WARNING = 0.01
+
+_NOISE_BINS = 32
+_NOISE_BIN_MIN = 256
+_FLAT_SHARE = 0.25
+_CACHE_BYTES = 640 * 1024 * 1024
+
+
+@dataclass(frozen=True)
+class PixelShiftStats:
+ """How well the four shots of one Pixel Shift RAW agree, measured while they are combined."""
+
+ mismatch: float # share of pixels whose two green samples, from two shots, differ beyond noise
+ pair_mismatch: tuple[float, float] # the same share for shots 1 and 3, and for shots 2 and 4
+ levels: tuple[float, ...] # mean level of each shot over the first shot
+ noise: tuple[float, float] # var(green difference) = gain * level + floor, in sensor counts
+ seconds: float # read and combine time
+
+ @property
+ def light_change(self) -> float:
+ return max(abs(level - 1.0) for level in self.levels)
+
+ @property
+ def disagreement(self) -> float:
+ """The larger of mismatch and light change, each over its warning threshold."""
+ return max(self.mismatch / MISMATCH_WARNING, self.light_change / LIGHT_WARNING)
+
+ @property
+ def shots_disagree(self) -> bool:
+ return self.disagreement > 1.0
+
+
+class _GreenCheck:
+ """Collects the green sample pairs of the four output parities and measures their agreement.
+
+ In a still, aligned scene the two green samples of a pixel differ only by noise. The noise
+ model comes from the flattest pixels of each level: on an edge, misaligned shots differ by
+ the edge itself, so a fit over all pixels grows with the misalignment and hides it.
+ """
+
+ def __init__(self) -> None:
+ self._pairs: list[tuple[tuple[int, int], np.ndarray, np.ndarray]] = []
+ self._fit: list[tuple[np.ndarray, np.ndarray, np.ndarray]] = []
+
+ def add(self, shots: tuple[int, int], first: np.ndarray, second: np.ndarray, total: np.ndarray) -> None:
+ diff = first - second
+ self._pairs.append(((min(shots), max(shots)), diff[::2, ::2].copy(), total[::2, ::2].copy()))
+ green = total.astype(np.float32)
+ grad = np.abs(green[1:-1, 2:] - green[1:-1, :-2]) + np.abs(green[2:, 1:-1] - green[:-2, 1:-1])
+ self._fit.append((diff[1:-1:4, 1:-1:4].ravel(), total[1:-1:4, 1:-1:4].ravel(), grad[::4, ::4].ravel()))
+
+ def _noise_model(self) -> tuple[float, float]:
+ if not self._fit:
+ return 0.0, 1.0
+ diff = np.concatenate([d for d, _t, _g in self._fit]).astype(np.float64)
+ level = 0.5 * np.concatenate([t for _d, t, _g in self._fit]).astype(np.float64)
+ grad = np.concatenate([g for _d, _t, g in self._fit])
+ if diff.size == 0:
+ return 0.0, 1.0
+ order = np.argsort(level, kind="stable")
+ diff, level, grad = diff[order], level[order], grad[order]
+ bins = max(1, min(_NOISE_BINS, diff.size // _NOISE_BIN_MIN))
+ centers, variances = [], []
+ for d, lv, gr in zip(np.array_split(diff, bins), np.array_split(level, bins), np.array_split(grad, bins)):
+ keep = gr <= np.quantile(gr, _FLAT_SHARE)
+ flat = d[keep]
+ mad = np.median(np.abs(flat - np.median(flat)))
+ centers.append(float(np.median(lv[keep])))
+ variances.append(float((1.4826 * mad) ** 2))
+ if len(centers) < 2 or max(centers) == min(centers):
+ return 0.0, max(float(np.mean(variances)), 1.0)
+ gain, floor = np.polyfit(np.array(centers), np.array(variances), 1)
+ return max(float(gain), 0.0), max(float(floor), 1.0)
+
+ def measure(self) -> tuple[float, tuple[float, float], tuple[float, float]]:
+ """(share of mismatched pixels, share per shot pair, noise model)."""
+ gain, floor = self._noise_model()
+ hits = {(0, 2): [0, 0], (1, 3): [0, 0]}
+ limit = np.float32(_MISMATCH_SIGMA * _MISMATCH_SIGMA)
+ for pair, diff, total in self._pairs:
+ allowed = (np.float32(0.5 * gain) * total.astype(np.float32) + np.float32(floor)) * limit
+ d = diff.astype(np.float32)
+ count = hits.setdefault(pair, [0, 0])
+ count[0] += int(np.count_nonzero(d * d > allowed))
+ count[1] += d.size
+ moving = sum(h[0] for h in hits.values())
+ total_px = sum(h[1] for h in hits.values())
+ share_13, share_24 = (hits[p][0] / hits[p][1] if hits[p][1] else 0.0 for p in ((0, 2), (1, 3)))
+ return (moving / total_px if total_px else 0.0), (share_13, share_24), (gain, floor)
+
+
+def combine_shots(shots: np.ndarray, cfa: np.ndarray, black: Sequence[int]) -> tuple[np.ndarray, _GreenCheck]:
+ """Combine four (H, W) shots into HxWx3 uint16 sensor counts above black.
+
+ R and B hold one count each, G the sum of its two counts, so the result is exact in 16 bits
+ for a sensor up to 15 bits. `cfa` is the 2x2 color-index block of the first shot
+ (raw_colors_visible: 0 R, 1 G, 2 B, 3 second G), `black` the black level per color index.
+ The last row and column take their neighbors, which the mapping leaves without samples.
+ """
+ count, h, w = shots.shape
+ if count != len(SHOT_SHIFT) or h < 3 or w < 3:
+ raise ValueError(f"Pixel Shift needs {len(SHOT_SHIFT)} shots of at least 3x3 photosites, got {shots.shape}")
+ counts = np.empty((h, w, 3), dtype=np.uint16)
+ check = _GreenCheck()
+ for p in (0, 1):
+ for q in (0, 1):
+ rows, cols = len(range(p, h - 1, 2)), len(range(q, w - 1, 2))
+ dst = counts[p : h - 1 : 2, q : w - 1 : 2]
+ greens = []
+ for k, (dy, dx) in enumerate(SHOT_SHIFT):
+ r0, c0 = p + dy, q + dx
+ site = int(cfa[r0 % 2, c0 % 2])
+ sample = shots[k, r0 : r0 + 2 * rows : 2, c0 : c0 + 2 * cols : 2].astype(np.int32)
+ sample -= int(black[site])
+ np.maximum(sample, 0, out=sample)
+ if site in (1, 3):
+ greens.append((k, sample))
+ else:
+ dst[..., site] = sample
+ if len(greens) != 2:
+ raise ValueError(f"Pixel Shift needs a Bayer CFA with two green sites, got {cfa.tolist()}")
+ (ka, ga), (kb, gb) = greens
+ total = ga + gb
+ np.minimum(total, 65535, out=total)
+ dst[..., 1] = total
+ check.add((ka, kb), ga, gb, total)
+ counts[h - 1] = counts[h - 2]
+ counts[:, w - 1] = counts[:, w - 2]
+ return counts, check
+
+
+def shot_levels(shots: np.ndarray, black: Sequence[int]) -> tuple[float, ...]:
+ """Mean level of each shot above black, over the first shot."""
+ offset = float(np.mean(black))
+ means = [float(shots[k].mean(dtype=np.float64)) - offset for k in range(shots.shape[0])]
+ return tuple(m / means[0] if means[0] > 0 else 1.0 for m in means)
+
+
+@dataclass(frozen=True)
+class PixelShiftImage:
+ """The four shots of one Pixel Shift RAW combined, in sensor counts above black, plus the
+ first shot's color data. It holds no scale: each decode path asks for its own."""
+
+ counts: np.ndarray # HxWx3 uint16; G holds the sum of its two samples
+ render_white: int # full scale of the render and preview decodes: _user_sat of the first shot
+ export_white: int # full scale of the linear export: LibRaw's white level minus black
+ rgb_xyz_matrix: np.ndarray
+ camera_whitebalance: tuple[float, ...]
+ daylight_whitebalance: tuple[float, ...]
+ sizes: Any # the first shot's LibRaw sizes: its visible area is the combined image
+ other: Any # the first shot's LibRaw capture data (timestamp, exposure)
+ stats: PixelShiftStats
+
+ @property
+ def nbytes(self) -> int:
+ return int(self.counts.nbytes)
+
+ def to_u16(self, white: int, gains: Sequence[float] = (1.0, 1.0, 1.0)) -> np.ndarray:
+ """16-bit linear camera RGB on `white` full scale, the way LibRaw scales one shot:
+ trunc(float32(counts) * float32(gain * 65535 / white)), clipped at 65535."""
+ out = np.empty(self.counts.shape, dtype=np.uint16)
+ for c, samples in ((0, 1), (1, 2), (2, 1)):
+ channel = self.counts[..., c].astype(np.float32)
+ channel *= np.float32(gains[c] * 65535.0 / (samples * white))
+ np.minimum(channel, np.float32(65535.0), out=channel)
+ out[..., c] = channel
+ return out
+
+
+def libraw_wb_gains(multipliers: Optional[Sequence[float]]) -> tuple[float, float, float]:
+ """(R, G, B) gains as LibRaw applies [R, G, B, G2] multipliers with highlight clipping: divided
+ by the smallest of the four. The two greens of a pixel share the mean of the two green gains."""
+ if multipliers is None or len(multipliers) < 3:
+ return (1.0, 1.0, 1.0)
+ r, g, b = (float(v) for v in multipliers[:3])
+ g2 = float(multipliers[3]) if len(multipliers) > 3 and float(multipliers[3]) > 0 else g
+ values = (r, g, b, g2)
+ if not all(np.isfinite(values)) or min(values) <= 0:
+ return (1.0, 1.0, 1.0)
+ low = min(values)
+ return (r / low, 0.5 * (g + g2) / low, b / low)
+
+
+class PixelShiftWrapper(NonStandardFileWrapper):
+ """A combined Pixel Shift image behind the rawpy calls NegPy makes on a camera RAW.
+
+ It carries the first shot's matrix, white balance, sizes and capture data, so the render
+ reads it like one shot. postprocess() honors use_camera_wb and user_wb as LibRaw does, which
+ keeps a bracket sibling on the reference white balance. It ignores highlight_mode and bright.
+ """
+
+ def __init__(self, image: PixelShiftImage, white: int) -> None:
+ super().__init__(wb_gains=None)
+ self.image = image
+ self.white = white
+ self.rgb_xyz_matrix = image.rgb_xyz_matrix
+ self.camera_whitebalance = image.camera_whitebalance
+ self.daylight_whitebalance = image.daylight_whitebalance
+ self.other = image.other
+
+ @property
+ def data(self) -> np.ndarray:
+ if self._data is None:
+ self._data = uint16_to_float32(self.image.to_u16(self.white))
+ return self._data
+
+ @property
+ def sizes(self) -> Any:
+ return self.image.sizes
+
+ def close(self) -> None:
+ self._data = None
+
+ def postprocess(self, **kwargs: Any) -> np.ndarray:
+ if kwargs.get("use_camera_wb"):
+ gains = libraw_wb_gains(self.camera_whitebalance)
+ else:
+ gains = libraw_wb_gains(kwargs.get("user_wb"))
+ u16 = self.image.to_u16(self.white, gains)
+ if kwargs.get("half_size"):
+ u16 = np.ascontiguousarray(u16[::2, ::2])
+ bps = kwargs.get("output_bps", 8)
+ gamma = kwargs.get("gamma")
+ if bps == 16 and gamma is not None and tuple(gamma) == (1, 1):
+ return u16
+ return NonStandardFileWrapper(source_u16=u16).postprocess(output_bps=bps, gamma=gamma)
+
+
+class _ImageCache:
+ """Combined images by file identity, least recently used out beyond a byte budget. One lock
+ per file, so decodes of one file that start together wait for a single combine."""
+
+ def __init__(self, max_bytes: int) -> None:
+ self._max_bytes = max_bytes
+ self._items: OrderedDict[tuple, PixelShiftImage] = OrderedDict()
+ self._lock = threading.Lock()
+ self._file_locks: dict[tuple, threading.Lock] = {}
+
+ def _get(self, key: tuple) -> Optional[PixelShiftImage]:
+ with self._lock:
+ image = self._items.get(key)
+ if image is not None:
+ self._items.move_to_end(key)
+ return image
+
+ def get_or_build(self, key: tuple, build: Callable[[], PixelShiftImage]) -> PixelShiftImage:
+ image = self._get(key)
+ if image is not None:
+ return image
+ with self._lock:
+ file_lock = self._file_locks.setdefault(key, threading.Lock())
+ with file_lock:
+ image = self._get(key)
+ if image is not None:
+ return image
+ # A failed or cancelled build keeps the lock, so a waiter and a newcomer still share it.
+ image = build()
+ with self._lock:
+ self._items[key] = image
+ self._file_locks.pop(key, None)
+ total = sum(item.nbytes for item in self._items.values())
+ while total > self._max_bytes and len(self._items) > 1:
+ _key, old = self._items.popitem(last=False)
+ total -= old.nbytes
+ return image
+
+ def clear(self) -> None:
+ with self._lock:
+ self._items.clear()
+
+
+_CACHE = _ImageCache(_CACHE_BYTES)
+
+
+def clear_cache() -> None:
+ """Drop every combined image held for this session."""
+ _CACHE.clear()
+
+
+def _build(file_path: str, should_cancel: Optional[Callable[[], bool]]) -> PixelShiftImage:
+ start = time.perf_counter()
+ shots: Optional[np.ndarray] = None
+ first: dict = {}
+ for k in range(len(SHOT_SHIFT)):
+ if should_cancel is not None and should_cancel():
+ raise InterruptedError("pixel shift combine cancelled")
+ with open_shot(file_path, k) as raw:
+ visible = raw.raw_image_visible
+ if shots is None:
+ shots = np.empty((len(SHOT_SHIFT), *visible.shape), dtype=np.uint16)
+ black = [int(v) for v in raw.black_level_per_channel]
+ export_white = int(raw.white_level) - min(black)
+ first = {
+ "cfa": np.array(raw.raw_colors_visible[:2, :2]),
+ "black": black,
+ "render_white": int(_user_sat(raw) or export_white),
+ "export_white": export_white,
+ # Copied: the arrays LibRaw hands back die with the decoder.
+ "rgb_xyz_matrix": np.array(raw.rgb_xyz_matrix, dtype=np.float64),
+ "camera_whitebalance": tuple(float(v) for v in raw.camera_whitebalance),
+ "daylight_whitebalance": tuple(float(v) for v in raw.daylight_whitebalance),
+ "sizes": raw.sizes,
+ "other": raw.other,
+ }
+ elif visible.shape != shots.shape[1:]:
+ raise ValueError(f"Pixel Shift shots differ in size: {visible.shape} and {shots.shape[1:]}")
+ shots[k] = visible
+ assert shots is not None
+ if should_cancel is not None and should_cancel():
+ raise InterruptedError("pixel shift combine cancelled")
+ counts, check = combine_shots(shots, first["cfa"], first["black"])
+ levels = shot_levels(shots, first["black"])
+ del shots
+ mismatch, pair_mismatch, noise = check.measure()
+ stats = PixelShiftStats(mismatch, pair_mismatch, levels, noise, time.perf_counter() - start)
+ logger.info(
+ "pixel shift %s: mismatch %.4f%% (shots 1/3 %.4f%%, 2/4 %.4f%%), levels %s, noise var %.3f*L + %.1f, %.2fs",
+ os.path.basename(file_path),
+ 100 * stats.mismatch,
+ 100 * pair_mismatch[0],
+ 100 * pair_mismatch[1],
+ " ".join(f"{v:.4f}" for v in levels),
+ noise[0],
+ noise[1],
+ stats.seconds,
+ )
+ return PixelShiftImage(
+ counts=counts,
+ render_white=first["render_white"],
+ export_white=first["export_white"],
+ rgb_xyz_matrix=first["rgb_xyz_matrix"],
+ camera_whitebalance=first["camera_whitebalance"],
+ daylight_whitebalance=first["daylight_whitebalance"],
+ sizes=first["sizes"],
+ other=first["other"],
+ stats=stats,
+ )
+
+
+def combined_image(file_path: str, should_cancel: Optional[Callable[[], bool]] = None) -> PixelShiftImage:
+ """The four shots of `file_path` combined, from the session cache when this file was already
+ combined. The cache key is the path, size and modification time."""
+ try:
+ st = os.stat(file_path)
+ key: tuple = (os.path.abspath(file_path), st.st_size, st.st_mtime_ns)
+ except OSError:
+ key = (os.path.abspath(file_path), -1, -1)
+ return _CACHE.get_or_build(key, lambda: _build(file_path, should_cancel))
diff --git a/negpy/infrastructure/loaders/raw_shots.py b/negpy/infrastructure/loaders/raw_shots.py
new file mode 100644
index 000000000..8b4b79eeb
--- /dev/null
+++ b/negpy/infrastructure/loaders/raw_shots.py
@@ -0,0 +1,214 @@
+"""Camera RAW files that hold several shots in one file, and the choice of what to decode.
+
+A Pentax Pixel Shift RAW holds four shots one photosite apart, an in-camera HDR RAW holds three
+exposures. LibRaw decodes one shot per open, the first unless told otherwise; rawpy does not
+report how many shots a file holds, so the TIFF structure of the file tells. Any TIFF-based RAW
+with several same-size CFA images offers its shots. Only a Pentax Pixel Shift combines: the shot
+order and step of another make need their own mapping. The per-edit choice `raw_shot` is 0 for
+Auto (combine a Pixel Shift, else the first shot) or N for shot N.
+"""
+
+import functools
+import os
+import struct
+from abc import ABC, abstractmethod
+from collections.abc import Callable, Sequence
+from dataclasses import dataclass
+from enum import StrEnum
+from typing import Any, Optional
+
+import tifffile
+
+from negpy.infrastructure.loaders.helpers import open_shot
+from negpy.infrastructure.loaders.pixel_shift import SHOT_SHIFT, PixelShiftStats, PixelShiftWrapper, combined_image
+
+_TIFF_MAGIC = (b"II*\0", b"MM\0*", b"II+\0", b"MM\0+")
+_CFA = 32803
+# Pentax makernote tag 0x0008 (Quality): 7 is "RAW with Pixel Shift" (LibRaw metadata/pentax.cpp).
+_QUALITY_TAG = 0x0008
+_PIXEL_SHIFT_QUALITY = 7
+
+
+class ShotKind(StrEnum):
+ PIXEL_SHIFT = "pixel_shift" # the shots combine into one image
+ SHOTS = "shots" # one shot at a time, such as the exposures of an in-camera HDR
+
+
+@dataclass(frozen=True)
+class ShotLayout:
+ count: int
+ kind: ShotKind
+
+
+@dataclass(frozen=True)
+class ShotsInfo:
+ """What a multi-shot RAW decoded to, for the panel that offers the choice."""
+
+ count: int
+ kind: ShotKind
+ shot: int # the decoded shot, 1 is the first; 0 when the shots are combined
+ stats: Optional[PixelShiftStats] = None
+ failed: bool = False # the chosen shots could not be decoded, so the first shot shows
+
+
+def composite_shots(parts: Sequence[Optional[ShotsInfo]]) -> Optional[ShotsInfo]:
+ """The shots of a composite's files read as one, since the parts share one raw_shot: a part
+ that failed, else the part whose shots disagree most, else the first part."""
+ known = [p for p in parts if p is not None]
+ failed = [p for p in known if p.failed]
+ if failed:
+ return failed[0]
+ disagree = [(s.disagreement, p) for p in known if (s := p.stats) is not None and s.shots_disagree]
+ if disagree:
+ return max(disagree, key=lambda d: d[0])[1]
+ return known[0] if known else None
+
+
+def probe_shots(file_path: str) -> Optional[ShotLayout]:
+ """The shots of a TIFF-based camera RAW (PEF, DNG, NEF, ARW, CR2, 3FR ...), read from its
+ structure without a decode. None for a file with one shot, a format that is not TIFF, or a
+ file that cannot be read."""
+ try:
+ st = os.stat(file_path)
+ with open(file_path, "rb") as f:
+ if f.read(4) not in _TIFF_MAGIC:
+ return None
+ except OSError:
+ return None
+ return _probe(os.path.abspath(file_path), st.st_size, st.st_mtime_ns)
+
+
+@functools.lru_cache(maxsize=256)
+def _probe(file_path: str, _size: int, _mtime_ns: int) -> Optional[ShotLayout]:
+ try:
+ with tifffile.TiffFile(file_path) as tif:
+ shapes = [(int(page.imagelength), int(page.imagewidth)) for page in _all_pages(tif.pages) if _is_cfa(page)]
+ if len(shapes) < 2 or len(set(shapes)) != 1:
+ return None
+ page0 = tif.pages[0]
+ make = str(_tag_value(page0, "Make") or "").lower()
+ quality = _pentax_quality(page0, tif.byteorder)
+ except Exception:
+ return None
+ pixel_shift = len(shapes) == len(SHOT_SHIFT) and quality == _PIXEL_SHIFT_QUALITY and ("pentax" in make or "ricoh" in make)
+ return ShotLayout(len(shapes), ShotKind.PIXEL_SHIFT if pixel_shift else ShotKind.SHOTS)
+
+
+def _all_pages(pages: Any) -> list:
+ found = []
+ for page in pages:
+ found.append(page)
+ sub = getattr(page, "pages", None)
+ if sub:
+ found.extend(_all_pages(sub))
+ return found
+
+
+def _is_cfa(page: Any) -> bool:
+ try:
+ return int(page.photometric) == _CFA
+ except (AttributeError, TypeError, ValueError):
+ return False
+
+
+def _tag_value(page: Any, name: str) -> Any:
+ tag = page.tags.get(name)
+ return None if tag is None else tag.value
+
+
+def _pentax_quality(page0: Any, byteorder: str) -> Optional[int]:
+ """Makernote Quality of the first IFD: EXIF MakerNote in a PEF, DNGPrivateData in a DNG."""
+ exif = _tag_value(page0, "ExifTag")
+ blobs = [exif.get("MakerNote")] if isinstance(exif, dict) else []
+ blobs.append(_tag_value(page0, "DNGPrivateData"))
+ for blob in blobs:
+ if isinstance(blob, (bytes, bytearray)):
+ quality = makernote_quality(bytes(blob), byteorder)
+ if quality is not None:
+ return quality
+ return None
+
+
+def makernote_quality(blob: bytes, byteorder: str = "<") -> Optional[int]:
+ """Tag 0x0008 of a Pentax makernote, read with the header rules of LibRaw parse_makernote:
+ "PENTAX \\0" and the byte order put the IFD at byte 10, "AOC\\0" at byte 6. A DNG converter
+ wraps the makernote in "Adobe\\0MakN", a size, the byte order and the original offset."""
+ if blob.startswith(b"Adobe\0MakN"):
+ blob = blob[20:]
+ if blob.startswith(b"PENTAX \0"):
+ order, ifd = blob[8:10], 10
+ elif blob.startswith(b"AOC\0"):
+ order, ifd = blob[4:6], 6
+ else:
+ return None
+ end = ">" if order == b"MM" else "<" if order == b"II" else byteorder
+ try:
+ (entries,) = struct.unpack_from(end + "H", blob, ifd)
+ for i in range(entries):
+ tag, kind, count = struct.unpack_from(end + "HHI", blob, ifd + 2 + 12 * i)
+ if tag == _QUALITY_TAG and kind == 3 and count == 1:
+ return int(struct.unpack_from(end + "H", blob, ifd + 2 + 12 * i + 8)[0])
+ except struct.error:
+ return None
+ return None
+
+
+class ShotSource(ABC):
+ """One way to decode a multi-shot RAW: a single shot, or the shots combined."""
+
+ def __init__(self, file_path: str, layout: ShotLayout) -> None:
+ self.file_path = file_path
+ self.layout = layout
+
+ @abstractmethod
+ def open(self, should_cancel: Optional[Callable[[], bool]] = None, export: bool = False) -> tuple[Any, ShotsInfo]:
+ """The decoder NegPy reads like a rawpy.RawPy, and what it holds. `export` asks for the
+ linear export's white scale where the decoder has one of its own."""
+
+
+class SingleShot(ShotSource):
+ """One shot, decoded by LibRaw like any camera RAW."""
+
+ def __init__(self, file_path: str, layout: ShotLayout, index: int) -> None:
+ super().__init__(file_path, layout)
+ self.index = index
+
+ def open(self, should_cancel: Optional[Callable[[], bool]] = None, export: bool = False) -> tuple[Any, ShotsInfo]:
+ raw = open_shot(self.file_path, self.index)
+ try:
+ raw.unpack() # now, so that a shot LibRaw does not hold fails here and not mid-render
+ except Exception:
+ raw.close()
+ raise
+ return raw, ShotsInfo(self.layout.count, self.layout.kind, self.index + 1)
+
+
+class CombinedShots(ShotSource):
+ """The four shots of a Pixel Shift RAW combined (see pixel_shift)."""
+
+ def open(self, should_cancel: Optional[Callable[[], bool]] = None, export: bool = False) -> tuple[Any, ShotsInfo]:
+ image = combined_image(self.file_path, should_cancel)
+ wrapper = PixelShiftWrapper(image, image.export_white if export else image.render_white)
+ return wrapper, ShotsInfo(self.layout.count, self.layout.kind, 0, image.stats)
+
+
+def shot_source(file_path: str, raw_shot: int) -> Optional[ShotSource]:
+ """How to decode `file_path` for the per-edit choice `raw_shot`, or None for a file with one
+ shot, which LibRaw decodes like any camera RAW. A shot number beyond the file's shots reads as Auto."""
+ layout = probe_shots(file_path)
+ if layout is None:
+ return None
+ if 1 <= raw_shot <= layout.count:
+ return SingleShot(file_path, layout, raw_shot - 1)
+ if layout.kind is ShotKind.PIXEL_SHIFT:
+ return CombinedShots(file_path, layout)
+ return SingleShot(file_path, layout, 0)
+
+
+def thumbnail_shot(file_path: str, raw_shot: int) -> int:
+ """The shot choice for a thumbnail decode. A thumbnail never combines: a Pixel Shift on Auto
+ shows its first shot. Every other choice passes through unchanged, so the thumbnail and the
+ canvas of an ordinary file share one cached decode."""
+ if raw_shot == 0 and isinstance(shot_source(file_path, 0), CombinedShots):
+ return 1
+ return raw_shot
diff --git a/negpy/infrastructure/loaders/rawpy_loader.py b/negpy/infrastructure/loaders/rawpy_loader.py
index a216328e2..9bd6b74ec 100644
--- a/negpy/infrastructure/loaders/rawpy_loader.py
+++ b/negpy/infrastructure/loaders/rawpy_loader.py
@@ -20,6 +20,7 @@
from negpy.infrastructure.loaders.ir_planes import find_ir_plane
from negpy.infrastructure.loaders.lens_metadata import bind_decode, read_lens_metadata
from negpy.infrastructure.loaders.memory import PreviewMemoryEstimate
+from negpy.infrastructure.loaders.raw_shots import ShotSource, ShotsInfo, shot_source
from negpy.kernel.system.logging import get_logger
logger = get_logger(__name__)
@@ -482,7 +483,10 @@ def load(
file_path: str,
preview_max_edge: Optional[int] = None,
should_cancel: Optional[Callable[[], bool]] = None,
+ raw_shot: Optional[int] = None,
) -> Tuple[ContextManager[Any], dict]:
+ """`raw_shot` is the per-edit shot choice of a multi-shot RAW (see raw_shots). None keeps
+ LibRaw's first shot and reports no shots, for callers that need no decode choice."""
preview_dims: Optional[Tuple[int, int]] = None
preview_ir: Optional[np.ndarray] = None
if preview_max_edge is not None:
@@ -529,6 +533,14 @@ def load(
}
return NonStandardFileWrapper(rgb), metadata
+ source = shot_source(file_path, raw_shot) if raw_shot is not None else None
+ fallback_shots: Optional[ShotsInfo] = None
+ if source is not None:
+ loaded = self._load_shots(file_path, source, should_cancel)
+ if loaded is not None:
+ return loaded
+ fallback_shots = ShotsInfo(source.layout.count, source.layout.kind, 1, failed=True)
+
if _is_dng(file_path):
raw = None
try:
@@ -569,10 +581,39 @@ def load(
"color_space": None,
"ir": _peek_hdri_ir_page(file_path),
}
+ if fallback_shots is not None:
+ metadata["raw_shots"] = fallback_shots
metadata["lens_correction"] = bind_decode(read_lens_metadata(file_path), raw)
return raw, metadata
+ @staticmethod
+ def _load_shots(
+ file_path: str, source: ShotSource, should_cancel: Optional[Callable[[], bool]]
+ ) -> Optional[Tuple[ContextManager[Any], dict]]:
+ """One shot, or the shots combined, of a multi-shot RAW, with the metadata of a plain decode
+ plus the `raw_shots` the panel reads. None when LibRaw refuses the shot or the combine
+ fails: the caller then decodes the first shot and reports the failure."""
+ try:
+ decoder, shots = source.open(should_cancel)
+ except InterruptedError:
+ raise
+ except Exception:
+ logger.warning("multi-shot decode failed, first shot used: %s", file_path, exc_info=True)
+ return None
+ if should_cancel is not None and should_cancel():
+ decoder.close()
+ raise InterruptedError("preview load cancelled")
+ metadata = {
+ "orientation": read_orientation(file_path),
+ "raw_flip": 0,
+ "color_space": None,
+ "ir": None,
+ "raw_shots": shots,
+ }
+ metadata["lens_correction"] = bind_decode(read_lens_metadata(file_path), decoder)
+ return decoder, metadata
+
def load_bounded_preview(
self,
file_path: str,
diff --git a/negpy/services/assets/rolls.py b/negpy/services/assets/rolls.py
index 8a2ee5618..05a946522 100644
--- a/negpy/services/assets/rolls.py
+++ b/negpy/services/assets/rolls.py
@@ -682,8 +682,8 @@ def virtual_rolls(repo: Any) -> List[tuple]:
# A roll's slides usually faded alike, so one Estimate or hand-tuned survival serves them
# all; its own card, so a per-frame Estimate never locks the crosstalk.
"fade": ("process", ("fade_ratio_r", "fade_ratio_g", "fade_ratio_b")),
- # How the roll's camera RAWs decode: white balance, demosaic and highlight recovery.
- "demosaic": ("process", ("linear_raw", "demosaic_preview", "demosaic_export", "highlight_reconstruction")),
+ # How the roll's camera RAWs decode: shot, white balance, demosaic and highlight recovery.
+ "demosaic": ("process", ("linear_raw", "demosaic_preview", "demosaic_export", "highlight_reconstruction", "raw_shot")),
"process": (
"process",
(
diff --git a/negpy/services/assets/thumbnail_fingerprint.py b/negpy/services/assets/thumbnail_fingerprint.py
index 5c9588f57..10bfcdb64 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 = 29
+THUMBNAIL_RENDER_VERSION = 30
# Marks a thumbnail made from the source preview, which runs none of the frame's settings.
QUICK = "quick"
diff --git a/negpy/services/export/linear_output.py b/negpy/services/export/linear_output.py
index c073a9f9e..bcbd06967 100644
--- a/negpy/services/export/linear_output.py
+++ b/negpy/services/export/linear_output.py
@@ -37,6 +37,7 @@
from negpy.infrastructure.loaders.lens_metadata import bind_decode, read_lens_metadata
from negpy.infrastructure.loaders.helpers import NonStandardFileWrapper, read_orientation, resolve_demosaic
from negpy.infrastructure.loaders.pakon_loader import PakonLoader
+from negpy.infrastructure.loaders.raw_shots import shot_source
from negpy.infrastructure.loaders.fff_loader import is_flextight_fff
from negpy.infrastructure.loaders.nef_loader import is_coolscan_nef
from negpy.infrastructure.loaders.noritsu_loader import is_noritsu_raw, detect_noritsu_dims
@@ -48,6 +49,9 @@
_peek_linearraw_4ch,
)
from negpy.kernel.image.logic import _to_uint16_jit, apply_exif_orientation, ensure_rgb, uint16_to_float32
+from negpy.kernel.system.logging import get_logger
+
+logger = get_logger(__name__)
@dataclass(frozen=True)
@@ -389,6 +393,7 @@ def _decode_source(
"""
wb_blocked = bool(wb_bake_block_reason(rgbscan, process))
demosaic = process.demosaic_export if process is not None else DemosaicMode.AUTO
+ raw_shot = process.raw_shot if process is not None else 0
if stitch is not None and stitch.stitch_enabled and stitch.stitch_paths:
rgb, ir, wb, meta = _decode_stitch(file_path, stitch, flatfield, process)
if apply_wb and not wb_blocked and wb is not None:
@@ -428,14 +433,14 @@ def _decode_source(
return rgb, ir, None, meta
if _is_camera_raw(file_path):
if rgbscan is not None and is_rgb_triplet(rgbscan):
- rgb, ir, wb, meta = _decode_camera_raw_triplet(file_path, rgbscan, demosaic)
+ rgb, ir, wb, meta = _decode_camera_raw_triplet(file_path, rgbscan, demosaic, raw_shot)
if apply_flatfield and flatfield is not None:
rgb = _apply_flatfield_correction(rgb, flatfield, orientation=read_orientation(file_path))
if apply_wb and not wb_blocked and wb is not None:
rgb = _apply_white_balance(rgb, wb)
return rgb, ir, wb, meta
meta = _read_source_meta_tiff(file_path)
- rgb, wb, decode_meta, lens_meta = _decode_camera_raw_buffer(file_path, demosaic)
+ rgb, wb, decode_meta, lens_meta = _decode_camera_raw_buffer(file_path, demosaic, raw_shot=raw_shot)
if apply_flatfield and flatfield is not None:
rgb = _apply_flatfield_correction(rgb, flatfield, orientation=read_orientation(file_path))
warped = bool(lens.distortion and lens_meta.distortion or lens.ca and lens_meta.ca)
@@ -653,12 +658,24 @@ def _decode_dng(file_path: str, expansion: Optional[float] = None) -> tuple[np.n
return rgb, ir
-def _decode_camera_raw_buffer(file_path: str, demosaic: str = DemosaicMode.AUTO) -> tuple[np.ndarray, _CameraWB, _SourceMeta, LensMetadata]:
+def _decode_camera_raw_buffer(
+ file_path: str, demosaic: str = DemosaicMode.AUTO, *, raw_shot: int = 0
+) -> tuple[np.ndarray, _CameraWB, _SourceMeta, LensMetadata]:
"""Decode a camera RAW to an oriented float32 buffer without applying user geometry.
+ `raw_shot` picks the shot of a multi-shot RAW, or combines a Pixel Shift (see raw_shots).
Returns (f32, camera_wb, source_meta, lens); `lens` is bound to this decode's visible area.
"""
- raw = rawpy.imread(file_path)
+ source = shot_source(file_path, raw_shot)
+ shots = None
+ raw = None
+ if source is not None:
+ try:
+ raw, shots = source.open(export=True)
+ except Exception:
+ logger.warning("multi-shot decode failed, first shot used: %s", file_path, exc_info=True)
+ if raw is None:
+ raw = rawpy.imread(file_path)
wb = _CameraWB(
as_shot=tuple(raw.camera_whitebalance), # type: ignore[arg-type]
daylight=tuple(raw.daylight_whitebalance), # type: ignore[arg-type]
@@ -666,6 +683,8 @@ def _decode_camera_raw_buffer(file_path: str, demosaic: str = DemosaicMode.AUTO)
ts = raw.other.timestamp
dt_str = ts.strftime("%Y:%m:%d %H:%M:%S") if ts else None
algo, label = resolve_demosaic(raw, demosaic)
+ if shots is not None:
+ label = "Pixel Shift" if shots.shot == 0 else f"{label}, shot {shots.shot}"
rgb = raw.postprocess(
gamma=(1, 1),
no_auto_bright=True,
@@ -720,10 +739,10 @@ def _decode(path: str) -> np.ndarray:
def _decode_camera_raw_triplet(
- file_path: str, rgbscan: RgbScanConfig, demosaic: str = DemosaicMode.AUTO
+ file_path: str, rgbscan: RgbScanConfig, demosaic: str = DemosaicMode.AUTO, raw_shot: int = 0
) -> tuple[np.ndarray, None, Optional[_CameraWB], _SourceMeta]:
"""Decode three narrowband exposures and merge into one RGB buffer."""
- primary_f32, wb, meta, _ = _decode_camera_raw_buffer(file_path, demosaic)
+ primary_f32, wb, meta, _ = _decode_camera_raw_buffer(file_path, demosaic, raw_shot=raw_shot)
file_meta = _read_source_meta_tiff(file_path)
merged_meta = _SourceMeta(
make=file_meta.make or meta.make,
@@ -737,7 +756,7 @@ def _decode_camera_raw_triplet(
def _decode(path: str) -> np.ndarray:
if path in cache:
return cache[path]
- buf, _, _, _ = _decode_camera_raw_buffer(path, demosaic)
+ buf, _, _, _ = _decode_camera_raw_buffer(path, demosaic, raw_shot=raw_shot)
cache[path] = buf
return buf
@@ -763,17 +782,18 @@ def _decode_stitch_part(
source metadata)."""
is_triplet = rgbscan is not None and is_rgb_triplet(rgbscan)
demosaic = process.demosaic_export if process is not None else DemosaicMode.AUTO
+ raw_shot = process.raw_shot if process is not None else 0
wb: Optional[_CameraWB] = None
meta = _SourceMeta()
if is_triplet:
- primary_f32, wb, meta, _ = _decode_camera_raw_buffer(file_path, demosaic)
+ primary_f32, wb, meta, _ = _decode_camera_raw_buffer(file_path, demosaic, raw_shot=raw_shot)
cache: dict[str, np.ndarray] = {file_path: primary_f32}
def _decode(path: str) -> np.ndarray:
if path in cache:
return cache[path]
- buf, _, _, _ = _decode_camera_raw_buffer(path, demosaic)
+ buf, _, _, _ = _decode_camera_raw_buffer(path, demosaic, raw_shot=raw_shot)
cache[path] = buf
return buf
diff --git a/negpy/services/rendering/image_processor.py b/negpy/services/rendering/image_processor.py
index 750eb17ac..e6c6dde7d 100644
--- a/negpy/services/rendering/image_processor.py
+++ b/negpy/services/rendering/image_processor.py
@@ -35,6 +35,7 @@
highlight_reconstruction_bright_gain,
highlight_reconstruction_token,
linear_raw_token,
+ raw_shot_token,
)
from negpy.features.process.sensor import apply_sensor_correction, effective_sensor_matrix, sensor_token
from negpy.features.exposure.analysis import COLOR_HIST_BINS
@@ -362,6 +363,7 @@ def precorrect_key(
shape,
linear_raw_token(settings.process),
demosaic_token(settings.process.demosaic_preview),
+ raw_shot_token(settings.process.raw_shot),
positive_decode(settings.process),
skip_flatfield,
gain_slices,
@@ -833,6 +835,7 @@ def run_pipeline(
+ highlight_reconstruction_bakes_wb_token(settings.process)
+ sensor_token(settings.process)
+ demosaic_token(settings.process.demosaic_preview)
+ + raw_shot_token(settings.process.raw_shot)
+ ir_bake_token(settings.retouch, ir_buffer is not None)
+ heal_token
+ luma_bake_token(settings.retouch)
@@ -1019,6 +1022,7 @@ def _decode_sensor_rgb(
highlight_mode: int = 0,
bake_camera_wb: bool = False,
load_ir: bool = True,
+ raw_shot: int = 0,
) -> Tuple[np.ndarray, Dict[str, Any]]:
"""Decode one RAW to sensor-native (output_color=raw), linear uint16 RGB.
@@ -1046,7 +1050,9 @@ def _decode_sensor_rgb(
Returns (rgb_uint16, loader_metadata).
"""
- ctx_mgr, metadata = loader_factory.get_loader(file_path, linear_raw=linear_raw, positive_decode=positive_decode, load_ir=load_ir)
+ ctx_mgr, metadata = loader_factory.get_loader(
+ file_path, linear_raw=linear_raw, positive_decode=positive_decode, load_ir=load_ir, raw_shot=raw_shot
+ )
with ctx_mgr as raw:
algo = get_best_demosaic_algorithm(raw, demosaic)
# Read before postprocess: camera_whitebalance is sensor metadata, unaffected by it,
@@ -1121,6 +1127,7 @@ def _load_source_f32(
flatfield_token(params.flatfield),
sensor_token(params.process),
demosaic_token(params.process.demosaic_export),
+ raw_shot_token(params.process.raw_shot),
fast_decode,
unmix,
params.retouch.ir_dust_remove,
@@ -1176,6 +1183,7 @@ def _decode_oriented_f32(
highlight_mode = effective_highlight_reconstruction(params.process)
bake_wb = highlight_reconstruction_bakes_wb(params.process)
demosaic = params.process.demosaic_export
+ raw_shot = params.process.raw_shot
rgbcfg = params.rgbscan
# A bracket wins over a triplet. The UI refuses the two together, and the export
# decode branches in this order, so an asset carrying both must not assemble
@@ -1201,6 +1209,7 @@ def _decode_oriented_f32(
fast=fast_decode,
wb_override=_NEUTRAL_WB,
demosaic=demosaic,
+ raw_shot=raw_shot,
positive_decode=positive_decode(params.process),
highlight_mode=highlight_mode,
)
@@ -1213,6 +1222,7 @@ def _decode_oriented_f32(
linear_raw,
wb_override=_NEUTRAL_WB,
demosaic=demosaic,
+ raw_shot=raw_shot,
positive_decode=positive_decode(params.process),
highlight_mode=highlight_mode,
)[0],
@@ -1228,6 +1238,7 @@ def _decode_oriented_f32(
fast=fast_decode,
wb_override=wb_override,
demosaic=demosaic,
+ raw_shot=raw_shot,
positive_decode=positive_decode(params.process),
highlight_mode=highlight_mode,
bake_camera_wb=bake_wb,
@@ -1282,6 +1293,7 @@ def _decode(path: str) -> np.ndarray:
fast=fast_decode,
wb_override=bracket_wb,
demosaic=demosaic,
+ raw_shot=raw_shot,
positive_decode=positive_decode(params.process),
highlight_mode=highlight_mode,
)[0],
@@ -1410,6 +1422,7 @@ def _prepare_export_source_locked(
+ highlight_reconstruction_bakes_wb_token(params.process)
+ sensor_token(params.process)
+ demosaic_token(params.process.demosaic_export)
+ + raw_shot_token(params.process.raw_shot)
+ ir_bake_token(params.retouch, ir_full is not None)
+ manual_bake_token(params.retouch)
+ luma_bake_token(params.retouch)
@@ -2036,6 +2049,7 @@ def render_display_array(
+ highlight_reconstruction_token(params.process)
+ highlight_reconstruction_bakes_wb_token(params.process)
+ sensor_token(params.process)
+ + raw_shot_token(params.process.raw_shot)
+ ir_bake_token(params.retouch, ir_full is not None)
+ manual_bake_token(params.retouch)
+ luma_bake_token(params.retouch)
diff --git a/negpy/services/rendering/preview_cache.py b/negpy/services/rendering/preview_cache.py
index 476bc5e2f..ce82cf7db 100644
--- a/negpy/services/rendering/preview_cache.py
+++ b/negpy/services/rendering/preview_cache.py
@@ -32,6 +32,7 @@ class PreviewCacheKey:
workspace_color_space: str
full_resolution: bool
demosaic: str = "Auto"
+ raw_shot: int = 0
half: int = 0
split_x: float = 0.5
crop_rect: tuple[float, float, float, float] | None = None
@@ -49,6 +50,7 @@ def as_tuple(self) -> Hashable:
self.workspace_color_space,
self.full_resolution,
self.demosaic,
+ self.raw_shot,
self.half,
round(self.split_x, 6),
self.crop_rect,
diff --git a/negpy/services/rendering/preview_manager.py b/negpy/services/rendering/preview_manager.py
index 7bbb4c676..c00bf4be4 100644
--- a/negpy/services/rendering/preview_manager.py
+++ b/negpy/services/rendering/preview_manager.py
@@ -1,6 +1,6 @@
import os
import time
-from collections.abc import Callable
+from collections.abc import Callable, Iterable
from typing import Any, Optional, Sequence, Tuple
import negpy_core
@@ -12,6 +12,7 @@
from negpy.infrastructure.capture.raw_demosaic import _user_sat
from negpy.infrastructure.display.color_spaces import WORKING_COLOR_SPACE
from negpy.infrastructure.loaders.factory import loader_factory
+from negpy.infrastructure.loaders.raw_shots import ShotsInfo, composite_shots
from negpy.infrastructure.loaders.helpers import (
NonStandardFileWrapper,
camera_wb_multipliers,
@@ -81,6 +82,7 @@ def _linear_preview_key(
full_resolution: bool,
half_slice: tuple[int, float, tuple[float, float, float, float] | None, float, str] | None,
demosaic: str,
+ raw_shot: int = 0,
positive_decode: str,
highlight_mode: int,
bake_camera_wb: bool,
@@ -94,6 +96,7 @@ def _linear_preview_key(
workspace_color_space=color_space,
full_resolution=full_resolution,
demosaic=demosaic,
+ raw_shot=raw_shot,
lens_token=lens_decode_token(lens_corrections, lens_flatfield),
half=half_slice[0] if half_slice else 0,
split_x=half_slice[1] if half_slice else 0.5,
@@ -106,6 +109,19 @@ def _linear_preview_key(
)
+def _shots_failed(shots: Optional[ShotsInfo]) -> bool:
+ """A decode that fell back to the first shot is not what the cache key names: the next load tries again."""
+ return shots is not None and shots.failed
+
+
+def _with_composite_shots(meta: dict, part_metas: Iterable[dict]) -> Optional[ShotsInfo]:
+ """Put the shots of every part of a composite in *meta*, so a failed or moving part warns as well."""
+ shots = composite_shots([m.get("raw_shots") for m in part_metas])
+ if shots is not None:
+ meta["raw_shots"] = shots
+ return shots
+
+
class PreviewManager:
"""
Loads RAW (and other) files for UI preview, with in-memory LRU and fast decode.
@@ -123,6 +139,7 @@ def prefetch_linear_preview(
file_hash: str | None,
half_slice: tuple[int, float, tuple[float, float, float, float] | None, float, str] | None = None,
demosaic: str = DemosaicMode.AUTO,
+ raw_shot: int = 0,
positive_decode: str = "",
integrated_gpu: bool = False,
protected_file_hashes: tuple[str, ...] = (),
@@ -142,6 +159,7 @@ def prefetch_linear_preview(
full_resolution=False,
half_slice=half_slice,
demosaic=demosaic,
+ raw_shot=raw_shot,
positive_decode=positive_decode,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
@@ -186,6 +204,7 @@ def prefetch_linear_preview(
file_hash=file_hash,
half_slice=half_slice,
demosaic=demosaic,
+ raw_shot=raw_shot,
positive_decode=positive_decode,
cache_protected_file_hashes=protected_hashes,
cache_preserve_full_resolution=True,
@@ -207,6 +226,7 @@ def peek_linear_preview(
full_resolution: bool = False,
half_slice: tuple[int, float, tuple[float, float, float, float] | None, float, str] | None = None,
demosaic: str = DemosaicMode.AUTO,
+ raw_shot: int = 0,
positive_decode: str = "",
highlight_mode: int = 0,
bake_camera_wb: bool = False,
@@ -223,6 +243,7 @@ def peek_linear_preview(
full_resolution=full_resolution,
half_slice=half_slice,
demosaic=demosaic,
+ raw_shot=raw_shot,
positive_decode=positive_decode,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
@@ -286,6 +307,7 @@ def _load_from_open_raw(
log_timings: bool = False,
half_slice: tuple[int, float, tuple[float, float, float, float] | None, float, str] | None = None,
demosaic: str = DemosaicMode.AUTO,
+ raw_shot: int = 0,
positive_decode: str = "",
cache_protected_file_hashes: frozenset[str] = frozenset(),
cache_preserve_full_resolution: bool = False,
@@ -504,7 +526,7 @@ def _load_from_open_raw(
)
if should_cancel is not None and should_cancel():
raise InterruptedError("preview load cancelled")
- if file_hash:
+ if file_hash and not _shots_failed(metadata.get("raw_shots")):
ck = _linear_preview_key(
file_hash,
color_space=color_space,
@@ -512,6 +534,7 @@ def _load_from_open_raw(
full_resolution=full_resolution,
half_slice=half_slice,
demosaic=demosaic,
+ raw_shot=raw_shot,
positive_decode=positive_decode,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
@@ -562,6 +585,7 @@ def load_linear_preview(
log_timings: bool = False,
half_slice: tuple[int, float, tuple[float, float, float, float] | None, float, str] | None = None,
demosaic: str = DemosaicMode.AUTO,
+ raw_shot: int = 0,
positive_decode: str = "",
cache_protected_file_hashes: frozenset[str] = frozenset(),
cache_preserve_full_resolution: bool = False,
@@ -592,6 +616,7 @@ def load_linear_preview(
full_resolution=full_resolution,
half_slice=half_slice,
demosaic=demosaic,
+ raw_shot=raw_shot,
positive_decode=positive_decode,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
@@ -609,6 +634,7 @@ def load_linear_preview(
positive_decode=positive_decode,
preview_max_edge=None if full_resolution else APP_CONFIG.preview_render_size,
should_cancel=should_cancel,
+ raw_shot=raw_shot,
)
if color_space is None:
@@ -622,6 +648,7 @@ def load_linear_preview(
full_resolution=full_resolution,
half_slice=half_slice,
demosaic=demosaic,
+ raw_shot=raw_shot,
positive_decode=positive_decode,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
@@ -646,6 +673,7 @@ def load_linear_preview(
log_timings,
half_slice=half_slice,
demosaic=demosaic,
+ raw_shot=raw_shot,
positive_decode=positive_decode,
cache_protected_file_hashes=cache_protected_file_hashes,
cache_preserve_full_resolution=cache_preserve_full_resolution,
@@ -704,6 +732,7 @@ def load_linear_preview_rgb(
full_resolution: bool = False,
file_hash: str | None = None,
demosaic: str = DemosaicMode.AUTO,
+ raw_shot: int = 0,
should_cancel: Optional[Callable[[], bool]] = None,
) -> Tuple[ImageBuffer, Dimensions, dict]:
"""Merge a narrowband R/G/B triplet into one linear preview: red channel from the
@@ -728,19 +757,20 @@ def load_linear_preview_rgb(
workspace_color_space=color_space,
full_resolution=full_resolution,
demosaic=demosaic,
+ raw_shot=raw_shot,
)
hit = self._cache.get(merged_key)
if hit is not None:
return hit # cache hit — caller must not mutate this buffer
red_out, dims, meta = self.load_linear_preview(
- red_path, color_space, False, full_resolution, file_hash, demosaic=demosaic, should_cancel=should_cancel
+ red_path, color_space, False, full_resolution, file_hash, demosaic=demosaic, raw_shot=raw_shot, should_cancel=should_cancel
)
- green_out, _, _ = self.load_linear_preview(
- green_path, color_space, False, full_resolution, None, demosaic=demosaic, should_cancel=should_cancel
+ green_out, _, green_meta = self.load_linear_preview(
+ green_path, color_space, False, full_resolution, None, demosaic=demosaic, raw_shot=raw_shot, should_cancel=should_cancel
)
- blue_out, _, _ = self.load_linear_preview(
- blue_path, color_space, False, full_resolution, None, demosaic=demosaic, should_cancel=should_cancel
+ blue_out, _, blue_meta = self.load_linear_preview(
+ blue_path, color_space, False, full_resolution, None, demosaic=demosaic, raw_shot=raw_shot, should_cancel=should_cancel
)
red = np.asarray(red_out, dtype=np.float32)
@@ -758,7 +788,8 @@ def _match(buf: ImageBuffer) -> np.ndarray:
# must never reach the capture-matrix fold downstream — not even the primary's alone.
meta = dict(meta)
meta["camera_wb"] = None
- if merged_key is not None:
+ shots = _with_composite_shots(meta, (meta, green_meta, blue_meta))
+ if merged_key is not None and not _shots_failed(shots):
# A freshly assembled buffer: the cache and the caller alias it, read-only.
self._cache.put(merged_key, out, dims, dict(meta))
return out, dims, meta
@@ -772,6 +803,7 @@ def load_linear_preview_hdr(
full_resolution: bool = False,
file_hash: str | None = None,
demosaic: str = DemosaicMode.AUTO,
+ raw_shot: int = 0,
should_cancel: Optional[Callable[[], bool]] = None,
highlight_mode: int = 0,
bake_camera_wb: bool = False,
@@ -801,6 +833,7 @@ def load_linear_preview_hdr(
workspace_color_space=color_space,
full_resolution=full_resolution,
demosaic=demosaic,
+ raw_shot=raw_shot,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
)
@@ -820,6 +853,7 @@ def load_linear_preview_hdr(
full_resolution,
file_hash,
demosaic=demosaic,
+ raw_shot=raw_shot,
should_cancel=should_cancel,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
@@ -829,23 +863,23 @@ def load_linear_preview_hdr(
# decoded, so this is available whether or not bake_camera_wb baked it into ref's
# own pixels.
bracket_wb = meta.get("camera_wb") if bake_camera_wb else None
+ part_metas: dict[str, dict] = {}
def _load(path: str) -> np.ndarray:
- arr = np.asarray(
- self.load_linear_preview(
- path,
- color_space,
- use_camera_wb,
- full_resolution,
- None,
- demosaic=demosaic,
- should_cancel=should_cancel,
- highlight_mode=highlight_mode,
- bake_camera_wb=bake_camera_wb,
- wb_override=bracket_wb,
- )[0],
- dtype=np.float32,
+ out, _, part_metas[path] = self.load_linear_preview(
+ path,
+ color_space,
+ use_camera_wb,
+ full_resolution,
+ None,
+ demosaic=demosaic,
+ raw_shot=raw_shot,
+ should_cancel=should_cancel,
+ highlight_mode=highlight_mode,
+ bake_camera_wb=bake_camera_wb,
+ wb_override=bracket_wb,
)
+ arr = np.asarray(out, dtype=np.float32)
if arr.shape[:2] != ref.shape[:2]:
# Preview sizing rounds per file, so a pixel or two between frames of one
# bracket is ordinary and resampling is right. A different *aspect* is not: a
@@ -861,7 +895,9 @@ def _load(path: str) -> np.ndarray:
providers = [lambda: ref, *[lambda p=p: _load(p) for p in other_paths]]
merged = merge_providers(providers, list(ratios), reference=0, align=align)
- if merged_key is not None:
+ meta = dict(meta)
+ shots = _with_composite_shots(meta, (meta, *(part_metas.get(p, {}) for p in other_paths)))
+ if merged_key is not None and not _shots_failed(shots):
# Cache it unscaled: the exposure is applied below and can change without costing
# another merge.
self._cache.put(merged_key, ensure_image(merged), dims, dict(meta))
@@ -877,6 +913,7 @@ def load_linear_preview_stitch(
file_hash: str | None = None,
flatfield_profile_id: str = "",
demosaic: str = DemosaicMode.AUTO,
+ raw_shot: int = 0,
should_cancel: Optional[Callable[[], bool]] = None,
highlight_mode: int = 0,
bake_camera_wb: bool = False,
@@ -899,6 +936,7 @@ def load_linear_preview_stitch(
workspace_color_space=color_space,
full_resolution=full_resolution,
demosaic=demosaic,
+ raw_shot=raw_shot,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
)
@@ -906,7 +944,7 @@ def load_linear_preview_stitch(
if hit is not None:
return hit # cache hit — caller must not mutate this buffer
- parts, irs = [], []
+ parts, irs, part_metas = [], [], []
meta: dict = {}
for i, path in enumerate((primary_path, *stitch.stitch_paths)):
green, blue = stitch.stitch_triplets[i] if i < len(stitch.stitch_triplets) else ("", "")
@@ -921,6 +959,7 @@ def load_linear_preview_stitch(
full_resolution,
None,
demosaic=demosaic,
+ raw_shot=raw_shot,
should_cancel=should_cancel,
)
else:
@@ -931,20 +970,23 @@ def load_linear_preview_stitch(
full_resolution,
None,
demosaic=demosaic,
+ raw_shot=raw_shot,
should_cancel=should_cancel,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
)
parts.append(apply_flatfield(np.asarray(out, dtype=np.float32), flatfield, orientation=part_meta.get("orientation", 1)))
irs.append(part_meta.get("ir_preview"))
+ part_metas.append(part_meta)
if i == 0:
meta = dict(part_meta)
rgb, ir = stitch_composite(parts, irs, stitch)
meta["ir_preview"] = ir
+ shots = _with_composite_shots(meta, part_metas)
out_buf = ensure_image(rgb)
dims = (stitch.stitch_canvas[1], stitch.stitch_canvas[0])
- if key is not None:
+ if key is not None and not _shots_failed(shots):
self._cache.put(key, out_buf, dims, meta)
return out_buf, dims, meta
@@ -958,6 +1000,7 @@ def load_splash_and_linear(
log_timings: bool = False,
half_slice: tuple[int, float, tuple[float, float, float, float] | None, float, str] | None = None,
demosaic: str = DemosaicMode.AUTO,
+ raw_shot: int = 0,
positive_decode: str = "",
should_cancel: Optional[Callable[[], bool]] = None,
highlight_mode: int = 0,
@@ -986,6 +1029,7 @@ def load_splash_and_linear(
full_resolution=full_resolution,
half_slice=half_slice,
demosaic=demosaic,
+ raw_shot=raw_shot,
positive_decode=positive_decode,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
@@ -1004,6 +1048,7 @@ def load_splash_and_linear(
positive_decode=positive_decode,
preview_max_edge=None if full_resolution else APP_CONFIG.preview_render_size,
should_cancel=should_cancel,
+ raw_shot=raw_shot,
)
except Exception as e:
logger.debug("preview load_splash_and_linear open failed: %s", e)
@@ -1020,6 +1065,7 @@ def load_splash_and_linear(
full_resolution=full_resolution,
half_slice=half_slice,
demosaic=demosaic,
+ raw_shot=raw_shot,
positive_decode=positive_decode,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
@@ -1048,6 +1094,7 @@ def load_splash_and_linear(
log_timings,
half_slice=half_slice,
demosaic=demosaic,
+ raw_shot=raw_shot,
positive_decode=positive_decode,
should_cancel=should_cancel,
highlight_mode=highlight_mode,
diff --git a/negpy/services/rendering/source_identity.py b/negpy/services/rendering/source_identity.py
index 3b37fcc56..d853a29af 100644
--- a/negpy/services/rendering/source_identity.py
+++ b/negpy/services/rendering/source_identity.py
@@ -17,6 +17,7 @@
effective_highlight_reconstruction,
effective_linear_raw,
highlight_reconstruction_bakes_wb,
+ raw_shot_token,
)
from negpy.features.process.path import positive_decode
from negpy.features.rgbscan.logic import rgbscan_token
@@ -38,6 +39,8 @@ def source_token(config: WorkspaceConfig) -> str:
# Preview only: folding the export choice in would re-decode the open frame whenever
# an export setting moved.
demosaic_token(config.process.demosaic_preview),
+ # Which shot of a multi-shot RAW, or the shots combined: changes the decoded numbers.
+ raw_shot_token(config.process.raw_shot),
rgbscan_token(config.rgbscan),
stitch_token(config.stitch),
hdr_token(config.hdr),
diff --git a/negpy/translations/USER_GUIDE.de.md b/negpy/translations/USER_GUIDE.de.md
index 878c17066..a77c04951 100644
--- a/negpy/translations/USER_GUIDE.de.md
+++ b/negpy/translations/USER_GUIDE.de.md
@@ -392,11 +392,12 @@ Misst die ganze Rolle einmal und teilt das Ergebnis, damit die Bilder eines Film
* **Szenen**: Gruppen von Bildern mit eigener Basislinie, zum Beispiel ein Motiv unter einem Licht ([Szenen](#scenes)). **Analysieren** misst die Szene, **Auswählen** wählt ihre Bilder aus und **Löschen** entfernt die Gruppe.
* **Szenen erkennen**: findet die Gruppen von Bildern unter anderem Licht als die Rolle, in beliebiger Bildreihenfolge, und zeigt sie auf dem Leuchttisch. Jede Gruppe wartet unter **Gefunden**. Um eine Gruppe zu ändern, ziehe Bilder auf eine andere Gruppe oder auf ein Bild ohne Szene, um sie auszuschließen. Rechtsklick › **Szene** macht dasselbe. Gib ihr einen Namen und **Übernehme** sie als Szene mit eigener Basislinie, oder **Verwirf** sie.
-
+
### 10.6 Raw-Dekodierung
Wie eine Kamera-RAW-Datei in Pixel umgewandelt wird. Wird bei einer Kamera angezeigt.
+* **Aufnahmen**: Eine RAW-Datei mit mehreren Aufnahmen, etwa ein HDR aus der Kamera, öffnet eine davon. Bei einer Pentax-Pixel-Shift-Datei kombiniert **Pixel Shift** ihre vier Aufnahmen, sodass jedes Pixel gemessenes Rot, Grün und Blau hat. Die Zeile darunter zeigt das Ergebnis. Eine Warnung erscheint, wenn die Aufnahmen nicht übereinstimmen. Nimm Pixel Shift mit dem Selbstauslöser oder einem Fernauslöser auf, denn der Auslöser bewegt die Kamera.
* **Kamera-WB / Lineares RAW**: Kamera-WB nutzt den Weißabgleich der Kamera. Lineares RAW dekodiert neutral, für schmalbandiges Licht. Die Einrichtung stellt es für dich ein.
* **Vorschau** / **Export**: die Demosaicing-Methode, die die Farben ergänzt, die ein Sensorpixel nicht sieht. **Auto** ist am Bildschirm schnell und nutzt für den Export **AHD**. **VNG** ist glatt und **PPG** schnell. **DCB** und **DHT** erhalten mehr feine Details, können aber Korn in Muster verwandeln. **AAHD** macht Kanten weicher. Es werden nur die Methoden deines LibRaw-Builds aufgeführt.
* **Wiederherstellung** (Dias): rekonstruiert ausgebrannte Lichter in einer Kamera-RAW-Datei. **Mischen** passt zu Sonne und Himmel. **Rekonstruieren** ist stärker, kann aber die Farbe falsch treffen.
diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md
index 11e9fc0a3..d99237ca8 100644
--- a/negpy/translations/USER_GUIDE.en.md
+++ b/negpy/translations/USER_GUIDE.en.md
@@ -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.
diff --git a/negpy/translations/USER_GUIDE.ja.md b/negpy/translations/USER_GUIDE.ja.md
index 9f0e10883..5be0e20f0 100644
--- a/negpy/translations/USER_GUIDE.ja.md
+++ b/negpy/translations/USER_GUIDE.ja.md
@@ -315,11 +315,12 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室
カメラではRAW デコードと光学のカードが表示されます。ナローバンド光のカメラでは、さらにトリクロームとキャリブレーションも表示されます。変更すると開いているすべてのフレームが更新されます(Ctrl+Z でフレームごとに戻せます)が、自分で選んだプリント素材はそのままです。カメラのスキャンでは、フレームごとに撮影時の光源からデコードとプリント素材を決めます。
-
+
## RAW デコード
カメラの RAW ファイルを画素に変える方法です。カメラのときに表示されます。
+- **ショット**:複数のショットを含む RAW ファイル(カメラ内 HDR など)は、そのうち1つを開きます。ペンタックスのピクセルシフトファイルでは、**ピクセルシフト**が4つのショットを合成し、すべての画素で赤・緑・青を実測した値にします。下の行に結果が表示されます。ショットが一致しないときは警告が出ます。シャッターボタンでカメラが動くため、ピクセルシフトはセルフタイマーかリモコンで撮影してください。
- **カメラWB/リニアRAW**:カメラWB はカメラのホワイトバランスを使います。リニアRAW はニュートラルにデコードし、ナローバンド光に向きます。セットアップが自動で設定します。
- **プレビュー**/**書き出し**:デモザイクの方法。センサーの各画素が捉えない色を補います。**自動**は画面表示で速く、書き出しでは **AHD** を使います。**VNG** はなめらか、**PPG** は高速です。**DCB** と **DHT** は細部を多く残しますが、粒子が模様のようになることがあります。**AAHD** は輪郭を柔らかくします。お使いの LibRaw にある方法だけが表示されます。
- **回復**(スライド):カメラ RAW の白飛びしたハイライトを再構成します。**ブレンド**は太陽や空に向きます。**再構成**はより強力ですが、色を誤ることがあります。
diff --git a/negpy/translations/USER_GUIDE.ko.md b/negpy/translations/USER_GUIDE.ko.md
index 142325ce5..629afa3b5 100644
--- a/negpy/translations/USER_GUIDE.ko.md
+++ b/negpy/translations/USER_GUIDE.ko.md
@@ -315,11 +315,12 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입
카메라에서는 RAW 디코딩과 광학 카드가 보입니다. 협대역 광의 카메라에서는 삼색과 보정 카드도 보입니다. 바꾸면 열려 있는 모든 프레임이 갱신되지만(Ctrl+Z로 프레임마다 되돌림), 직접 고른 프린트 소재는 그대로입니다. 카메라 스캔은 프레임마다 촬영한 광원에 따라 디코딩과 프린트 소재를 정합니다.
-
+
## RAW 디코딩
카메라 RAW 파일을 픽셀로 바꾸는 방식입니다. 카메라일 때 보입니다.
+- **샷**: 여러 샷이 담긴 RAW 파일(카메라 내 HDR 등)은 그중 하나를 엽니다. 펜탁스 픽셀 시프트 파일에서는 **픽셀 시프트**가 네 샷을 합쳐 모든 픽셀이 측정된 빨강, 초록, 파랑을 갖게 합니다. 아래 줄에 결과가 표시됩니다. 샷이 일치하지 않으면 경고가 나타납니다. 셔터 버튼이 카메라를 움직이므로 픽셀 시프트는 셀프타이머나 리모컨으로 촬영하세요.
- **카메라 WB / 선형 RAW**: 카메라 WB는 카메라의 화이트 밸런스를 씁니다. 선형 RAW는 중립으로 디코딩하며 협대역 광에 알맞습니다. 설정이 자동으로 정합니다.
- **미리보기** / **내보내기**: 디모자이크 방식으로, 센서의 각 픽셀이 보지 못하는 색을 채웁니다. **자동**은 화면에서 빠르고 내보내기에는 **AHD**를 씁니다. **VNG**는 부드럽고 **PPG**는 빠릅니다. **DCB**와 **DHT**는 세부를 더 살리지만 입자가 무늬처럼 보일 수 있습니다. **AAHD**는 가장자리를 부드럽게 합니다. 사용 중인 LibRaw에 있는 방식만 표시됩니다.
- **복구**(슬라이드): 카메라 RAW의 날아간 밝은 영역을 다시 만듭니다. **블렌드**는 해와 하늘에 알맞습니다. **재구성**은 더 강하지만 색이 틀릴 수 있습니다.
diff --git a/negpy/translations/USER_GUIDE.zh_CN.md b/negpy/translations/USER_GUIDE.zh_CN.md
index b9cbcc7a3..a7b1f7485 100644
--- a/negpy/translations/USER_GUIDE.zh_CN.md
+++ b/negpy/translations/USER_GUIDE.zh_CN.md
@@ -315,11 +315,12 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样
相机会显示 RAW 解码和光学卡片。使用窄带光的相机还会显示三色扫描和校准卡片。更改后所有打开的帧都会更新(Ctrl+Z 可逐帧撤销),但你自己选择的印相材料保持不变。相机扫描按每帧的光源设置解码和印相材料。
-
+
## RAW 解码
把相机 RAW 文件变成像素的方式。使用相机时显示。
+- **拍摄**:包含多次拍摄的 RAW 文件(例如相机内 HDR)会打开其中一次。对于宾得像素偏移文件,**像素偏移**会合成它的四次拍摄,使每个像素都有实测的红、绿、蓝。下方一行显示结果。各次拍摄不一致时会显示警告。请用自拍定时器或遥控器拍摄像素偏移,因为快门按钮会让相机移动。
- **相机白平衡 / 线性 RAW**:相机白平衡使用相机的白平衡。线性 RAW 以中性方式解码,适合窄带光。设置会自动选择。
- **预览** / **导出**:去马赛克方法,补出每个传感器像素看不到的颜色。**自动**在屏幕上更快,导出时使用 **AHD**。**VNG** 平滑,**PPG** 快速。**DCB** 和 **DHT** 保留更多细节,但可能把颗粒变成花纹。**AAHD** 让边缘更柔和。只列出你的 LibRaw 版本中有的方法。
- **恢复**(反转片):重建相机 RAW 中过曝的高光。**混合**适合太阳和天空。**重建**更强,但颜色可能出错。
diff --git a/negpy/translations/de.json b/negpy/translations/de.json
index c3a929074..976545fb5 100644
--- a/negpy/translations/de.json
+++ b/negpy/translations/de.json
@@ -31,6 +31,7 @@
"A": "A",
"A roll is a group of frames, usually one film. + imports a folder as a roll. NegPy never moves or changes your files.": "Eine Rolle ist eine Gruppe von Bildern, meist ein Film. + importiert einen Ordner als Rolle. NegPy verschiebt oder ändert deine Dateien nie.",
"A test strip prints the frame as a grid of brightness and contrast steps. Click the one you like.": "Ein Probestreifen zeigt das Bild als Raster aus Helligkeits- und Kontraststufen. Klicke auf die Stufe, die dir gefällt.",
+ "A RAW that holds several shots opens one of them, or Pixel Shift combines four shots into measured colors at every pixel.": "Eine Raw-Datei mit mehreren Aufnahmen öffnet eine davon, oder Pixel Shift kombiniert vier Aufnahmen zu gemessenen Farben in jedem Pixel.",
"A Scanlight or RGB LED light with pure red, green and blue.": "Ein Scanlight oder eine RGB-LED-Lichtquelle mit reinem Rot, Grün und Blau.",
"A Trichrome triplet has no sensor leak to remove, so the profile is skipped and your choice is kept.": "Ein Trichrome-Triplet hat keine Sensor-Farbvermischung, die entfernt werden müsste. Das Profil wird übersprungen, und deine Auswahl bleibt erhalten.",
"A calibration is running. Wait for it to finish.": "Eine Kalibrierung läuft. Warte, bis sie fertig ist.",
@@ -1604,6 +1605,7 @@
"NegPy": "NegPy",
"NegPy Hit an Error": "In NegPy ist ein Fehler aufgetreten",
"NegPy Sidecars (*.negpy)": "NegPy-Sidecar-Dateien (*.negpy)",
+ "NegPy could not decode the chosen shots, so the first shot shows.": "NegPy konnte die gewählten Aufnahmen nicht dekodieren, daher wird die erste Aufnahme gezeigt.",
"NegPy data folder": "NegPy-Datenordner",
"NegPy meters the clear base and solves the LED levels and the shutter for this stock, then saves the preset": "NegPy misst die klare Basis, ermittelt die LED-Pegel und die Verschlusszeit für diese Sorte und speichert dann die Vorgabe",
"NegPy turns scans of film into finished pictures. It works like a darkroom: you pick a paper, then set brightness, contrast and color.
The chapter menu jumps anywhere. A circle marks a task to try; Undo reverses it.": "NegPy macht aus Filmscans fertige Bilder. Es arbeitet wie eine Dunkelkammer: Du wählst ein Papier und stellst dann Helligkeit, Kontrast und Farbe ein.
Das Kapitelmenü springt überallhin. Ein Kreis markiert eine Aufgabe zum Ausprobieren. Rückgängig macht sie rückgängig.",
@@ -1671,11 +1673,13 @@
"Normal": "Normal",
"Normalization Bounds": "Normalisierungsgrenzen",
"Not a camera RAW, so a film scanner.": "Kein Kamera-RAW, also ein Filmscanner.",
+ "Not applied to Pixel Shift.": "Wird nicht auf Pixel Shift angewendet.",
"Not applied to a merged bracket.": "Wird nicht auf eine zusammengeführte Belichtungsreihe angewendet.",
"Not applied to a transparency.": "Wird nicht auf ein Dia angewendet.",
"Not applied to an as-captured transparency.": "Wird nicht auf ein Dia im Aufnahmezustand angewendet.",
"Not applied to slides.": "Wird nicht auf Dias angewendet.",
"Not sure which slider to move? Pick Better, the balance scale, shows two versions beside your edit. Click the better one; each round tunes one control.": "Unsicher, welchen Regler du bewegen sollst? Besseres wählen, die Waage, zeigt zwei Versionen neben deiner Bearbeitung. Klicke auf die bessere: Jede Runde stimmt einen Regler ab.",
+ "Not used for Pixel Shift: every pixel has measured colors.": "Wird für Pixel Shift nicht verwendet: Jedes Pixel hat gemessene Farben.",
"Not used for a Trichrome triplet.": "Wird für ein Trichrome-Triplett nicht verwendet.",
"Note: the Sheet filter is on, so the analysis reads only the {value}.": "Hinweis: Der Bogenfilter ist an, daher liest die Analyse nur {value}.",
"Notes": "Notizen",
@@ -1837,6 +1841,8 @@
"Pick which of the current settings to store as a new preset": "Wähle, welche der aktuellen Einstellungen als neue Vorgabe gespeichert werden",
"Picking a roll loads its saved baseline onto the loaded files.": "Beim Wählen einer Rolle wird ihre gespeicherte Basislinie auf die geladenen Dateien angewendet.",
"Pin {value} · reads {measured}": "Pin {value} · liest {measured}",
+ "Pixel Shift": "Pixel Shift",
+ "Pixel Shift: {value} combined.": "Pixel Shift: {value} kombiniert.",
"Pixels": "Pixel",
"Place": "Ort",
"Place Zones": "Zonen platzieren",
@@ -2180,6 +2186,7 @@
"Run this preset on every export; off keeps it for later": "Diese Vorgabe bei jedem Export ausführen. Aus hebt sie für später auf",
"SCENES": "SZENEN",
"SELECTED MASK": "AUSGEWÄHLTE MASKE",
+ "SHOTS": "AUFNAHMEN",
"SIZE": "GRÖSSE",
"SPLIT TONING": "SPLITTONUNG",
"STEP {number}": "SCHRITT {number}",
@@ -2441,6 +2448,9 @@
"Shortcut Hints": "Tastenkürzel-Hinweise",
"Shortcut hints (show a control's keys after a mouse edit)": "Tastenkürzel-Hinweise (Tasten eines Reglers nach einer Mausbearbeitung anzeigen)",
"Shot": "Aufnahme",
+ "Shot {n}": "Aufnahme {n}",
+ "Shot {shot} of {count}.": "Aufnahme {shot} von {count}.",
+ "Shots": "Aufnahmen",
"Shoulder": "Schulter",
"Shoulder Trim": "Schulter-Feinabstimmung",
"Shoulder Width": "Schulterbreite",
@@ -2690,6 +2700,7 @@
"The lens model, written to the photo's metadata": "Das Objektivmodell, in die Metadaten des Fotos geschrieben",
"The lens this preset writes onto a frame": "Das Objektiv, das diese Vorgabe in ein Bild schreibt",
"The light behind the negative": "Das Licht hinter dem Negativ",
+ "The light changed by {value} between the shots. Use a steady light, or pick one shot.": "Das Licht hat sich zwischen den Aufnahmen um {value} geändert. Nutze ein gleichmäßiges Licht oder wähle eine einzelne Aufnahme.",
"The long edge of the preview on the canvas.": "Die lange Kante der Vorschau auf der Arbeitsfläche.",
"The maker of the camera or lens, written to the photo's metadata": "Der Hersteller von Kamera oder Objektiv, in die Metadaten des Fotos geschrieben",
"The mask acts on every tone inside its shape.": "Die Maske wirkt auf alle Tonwerte innerhalb ihrer Form.",
@@ -2727,6 +2738,8 @@
"The scanner sat idle long enough to return the strip. Frame selection cleared.": "Der Scanner war so lange im Leerlauf, dass er den Streifen ausgegeben hat. Bildauswahl gelöscht.",
"The scanner sat idle long enough to return the strip. Measuring it again…": "Der Scanner war so lange im Leerlauf, dass er den Streifen ausgegeben hat. Messung läuft erneut…",
"The shape of the final print, or Original to keep the frame's shape.": "Die Form des fertigen Abzugs, oder Original, um die Form des Bildes zu behalten.",
+ "The shots differ in {mismatch} of the picture. Shot brightness: {levels}. Time: {seconds} s.": "Die Aufnahmen unterscheiden sich in {mismatch} des Bildes. Helligkeit der Aufnahmen: {levels}. Zeit: {seconds} s.",
+ "The shots do not match in {value} of the picture. Use the self-timer or a remote, or pick one shot.": "Die Aufnahmen stimmen in {value} des Bildes nicht überein. Nutze den Selbstauslöser oder einen Fernauslöser, oder wähle eine einzelne Aufnahme.",
"The source files and their sidecars go to the Trash. The edits stay in the database, so a restored file keeps its edit.": "Die Quelldateien und ihre Sidecar-Dateien kommen in den Papierkorb. Die Bearbeitungen bleiben in der Datenbank, eine wiederhergestellte Datei behält also ihre Bearbeitung.",
"The state or region where the frame was shot.": "Das Bundesland oder die Region, in der das Bild aufgenommen wurde.",
"The strip stopped after {frames}. Move the film, then click Scan.": "Der Streifen stoppte nach {frames}. Bewege den Film und klicke dann auf Scan.",
@@ -3156,6 +3169,7 @@
"set": "Satz|Sätze",
"setting": "Einstellung|Einstellungen",
"sheet": "Bogen|Bögen",
+ "shot": "Aufnahme|Aufnahmen",
"slot": "Schacht|Schächte",
"strip": "Streifen|Streifen",
"thin end": "dünnes Ende",
diff --git a/negpy/translations/ja.json b/negpy/translations/ja.json
index e3b1ea2c2..2abfdd6ab 100644
--- a/negpy/translations/ja.json
+++ b/negpy/translations/ja.json
@@ -31,6 +31,7 @@
"A": "A",
"A roll is a group of frames, usually one film. + imports a folder as a roll. NegPy never moves or changes your files.": "ロールはフレームのグループで、ふつうはフィルム 1 本分です。+ でフォルダーをロールとして読み込みます。NegPy がファイルを移動したり変更したりすることはありません。",
"A test strip prints the frame as a grid of brightness and contrast steps. Click the one you like.": "テストストリップは、明るさとコントラストを段階的に変えたグリッドでフレームをプリントします。気に入ったものをクリックします。",
+ "A RAW that holds several shots opens one of them, or Pixel Shift combines four shots into measured colors at every pixel.": "複数のショットを含む RAW はそのうち1つを開きます。ピクセルシフトは4つのショットを合成し、すべての画素で実測した色を得ます。",
"A Scanlight or RGB LED light with pure red, green and blue.": "純粋な赤・緑・青を出す Scanlight または RGB LED 光源。",
"A Trichrome triplet has no sensor leak to remove, so the profile is skipped and your choice is kept.": "三色トリプレットには取り除くセンサーの漏れがないため、プロファイルは適用せず、選択はそのまま保持します。",
"A calibration is running. Wait for it to finish.": "キャリブレーションを実行中です。完了するまでお待ちください。",
@@ -1604,6 +1605,7 @@
"NegPy": "NegPy",
"NegPy Hit an Error": "NegPy でエラーが発生しました",
"NegPy Sidecars (*.negpy)": "NegPy サイドカー (*.negpy)",
+ "NegPy could not decode the chosen shots, so the first shot shows.": "NegPy は選んだショットをデコードできなかったため、最初のショットを表示しています。",
"NegPy data folder": "NegPyデータフォルダー",
"NegPy meters the clear base and solves the LED levels and the shutter for this stock, then saves the preset": "NegPy が透明なベースを測光し、このフィルムの LED レベルとシャッターを求めてプリセットを保存します",
"NegPy turns scans of film into finished pictures. It works like a darkroom: you pick a paper, then set brightness, contrast and color.
The chapter menu jumps anywhere. A circle marks a task to try; Undo reverses it.": "NegPy はフィルムのスキャンを完成した写真にします。暗室と同じように、紙を選んでから明るさ、コントラスト、色を決めます。
章のメニューでどこへでも移動できます。丸印は試す作業で、元に戻すで取り消せます。",
@@ -1671,11 +1673,13 @@
"Normal": "ノーマル",
"Normalization Bounds": "正規化境界",
"Not a camera RAW, so a film scanner.": "カメラ RAW ではないので、フィルムスキャナーです。",
+ "Not applied to Pixel Shift.": "ピクセルシフトには適用されません。",
"Not applied to a merged bracket.": "合成したブラケットには適用されません。",
"Not applied to a transparency.": "スライドには適用されません。",
"Not applied to an as-captured transparency.": "撮影したままのスライドには適用されません。",
"Not applied to slides.": "スライドには適用されません。",
"Not sure which slider to move? Pick Better, the balance scale, shows two versions beside your edit. Click the better one; each round tunes one control.": "どのスライダーを動かすか迷ったら、天びんのより良いものを選ぶが編集の横に 2 つの版を表示します。良い方をクリックします。各回で一つの調整を合わせます。",
+ "Not used for Pixel Shift: every pixel has measured colors.": "ピクセルシフトでは使われません。すべての画素に実測した色があります。",
"Not used for a Trichrome triplet.": "トリクロームトリプレットには使用されません。",
"Note: the Sheet filter is on, so the analysis reads only the {value}.": "注: シートフィルターがオンのため、解析は {value} だけを読み取ります。",
"Notes": "メモ",
@@ -1837,6 +1841,8 @@
"Pick which of the current settings to store as a new preset": "現在の設定のうちどれを新しいプリセットとして保存するかを選択します",
"Picking a roll loads its saved baseline onto the loaded files.": "ロールを選択すると、保存されているベースラインがロードされたファイルにロードされます。",
"Pin {value} · reads {measured}": "ピン {value} · 読み取り値 {measured}",
+ "Pixel Shift": "ピクセルシフト",
+ "Pixel Shift: {value} combined.": "ピクセルシフト:{value}を合成しました。",
"Pixels": "ピクセル",
"Place": "場所",
"Place Zones": "ゾーンを配置",
@@ -2180,6 +2186,7 @@
"Run this preset on every export; off keeps it for later": "エクスポートのたびにこのプリセットを実行します。オフでは後で使うために残します",
"SCENES": "シーン",
"SELECTED MASK": "選択中のマスク",
+ "SHOTS": "ショット",
"SIZE": "サイズ",
"SPLIT TONING": "スプリットトーン",
"STEP {number}": "ステップ {number}",
@@ -2441,6 +2448,9 @@
"Shortcut Hints": "ショートカットのヒント",
"Shortcut hints (show a control's keys after a mouse edit)": "ショートカットのヒント(マウスで操作したコントロールのキーを表示)",
"Shot": "撮影",
+ "Shot {n}": "ショット {n}",
+ "Shot {shot} of {count}.": "{count} ショット中の {shot} 番目。",
+ "Shots": "ショット",
"Shoulder": "ショルダー",
"Shoulder Trim": "ショルダー微調整",
"Shoulder Width": "ショルダー幅",
@@ -2690,6 +2700,7 @@
"The lens model, written to the photo's metadata": "写真のメタデータに書き込むレンズのモデル",
"The lens this preset writes onto a frame": "このプリセットがフレームに書き込むレンズ",
"The light behind the negative": "ネガの背後にある光源",
+ "The light changed by {value} between the shots. Use a steady light, or pick one shot.": "ショットの間に光が {value} 変化しました。安定した光源を使うか、1つのショットを選んでください。",
"The long edge of the preview on the canvas.": "キャンバス上のプレビューの長辺。",
"The maker of the camera or lens, written to the photo's metadata": "写真のメタデータに書き込むカメラまたはレンズのメーカー",
"The mask acts on every tone inside its shape.": "マスクは、その形状内のすべてのトーンに作用します。",
@@ -2727,6 +2738,8 @@
"The scanner sat idle long enough to return the strip. Frame selection cleared.": "スキャナーはストリップを返すのに十分な時間アイドル状態でした - フレーム選択がクリアされました",
"The scanner sat idle long enough to return the strip. Measuring it again…": "スキャナーが長くアイドル状態だったため、ストリップを排出しました。再測定中…",
"The shape of the final print, or Original to keep the frame's shape.": "最終プリントの形。フレームの形のままにするには「オリジナル」を選びます。",
+ "The shots differ in {mismatch} of the picture. Shot brightness: {levels}. Time: {seconds} s.": "ショットは画面の {mismatch} で異なります。各ショットの明るさ:{levels}。時間:{seconds} 秒。",
+ "The shots do not match in {value} of the picture. Use the self-timer or a remote, or pick one shot.": "ショットが画面の {value} で一致しません。セルフタイマーかリモコンを使うか、1つのショットを選んでください。",
"The source files and their sidecars go to the Trash. The edits stay in the database, so a restored file keeps its edit.": "ソース ファイルとそのサイドカーはゴミ箱に移動します。編集内容はデータベースに残るため、復元されたファイルには編集内容が保持されます。",
"The state or region where the frame was shot.": "フレームを撮影した州または地域。",
"The strip stopped after {frames}. Move the film, then click Scan.": "{frames}でストリップが停止しました。フィルムを動かしてから「スキャン」をクリックしてください。",
@@ -3156,6 +3169,7 @@
"set": "セット",
"setting": "設定",
"sheet": "シート",
+ "shot": "ショット",
"slot": "スロット",
"strip": "ストリップ",
"thin end": "薄い側",
diff --git a/negpy/translations/ko.json b/negpy/translations/ko.json
index 540fd1ac8..4614c295d 100644
--- a/negpy/translations/ko.json
+++ b/negpy/translations/ko.json
@@ -31,6 +31,7 @@
"A": "A",
"A roll is a group of frames, usually one film. + imports a folder as a roll. NegPy never moves or changes your files.": "롤은 프레임 묶음으로, 보통 필름 한 롤입니다. +로 폴더를 롤로 가져옵니다. NegPy는 파일을 옮기거나 바꾸지 않습니다.",
"A test strip prints the frame as a grid of brightness and contrast steps. Click the one you like.": "테스트 스트립은 밝기와 대비를 단계별로 바꾼 격자로 프레임을 인화합니다. 마음에 드는 것을 클릭하세요.",
+ "A RAW that holds several shots opens one of them, or Pixel Shift combines four shots into measured colors at every pixel.": "여러 샷이 담긴 RAW는 그중 하나를 엽니다. 픽셀 시프트는 네 샷을 합쳐 모든 픽셀에서 측정된 색을 얻습니다.",
"A Scanlight or RGB LED light with pure red, green and blue.": "순수한 빨강, 초록, 파랑을 내는 Scanlight나 RGB LED 광원입니다.",
"A Trichrome triplet has no sensor leak to remove, so the profile is skipped and your choice is kept.": "삼색 트리플릿에는 제거할 센서 누출이 없으므로 프로필을 건너뛰고 선택은 유지합니다.",
"A calibration is running. Wait for it to finish.": "교정이 실행 중입니다. 끝날 때까지 기다리세요.",
@@ -1604,6 +1605,7 @@
"NegPy": "NegPy",
"NegPy Hit an Error": "NegPy에 오류가 발생했습니다",
"NegPy Sidecars (*.negpy)": "NegPy 사이드카(*.negpy)",
+ "NegPy could not decode the chosen shots, so the first shot shows.": "NegPy가 선택한 샷을 디코딩하지 못해 첫 번째 샷을 표시합니다.",
"NegPy data folder": "NegPy 데이터 폴더",
"NegPy meters the clear base and solves the LED levels and the shutter for this stock, then saves the preset": "NegPy가 투명 베이스를 측광하여 이 필름의 LED 레벨과 셔터를 계산한 뒤 사전 설정을 저장합니다",
"NegPy turns scans of film into finished pictures. It works like a darkroom: you pick a paper, then set brightness, contrast and color.
The chapter menu jumps anywhere. A circle marks a task to try; Undo reverses it.": "NegPy는 필름 스캔을 완성된 사진으로 만듭니다. 암실처럼 인화지를 고른 뒤 밝기, 대비, 색을 정합니다.
장 메뉴로 어디든 이동할 수 있습니다. 동그라미는 해 볼 작업이며, 실행 취소로 되돌릴 수 있습니다.",
@@ -1671,11 +1673,13 @@
"Normal": "보통",
"Normalization Bounds": "정규화 범위",
"Not a camera RAW, so a film scanner.": "카메라 RAW가 아니므로 필름 스캐너입니다.",
+ "Not applied to Pixel Shift.": "픽셀 시프트에는 적용되지 않습니다.",
"Not applied to a merged bracket.": "병합된 브래킷에는 적용되지 않습니다.",
"Not applied to a transparency.": "슬라이드에는 적용되지 않습니다.",
"Not applied to an as-captured transparency.": "캡처한 그대로의 슬라이드에는 적용되지 않습니다.",
"Not applied to slides.": "슬라이드에는 적용되지 않습니다.",
"Not sure which slider to move? Pick Better, the balance scale, shows two versions beside your edit. Click the better one; each round tunes one control.": "어떤 슬라이더를 움직일지 모르겠다면, 저울 모양의 더 나은 선택이 편집 옆에 두 버전을 보여줍니다. 더 나은 쪽을 클릭하세요. 매 회 하나의 조정을 맞춥니다.",
+ "Not used for Pixel Shift: every pixel has measured colors.": "픽셀 시프트에는 사용되지 않습니다. 모든 픽셀에 측정된 색이 있습니다.",
"Not used for a Trichrome triplet.": "삼색 트리플릿에는 사용되지 않습니다.",
"Note: the Sheet filter is on, so the analysis reads only the {value}.": "참고: 시트 필터가 켜져 있으므로 분석은 {value}만 읽습니다.",
"Notes": "메모",
@@ -1837,6 +1841,8 @@
"Pick which of the current settings to store as a new preset": "현재 설정 중 새 사전 설정으로 저장할 설정을 선택하세요.",
"Picking a roll loads its saved baseline onto the loaded files.": "롤을 선택하면 저장된 기준선이 로드된 파일에 로드됩니다.",
"Pin {value} · reads {measured}": "핀 {value} · 측정값 {measured}",
+ "Pixel Shift": "픽셀 시프트",
+ "Pixel Shift: {value} combined.": "픽셀 시프트: {value} 합성됨.",
"Pixels": "픽셀",
"Place": "장소",
"Place Zones": "존 배치",
@@ -2180,6 +2186,7 @@
"Run this preset on every export; off keeps it for later": "내보낼 때마다 이 프리셋을 실행합니다. 끄면 나중을 위해 남겨 둡니다",
"SCENES": "장면",
"SELECTED MASK": "선택한 마스크",
+ "SHOTS": "샷",
"SIZE": "크기",
"SPLIT TONING": "스플릿 토닝",
"STEP {number}": "단계 {number}",
@@ -2441,6 +2448,9 @@
"Shortcut Hints": "단축키 힌트",
"Shortcut hints (show a control's keys after a mouse edit)": "단축키 힌트 (마우스로 조작한 컨트롤의 키 표시)",
"Shot": "촬영",
+ "Shot {n}": "샷 {n}",
+ "Shot {shot} of {count}.": "{count}개 샷 중 {shot}번째.",
+ "Shots": "샷",
"Shoulder": "숄더",
"Shoulder Trim": "숄더 미세 조정",
"Shoulder Width": "숄더 폭",
@@ -2690,6 +2700,7 @@
"The lens model, written to the photo's metadata": "사진의 메타데이터에 기록되는 렌즈 모델",
"The lens this preset writes onto a frame": "이 사전 설정이 프레임에 기록하는 렌즈",
"The light behind the negative": "네거티브 뒤의 광원",
+ "The light changed by {value} between the shots. Use a steady light, or pick one shot.": "샷 사이에 빛이 {value} 바뀌었습니다. 안정된 광원을 쓰거나 샷 하나를 고르세요.",
"The long edge of the preview on the canvas.": "캔버스 미리보기의 긴 변입니다.",
"The maker of the camera or lens, written to the photo's metadata": "사진의 메타데이터에 기록되는 카메라나 렌즈 제조사",
"The mask acts on every tone inside its shape.": "마스크는 모양 내부의 모든 톤에 작용합니다.",
@@ -2727,6 +2738,8 @@
"The scanner sat idle long enough to return the strip. Frame selection cleared.": "스캐너가 스트립을 반환할 만큼 오랫동안 유휴 상태로 유지됨 - 프레임 선택이 지워짐",
"The scanner sat idle long enough to return the strip. Measuring it again…": "스캐너는 스트립을 반환할 만큼 오랫동안 유휴 상태였습니다. 다시 측정합니다…",
"The shape of the final print, or Original to keep the frame's shape.": "최종 인화의 비율로, 원본을 고르면 프레임의 비율을 유지합니다.",
+ "The shots differ in {mismatch} of the picture. Shot brightness: {levels}. Time: {seconds} s.": "샷이 화면의 {mismatch}에서 다릅니다. 샷 밝기: {levels}. 시간: {seconds}초.",
+ "The shots do not match in {value} of the picture. Use the self-timer or a remote, or pick one shot.": "샷이 화면의 {value}에서 일치하지 않습니다. 셀프타이머나 리모컨을 쓰거나 샷 하나를 고르세요.",
"The source files and their sidecars go to the Trash. The edits stay in the database, so a restored file keeps its edit.": "소스 파일과 해당 사이드카는 휴지통으로 이동됩니다. 편집 내용은 데이터베이스에 유지되므로 복원된 파일에도 편집 내용이 유지됩니다.",
"The state or region where the frame was shot.": "프레임을 찍은 시/도 또는 지역입니다.",
"The strip stopped after {frames}. Move the film, then click Scan.": "{frames} 후 스트립이 멈췄습니다. 필름을 옮긴 다음 스캔을 클릭하세요.",
@@ -3156,6 +3169,7 @@
"set": "세트",
"setting": "설정",
"sheet": "시트",
+ "shot": "샷",
"slot": "슬롯",
"strip": "스트립",
"thin end": "옅은 끝",
diff --git a/negpy/translations/zh_CN.json b/negpy/translations/zh_CN.json
index 89cb312ae..72fc14297 100644
--- a/negpy/translations/zh_CN.json
+++ b/negpy/translations/zh_CN.json
@@ -31,6 +31,7 @@
"A": "A",
"A roll is a group of frames, usually one film. + imports a folder as a roll. NegPy never moves or changes your files.": "胶卷是一组帧,通常是一卷胶片。+ 把文件夹导入为胶卷。NegPy 从不移动或更改你的文件。",
"A test strip prints the frame as a grid of brightness and contrast steps. Click the one you like.": "试条以亮度和反差逐级变化的网格印出此帧。点击你喜欢的那一格。",
+ "A RAW that holds several shots opens one of them, or Pixel Shift combines four shots into measured colors at every pixel.": "包含多次拍摄的 RAW 会打开其中一次;像素偏移则把四次拍摄合成为每个像素都有实测颜色的图像。",
"A Scanlight or RGB LED light with pure red, green and blue.": "发出纯红、绿、蓝光的 Scanlight 或 RGB LED 光源。",
"A Trichrome triplet has no sensor leak to remove, so the profile is skipped and your choice is kept.": "三色组没有需要去除的传感器串色,因此跳过配置,并保留你的选择。",
"A calibration is running. Wait for it to finish.": "正在校准,请等待完成",
@@ -1604,6 +1605,7 @@
"NegPy": "NegPy",
"NegPy Hit an Error": "NegPy 发生错误",
"NegPy Sidecars (*.negpy)": "NegPy 附属文件 (*.negpy)",
+ "NegPy could not decode the chosen shots, so the first shot shows.": "NegPy 无法解码所选的拍摄,因此显示第一次拍摄。",
"NegPy data folder": "NegPy 数据文件夹",
"NegPy meters the clear base and solves the LED levels and the shutter for this stock, then saves the preset": "NegPy 测光透明片基,求解此胶片的 LED 亮度和快门,然后保存预设",
"NegPy turns scans of film into finished pictures. It works like a darkroom: you pick a paper, then set brightness, contrast and color.
The chapter menu jumps anywhere. A circle marks a task to try; Undo reverses it.": "NegPy 把胶片扫描变成完成的照片。它像暗房一样工作:先选相纸,再设置亮度、反差和颜色。
章节菜单可跳到任意位置。圆圈标记可以尝试的任务;撤销可还原。",
@@ -1671,11 +1673,13 @@
"Normal": "正常",
"Normalization Bounds": "归一化边界",
"Not a camera RAW, so a film scanner.": "不是相机 RAW 文件,因此为胶片扫描仪。",
+ "Not applied to Pixel Shift.": "不应用于像素偏移。",
"Not applied to a merged bracket.": "不应用于合并后的包围曝光。",
"Not applied to a transparency.": "不应用于反转片。",
"Not applied to an as-captured transparency.": "不应用于保持原样的透明正片。",
"Not applied to slides.": "不应用于反转片。",
"Not sure which slider to move? Pick Better, the balance scale, shows two versions beside your edit. Click the better one; each round tunes one control.": "不知道该动哪个滑块?天平图标的择优会在你的编辑旁显示两个版本。点击更好的那个;每一轮调整一个控件。",
+ "Not used for Pixel Shift: every pixel has measured colors.": "像素偏移不使用此项:每个像素都有实测颜色。",
"Not used for a Trichrome triplet.": "三色组不使用此设置。",
"Note: the Sheet filter is on, so the analysis reads only the {value}.": "注意:胶片条筛选已开启,因此分析只读取 {value}。",
"Notes": "备注",
@@ -1837,6 +1841,8 @@
"Pick which of the current settings to store as a new preset": "选择当前设置中要保存到新预设的项目",
"Picking a roll loads its saved baseline onto the loaded files.": "选择胶卷会将其已保存基准加载到当前文件。",
"Pin {value} · reads {measured}": "定位点 {value} · 读数 {measured}",
+ "Pixel Shift": "像素偏移",
+ "Pixel Shift: {value} combined.": "像素偏移:已合成 {value}。",
"Pixels": "像素",
"Place": "地点",
"Place Zones": "放置区域",
@@ -2180,6 +2186,7 @@
"Run this preset on every export; off keeps it for later": "每次导出都运行此预设;关闭则留作以后使用",
"SCENES": "场景",
"SELECTED MASK": "所选蒙版",
+ "SHOTS": "拍摄",
"SIZE": "尺寸",
"SPLIT TONING": "分离色调",
"STEP {number}": "步骤 {number}",
@@ -2441,6 +2448,9 @@
"Shortcut Hints": "快捷键提示",
"Shortcut hints (show a control's keys after a mouse edit)": "快捷键提示(用鼠标操作后显示控件的按键)",
"Shot": "拍摄",
+ "Shot {n}": "第 {n} 次拍摄",
+ "Shot {shot} of {count}.": "第 {shot} 次拍摄,共 {count} 次。",
+ "Shots": "拍摄",
"Shoulder": "肩部",
"Shoulder Trim": "肩部微调",
"Shoulder Width": "肩部宽度",
@@ -2690,6 +2700,7 @@
"The lens model, written to the photo's metadata": "镜头型号,写入照片的元数据",
"The lens this preset writes onto a frame": "此预设写入画面的镜头",
"The light behind the negative": "负片背后的光源",
+ "The light changed by {value} between the shots. Use a steady light, or pick one shot.": "各次拍摄之间光线变化了 {value}。请使用稳定的光源,或选择单次拍摄。",
"The long edge of the preview on the canvas.": "画布上预览的长边。",
"The maker of the camera or lens, written to the photo's metadata": "相机或镜头的品牌,写入照片的元数据",
"The mask acts on every tone inside its shape.": "蒙版作用于形状内的全部影调。",
@@ -2727,6 +2738,8 @@
"The scanner sat idle long enough to return the strip. Frame selection cleared.": "扫描仪空闲超时,已退片并清除画面选择",
"The scanner sat idle long enough to return the strip. Measuring it again…": "扫描仪空闲超时已退片,正在重新测量…",
"The shape of the final print, or Original to keep the frame's shape.": "最终印相的形状,或选择“原始”保持画面形状。",
+ "The shots differ in {mismatch} of the picture. Shot brightness: {levels}. Time: {seconds} s.": "各次拍摄在画面的 {mismatch} 处不一致。各次亮度:{levels}。用时:{seconds} 秒。",
+ "The shots do not match in {value} of the picture. Use the self-timer or a remote, or pick one shot.": "各次拍摄在画面的 {value} 处不一致。请使用自拍定时器或遥控器,或选择单次拍摄。",
"The source files and their sidecars go to the Trash. The edits stay in the database, so a restored file keeps its edit.": "源文件及附属文件将移到废纸篓。编辑保留在数据库,恢复文件后仍可使用。",
"The state or region where the frame was shot.": "拍摄此画面的省份或地区。",
"The strip stopped after {frames}. Move the film, then click Scan.": "胶片条在 {frames} 后停止。请移动胶片,然后点击“扫描”。",
@@ -3156,6 +3169,7 @@
"set": "组",
"setting": "设置",
"sheet": "页",
+ "shot": "次拍摄",
"slot": "格",
"strip": "条",
"thin end": "薄端",
diff --git a/tests/test_batch_autocrop_worker.py b/tests/test_batch_autocrop_worker.py
index 8f6cf398d..bf5411f18 100644
--- a/tests/test_batch_autocrop_worker.py
+++ b/tests/test_batch_autocrop_worker.py
@@ -30,6 +30,7 @@ def load_linear_preview(
full_resolution,
file_hash,
demosaic="Auto",
+ raw_shot=0,
positive_decode="", # noqa: ARG002 (accepted, not asserted on)
highlight_mode=0, # noqa: ARG002 (accepted, not asserted on)
bake_camera_wb=False, # noqa: ARG002 (accepted, not asserted on)
@@ -58,6 +59,7 @@ def load_linear_preview_rgb(
full_resolution,
file_hash,
demosaic="Auto", # noqa: ARG002 (accepted, not asserted on)
+ raw_shot=0,
):
self.rgb_calls.append(
{
diff --git a/tests/test_batch_norm_wb.py b/tests/test_batch_norm_wb.py
index 93c9722f4..fd33ae1b0 100644
--- a/tests/test_batch_norm_wb.py
+++ b/tests/test_batch_norm_wb.py
@@ -27,6 +27,7 @@ def load_linear_preview(
full_resolution,
file_hash,
demosaic="Auto",
+ raw_shot=0,
positive_decode="",
highlight_mode=0,
bake_camera_wb=False,
@@ -205,6 +206,7 @@ def load_linear_preview(
full_resolution,
file_hash,
demosaic="Auto",
+ raw_shot=0,
positive_decode="",
highlight_mode=0,
bake_camera_wb=False,
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_demosaic_sidebar.py b/tests/test_demosaic_sidebar.py
index f74ff1caa..36a1df6d7 100644
--- a/tests/test_demosaic_sidebar.py
+++ b/tests/test_demosaic_sidebar.py
@@ -33,7 +33,15 @@ def _sidebar(preview, export):
# No active roll (state.active_roll_id absent -> getattr below reads None): set_roll_default
# falls back to a plain per-frame apply_config, same as demosaic always behaved before it.
controller = SimpleNamespace(
- state=SimpleNamespace(config=config, active_roll_id=None, preview_cam_xyz=None, current_file_hash=None),
+ state=SimpleNamespace(
+ config=config,
+ active_roll_id=None,
+ preview_cam_xyz=None,
+ current_file_hash=None,
+ current_file_path=None,
+ preview_raw_shots=None,
+ preview_raw_shots_path="",
+ ),
session=SimpleNamespace(repo=repo, update_config=lambda *a, **k: None),
apply_config=lambda cfg, **k: applied.append(cfg),
)
@@ -173,3 +181,57 @@ def test_the_capture_choice_reaches_the_controller():
w.linear_raw_btn.setCurrentIndex(w.linear_raw_btn.findData(False))
_args, kwargs = w.controller.set_roll_default.call_args
assert kwargs["linear_raw"] is False
+
+
+def _with_shots(sidebar, info, raw_shot=0):
+ state = sidebar.controller.state
+ state.config = replace(state.config, process=replace(state.config.process, raw_shot=raw_shot))
+ state.current_file_path = "/x/ps.pef"
+ state.preview_raw_shots = info
+ state.preview_raw_shots_path = "/x/ps.pef"
+ sidebar.sync_ui()
+
+
+def test_shots_row_hides_for_a_single_shot_file() -> None:
+ sidebar, _ = _sidebar(DemosaicMode.AUTO, DemosaicMode.AUTO)
+ sidebar.sync_ui()
+ assert sidebar.shot_combo.isHidden() and sidebar.shots_hint.isHidden()
+
+
+def test_pixel_shift_offers_the_combine_and_each_shot() -> None:
+ from negpy.infrastructure.loaders.pixel_shift import PixelShiftStats
+ from negpy.infrastructure.loaders.raw_shots import ShotKind, ShotsInfo
+
+ sidebar, applied = _sidebar(DemosaicMode.AUTO, DemosaicMode.AUTO)
+ stats = PixelShiftStats(0.0001, (0.0001, 0.0001), (1.0, 1.0, 1.0, 1.0), (2.0, 800.0), 3.0)
+ _with_shots(sidebar, ShotsInfo(4, ShotKind.PIXEL_SHIFT, 0, stats))
+ assert not sidebar.shot_combo.isHidden()
+ labels = [sidebar.shot_combo.itemText(i) for i in range(sidebar.shot_combo.count())]
+ assert labels == ["Pixel Shift", "Shot 1", "Shot 2", "Shot 3", "Shot 4"]
+ assert sidebar.shot_combo.currentData() == 0
+ assert sidebar.shots_hint.text() == "Pixel Shift: 4 shots combined."
+ assert not sidebar.preview_combo.isEnabled() and not sidebar.combined_hint.isHidden()
+ sidebar.shot_combo.setCurrentIndex(3)
+ assert applied[-1].process.raw_shot == 3
+
+
+def test_disagreeing_shots_warn() -> None:
+ from negpy.infrastructure.loaders.pixel_shift import PixelShiftStats
+ from negpy.infrastructure.loaders.raw_shots import ShotKind, ShotsInfo
+
+ sidebar, _ = _sidebar(DemosaicMode.AUTO, DemosaicMode.AUTO)
+ stats = PixelShiftStats(0.05, (0.07, 0.03), (1.0, 1.0, 1.0, 1.0), (2.0, 800.0), 3.0)
+ _with_shots(sidebar, ShotsInfo(4, ShotKind.PIXEL_SHIFT, 0, stats))
+ assert sidebar.shots_hint.property("hint") == "warning"
+ assert "self-timer" in sidebar.shots_hint.text()
+
+
+def test_hdr_offers_only_its_shots() -> None:
+ from negpy.infrastructure.loaders.raw_shots import ShotKind, ShotsInfo
+
+ sidebar, _ = _sidebar(DemosaicMode.AUTO, DemosaicMode.AUTO)
+ _with_shots(sidebar, ShotsInfo(3, ShotKind.SHOTS, 1))
+ labels = [sidebar.shot_combo.itemText(i) for i in range(sidebar.shot_combo.count())]
+ assert labels == ["Shot 1", "Shot 2", "Shot 3"]
+ assert sidebar.shot_combo.currentData() == 1 # Auto reads as the first shot
+ assert sidebar.preview_combo.isEnabled()
diff --git a/tests/test_image_processor.py b/tests/test_image_processor.py
index 348fafbfb..2c23e933b 100644
--- a/tests/test_image_processor.py
+++ b/tests/test_image_processor.py
@@ -151,6 +151,7 @@ def fake(
highlight_mode=0,
bake_camera_wb=False,
load_ir=True,
+ raw_shot=0,
):
calls.append(fast if record is None else record(fast, demosaic))
return np.zeros((4, 4, 3), dtype=np.uint16), {"orientation": 1, "color_space": "sRGB"}
diff --git a/tests/test_linear_output.py b/tests/test_linear_output.py
index 4425bd3bc..39e63954a 100644
--- a/tests/test_linear_output.py
+++ b/tests/test_linear_output.py
@@ -932,7 +932,7 @@ class TestTripletExport:
def _patch_decode(self, paths: tuple[str, str, str], bufs: dict[str, np.ndarray]):
mapping = {paths[0]: bufs["r"], paths[1]: bufs["g"], paths[2]: bufs["b"]}
- def fake_decode(path: str, demosaic="Auto"):
+ def fake_decode(path: str, demosaic="Auto", **_kw):
return mapping[path], _MOCK_WB, _MOCK_META, LensMetadata()
return mock.patch(
@@ -1116,7 +1116,7 @@ class TestStitchExport:
"""Linear Output with stitch composites."""
def _patch_decode(self, path_to_buf: dict[str, np.ndarray]):
- def fake_decode(path: str, demosaic="Auto"):
+ def fake_decode(path: str, demosaic="Auto", **_kw):
return path_to_buf[path], _MOCK_WB, _MOCK_META, LensMetadata()
return mock.patch(
@@ -1342,7 +1342,7 @@ class TestLinearCorrections:
"""Tests for optional per-step corrections (WB, flatfield, sensor)."""
def _patch_decode(self, path_to_buf: dict[str, np.ndarray]):
- def fake_decode(path: str, demosaic="Auto"):
+ def fake_decode(path: str, demosaic="Auto", **_kw):
return path_to_buf[path], _MOCK_WB, _MOCK_META, LensMetadata()
return mock.patch(
@@ -2360,7 +2360,7 @@ def _decode(self, tmp_path: str, geometry: GeometryConfig, levels: dict[str, flo
open(p, "wb").close()
paths[p] = np.full((self._H, self._W, 3), levels[name], dtype=np.float32)
- def fake_decode(path: str, demosaic="Auto"):
+ def fake_decode(path: str, demosaic="Auto", **_kw):
return paths[path].copy(), _MOCK_WB, _MOCK_META, LensMetadata()
first = next(iter(paths))
@@ -2441,7 +2441,7 @@ def _decode(self, tmp_path: str, geometry: GeometryConfig, apply_lens: bool = Tr
bufs[p] = np.full((self._H, self._W, 3), 0.4, dtype=np.float32)
lens = LensMetadata(source="test", warps=(_MirrorWarp(),))
- def fake_decode(path: str, demosaic="Auto"):
+ def fake_decode(path: str, demosaic="Auto", **_kw):
return bufs[path].copy(), _MOCK_WB, _MOCK_META, lens
with (
diff --git a/tests/test_metadata_lens.py b/tests/test_metadata_lens.py
index f6cc938a8..cb3d95fb8 100644
--- a/tests/test_metadata_lens.py
+++ b/tests/test_metadata_lens.py
@@ -676,7 +676,9 @@ def test_positive_decode_and_lens_mode_have_independent_preview_cache_entries(tm
image = np.repeat(ramp[..., None], 3, axis=2)
lens = LensMetadata("Sony", (SonyWarp((-1000,) * 16, (32768,) * 16, (-16384,) * 16),))
- def get_loader(file_path, *, linear_raw=False, positive_decode="", preview_max_edge=None, should_cancel=None, load_ir=True):
+ def get_loader(
+ file_path, *, linear_raw=False, positive_decode="", preview_max_edge=None, should_cancel=None, load_ir=True, raw_shot=None
+ ):
pixels = image * (0.5 if positive_decode else 1.0)
return NonStandardFileWrapper(pixels), {"orientation": 1, "color_space": "Adobe RGB", "lens_correction": lens}
diff --git a/tests/test_pixel_shift.py b/tests/test_pixel_shift.py
new file mode 100644
index 000000000..12a9f597e
--- /dev/null
+++ b/tests/test_pixel_shift.py
@@ -0,0 +1,551 @@
+import os
+import struct
+from types import SimpleNamespace
+from typing import Any
+
+import numpy as np
+import pytest
+import tifffile
+
+from negpy.features.process.models import DemosaicMode
+from negpy.infrastructure.loaders import pixel_shift, raw_shots
+from negpy.infrastructure.loaders.helpers import NonStandardFileWrapper, camera_wb_multipliers, camera_xyz_matrix, resolve_demosaic
+from negpy.infrastructure.loaders.pixel_shift import (
+ LIGHT_WARNING,
+ MISMATCH_WARNING,
+ SHOT_SHIFT,
+ PixelShiftImage,
+ PixelShiftStats,
+ PixelShiftWrapper,
+ combine_shots,
+ libraw_wb_gains,
+ shot_levels,
+)
+from negpy.infrastructure.loaders.raw_shots import (
+ CombinedShots,
+ ShotKind,
+ ShotLayout,
+ ShotsInfo,
+ SingleShot,
+ composite_shots,
+ makernote_quality,
+ probe_shots,
+ shot_source,
+)
+
+RGGB = np.array([[0, 1], [3, 2]])
+BLACK = (256, 256, 256, 256)
+
+
+@pytest.fixture(autouse=True)
+def _fresh_caches():
+ pixel_shift.clear_cache()
+ raw_shots._probe.cache_clear()
+ yield
+ pixel_shift.clear_cache()
+ raw_shots._probe.cache_clear()
+
+
+def _scene(h: int, w: int, seed: int = 1) -> np.ndarray:
+ """A known RGB scene in sensor counts above black, with texture on every scale."""
+ rng = np.random.default_rng(seed)
+ base = rng.integers(500, 12000, size=(h, w, 3))
+ return base.astype(np.int64)
+
+
+def _shots_of(scene: np.ndarray, shift=SHOT_SHIFT, cfa=RGGB, black=BLACK) -> np.ndarray:
+ """Sample `scene` the way the camera does: shot k at photosite (y, x) reads the scene at
+ (y - dy, x - dx) through the photosite's own color filter."""
+ h, w, _ = scene.shape
+ color = np.where(np.tile(cfa, (h // 2 + 1, w // 2 + 1))[:h, :w] == 3, 1, np.tile(cfa, (h // 2 + 1, w // 2 + 1))[:h, :w])
+ site = np.tile(cfa, (h // 2 + 1, w // 2 + 1))[:h, :w]
+ shots = np.zeros((len(shift), h, w), np.uint16)
+ yy, xx = np.mgrid[0:h, 0:w]
+ for k, (dy, dx) in enumerate(shift):
+ sy, sx = np.clip(yy - dy, 0, h - 1), np.clip(xx - dx, 0, w - 1)
+ shots[k] = scene[sy, sx, color] + np.array(black)[site]
+ return shots
+
+
+def test_combine_returns_the_scene_exactly():
+ scene = _scene(32, 48)
+ counts, _check = combine_shots(_shots_of(scene), RGGB, BLACK)
+ assert counts.shape == (32, 48, 3) and counts.dtype == np.uint16
+ inner = (slice(0, 31), slice(0, 47))
+ assert np.array_equal(counts[inner][..., 0], scene[inner][..., 0])
+ assert np.array_equal(counts[inner][..., 2], scene[inner][..., 2])
+ assert np.array_equal(counts[inner][..., 1], 2 * scene[inner][..., 1])
+ # The last row and column copy their neighbors.
+ assert np.array_equal(counts[31], counts[30]) and np.array_equal(counts[:, 47], counts[:, 46])
+
+
+def test_swapped_shots_do_not_return_the_scene():
+ scene = _scene(32, 48)
+ shots = _shots_of(scene)
+ counts, _check = combine_shots(shots[[0, 3, 2, 1]], RGGB, BLACK)
+ assert not np.array_equal(counts[:31, :47, 0], scene[:31, :47, 0])
+
+
+def test_combine_clips_counts_below_black_to_zero():
+ shots = np.full((4, 8, 8), 100, np.uint16)
+ counts, _check = combine_shots(shots, RGGB, BLACK)
+ assert counts.max() == 0
+
+
+def test_combine_rejects_a_cfa_without_two_greens():
+ with pytest.raises(ValueError):
+ combine_shots(np.zeros((4, 8, 8), np.uint16), np.array([[0, 0], [2, 2]]), BLACK)
+
+
+def test_aligned_still_shots_agree():
+ rng = np.random.default_rng(3)
+ scene = _scene(256, 256)
+ shots = _shots_of(scene).astype(np.int64)
+ shots += rng.normal(0, 20, size=shots.shape).astype(np.int64) # sensor noise, independent per shot
+ _counts, check = combine_shots(np.clip(shots, 0, 65535).astype(np.uint16), RGGB, BLACK)
+ mismatch, _pairs, (gain, floor) = check.measure()
+ assert mismatch < MISMATCH_WARNING
+ assert floor >= 1.0 and gain >= 0.0
+
+
+def _blocks(h: int, w: int, size: int = 16, seed: int = 2) -> np.ndarray:
+ """Flat patches with sharp edges: where the noise model reads its flat pixels."""
+ rng = np.random.default_rng(seed)
+ levels = rng.integers(500, 12000, size=(h // size + 1, w // size + 1, 3))
+ return np.repeat(np.repeat(levels, size, axis=0), size, axis=1)[:h, :w].astype(np.int64)
+
+
+def test_a_shot_that_moved_disagrees():
+ scene = _blocks(256, 256)
+ shift = list(SHOT_SHIFT)
+ shift[2] = (2, 2) # the third shot landed one photosite off
+ _counts, check = combine_shots(_shots_of(scene, shift=tuple(shift)), RGGB, BLACK)
+ mismatch, (pair_13, pair_24), _noise = check.measure()
+ assert mismatch > MISMATCH_WARNING
+ assert pair_13 > pair_24 # the third shot pairs with the first
+
+
+def test_stats_flag_a_light_change():
+ stats = PixelShiftStats(0.0, (0.0, 0.0), (1.0, 1.0, 1.0 + 2 * LIGHT_WARNING, 1.0), (1.0, 1.0), 0.1)
+ assert stats.shots_disagree
+ assert not PixelShiftStats(0.0, (0.0, 0.0), (1.0, 1.0, 1.0, 1.0), (1.0, 1.0), 0.1).shots_disagree
+
+
+def test_shot_levels_are_relative_to_the_first_shot():
+ shots = np.stack([np.full((4, 4), 256 + v, np.uint16) for v in (1000, 1000, 1050, 950)])
+ assert shot_levels(shots, BLACK) == pytest.approx((1.0, 1.0, 1.05, 0.95))
+
+
+def _image(counts: np.ndarray, **kw: Any) -> PixelShiftImage:
+ values: dict[str, Any] = dict(
+ counts=counts,
+ render_white=16119,
+ export_white=16127,
+ rgb_xyz_matrix=np.eye(4, 3),
+ camera_whitebalance=(26048.0, 8192.0, 14208.0, 8160.0),
+ daylight_whitebalance=(2.0, 1.0, 1.5, 0.0),
+ sizes=SimpleNamespace(height=counts.shape[0], width=counts.shape[1], iheight=counts.shape[0], iwidth=counts.shape[1]),
+ other=SimpleNamespace(timestamp=None),
+ stats=PixelShiftStats(0.0, (0.0, 0.0), (1.0, 1.0, 1.0, 1.0), (2.0, 800.0), 1.0),
+ )
+ values.update(kw)
+ return PixelShiftImage(**values)
+
+
+def test_to_u16_scales_like_libraw():
+ counts = np.array([[[1, 2, 3], [8000, 16000, 16119]], [[16119, 32238, 20000], [0, 0, 0]]], np.uint16)
+ u16 = _image(counts).to_u16(16119)
+ expected_r = np.floor(counts[..., 0].astype(np.float32) * np.float32(65535 / 16119))
+ expected_g = np.floor(counts[..., 1].astype(np.float32) * np.float32(65535 / (2 * 16119)))
+ assert np.array_equal(u16[..., 0], np.minimum(expected_r, 65535).astype(np.uint16))
+ assert np.array_equal(u16[..., 1], np.minimum(expected_g, 65535).astype(np.uint16))
+ assert u16[1, 0, 0] == 65535 and u16[1, 0, 1] == 65535 and u16[1, 0, 2] == 65535
+
+
+def test_libraw_wb_gains_divide_by_the_smallest_of_four():
+ assert libraw_wb_gains((26048.0, 8192.0, 14208.0, 8160.0)) == pytest.approx((26048 / 8160, 8176 / 8160, 14208 / 8160))
+ assert libraw_wb_gains((2.0, 1.0, 1.5)) == pytest.approx((2.0, 1.0, 1.5))
+ assert libraw_wb_gains([1, 1, 1, 1]) == (1.0, 1.0, 1.0)
+ assert libraw_wb_gains(None) == (1.0, 1.0, 1.0)
+ assert libraw_wb_gains((0.0, 1.0, 1.0)) == (1.0, 1.0, 1.0)
+
+
+def test_wrapper_reads_like_a_camera_raw():
+ counts = np.full((8, 12, 3), 4000, np.uint16)
+ wrapper = PixelShiftWrapper(_image(counts), 16119)
+ assert isinstance(wrapper, NonStandardFileWrapper)
+ assert resolve_demosaic(wrapper, DemosaicMode.AHD)[1] is None
+ assert camera_xyz_matrix(wrapper) == np.eye(3).tolist()
+ assert camera_wb_multipliers(wrapper) == [26048.0, 8192.0, 14208.0]
+ linear = wrapper.postprocess(gamma=(1, 1), output_bps=16, user_wb=[1, 1, 1, 1], use_camera_wb=False)
+ assert linear.dtype == np.uint16 and linear.shape == (8, 12, 3)
+ assert np.array_equal(linear, _image(counts).to_u16(16119))
+ balanced = wrapper.postprocess(gamma=(1, 1), output_bps=16, use_camera_wb=True)
+ assert balanced[0, 0, 0] > linear[0, 0, 0] and balanced[0, 0, 2] > linear[0, 0, 2]
+ pinned = wrapper.postprocess(gamma=(1, 1), output_bps=16, use_camera_wb=False, user_wb=[2.0, 1.0, 1.5, 1.0])
+ assert abs(int(pinned[0, 0, 0]) - 2 * int(linear[0, 0, 0])) <= 1
+ assert wrapper.postprocess(half_size=True, gamma=(1, 1), output_bps=16).shape == (4, 6, 3)
+ assert wrapper.postprocess().dtype == np.uint8
+ assert wrapper.data.shape == (8, 12, 3) and wrapper.data.dtype == np.float32
+
+
+def _makernote(quality: int, order: bytes = b"II") -> bytes:
+ end = "<" if order == b"II" else ">"
+ entry = struct.pack(end + "HHI", 0x0008, 3, 1) + struct.pack(end + "H", quality) + b"\0\0"
+ return b"PENTAX \0" + order + struct.pack(end + "H", 1) + entry + b"\0\0\0\0"
+
+
+def test_makernote_quality_reads_every_header():
+ assert makernote_quality(_makernote(7)) == 7
+ assert makernote_quality(_makernote(6, b"MM")) == 6
+ aoc = b"AOC\0II" + _makernote(7)[10:]
+ assert makernote_quality(aoc) == 7
+ adobe = b"Adobe\0MakN" + struct.pack(">I", 100) + b"II" + b"\0\0\0\0" + _makernote(7)
+ assert makernote_quality(adobe) == 7
+ assert makernote_quality(b"NIKON\0\0") is None
+ assert makernote_quality(b"PENTAX \0II\x05") is None
+
+
+_CFA_TAGS = [
+ (33421, "H", 2, (2, 2), True), # CFARepeatPatternDim
+ (33422, "B", 4, (0, 1, 1, 2), True), # CFAPattern RGGB
+ (50714, "H", 1, (256,), True), # BlackLevel
+ (50717, "H", 1, (16383,), True), # WhiteLevel
+]
+
+
+def _write_multishot_dng(path, shots: np.ndarray, make: str = "PENTAX", quality: int = 7) -> None:
+ """A Pentax-style DNG: thumbnail IFD0 with the makernote in DNGPrivateData, one CFA SubIFD per shot."""
+ ifd0 = [
+ (50706, "B", 4, (1, 4, 0, 0), True), # DNGVersion
+ (50708, "s", 0, "Sim", True), # UniqueCameraModel
+ (50721, "2i", 9, (1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 1, 1), True), # ColorMatrix1
+ (50728, "2I", 3, (1, 1, 1, 1, 1, 1), True), # AsShotNeutral
+ (271, "s", 0, make, True), # Make
+ (50740, "B", len(_makernote(quality)), _makernote(quality), True), # DNGPrivateData
+ ]
+ with tifffile.TiffWriter(path) as tw:
+ tw.write(np.zeros((8, 12, 3), np.uint8), photometric="rgb", subfiletype=1, subifds=len(shots), extratags=ifd0)
+ for shot in shots:
+ tw.write(shot, photometric=32803, subfiletype=0, extratags=_CFA_TAGS)
+
+
+def test_probe_finds_a_pixel_shift_dng(tmp_path):
+ path = tmp_path / "ps.dng"
+ _write_multishot_dng(path, _shots_of(_scene(32, 48)))
+ assert probe_shots(str(path)) == ShotLayout(4, ShotKind.PIXEL_SHIFT)
+
+
+def test_probe_reads_other_multishot_files_as_shots(tmp_path):
+ hdr = tmp_path / "hdr.dng"
+ _write_multishot_dng(hdr, _shots_of(_scene(32, 48))[:3], quality=6)
+ assert probe_shots(str(hdr)) == ShotLayout(3, ShotKind.SHOTS)
+ other = tmp_path / "other.dng"
+ _write_multishot_dng(other, _shots_of(_scene(32, 48)), make="Sim")
+ assert probe_shots(str(other)) == ShotLayout(4, ShotKind.SHOTS)
+
+
+def test_probe_ignores_single_shot_unreadable_and_other_files(tmp_path):
+ single = tmp_path / "one.dng"
+ _write_multishot_dng(single, _shots_of(_scene(32, 48))[:1])
+ assert probe_shots(str(single)) is None
+ assert probe_shots(str(tmp_path / "missing.dng")) is None
+ broken = tmp_path / "broken.pef"
+ broken.write_bytes(b"not a tiff")
+ assert probe_shots(str(broken)) is None
+ assert probe_shots(str(tmp_path / "frame.cr3")) is None
+
+
+def test_shot_source_follows_the_choice(monkeypatch):
+ layout = ShotLayout(4, ShotKind.PIXEL_SHIFT)
+ monkeypatch.setattr(raw_shots, "probe_shots", lambda _path: layout)
+ assert isinstance(shot_source("x.pef", 0), CombinedShots)
+ single = shot_source("x.pef", 3)
+ assert isinstance(single, SingleShot) and single.index == 2
+ assert isinstance(shot_source("x.pef", 9), CombinedShots) # a stale shot number reads as Auto
+ monkeypatch.setattr(raw_shots, "probe_shots", lambda _path: ShotLayout(3, ShotKind.SHOTS))
+ auto = shot_source("x.pef", 0)
+ assert isinstance(auto, SingleShot) and auto.index == 0
+ monkeypatch.setattr(raw_shots, "probe_shots", lambda _path: None)
+ assert shot_source("x.pef", 0) is None
+
+
+def test_one_combine_per_file(monkeypatch, tmp_path):
+ path = tmp_path / "ps.pef"
+ path.write_bytes(b"x")
+ built = []
+
+ def fake_build(file_path, _should_cancel):
+ built.append(file_path)
+ return _image(np.zeros((4, 4, 3), np.uint16))
+
+ monkeypatch.setattr(pixel_shift, "_build", fake_build)
+ first = pixel_shift.combined_image(str(path))
+ assert pixel_shift.combined_image(str(path)) is first
+ assert len(built) == 1
+ os.utime(path, ns=(1, 1)) # a new modification time is a new file
+ pixel_shift.combined_image(str(path))
+ assert len(built) == 2
+
+
+def test_combine_from_a_synthetic_dng(tmp_path):
+ """End to end through LibRaw: the SubIFDs of a DNG are its shots."""
+ path = tmp_path / "ps.dng"
+ scene = _scene(32, 48)
+ _write_multishot_dng(path, _shots_of(scene))
+ source = shot_source(str(path), 0)
+ assert isinstance(source, CombinedShots)
+ wrapper, info = source.open()
+ assert info.shot == 0 and info.count == 4 and info.stats is not None
+ counts = wrapper.image.counts
+ h, w = counts.shape[:2]
+ assert np.array_equal(counts[: h - 1, : w - 1, 0], scene[: h - 1, : w - 1, 0])
+ shot2, info2 = shot_source(str(path), 2).open()
+ with shot2:
+ assert info2.shot == 2
+ assert not np.array_equal(shot2.raw_image_visible, _shots_of(scene)[0])
+
+
+@pytest.mark.slow
+def test_a_real_pixel_shift_raw():
+ """Set NEGPY_PIXEL_SHIFT_RAW to a Pentax Pixel Shift PEF or DNG shot with a self-timer."""
+ path = os.environ.get("NEGPY_PIXEL_SHIFT_RAW")
+ if not path:
+ pytest.skip("NEGPY_PIXEL_SHIFT_RAW is not set")
+ source = shot_source(path, 0)
+ assert isinstance(source, CombinedShots)
+ wrapper, info = source.open()
+ with raw_shots.open_shot(path, 0) as first:
+ assert wrapper.image.counts.shape[:2] == first.raw_image_visible.shape
+ assert not info.stats.shots_disagree
+
+
+def test_rawpy_loader_combines_only_when_the_edit_asks(tmp_path):
+ from negpy.infrastructure.loaders.rawpy_loader import RawpyLoader
+
+ path = tmp_path / "ps.dng"
+ scene = _scene(32, 48)
+ _write_multishot_dng(path, _shots_of(scene))
+ decoder, metadata = RawpyLoader().load(str(path), raw_shot=0)
+ assert isinstance(decoder, PixelShiftWrapper)
+ assert metadata["raw_shots"].shot == 0 and metadata["raw_shots"].stats is not None
+ assert metadata["raw_flip"] == 0 and metadata["color_space"] is None and metadata["ir"] is None
+ assert "lens_correction" in metadata
+ plain, plain_meta = RawpyLoader().load(str(path))
+ with plain:
+ assert not isinstance(plain, NonStandardFileWrapper)
+ assert "raw_shots" not in plain_meta
+ shot, shot_meta = RawpyLoader().load(str(path), preview_max_edge=64, raw_shot=3)
+ with shot:
+ assert not isinstance(shot, NonStandardFileWrapper)
+ assert shot_meta["raw_shots"].shot == 3
+
+
+def test_rawpy_loader_falls_back_to_the_first_shot(tmp_path, monkeypatch):
+ from negpy.infrastructure.loaders.rawpy_loader import RawpyLoader
+
+ path = tmp_path / "ps.dng"
+ _write_multishot_dng(path, _shots_of(_scene(32, 48)))
+
+ def broken(*_args, **_kw):
+ raise ValueError("combine failed")
+
+ monkeypatch.setattr(pixel_shift, "_build", broken)
+ decoder, metadata = RawpyLoader().load(str(path), raw_shot=0)
+ with decoder:
+ assert not isinstance(decoder, NonStandardFileWrapper)
+ assert metadata["raw_shots"].failed and metadata["raw_shots"].shot == 1
+
+
+def test_linear_export_uses_the_export_white(tmp_path):
+ from negpy.services.export.linear_output import _decode_camera_raw_buffer
+
+ path = tmp_path / "ps.dng"
+ scene = _scene(32, 48)
+ _write_multishot_dng(path, _shots_of(scene))
+ f32, _wb, meta, _lens = _decode_camera_raw_buffer(str(path), raw_shot=0)
+ assert meta.demosaic == "Pixel Shift"
+ image = pixel_shift.combined_image(str(path))
+ expected = image.to_u16(image.export_white).astype(np.float32) / 65535.0
+ assert np.allclose(f32, expected, atol=1 / 65535)
+ _f32, _wb2, meta2, _lens2 = _decode_camera_raw_buffer(str(path), raw_shot=2)
+ assert meta2.demosaic.endswith("shot 2")
+
+
+def test_a_thumbnail_never_combines(tmp_path):
+ path = tmp_path / "ps.dng"
+ _write_multishot_dng(path, _shots_of(_scene(32, 48)))
+ assert raw_shots.thumbnail_shot(str(path), 0) == 1
+ assert raw_shots.thumbnail_shot(str(path), 3) == 3
+ plain = tmp_path / "one.dng"
+ _write_multishot_dng(plain, _shots_of(_scene(32, 48))[:1])
+ assert raw_shots.thumbnail_shot(str(plain), 0) == 0
+
+
+def test_the_shot_choice_is_a_decode_input():
+ from dataclasses import replace
+
+ from negpy.domain.models import WorkspaceConfig
+ from negpy.services.rendering.source_identity import source_token
+
+ config = WorkspaceConfig()
+ other = replace(config, process=replace(config.process, raw_shot=2))
+ assert source_token(config) != source_token(other)
+ assert replace(config.process, raw_shot="junk").raw_shot == 0
+ assert replace(config.process, raw_shot=-3).raw_shot == 0
+ assert WorkspaceConfig.from_flat_dict(other.to_dict()).process.raw_shot == 2
+
+
+def test_shot_texts():
+ from negpy.desktop.session import raw_shots_details, raw_shots_summary, raw_shots_warning
+ from negpy.infrastructure.loaders.raw_shots import ShotsInfo
+
+ good = PixelShiftStats(0.0001, (0.0001, 0.0001), (1.0, 1.0, 1.0, 1.0), (2.0, 800.0), 3.2)
+ moved = PixelShiftStats(0.05, (0.07, 0.03), (1.0, 1.0, 1.0, 1.0), (2.0, 800.0), 3.2)
+ assert raw_shots_summary(ShotsInfo(4, ShotKind.PIXEL_SHIFT, 0, good)) == "Pixel Shift: 4 shots combined."
+ assert raw_shots_summary(ShotsInfo(3, ShotKind.SHOTS, 2)) == "Shot 2 of 3."
+ assert raw_shots_summary(None) == ""
+ assert raw_shots_warning(ShotsInfo(4, ShotKind.PIXEL_SHIFT, 0, good)) == ""
+ assert "5.00%" in raw_shots_warning(ShotsInfo(4, ShotKind.PIXEL_SHIFT, 0, moved))
+ assert "3.2 s" in raw_shots_details(ShotsInfo(4, ShotKind.PIXEL_SHIFT, 0, good))
+ assert raw_shots_details(ShotsInfo(3, ShotKind.SHOTS, 1)) == ""
+
+
+def test_a_failed_decode_warns():
+ from negpy.desktop.session import raw_shots_warning
+ from negpy.infrastructure.loaders.raw_shots import ShotsInfo
+
+ assert "first shot" in raw_shots_warning(ShotsInfo(4, ShotKind.PIXEL_SHIFT, 1, failed=True))
+
+
+def test_a_failed_decode_is_not_cached(tmp_path, monkeypatch):
+ from negpy.services.rendering.preview_manager import PreviewManager
+
+ path = tmp_path / "ps.dng"
+ _write_multishot_dng(path, _shots_of(_scene(32, 48)))
+
+ def broken(*_args, **_kw):
+ raise ValueError("combine failed")
+
+ monkeypatch.setattr(pixel_shift, "_build", broken)
+ manager = PreviewManager()
+ manager.load_linear_preview(str(path), color_space="Adobe RGB", file_hash="h", raw_shot=0)
+ assert len(manager._cache._data) == 0
+ monkeypatch.undo()
+ manager.load_linear_preview(str(path), color_space="Adobe RGB", file_hash="h", raw_shot=0)
+ assert len(manager._cache._data) == 1
+
+
+def _info(failed: bool = False, mismatch: float = 0.0, light: float = 0.0) -> ShotsInfo:
+ stats = PixelShiftStats(mismatch, (mismatch, mismatch), (1.0, 1.0 + light, 1.0, 1.0), (2.0, 1.0), 0.0)
+ return ShotsInfo(count=4, kind=ShotKind.PIXEL_SHIFT, shot=1 if failed else 0, stats=None if failed else stats, failed=failed)
+
+
+def test_a_composite_reports_its_worst_part():
+ still, moving, flicker, broken = _info(), _info(mismatch=4 * MISMATCH_WARNING), _info(light=3 * LIGHT_WARNING), _info(failed=True)
+ assert composite_shots([still, flicker, moving]) is moving
+ assert composite_shots([still, flicker]) is flicker
+ assert composite_shots([moving, broken]) is broken
+ assert composite_shots([None, still, _info()]) is still
+ assert composite_shots([None, None]) is None
+
+
+def _composite_manager(tmp_path, infos):
+ """A PreviewManager whose single-file decode returns each part with its own shots."""
+ from unittest.mock import MagicMock
+
+ from negpy.services.rendering.preview_manager import PreviewManager
+
+ paths = [tmp_path / f"part{i}.pef" for i in range(len(infos))]
+ for path in paths:
+ path.write_bytes(b"x")
+ shots = dict(zip(map(str, paths), infos))
+
+ def decode(path, *_args, **_kwargs):
+ meta = {} if shots[path] is None else {"raw_shots": shots[path]}
+ return np.full((80, 80, 3), 0.4, dtype=np.float32), (80, 80), meta
+
+ manager = PreviewManager()
+ manager.load_linear_preview = MagicMock(side_effect=decode)
+ return manager, [str(p) for p in paths]
+
+
+def test_a_triplet_with_a_failed_exposure_warns_and_decodes_again(tmp_path):
+ from negpy.features.rgbscan.models import RgbScanConfig
+
+ manager, (red, green, blue) = _composite_manager(tmp_path, (_info(), _info(failed=True), _info()))
+ rgbscan = RgbScanConfig(enabled=True, green_path=green, blue_path=blue, align=False)
+ _out, _dims, meta = manager.load_linear_preview_rgb(red, rgbscan, "Adobe RGB", file_hash="red")
+ assert meta["raw_shots"].failed
+ manager.load_linear_preview_rgb(red, rgbscan, "Adobe RGB", file_hash="red")
+ assert manager.load_linear_preview.call_count == 6
+
+
+def test_a_triplet_with_a_moving_exposure_warns_and_stays_cached(tmp_path):
+ from negpy.features.rgbscan.models import RgbScanConfig
+
+ moving = _info(mismatch=4 * MISMATCH_WARNING)
+ manager, (red, green, blue) = _composite_manager(tmp_path, (_info(), _info(), moving))
+ rgbscan = RgbScanConfig(enabled=True, green_path=green, blue_path=blue, align=False)
+ _out, _dims, meta = manager.load_linear_preview_rgb(red, rgbscan, "Adobe RGB", file_hash="red")
+ _out, _dims, again = manager.load_linear_preview_rgb(red, rgbscan, "Adobe RGB", file_hash="red")
+ assert meta["raw_shots"] is moving and again["raw_shots"] is moving
+ assert manager.load_linear_preview.call_count == 3
+
+
+def test_a_bracket_with_a_failed_frame_warns_and_decodes_again(tmp_path):
+ from negpy.features.hdr.models import HdrConfig
+
+ manager, (reference, sibling) = _composite_manager(tmp_path, (_info(), _info(failed=True)))
+ hdr = HdrConfig(hdr_enabled=True, hdr_paths=(sibling,), hdr_ratios=(1.0, 2.0), hdr_align=False)
+ _out, _dims, meta = manager.load_linear_preview_hdr(reference, hdr, "Adobe RGB", file_hash="ref")
+ assert meta["raw_shots"].failed
+ manager.load_linear_preview_hdr(reference, hdr, "Adobe RGB", file_hash="ref")
+ assert manager.load_linear_preview.call_count == 4
+
+
+def test_a_stitch_with_a_failed_part_warns_and_decodes_again(tmp_path):
+ from negpy.features.stitch.models import StitchConfig
+
+ manager, (first, second) = _composite_manager(tmp_path, (None, _info(failed=True)))
+ stitch = StitchConfig(
+ stitch_enabled=True,
+ stitch_paths=(second,),
+ stitch_transforms=((1.0, 0.0, 0.0, 0.0, 1.0, 0.0), (1.0, 0.0, 40.0, 0.0, 1.0, 0.0)),
+ stitch_canvas=(120, 80),
+ stitch_sizes=((80, 80), (80, 80)),
+ )
+ _out, _dims, meta = manager.load_linear_preview_stitch(first, stitch, "Adobe RGB", file_hash="first#stitch")
+ assert meta["raw_shots"].failed
+ manager.load_linear_preview_stitch(first, stitch, "Adobe RGB", file_hash="first#stitch")
+ assert manager.load_linear_preview.call_count == 4
+
+
+def test_one_combine_after_a_cancelled_one(monkeypatch, tmp_path):
+ import threading
+
+ path = tmp_path / "ps.pef"
+ path.write_bytes(b"x")
+ started, release = threading.Event(), threading.Event()
+ builds = []
+
+ def fake_build(_file_path, should_cancel):
+ builds.append(should_cancel)
+ if should_cancel is not None:
+ raise InterruptedError("cancelled")
+ started.set()
+ release.wait(5)
+ return _image(np.zeros((4, 4, 3), np.uint16))
+
+ monkeypatch.setattr(pixel_shift, "_build", fake_build)
+ with pytest.raises(InterruptedError):
+ pixel_shift.combined_image(str(path), should_cancel=lambda: True)
+ first = threading.Thread(target=pixel_shift.combined_image, args=(str(path),))
+ first.start()
+ started.wait(5)
+ second = threading.Thread(target=pixel_shift.combined_image, args=(str(path),))
+ second.start()
+ release.set()
+ first.join(5)
+ second.join(5)
+ assert len(builds) == 2 # the cancelled build and one real build, shared by both callers
diff --git a/tests/test_rgbscan_white_balance.py b/tests/test_rgbscan_white_balance.py
index 81e88f425..6a01eaad3 100644
--- a/tests/test_rgbscan_white_balance.py
+++ b/tests/test_rgbscan_white_balance.py
@@ -58,7 +58,7 @@ def test_triplet_render_decode_pins_every_exposure_neutral(tmp_path):
processor = ImageProcessor()
calls: list = []
- def fake_decode(path, linear_raw, fast=False, wb_override=None, demosaic="Auto", positive_decode="", highlight_mode=0):
+ def fake_decode(path, linear_raw, fast=False, wb_override=None, demosaic="Auto", positive_decode="", highlight_mode=0, raw_shot=0):
calls.append((path, wb_override))
return np.zeros((4, 4, 3), dtype=np.uint16), {"cam_xyz": None, "camera_wb": [1.9, 1.0, 1.55]}
@@ -82,7 +82,7 @@ def test_triplet_render_ignores_the_primarys_own_camera_wb_downstream(tmp_path):
processor = ImageProcessor()
processor._decode_sensor_rgb = (
- lambda path, linear_raw, fast=False, wb_override=None, demosaic="Auto", positive_decode="", highlight_mode=0: (
+ lambda path, linear_raw, fast=False, wb_override=None, demosaic="Auto", positive_decode="", highlight_mode=0, raw_shot=0: (
np.zeros((4, 4, 3), dtype=np.uint16),
{"cam_xyz": [[1, 0, 0], [0, 1, 0], [0, 0, 1]], "camera_wb": [1.9, 1.0, 1.55]},
)
@@ -111,7 +111,7 @@ def test_triplet_with_linear_raw_off_still_decodes_neutral_at_the_rawpy_call(tmp
spies = {path: _SpyRaw() for path in (red, green, blue)}
- def fake_get_loader(path, linear_raw=False, positive_decode="", load_ir=True):
+ def fake_get_loader(path, linear_raw=False, positive_decode="", load_ir=True, raw_shot=None):
return spies[path], {}
processor = ImageProcessor()
diff --git a/tests/test_slider_widgets.py b/tests/test_slider_widgets.py
index 6593cefee..a0bbd1bfc 100644
--- a/tests/test_slider_widgets.py
+++ b/tests/test_slider_widgets.py
@@ -12,6 +12,19 @@ def _label_event(event_type, x: float, button=Qt.MouseButton.LeftButton, buttons
return QMouseEvent(event_type, pos, pos, button, buttons, Qt.KeyboardModifier.NoModifier)
+def test_a_slider_the_gc_cleared_ignores_the_events_of_its_children(qapp):
+ """The cycle GC clears the wrapper before Qt deletes its children, and a visible child still sends it Hide."""
+ slider = CompactSlider("Density", 0.0, 2.0, 1.0)
+ children = (slider.label, slider.spin, slider.slider)
+ attributes = dict(slider.__dict__)
+ slider.__dict__.clear()
+ try:
+ for child in children:
+ assert slider.eventFilter(child, QEvent(QEvent.Type.Hide)) is False
+ finally:
+ slider.__dict__.update(attributes)
+
+
def test_adjust_by_emits_change_and_commit(qapp):
slider = CompactSlider("Density", 0.0, 2.0, 1.0)
changed = MagicMock()
diff --git a/tests/test_thumbnail_refresh_worker.py b/tests/test_thumbnail_refresh_worker.py
index 2852023e7..a49110928 100644
--- a/tests/test_thumbnail_refresh_worker.py
+++ b/tests/test_thumbnail_refresh_worker.py
@@ -52,6 +52,7 @@ def load_linear_preview(
full_resolution,
file_hash,
demosaic="Auto",
+ raw_shot=0,
positive_decode="", # noqa: ARG002 (accepted, not asserted on)
highlight_mode=0, # noqa: ARG002 (accepted, not asserted on)
bake_camera_wb=False, # noqa: ARG002 (accepted, not asserted on)
@@ -71,6 +72,7 @@ def load_linear_preview_rgb(
full_resolution,
file_hash,
demosaic="Auto", # noqa: ARG002 (accepted, not asserted on)
+ raw_shot=0,
):
self.rgb_calls.append({"red_path": red_path, "file_hash": file_hash})
raw = np.full((4, 6, 3), 0.25, dtype=np.float32)
@@ -86,6 +88,7 @@ def load_linear_preview_stitch(
file_hash,
flatfield_profile_id="",
demosaic="Auto", # noqa: ARG002 (accepted, not asserted on)
+ raw_shot=0,
):
self.stitch_calls.append({"primary_path": primary_path, "file_hash": file_hash, "flatfield_profile_id": flatfield_profile_id})
raw = np.full((4, 6, 3), 0.35, dtype=np.float32)
@@ -335,9 +338,9 @@ def test_thumbnail_render_frame_started_fires_before_each_decode(qapp, monkeypat
order: list[str] = []
real_decode = render_workers._decode_asset_preview_with_meta
- def _decode(service, file_info, config, color_space):
+ def _decode(service, file_info, config, color_space, **kwargs):
order.append(f"decode:{file_info['name']}")
- return real_decode(service, file_info, config, color_space)
+ return real_decode(service, file_info, config, color_space, **kwargs)
monkeypatch.setattr(render_workers, "_decode_asset_preview_with_meta", _decode)
worker.frame_started.connect(lambda _index, _total, name: order.append(f"started:{name}"))
@@ -353,7 +356,7 @@ def test_thumbnail_render_failed_decode_reports_the_time_it_took(qapp, monkeypat
ticks = iter([10.0, 40.0])
monkeypatch.setattr(render_workers.time, "perf_counter", lambda: next(ticks))
- def _decode(*_args):
+ def _decode(*_args, **_kwargs):
raise OSError("share went away")
monkeypatch.setattr(render_workers, "_decode_asset_preview_with_meta", _decode)
@@ -443,7 +446,7 @@ def _counting_decode(monkeypatch, wait: threading.Barrier | None = None) -> dict
lock = threading.Lock()
real_decode = render_workers._decode_asset_preview_with_meta
- def _decode(*args):
+ def _decode(*args, **kwargs):
with lock:
seen["now"] += 1
seen["most"] = max(seen["most"], seen["now"])
@@ -451,7 +454,7 @@ def _decode(*args):
try:
if wait is not None:
wait.wait()
- return real_decode(*args)
+ return real_decode(*args, **kwargs)
finally:
with lock:
seen["now"] -= 1
diff --git a/tests/thumbnail_fingerprint_defaults.json b/tests/thumbnail_fingerprint_defaults.json
index 52d28c0f9..1a8e15f86 100644
--- a/tests/thumbnail_fingerprint_defaults.json
+++ b/tests/thumbnail_fingerprint_defaults.json
@@ -169,6 +169,7 @@
"meter_rect": "null",
"positive_source": "false",
"process_mode": "\"Color Negative\"",
+ "raw_shot": "0",
"roll_name": "null",
"sensor_matrix": "null",
"sensor_profile": "\"None\"",
@@ -211,5 +212,5 @@
"vanadium_strength": "0.0"
}
},
- "render_version": 29
+ "render_version": 30
}