fix(processing): downscale oversized JPEG/PNG on low-RAM boards - #3255
Conversation
- 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
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
- 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
There was a problem hiding this comment.
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.
- 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
|
There was a problem hiding this comment.
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_pipelinenow 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, butImage.opencan raiseDecompressionBombErrorfirst whenImage.MAX_IMAGE_PIXELSis enforced (e.g. >2× the cap). The key guarantee is that the upload predicate won’t raise and the task will fail cleanly withon_failurewritingmetadata.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
DecompressionBombErrorbefore 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."""



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:
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.carried to the output. Re-attaching it would describe a rotation
already applied, and the viewer's
QImageReader::setAutoTransformwould 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.
image paths, so they cannot drift apart.
on a Pi 4 / 5 / x86.
per-frame handling), as is SVG (no pixel buffer).
Metadata records the resize as
downscaledplusoriginal_resolution(in the operator's display orientation), and
convertedstaysfalsebecause 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:
Pi 3 64-bit — aarch64, Qt6/eglfs, 787 MB, celery cap 543 MiB:
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 noExif\0\0/0xFFE1APP1 markersurvives 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_boardhelperdid not perturb the existing path. Extra coverage added on hardware:
RGBA PNG keeps its alpha, CMYK JPEG flattens to RGB, 12 MP
.jpegresizes correctly, and re-running the task on an already-downscaled
asset is idempotent.
Two issues the hardware run found, both fixed in this PR:
original_resolutionreported the stored orientation, so anOrientation=6 phone photo was described as
6000x4000when theoperator 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.
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
app/views.py assets_upload). Both runsdrove the v2 REST API; that call site takes the identical one-line
predicate swap, but the multipart web-form path was not exercised.
NORMALIZE_IMAGE_EXTSbehaviour, unchanged here, and thebyte-identical TIFF/BMP conversions cover the shared-helper risk.
shared helper.
Checklist
x86 is deliberately unticked: it is not a low-RAM board, so
is_low_ram_device()is false there and this code cannot execute. Therelevant no-op-on-normal-RAM behaviour is covered by unit tests and was
confirmed on hardware (a full-resolution upload is left untouched).