From 1cb0881975b3117066d0dfa981d73a7a3fe10910 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sat, 4 Jul 2026 21:14:17 +0200 Subject: [PATCH] fix: use each triplet's own exposures in batch export (#382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch export built per-frame params from the saved DB config and never re-injected the frame's own RGB-scan green/blue exposures from the asset dict. A frame whose DB config held a stale/missing exposure path (prior session, RGB-mode regroup, or session restore) decoded a nonexistent file, which rawpy surfaces as b'Input/output error' — so "export all" failed on most triplet frames while individual export (which re-injects via select_file) worked. Route all three batch task-builders through resolve_asset_rgbscan so each frame uses its own discovery-verified exposures, matching single-file export. Guard _load_source_f32 to raise a clear FileNotFoundError when a triplet exposure is genuinely missing instead of the cryptic LibRaw bytes. --- docs/CHANGELOG.md | 5 ++++ negpy/desktop/controller.py | 15 ++++++++--- negpy/desktop/session.py | 12 +++++++++ negpy/services/rendering/image_processor.py | 4 +++ tests/test_image_processor.py | 10 +++++--- tests/test_rgbscan.py | 28 +++++++++++++++++++++ 6 files changed, 67 insertions(+), 7 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f56b9df0..69f3af4b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,11 @@ # Change Log +## 0.33.1 + +- Fix: **exporting all RGB-scan triplets no longer fails** with "Input/output error" on most frames. Batch export was reusing stale saved paths for each frame's green/blue exposures instead of the ones the triplet was actually built from, so it tried to read files that weren't there; it now uses each frame's own exposures, the same as exporting one at a time. + + ## 0.33.0 - New: **Temperature slider** — a Kelvin lever above the CMY white balance: drag it and Magenta/Yellow move together along the warm–cool axis in the right ratio, like re-dialing a dichroic filter pack, while your green–magenta tint stays put. Move M/Y (or Pick WB) yourself and the slider reads back the print's temperature instead. Warm sits on the right, travel is mired-linear (equal drag = equal perceived shift), and `T`/`G` nudge it from the keyboard. The thermometer button locks the temperature for the roll: every frame you open gets re-aimed to it — keeping its own tint — and the lock survives restarts. diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 63d37576..3e69e46a 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -9,7 +9,7 @@ from PyQt6.QtWidgets import QMessageBox from negpy.desktop.converters import ImageConverter -from negpy.desktop.session import AppState, DesktopSessionManager, ToolMode +from negpy.desktop.session import AppState, DesktopSessionManager, ToolMode, resolve_asset_rgbscan from negpy.desktop.workers.export import ExportTask, ExportWorker from negpy.desktop.workers.render import ( AssetDiscoveryTask, @@ -1338,6 +1338,13 @@ def _validate_preset_paths(self, presets: List[ExportPreset]) -> bool: self.session.save_export_presets() return True + def _batch_params_for(self, f: dict) -> WorkspaceConfig: + """Resolve a visible frame's export params: its saved DB config (else the current + config), with its own RGB-scan green/blue re-injected from the asset dict — the + same authoritative source individual export gets via select_file.""" + params = self.session.repo.load_file_settings(f["hash"]) or self.state.config + return resolve_asset_rgbscan(params, f) + def _tasks_for_file( self, file_info: dict, @@ -1481,7 +1488,7 @@ def request_batch_export(self, override_settings: bool = False, files: list[dict tasks = [] for f in files: - params = self.session.repo.load_file_settings(f["hash"]) or self.state.config + params = self._batch_params_for(f) if override_settings: params = replace(params, export=current_export) @@ -1587,7 +1594,7 @@ def request_preset_batch_export(self) -> None: tasks = [] for f in visible_files: - params = self.session.repo.load_file_settings(f["hash"]) or self.state.config + params = self._batch_params_for(f) bounds_override = None if f["hash"] == self.state.current_file_hash: @@ -1632,7 +1639,7 @@ def request_contact_sheet(self) -> None: tasks = [] for f in visible_files: - params = self.session.repo.load_file_settings(f["hash"]) or self.state.config + params = self._batch_params_for(f) tasks.append( ExportTask( file_info=f, diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index b1fbcfb7..d8dd2478 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -8,6 +8,7 @@ from PyQt6.QtCore import QAbstractListModel, QModelIndex, QObject, Qt, pyqtSignal from negpy.domain.models import ExportPreset, WorkspaceConfig +from negpy.features.rgbscan.models import RgbScanConfig from negpy.infrastructure.storage.repository import StorageRepository from negpy.kernel.system.config import APP_CONFIG from negpy.services.assets.sidecar import load_or_promote @@ -335,6 +336,17 @@ def build_synced_config( return out +def resolve_asset_rgbscan(params: WorkspaceConfig, asset: dict) -> WorkspaceConfig: + """Overlay a frame's own RGB-scan triplet paths (from the asset dict) onto its export + params — the authoritative source select_file uses. A non-triplet frame gets rgbscan + reset so a batch frame never inherits the currently-open frame's leaked/stale triplet.""" + green, blue = asset.get("green_path"), asset.get("blue_path") + if green and blue: + align = bool(asset.get("align", params.rgbscan.align)) + return replace(params, rgbscan=RgbScanConfig(enabled=True, green_path=green, blue_path=blue, align=align)) + return replace(params, rgbscan=RgbScanConfig()) + + class DesktopSessionManager(QObject): """ Manages application state, file list, and configuration persistence. diff --git a/negpy/services/rendering/image_processor.py b/negpy/services/rendering/image_processor.py index f9bac75e..8745fa54 100644 --- a/negpy/services/rendering/image_processor.py +++ b/negpy/services/rendering/image_processor.py @@ -231,6 +231,10 @@ def _load_source_f32( if is_triplet: # Assemble one frame from the R/G/B exposures; the primary (red) file is # already decoded above, so reuse it and decode only green/blue. + for label, path in (("green", rgbcfg.green_path), ("blue", rgbcfg.blue_path)): + if not os.path.exists(path): + raise FileNotFoundError(f"RGB-scan {label} exposure not found: {path}") + def _decode(path: str) -> np.ndarray: if path == file_path: return rgb diff --git a/tests/test_image_processor.py b/tests/test_image_processor.py index c6d7d3c9..b763b9bf 100644 --- a/tests/test_image_processor.py +++ b/tests/test_image_processor.py @@ -94,18 +94,22 @@ def test_load_source_f32_cache_key_separates_fast_decode(monkeypatch) -> None: assert calls == [True, False] -def test_load_source_f32_never_fast_decodes_rgbscan_triplets(monkeypatch) -> None: +def test_load_source_f32_never_fast_decodes_rgbscan_triplets(monkeypatch, tmp_path) -> None: from dataclasses import replace from negpy.features.rgbscan.models import RgbScanConfig + r, g, b = (tmp_path / n for n in ("r.raw", "g.raw", "b.raw")) + for f in (r, g, b): + f.write_bytes(b"x") + service = ImageProcessor() calls: list = [] monkeypatch.setattr(service, "_decode_sensor_rgb", _fake_decode_recorder(calls)) cfg = replace( WorkspaceConfig(), - rgbscan=RgbScanConfig(enabled=True, green_path="/nonexistent/g.raw", blue_path="/nonexistent/b.raw", align=False), + rgbscan=RgbScanConfig(enabled=True, green_path=str(g), blue_path=str(b), align=False), ) - service._load_source_f32("/nonexistent/r.raw", cfg, fast_decode=True) + service._load_source_f32(str(r), cfg, fast_decode=True) assert calls and calls[0] is False diff --git a/tests/test_rgbscan.py b/tests/test_rgbscan.py index aba0b6b9..5f5825ac 100644 --- a/tests/test_rgbscan.py +++ b/tests/test_rgbscan.py @@ -1,4 +1,5 @@ import os +from dataclasses import replace import numpy as np import pytest @@ -203,3 +204,30 @@ def test_config_roundtrip_preserves_rgbscan(): cfg = type(cfg)(**{**cfg.__dict__, "rgbscan": RgbScanConfig(enabled=True, green_path="/g", blue_path="/b")}) restored = WorkspaceConfig.from_flat_dict(cfg.to_dict()) assert restored.rgbscan == cfg.rgbscan + + +def test_resolve_asset_rgbscan_injects_from_asset(): + """Batch export must use the asset dict's own green/blue, overriding a stale DB config.""" + from negpy.desktop.session import resolve_asset_rgbscan + + stale = replace(WorkspaceConfig(), rgbscan=RgbScanConfig(enabled=True, green_path="/old_g", blue_path="/old_b")) + asset = {"path": "/r", "green_path": "/g", "blue_path": "/b"} + out = resolve_asset_rgbscan(stale, asset) + assert out.rgbscan == RgbScanConfig(enabled=True, green_path="/g", blue_path="/b", align=True) + + +def test_resolve_asset_rgbscan_honors_align(): + from negpy.desktop.session import resolve_asset_rgbscan + + asset = {"green_path": "/g", "blue_path": "/b", "align": False} + out = resolve_asset_rgbscan(WorkspaceConfig(), asset) + assert out.rgbscan.align is False + + +def test_resolve_asset_rgbscan_resets_when_not_triplet(): + """A non-triplet frame must not inherit a leaked/enabled triplet config.""" + from negpy.desktop.session import resolve_asset_rgbscan + + leaked = replace(WorkspaceConfig(), rgbscan=RgbScanConfig(enabled=True, green_path="/g", blue_path="/b")) + out = resolve_asset_rgbscan(leaked, {"path": "/r"}) + assert out.rgbscan == RgbScanConfig()