Skip to content

fix(processing): downscale large images on low-RAM boards before encode - #3235

Merged
vpetersson merged 2 commits into
Screenly:masterfrom
vpetersson-bot:fix/lowram-image-downscale
Aug 2, 2026
Merged

fix(processing): downscale large images on low-RAM boards before encode#3235
vpetersson merged 2 commits into
Screenly:masterfrom
vpetersson-bot:fix/lowram-image-downscale

Conversation

@vpetersson-bot

Copy link
Copy Markdown
Contributor

Downscales oversized images to the 1080p budget on low-RAM boards (Pi 2/3) before the memory-heavy WebP encode, so a large photo can no longer OOM/crash the normalisation worker.

Problem

The image-normalisation pipeline has no low-RAM pixel cap. The 1080p _LOW_RAM_MAX_PIXELS guard (_exceeds_low_ram_pixel_cap) is wired only into the video path; _convert_image_to_webp decodes, RGBA-converts, and lossless-WebP-encodes an image at full resolution regardless of board RAM (the only image guard is the 50 MP decompression-bomb cap).

A many-MP photo (HEIC / AVIF / TIFF — e.g. an iPhone upload) drives peak RSS to ~770 MB (measured on x86), while a Pi 2/3 normalisation container is capped around 540 MiB. The encode exhausts RAM+swap — on a Pi 3B+ this OOMd hard enough to reboot the board — and method=6 lossless is minutes-slow on that CPU regardless.

Fix

On low-RAM boards, downscale a > 1080p image to the pixel budget (aspect-preserving, via Image.thumbnail) before the RGBA convert + encode. A 1080p board cant display more detail, so this is lossless for signage. thumbnail shrinks in place and, for JPEG, uses libjpeg draft decoding so even the decode of the oversized source stays cheap.

Reuses the existing _exceeds_low_ram_pixel_cap helper. Note the deliberate asymmetry with video: the video gate rejects an over-cap upload (a 4K clip cant be re-encoded on-device), whereas an image downscales cleanly and stays a successful upload.

Validation

Unit tests (downscale on low-RAM, full-res on normal-RAM) plus the full test_processing.py suite (124 passed); ruff check + format clean.

On the Pi 3B+ testbed (celery container capped ~543 MiB):

  • Stock: a real 24 MP photo drove peak ~770 MB and rebooted the board.
  • Patched (real pipeline): the same 24 MP HEIC normalised to a 1175x1763 WebP; celery peaked ~195 MiB, board stayed up (restarts=0, OOMKilled=false).
  • Micro-benchmarks under the container memory limit: JPEG 144 MiB / 27 s, HEIC 189 MiB / 38 s (vs the crash before).

Context

Found while testing the EXIF-orientation fix (issue 3232) on constrained hardware. This is an independent, pre-existing bug — the two changes touch adjacent lines in _convert_image_to_webp but are otherwise unrelated.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HJ3ucEkn62cbgPoisAZ5LQ

The image-normalisation pipeline had no low-RAM pixel cap. The
1080p `_LOW_RAM_MAX_PIXELS` guard (`_exceeds_low_ram_pixel_cap`) was
wired only into the video path; `_convert_image_to_webp` decoded,
RGBA-converted, and lossless-WebP-encoded an image at full resolution
regardless of board RAM. A many-MP photo (HEIC / AVIF / TIFF) drives
peak RSS to ~770 MB — measured on x86 — while a Pi 2/3 normalisation
container is capped around 540 MiB, so the encode exhausts RAM+swap.
On a Pi 3B+ this OOM'd hard enough to reboot the board, and `method=6`
lossless is minutes-slow on that CPU regardless.

Downscale a > 1080p image to the pixel budget (aspect-preserving,
`thumbnail`) before the RGBA convert + encode on low-RAM boards. A
1080p board can't display more detail, so this is lossless for signage.
Unlike the video gate (which rejects over-cap uploads — a 4K clip can't
be re-encoded on-device), an image downscales cleanly, so it stays a
successful upload rather than a "Failed" pill.

Validated on the Pi 3B+ testbed (container capped ~543 MiB):
- Stock: a real 24 MP photo drove peak ~770 MB and rebooted the board.
- Patched, real pipeline: the same 24 MP HEIC normalised to a 1175x1763
  WebP, celery peaked ~195 MiB, board stayed up (restarts=0,
  OOMKilled=false); JPEG and HEIC micro-benchmarks under the container
  limit peaked 144 / 189 MiB in 27 / 38 s.

Found while testing the EXIF-orientation fix (issue 3232) on
constrained hardware; independent of that change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJ3ucEkn62cbgPoisAZ5LQ
@vpetersson-bot
vpetersson-bot requested a review from a team as a code owner August 2, 2026 07:38
@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (master@7acda8c). Learn more about missing BASE report.

Additional details and impacted files
@@            Coverage Diff            @@
##             master    #3235   +/-   ##
=========================================
  Coverage          ?   89.44%           
=========================================
  Files             ?       76           
  Lines             ?     8343           
  Branches          ?      892           
=========================================
  Hits              ?     7462           
  Misses            ?      666           
  Partials          ?      215           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Self-review follow-up: max(1, ...) on the thumbnail target so a crafted
sub-5px-tall image (still under the 50 MP bomb cap) can't floor a side
to 0 and make thumbnail() raise. Add a test that an already-under-cap
image is left untouched on a low-RAM board.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJ3ucEkn62cbgPoisAZ5LQ
@sonarqubecloud

sonarqubecloud Bot commented Aug 2, 2026

Copy link
Copy Markdown

@vpetersson-bot

Copy link
Copy Markdown
Contributor Author

Testbed validation

Reproduced the crash and verified the fix on a real Raspberry Pi 3B+ (1 GB; celery normalisation container capped ~543 MiB RAM + ~543 MiB swap), plus an x86 baseline.

The crash (stock code)

A real 24 MP photo through the normalisation path drove peak RSS to ~770 MB — well over the 543 MiB cap. On the Pi 3B+ this exhausted RAM+swap and rebooted the board (uptime reset to "up 2 min", all containers restarted).

The fix (patched) — real pipeline

Uploaded the same 24 MP HEIC through the actual file_asset → asset-create → celery flow with the patch deployed (is_low_ram_device() confirmed True on the board):

  • Normalised to a downscaled 1175×1763 WebP (2 071 525 px ≤ the 2 073 600 cap).
  • celery peaked ~195 MiB / 543 MiBboard stayed up (uptime unbroken, RestartCount=0, OOMKilled=false).

Micro-benchmarks under the exact container limit (--memory=543m --memory-swap=1086m)

Fixture (24 MP) Result Peak RSS Time
JPEG, patched downscaled 1762×1175 144 MiB 27 s
HEIC, patched downscaled 1175×1763 189 MiB 38 s
(stock, either) ~770 MB crashed / rebooted

The downscale also removes the other low-RAM pain point: method=6 lossless on a full 24 MP image was minutes-slow on the Pi 3 CPU; on the downscaled image it is seconds.

Testbed restored to pristine afterwards (patched processing.py reverted to the shipped image, test asset deleted, fixtures cleaned).

@vpetersson
vpetersson merged commit d049783 into Screenly:master Aug 2, 2026
11 checks 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