Skip to content

Contact sheet: render tiles at proof scale (fixes the remaining memory blowup) - #666

Merged
marcinz606 merged 1 commit into
marcinz606:mainfrom
linkmodo:fix/contact-sheet-proof-scale
Jul 29, 2026
Merged

Contact sheet: render tiles at proof scale (fixes the remaining memory blowup)#666
marcinz606 merged 1 commit into
marcinz606:mainfrom
linkmodo:fix/contact-sheet-proof-scale

Conversation

@linkmodo

Copy link
Copy Markdown
Contributor

The bug

Still reported after #600: "When I export a contact sheet, the used memory goes through the roof, swap also, and I get only one view on the contact sheet out of my 24 selected views."

#600 was a real fix but only addressed the accumulation side — the tiles the worker holds in a list. It left the dominant cost untouched.

Root cause

render_display_array takes target_long_px, but that value only fed scale_factor. Nothing downsampled the input: run_pipeline renders whatever buffer it's given, and the GPU path's _readback_downsampled reads the texture back at native size despite the name. So every contact-sheet tile ran the entire pipeline on the full-resolution source and then discarded all but a 1200px tile.

Measured per 24MP frame (peak working set / peak commit, K32GetProcessMemoryInfo):

Path Before After
CPU 3.53 GB / 4.28 GB 1.23 GB / 1.99 GB
GPU 1.54 GB / 8.12 GB 1.23 GB / 2.70 GB

Peak is hit on the first tile, so this is per-frame transient cost, not accumulation — which is exactly what "memory through the roof + swap" describes. Once an allocation fails, render_display_array's blanket except returns None and the tile is dropped, leaving a sheet with a handful (or one) of 24 frames. Machine-dependent, so it doesn't reproduce on a high-RAM box — verified 24/24 tiles survive here both before and after; the fix is validated by the memory numbers, not by a repro of the drop.

Changes

  • Downsample the source (and IR) to target_long_px before the pipeline runs. This is the actual fix. scale_factor shrinks proportionally with the buffer, so effect radii land in the same relative place.
  • Bound the paper layout to the tile too. A Print/Target-px export setting sizes paper from print_size × DPI, which would re-inflate the render to full print resolution right after the downsample (a 60cm @ 600dpi setting → ~14000px paper). Bounded with the same virtual-DPI convention PrintService.preview_paper_layout already uses for UI previews, so borders stay proportional. ORIGINAL mode is deliberately left alone — it can't upscale, and forcing PRINT there would change framing for the default case.
  • Release the full-res source cache after downsampling. Contact-sheet frames are each decoded once, so that entry only pinned ~300 MB across the next frame's decode. (This is the proof path only; run_batch still benefits from the cache for multi-format presets.)

Notes

  • render_display_array has no caller outside run_contact_sheet, so the blast radius is the contact sheet only.
  • Tiles are visually unchanged: mean |delta| ≈ 0.9/255 against pre-fix output on a synthetic hard-edged checkerboard (worst case for resampling), from rendering at proof scale rather than full-res-then-shrink.
  • Pre-existing and left alone: the CPU and GPU paths use different scale_factor conventions here (CPU derives it from preview_render_size inside run_pipeline, the GPU call passes it relative to target_long_px). Both shrink proportionally so the fix is neutral to it, but they don't match each other — worth a separate look.

Test plan

  • 2 new tests in tests/test_contact_sheet_tiles.py: the pipeline must receive a buffer already bounded to target_long_px (spying on run_pipeline), and a 60cm @ 600dpi Print export must not inflate either the pipeline input or the returned tile. Both verified to fail when the source-downsample and layout-bound are neutered.
  • 12 tests in that file pass; 70 across the contact-sheet + export suites.
  • Full suite: 2531 passed, 3 pre-existing Windows path-separator failures (test_effective_input_icc, test_output_dir_subfolder_of_source, test_sidecar_path), unrelated.
  • make format / ruff check clean.

Follow-up to the earlier tile fix, which shrank the tiles the worker
accumulates but left the dominant cost untouched: the render itself still
ran the whole pipeline on the full-resolution source and threw away all
but a 1200px tile. target_long_px only fed scale_factor; run_pipeline
never downsamples, and the GPU path's _readback_downsampled reads the
texture back at native size despite its name.

Measured per 24MP frame (peak working set / peak commit):
  CPU  3.53GB / 4.28GB  ->  1.23GB / 1.99GB
  GPU  1.54GB / 8.12GB  ->  1.23GB / 2.70GB
Peak is now flat across a roll (24 frames, no growth) instead of a
multi-GB spike per frame that swapped and then failed allocation, which
is what dropped tiles silently.

- Downsample the source (and IR) to target_long_px before the pipeline.
- Bound the paper layout to the tile as well: a Print/Target-px export
  setting sizes paper from print_size x DPI and would re-inflate the
  render to print resolution immediately after the downsample. Uses the
  same virtual-DPI convention as PrintService.preview_paper_layout so
  borders stay proportional. ORIGINAL mode is left alone (it can't
  upscale) to avoid changing framing for the default case.
- Drop the full-res source cache entry after downsampling: contact-sheet
  frames are decoded once each, so it only pinned ~300MB across the next
  frame's decode.

Tiles are unchanged visually (mean |delta| ~0.9/255 on a synthetic
hard-edged target, from resampling at proof scale rather than
full-res-then-shrink). render_display_array has no caller outside the
contact sheet.
@marcinz606
marcinz606 merged commit da9a033 into marcinz606:main Jul 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants