diff --git a/negpy/desktop/workers/export.py b/negpy/desktop/workers/export.py index c96abd14..d73907a2 100644 --- a/negpy/desktop/workers/export.py +++ b/negpy/desktop/workers/export.py @@ -234,6 +234,10 @@ def run_contact_sheet( if tile is not None: tiles.append(tile) labels.append(task.file_info["name"]) + else: + # A dropped tile silently shrinks the sheet; report it so the + # run doesn't look like a clean success with frames missing. + self.error.emit(f"{name}: could not be rendered for the contact sheet") sheets = ContactSheetService.build_sheets( tiles, diff --git a/negpy/services/rendering/image_processor.py b/negpy/services/rendering/image_processor.py index 51b64f12..d0a93ff5 100644 --- a/negpy/services/rendering/image_processor.py +++ b/negpy/services/rendering/image_processor.py @@ -77,6 +77,16 @@ def _use_half_size_decode(raw: Any, linear_raw: bool) -> bool: return not isinstance(raw, NonStandardFileWrapper) and not (is_xtrans(raw) and linear_raw) +def _downsample_to_long_edge(buf: np.ndarray, long_px: int) -> np.ndarray: + """Shrink so the long edge is at most long_px; never upscales.""" + h, w = buf.shape[:2] + long_edge = max(h, w) + if long_px <= 0 or long_edge <= long_px: + return buf + s = long_px / long_edge + return cv2.resize(buf, (max(1, int(round(w * s))), max(1, int(round(h * s)))), interpolation=cv2.INTER_AREA) + + def _detection_downsample(buf: np.ndarray) -> np.ndarray: """Dust detection always runs at preview scale so preview and export produce the identical region set (WYSIWYG) and full-res export detection stays cheap.""" @@ -781,6 +791,11 @@ def render_display_array( Mirrors the export render path but at small resolution and in display space, so a contact-sheet tile matches the on-canvas look. Returns None on failure. + + The result is downsampled to target_long_px: the caller holds every tile in + memory at once, so returning full-res here made peak cost scale with + frames x full resolution (a 24MP roll cost ~72MB per tile instead of ~3MB) + until allocations failed and tiles were silently dropped. """ try: from negpy.infrastructure.display.color_mgmt import apply_display_transform @@ -827,6 +842,9 @@ def render_display_array( if isinstance(buffer, np.ndarray) and buffer.ndim == 3 and buffer.shape[2] == 4: buffer = buffer[:, :, :3] buffer = apply_display_transform(buffer, working_color_space) + # Downsample after the display transform, matching where the sheet + # compositor resamples, so the tile looks identical — just smaller. + buffer = _downsample_to_long_edge(buffer, target_long_px) return float_to_uint8(buffer) except Exception as e: logger.error(f"Contact-sheet tile render failed for {file_path}: {e}") diff --git a/tests/test_contact_sheet_tiles.py b/tests/test_contact_sheet_tiles.py new file mode 100644 index 00000000..752fc399 --- /dev/null +++ b/tests/test_contact_sheet_tiles.py @@ -0,0 +1,145 @@ +"""Contact-sheet tiles must come back small, and a failed tile must be reported. + +Regression: render_display_array returned the tile at full source resolution while +the worker holds every tile in memory at once, so peak cost scaled with +frames x full resolution (~72MB/tile on a 24MP roll). Once allocation failed, the +worker silently dropped the tile, so a 20-frame run could produce a sheet with a +single image while the progress dialog still counted every file. +""" + +import os +import tempfile +import unittest +from unittest.mock import MagicMock + +import numpy as np +import tifffile + +from negpy.domain.models import WorkspaceConfig +from negpy.desktop.workers.export import ExportTask, ExportWorker +from negpy.infrastructure.display.color_spaces import WORKING_COLOR_SPACE +from negpy.services.rendering.image_processor import ImageProcessor, _downsample_to_long_edge + + +class TestDownsampleHelper(unittest.TestCase): + def test_shrinks_long_edge_to_target(self): + out = _downsample_to_long_edge(np.zeros((4000, 6000, 3), dtype=np.uint8), 1200) + self.assertEqual(max(out.shape[:2]), 1200) + + def test_preserves_aspect_ratio(self): + out = _downsample_to_long_edge(np.zeros((1000, 2000, 3), dtype=np.uint8), 500) + self.assertEqual(out.shape[:2], (250, 500)) + + def test_never_upscales(self): + src = np.zeros((300, 400, 3), dtype=np.uint8) + self.assertIs(_downsample_to_long_edge(src, 1200), src) + + def test_non_positive_target_is_a_noop(self): + src = np.zeros((300, 400, 3), dtype=np.uint8) + self.assertIs(_downsample_to_long_edge(src, 0), src) + + +class TestRenderDisplayArraySize(unittest.TestCase): + """The real render path must honour target_long_px, not return full res.""" + + @classmethod + def setUpClass(cls): + cls._tmp = tempfile.TemporaryDirectory() + cls.path = os.path.join(cls._tmp.name, "frame.tiff") + h, w = 1600, 2400 + tifffile.imwrite(cls.path, (np.random.default_rng(0).random((h, w, 3)) * 60000).astype(np.uint16), photometric="rgb") + cls.proc = ImageProcessor() + + @classmethod + def tearDownClass(cls): + cls.proc.cleanup() + cls._tmp.cleanup() + + def _render(self, target_long_px: int, prefer_gpu: bool): + return self.proc.render_display_array( + self.path, + WorkspaceConfig(), + "hash-1", + target_long_px=target_long_px, + prefer_gpu=prefer_gpu, + working_color_space=WORKING_COLOR_SPACE, + fast_decode=True, + ) + + def test_tile_respects_target_long_px_cpu(self): + tile = self._render(600, prefer_gpu=False) + self.assertIsNotNone(tile) + self.assertEqual(max(tile.shape[:2]), 600) + + def test_tile_respects_target_long_px_gpu(self): + # Falls back to the CPU engine when no GPU is present; either way the + # returned tile must be bounded. + tile = self._render(600, prefer_gpu=True) + self.assertIsNotNone(tile) + self.assertEqual(max(tile.shape[:2]), 600) + + def test_tile_is_far_smaller_than_the_source(self): + tile = self._render(600, prefer_gpu=False) + source_px = 1600 * 2400 + self.assertLess(tile.shape[0] * tile.shape[1] * 8, source_px) + + +class TestContactSheetReportsDroppedTiles(unittest.TestCase): + """A tile that fails to render must be surfaced, not silently omitted.""" + + def _worker_with(self, tile_results): + worker = ExportWorker() + worker._processor = MagicMock() + worker._processor.render_display_array.side_effect = tile_results + return worker + + def _tasks(self, n): + return [ + ExportTask( + file_info={"path": f"/src/f{i}.raw", "name": f"f{i}.raw", "hash": f"h{i}"}, + params=WorkspaceConfig(), + export_settings=WorkspaceConfig().export, + ) + for i in range(n) + ] + + def test_failed_tiles_emit_an_error_each(self): + good = np.zeros((10, 15, 3), dtype=np.uint8) + worker = self._worker_with([good, None, None, good]) + errors: list[str] = [] + worker.error.connect(errors.append) + + with tempfile.TemporaryDirectory() as out: + worker.run_contact_sheet(self._tasks(4), out, 600, 16, 32, 38, False, "#000000", "#ffffff") + + self.assertEqual(len(errors), 2) + self.assertTrue(all("contact sheet" in e for e in errors)) + + def test_all_tiles_good_reports_no_errors(self): + good = np.zeros((10, 15, 3), dtype=np.uint8) + worker = self._worker_with([good, good]) + errors: list[str] = [] + worker.error.connect(errors.append) + + with tempfile.TemporaryDirectory() as out: + worker.run_contact_sheet(self._tasks(2), out, 600, 16, 32, 38, False, "#000000", "#ffffff") + + written = [f for f in os.listdir(out) if f.endswith(".jpg")] + + self.assertEqual(errors, []) + self.assertEqual(len(written), 1) + + def test_gpu_resources_released_once_per_batch(self): + """The texture pool is evacuated per batch, not per tile — rebuilding it + every frame would cost more than it saves now that tiles are small.""" + good = np.zeros((10, 15, 3), dtype=np.uint8) + worker = self._worker_with([good, good, good]) + + with tempfile.TemporaryDirectory() as out: + worker.run_contact_sheet(self._tasks(3), out, 600, 16, 32, 38, False, "#000000", "#ffffff") + + self.assertEqual(worker._processor.cleanup.call_count, 1) + + +if __name__ == "__main__": + unittest.main()