Skip to content

fix(processing): downscale oversized JPEG/PNG on low-RAM boards - #3255

Merged
vpetersson merged 5 commits into
Screenly:masterfrom
vpetersson-bot:fix/lowram-downscale-jpeg
Aug 6, 2026
Merged

fix(processing): downscale oversized JPEG/PNG on low-RAM boards#3255
vpetersson merged 5 commits into
Screenly:masterfrom
vpetersson-bot:fix/lowram-downscale-jpeg

Conversation

@vpetersson-bot

@vpetersson-bot vpetersson-bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Issues Fixed

Follow-up to #3232
("Autoscale images for display"), which was only half delivered: the
downscale added in #3235 fires
only for formats in NORMALIZE_IMAGE_EXTS.

Description

JPEG and PNG deliberately bypass the WebP normalisation pipeline, so
nothing capped their resolution. A phone photo is the most likely
oversized upload there is, and it stayed at full resolution on disk with
the viewer paying the decode at render time.

This resizes those formats in place on low-RAM boards only:

  • format and URI are preserved, so there is no viewer-side path churn.
    Re-encoding a photographic JPEG as lossless WebP would inflate it
    several times over, which is why this is a separate path from
    _convert_image_to_webp.
  • EXIF orientation is baked into the pixels and the tag is not
    carried to the output. Re-attaching it would describe a rotation
    already applied, and the viewer's QImageReader::setAutoTransform
    would rotate a second time, turning a correct portrait sideways.
    There is a regression test pinning exactly this, and it was verified
    on hardware including rotation direction.
  • the decompression-bomb cap and the low-RAM fit are now shared by both
    image paths, so they cannot drift apart.
  • normal-RAM boards are untouched: no resizing of an operator's upload
    on a Pi 4 / 5 / x86.
  • GIF and animated WebP are deliberately excluded (they would need
    per-frame handling), as is SVG (no pixel buffer).

