Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ def __init__(self, session_manager: DesktopSessionManager):
self.norm_worker.moveToThread(self.norm_thread)
self.batch_autocrop_worker = BatchAutoCropWorker(self.batch_autocrop_preview_service)
self.batch_autocrop_worker.moveToThread(self.norm_thread)
self.thumbnail_render_worker = ThumbnailRenderWorker(self.thumbnail_render_preview_service)
self.thumbnail_render_worker = ThumbnailRenderWorker(self.thumbnail_render_preview_service, self.preview_service)
self.thumbnail_render_worker.moveToThread(self.norm_thread)
self.norm_thread.start()

Expand Down
43 changes: 39 additions & 4 deletions negpy/desktop/workers/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -1591,9 +1591,10 @@ class ThumbnailRenderWorker(QObject):
cancelled = pyqtSignal()
error = pyqtSignal(str)

def __init__(self, preview_service) -> None:
def __init__(self, preview_service, live_preview_service=None) -> None:
super().__init__()
self._preview_service = preview_service
self._live_preview_service = live_preview_service
self._processor = ImageProcessor(use_gpu=False)
self._cancel_lock = threading.RLock()
self._cancelled_generations: set[int] = set()
Expand All @@ -1619,6 +1620,42 @@ def _cancel_requested(self, generation: int) -> bool:
with self._cancel_lock:
return generation in self._cancelled_generations

def _peek_live_preview(self, frame: ThumbnailRenderInput, workspace_color_space: str) -> Optional[tuple[np.ndarray, dict]]:
"""A plain frame's decode from the navigation cache, copied, or None; never writes or
reorders it. The key is the whole-scan one `_decode_asset_preview_with_meta` slices, so
a half-frame asset or roll fork the live path cached under its own hash and half slice
is not found."""
from negpy.services.assets.half_frame import base_hash, slice_for_asset

config = frame.config
if self._live_preview_service is None or stitch_active(config.stitch) or hdr_active(config.hdr) or is_rgb_triplet(config.rgbscan):
return None
hit = self._live_preview_service.peek_linear_preview(
frame.file_info["path"],
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,
positive_source=config.process.positive_source,
highlight_mode=effective_highlight_reconstruction(config.process),
bake_camera_wb=highlight_reconstruction_bakes_wb(config.process),
lens_corrections=metadata_lens_corrections(config),
lens_flatfield=config.flatfield,
)
logger.debug("thumbnail refresh live cache %s: %s", "miss" if hit is None else "hit", frame.file_info["path"])
if hit is None:
return None
raw, _dims, meta = hit
# The navigation cache still serves these arrays, so the pipeline gets its own.
meta = {k: np.copy(v) if isinstance(v, np.ndarray) else v for k, v in meta.items()}
return slice_for_asset(np.copy(raw), frame.file_info), meta

def _decode(self, frame: ThumbnailRenderInput, workspace_color_space: str) -> tuple[np.ndarray, dict]:
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)