Metadata records the resize as downscaled plus original_resolution
(in the operator's display orientation), and converted stays false
because no format conversion happened.

Hardware validation

Validated on both low-RAM architectures via the overlay method (§3 of
the testbed-qa runbook), driving the real v2 upload API rather than a DB
insert, since the change is in the dispatch decision.

Pi 2 — armv7l, Qt5/linuxfb, 801 MB, celery cap 552 MiB:

Before After
Stored file 6000x4000, byte-identical to source 1762x1175 (2,070,350 px)
Celery hop never fired 8.5 s, peak 257.1 MiB of 552 MiB cap
Viewer decode delta +81.8 / +94.2 MiB +9.2 / +2.9 MiB (89-97% less)

Pi 3 64-bit — aarch64, Qt6/eglfs, 787 MB, celery cap 543 MiB:

Before After
Stored file 6000x4000, 6.55 MB 1762x1175, 146 KB
Celery hop never fired 6.97 s, peak 166.0 MiB of 543 MiB cap
Viewer cgroup peak 169.1 MiB 75.1 MiB (-55.6%)
Viewer RSS 136.8 MiB 45.0 MiB (-67.1%)

On both boards: oom_kill 0, no reboot, uptime monotonic, no leftover
.tmp, and the two renders are visually indistinguishable on screen.

Double-rotation hazard — verified on both. Stored pixels come out
portrait, getexif() is empty, and no Exif\0\0 / 0xFFE1 APP1 marker
survives anywhere in the file bytes. Rotation direction was confirmed
with an asymmetric quadrant fixture whose stored quadrant centres came
back byte-identical to a dev-host 90-degree-CW prediction. Display
captures show a portrait content box with the marker band on top; a
surviving tag would have rendered landscape.

No regressions — small JPEG and small PNG both byte-identical after
upload (not re-encoded); TIFF and BMP still convert to WebP with output
byte-identical to the pre-change baseline conversions, which is the
evidence that extracting the shared _guard_and_fit_for_board helper
did not perturb the existing path. Extra coverage added on hardware:
RGBA PNG keeps its alpha, CMYK JPEG flattens to RGB, 12 MP .jpeg
resizes correctly, and re-running the task on an already-downscaled
asset is idempotent.

Two issues the hardware run found, both fixed in this PR:

  1. original_resolution reported the stored orientation, so an
    Orientation=6 phone photo was described as 6000x4000 when the
    operator uploaded a 4000x6000 portrait — in the one field that exists
    to explain the resize to that operator. Now reported in display
    orientation, with a test over all nine Orientation values.
  2. A flat RGBA PNG can grow on resize (608 KB -> 3.52 MB measured).
    Documented and logged rather than guarded against: the objective is
    the decode buffer, not the file, and keeping the small source leaves
    the viewer allocating ~28 MB of ARGB instead of ~8 MB.

Not verified

  • The HTML upload path (app/views.py assets_upload). Both runs
    drove the v2 REST API; that call site takes the identical one-line
    predicate swap, but the multipart web-form path was not exercised.
  • HEIC / AVIF legs of the orientation bake — pre-existing
    NORMALIZE_IMAGE_EXTS behaviour, unchanged here, and the
    byte-identical TIFF/BMP conversions cover the shared-helper risk.
  • The >50 MP bomb rejection — untouched beyond being moved into the
    shared helper.

Checklist

  • I have performed a self-review of my own code.
  • New and existing unit tests pass locally and on CI with my changes.
  • I have done an end-to-end test for Raspberry Pi devices.
  • I have tested my changes for x86 devices.
  • I added a documentation for the changes I have made (when necessary).

x86 is deliberately unticked: it is not a low-RAM board, so
is_low_ram_device() is false there and this code cannot execute. The
relevant no-op-on-normal-RAM behaviour is covered by unit tests and was
confirmed on hardware (a full-resolution upload is left untouched).

- the low-RAM pixel cap only applied to NORMALIZE_IMAGE_EXTS, so a
  phone JPEG bypassed it and reached the viewer at full resolution
- resize those in place, keeping format and URI: re-encoding a
  photographic JPEG as lossless WebP would inflate it several times
- bake EXIF orientation and drop the tag so the viewer's
  setAutoTransform cannot rotate a second time
- share the bomb cap and low-RAM fit between both image paths
@vpetersson-bot vpetersson-bot self-assigned this Aug 5, 2026
@vpetersson-bot
vpetersson-bot requested a review from a team as a code owner August 5, 2026 17:01
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.25000% with 18 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (master@056a5c6). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/anthias_server/processing.py 80.00% 14 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##             master    #3255   +/-   ##
=========================================
  Coverage          ?   90.73%           
=========================================
  Files             ?       76           
  Lines             ?     8439           
  Branches          ?      896           
=========================================
  Hits              ?     7657           
  Misses            ?      562           
  Partials          ?      220           

☔ 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.

- a phone photo stored landscape with Orientation=6 was reported as
  6000x4000 when the operator uploaded a 4000x6000 portrait
- the field exists to explain a softer image to that operator, so it
  must name the orientation they saw
- found during hardware validation on the Pi 3-64 testbed
- a flat RGBA PNG can grow on resize (608KB -> 3.52MB measured on the
  Pi 2) because resampling replaces cheap single-colour runs with
  gradients PNG filters cannot compress
- accepted deliberately: the objective is the decode buffer, not the
  file, and keeping the small source leaves the viewer allocating
  ~28MB of ARGB instead of ~8MB
- log the growth so a larger asset dir is answerable, not a mystery
- verified against two genuine iPhone 12 Pro HEICs (irot 90 and 270)
  on both the pinned pillow-heif 1.4.0 and 1.5.0
- pillow-heif applies the HEIF irot box at decode AND normalises the
  EXIF Orientation tag to 1, so exif_transpose is a no-op for HEIC,
  not load-bearing as the docstring claimed
- AVIF verified separately as genuinely load-bearing
- pin the normalisation with a test, so a future pillow-heif that
  stops doing it fails loudly instead of silently double-rotating

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends Anthias’ image-processing pipeline so that oversized JPEG/PNG uploads (which intentionally bypass WebP conversion) are downscaled in place on low-RAM boards, reducing viewer decode memory usage while preserving format/URI and avoiding EXIF double-rotation issues.

Changes:

  • Added a low-RAM-only “downscale-in-place” path for JPEG/PNG, sharing decompression-bomb guarding and sizing logic with the existing WebP conversion path.
  • Updated upload dispatch predicates to route images through Celery when they need either conversion or low-RAM downscaling.
  • Added/extended tests covering HEIF orientation behavior, downscale dispatch, in-place format preservation, EXIF handling, and metadata (downscaled, original_resolution, converted).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/anthias_server/processing.py Adds low-RAM JPEG/PNG downscale support, shared guard/fit helper, EXIF orientation baking helper, and metadata updates.
src/anthias_server/app/views.py Switches HTML upload path to use the new union predicate for deciding whether to enqueue Celery processing.
src/anthias_server/api/serializers/mixins.py Switches v2 asset creation path to use the union predicate for deciding whether to enqueue Celery processing.
tests/test_processing.py Adds regression/behavior tests for orientation handling and low-RAM JPEG/PNG downscaling and metadata.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/anthias_server/app/views.py Outdated
Comment thread src/anthias_server/processing.py
- DecompressionBombError derives from Exception, not OSError/ValueError,
  so it escaped the header probe and surfaced as a 500 on upload
- route a bomb into the Celery task instead, which rejects it and shows
  the operator a Failed pill with the dimensions, as over-cap HEIC does
- also catch DecompressionBombWarning, which a warnings filter of error
  promotes into a raise
- rename needs_image_normalize to needs_image_pipeline now that it
  covers downscale-only routing too
@sonarqubecloud

sonarqubecloud Bot commented Aug 5, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/anthias_server/app/views.py:749

  • needs_image_pipeline now covers both WebP conversion and low-RAM JPEG/PNG downscaling, so this toast can fire even when no format conversion happens. The message is misleading for the resize-only path; consider a neutral wording like “processing image…”.
    if needs_image_pipeline:
        from anthias_server.processing import dispatch_normalize_image

        dispatch_normalize_image(asset.asset_id)
        return _asset_table_response(

src/anthias_server/processing.py:198

  • The docstring claims the Celery task’s _guard_and_fit_for_board “rejects” decompression bombs, but Image.open can raise DecompressionBombError first when Image.MAX_IMAGE_PIXELS is enforced (e.g. >2× the cap). The key guarantee is that the upload predicate won’t raise and the task will fail cleanly with on_failure writing metadata.error_message; the docstring should describe that behavior rather than attributing all bomb rejections to _guard_and_fit_for_board.
    A bomb returns ``True`` rather than ``False``: routing it into the
    Celery task is what gets the operator a real answer. The task's
    ``_guard_and_fit_for_board`` rejects it deterministically and
    ``on_failure`` writes ``metadata.error_message`` and clears
    ``is_processing``, so the row shows a "Failed" pill explaining the

tests/test_processing.py:2191

  • This test docstring also says the Celery task “rejects” the bomb “with the dimensions”. In practice the task may fail via Pillow’s DecompressionBombError before the explicit size guard runs, so the important assertion is the routing/clean failure path rather than the specific rejection mechanism.
    It returns ``True`` — routing the bomb into the Celery task, which
    rejects it deterministically and surfaces a "Failed" pill with the
    dimensions, the same way an over-cap HEIC/TIFF already does."""

@vpetersson
vpetersson merged commit 82c5def into Screenly:master Aug 6, 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.

3 participants