def _emit_finished_unless_cancelled(self, generation: int, rendered_count: int) -> None:
"""Atomically choose the terminal signal for a generation, so a `cancel()` racing
the last frame's completion cannot land between the check and the emit."""
Expand Down Expand Up @@ -1653,9 +1690,7 @@ def process(self, task: ThumbnailRenderTask) -> None:
break
name = str(frame.file_info.get("name") or frame.file_info.get("path") or done)
try:
buffer, meta = _decode_asset_preview_with_meta(
self._preview_service, frame.file_info, frame.config, task.workspace_color_space
)
buffer, meta = self._decode(frame, task.workspace_color_space)
cam_xyz = meta.get("cam_xyz")
if frame.icc_input_active:
cam_xyz = wb_only_cam_xyz(cam_xyz)
Expand Down
6 changes: 6 additions & 0 deletions negpy/services/rendering/preview_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ def get(self, key: PreviewCacheKey) -> Optional[tuple[ImageBuffer, Dimensions, d
self._order.append(t)
return ent.buffer, ent.dims, ent.metadata

def peek(self, key: PreviewCacheKey) -> Optional[tuple[ImageBuffer, Dimensions, dict]]:
"""Like ``get``, but leaves the LRU order as it is."""
with self._lock:
ent = self._data.get(key.as_tuple())
return None if ent is None else (ent.buffer, ent.dims, ent.metadata)

def put(
self,
key: PreviewCacheKey,
Expand Down
151 changes: 103 additions & 48 deletions negpy/services/rendering/preview_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,38 @@ def _output_dimensions_from_raw(raw: Any, postprocessed_h: int, postprocessed_w:
del _warmup


def _linear_preview_key(
file_hash: str,
*,
color_space: str,
use_camera_wb: bool,
full_resolution: bool,
half_slice: tuple[int, float, tuple[float, float, float, float] | None, float] | None,
demosaic: str,
positive_source: bool,
highlight_mode: int,
bake_camera_wb: bool,
lens_corrections: LensCorrections,
lens_flatfield: FlatFieldConfig,
) -> PreviewCacheKey:
"""The cache key of one plain-frame linear decode; every put and lookup builds it here."""
return PreviewCacheKey(
file_hash=file_hash,
use_camera_wb=use_camera_wb,
workspace_color_space=color_space,
full_resolution=full_resolution,
demosaic=demosaic,
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,
crop_rect=half_slice[2] if half_slice else None,
gutter_thickness=half_slice[3] if half_slice else 0.0,
positive_source=positive_source,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
)


class PreviewManager:
"""
Loads RAW (and other) files for UI preview, with in-memory LRU and fast decode.
Expand Down Expand Up @@ -111,20 +143,18 @@ def prefetch_linear_preview(
"""Warm one preview when its cache and system-memory budgets both admit it."""
if not file_hash:
return False
key = PreviewCacheKey(
file_hash=file_hash,
key = _linear_preview_key(
file_hash,
color_space=color_space,
use_camera_wb=use_camera_wb,
workspace_color_space=color_space,
full_resolution=False,
half_slice=half_slice,
demosaic=demosaic,
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,
crop_rect=half_slice[2] if half_slice else None,
gutter_thickness=half_slice[3] if half_slice else 0.0,
positive_source=positive_source,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
lens_corrections=lens_corrections,
lens_flatfield=lens_flatfield,
)
if self._cache.contains(key):
return True
Expand Down Expand Up @@ -175,6 +205,41 @@ def prefetch_linear_preview(
)
return True

def peek_linear_preview(
self,
file_path: str,
color_space: str,
*,
use_camera_wb: bool,
file_hash: str | None,
full_resolution: bool = False,
half_slice: tuple[int, float, tuple[float, float, float, float] | None, float] | None = None,
demosaic: str = DemosaicMode.AUTO,
positive_source: bool = False,
highlight_mode: int = 0,
bake_camera_wb: bool = False,
lens_corrections: LensCorrections = LensCorrections(),
lens_flatfield: FlatFieldConfig = FlatFieldConfig(),
) -> Optional[Tuple[ImageBuffer, Dimensions, dict]]:
"""The cached decode ``load_linear_preview`` would return for these arguments, or None.
Never decodes and never reorders the LRU. The buffer is shared: do not mutate it."""
if not file_hash:
return None
key = _linear_preview_key(
file_hash,
color_space=color_space,
use_camera_wb=use_camera_wb,
full_resolution=full_resolution,
half_slice=half_slice,
demosaic=demosaic,
positive_source=positive_source,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
lens_corrections=lens_corrections,
lens_flatfield=lens_flatfield,
)
return self._cache.peek(key)

# Internal helpers. They operate on an already-open raw object, so a caller that needs
# both splash and linear shares one file open.

Expand Down Expand Up @@ -446,20 +511,18 @@ def _load_from_open_raw(
if should_cancel is not None and should_cancel():
raise InterruptedError("preview load cancelled")
if file_hash:
ck = PreviewCacheKey(
file_hash=file_hash,
ck = _linear_preview_key(
file_hash,
color_space=color_space,
use_camera_wb=use_camera_wb,
workspace_color_space=color_space,
full_resolution=full_resolution,
half_slice=half_slice,
demosaic=demosaic,
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,
crop_rect=half_slice[2] if half_slice else None,
gutter_thickness=half_slice[3] if half_slice else 0.0,
positive_source=positive_source,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
lens_corrections=lens_corrections,
lens_flatfield=lens_flatfield,
)
# The cache entry aliases the returned buffer, under the same read-only contract as
# a cache hit, so there is no defensive copy. On HQ loads that copy was a large part
Expand Down Expand Up @@ -531,20 +594,18 @@ def load_linear_preview(

# Fast path: skip file open entirely when all cache-key params are known upfront.
if file_hash and color_space is not None:
ck = PreviewCacheKey(
file_hash=file_hash,
ck = _linear_preview_key(
file_hash,
color_space=color_space,
use_camera_wb=use_camera_wb,
workspace_color_space=color_space,
full_resolution=full_resolution,
half_slice=half_slice,
demosaic=demosaic,
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,
crop_rect=half_slice[2] if half_slice else None,
gutter_thickness=half_slice[3] if half_slice else 0.0,
positive_source=positive_source,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
lens_corrections=lens_corrections,
lens_flatfield=lens_flatfield,
)
hit = self._cache.get(ck)
if hit is not None:
Expand All @@ -563,20 +624,18 @@ def load_linear_preview(
color_space = metadata.get("color_space") or WORKING_COLOR_SPACE
# Re-check now that color_space is resolved from metadata.
if file_hash:
ck = PreviewCacheKey(
file_hash=file_hash,
ck = _linear_preview_key(
file_hash,
color_space=color_space,
use_camera_wb=use_camera_wb,
workspace_color_space=color_space,
full_resolution=full_resolution,
half_slice=half_slice,
demosaic=demosaic,
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,
crop_rect=half_slice[2] if half_slice else None,
gutter_thickness=half_slice[3] if half_slice else 0.0,
positive_source=positive_source,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
lens_corrections=lens_corrections,
lens_flatfield=lens_flatfield,
)
hit = self._cache.get(ck)
if hit is not None:
Expand Down Expand Up @@ -929,20 +988,18 @@ def load_splash_and_linear(

# Fast path: skip file open entirely when all cache-key params are known upfront.
if file_hash and color_space is not None:
ck = PreviewCacheKey(
file_hash=file_hash,
ck = _linear_preview_key(
file_hash,
color_space=color_space,
use_camera_wb=use_camera_wb,
workspace_color_space=color_space,
full_resolution=full_resolution,
half_slice=half_slice,
demosaic=demosaic,
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,
crop_rect=half_slice[2] if half_slice else None,
gutter_thickness=half_slice[3] if half_slice else 0.0,
positive_source=positive_source,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
lens_corrections=lens_corrections,
lens_flatfield=lens_flatfield,
)
hit = self._cache.get(ck)
if hit is not None:
Expand All @@ -965,20 +1022,18 @@ def load_splash_and_linear(
color_space = metadata.get("color_space") or WORKING_COLOR_SPACE
# Re-check now that color_space is resolved from metadata.
if file_hash:
ck = PreviewCacheKey(
file_hash=file_hash,
ck = _linear_preview_key(
file_hash,
color_space=color_space,
use_camera_wb=use_camera_wb,
workspace_color_space=color_space,
full_resolution=full_resolution,
half_slice=half_slice,
demosaic=demosaic,
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,
crop_rect=half_slice[2] if half_slice else None,
gutter_thickness=half_slice[3] if half_slice else 0.0,
positive_source=positive_source,
highlight_mode=highlight_mode,
bake_camera_wb=bake_camera_wb,
lens_corrections=lens_corrections,
lens_flatfield=lens_flatfield,
)
hit = self._cache.get(ck)
if hit is not None:
Expand Down
32 changes: 32 additions & 0 deletions tests/test_preview_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,3 +434,35 @@ def navigate(*_args, should_cancel, **_kwargs):
)

assert seen == [True, False]


def test_cache_peek_leaves_lru_order_unchanged() -> None:
c = PreviewBufferCache(_small_cfg())
a = np.zeros((4, 4, 3), dtype=np.float32)
first, second, third = (PreviewCacheKey(h, False, "Adobe RGB", False) for h in ("h1", "h2", "h3"))
c.put(first, a, (4, 4), {})
c.put(second, a.copy(), (4, 4), {})

assert c.peek(first)[0] is a
c.put(third, a.copy(), (4, 4), {})

assert c.peek(first) is None # still the oldest, so evicted
assert c.peek(second) is not None


def test_cache_get_still_reorders() -> None:
c = PreviewBufferCache(_small_cfg())
a = np.zeros((4, 4, 3), dtype=np.float32)
first, second, third = (PreviewCacheKey(h, False, "Adobe RGB", False) for h in ("h1", "h2", "h3"))
c.put(first, a, (4, 4), {})
c.put(second, a.copy(), (4, 4), {})

assert c.get(first) is not None
c.put(third, a.copy(), (4, 4), {})

assert c.peek(first) is not None
assert c.peek(second) is None


def test_cache_peek_misses_an_absent_key() -> None:
assert PreviewBufferCache(_small_cfg()).peek(PreviewCacheKey("none", False, "Adobe RGB", False)) is None
22 changes: 22 additions & 0 deletions tests/test_preview_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -622,3 +622,25 @@ def __exit__(self, *args: object) -> None:
_, kwargs = raw.postprocess.call_args
assert kwargs["demosaic_algorithm"] == rawpy.DemosaicAlgorithm.VNG
assert "half_size" not in kwargs


def test_peek_linear_preview_hits_a_decode_load_linear_preview_cached() -> None:
ctx = NonStandardFileWrapper(np.full((120, 160, 3), 0.25, dtype=np.float32))
mgr = PreviewManager()
with patch("negpy.services.rendering.preview_manager.loader_factory") as lf:
lf.get_loader.return_value = (ctx, {"color_space": "Adobe RGB"})
buf, dims, _meta = mgr.load_linear_preview("/fake/path.tif", "Adobe RGB", use_camera_wb=True, file_hash="peek-hash")
lf.reset_mock()

hit = mgr.peek_linear_preview("/fake/path.tif", "Adobe RGB", use_camera_wb=True, file_hash="peek-hash")
wrong_wb = mgr.peek_linear_preview("/fake/path.tif", "Adobe RGB", use_camera_wb=False, file_hash="peek-hash")
absent = mgr.peek_linear_preview("/fake/path.tif", "Adobe RGB", use_camera_wb=True, file_hash="other-hash")

lf.get_loader.assert_not_called()
assert hit is not None and hit[0] is buf and hit[1] == dims
assert wrong_wb is None
assert absent is None


def test_peek_linear_preview_without_a_hash_is_a_miss() -> None:
assert PreviewManager().peek_linear_preview("/fake/path.tif", "Adobe RGB", use_camera_wb=True, file_hash=None) is None
Loading
Loading