Pictures: a phone photo as avatar or logo holds a web worker for seconds re-encoding full-size PNGs #482

Closed
opened 2026-10-01 10:47:00 +00:00 by tiagoagueda · 1 comment
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading core/pictures.py and timing as_stored on camera-sized JPEGs.

Related to #265.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at accounts/forms.py:397 (the profile form's call to avatars.process); core/pictures.py, accounts/avatars.py:78, jobs/logos.py:97 and the Dockerfile have not changed.

as_stored keeps the image at full resolution, converts it to RGBA and calls encode_within, which writes a complete PNG with optimize=True, and when that is over the 1 MB budget shrinks the picture by only 20% and encodes it again, until it fits. A photograph never compresses to 1 MB as PNG at camera resolution, so an ordinary phone photo goes through eight or nine full optimising encodes before it is stored at around 600 pixels. The module's comment says the reduction "effectively never runs"; that holds for flat artwork only.

  • Where: core/pictures.py:88-112, encode_within (REDUCTION = 0.8 at :57); core/pictures.py:115-131, as_stored; called from accounts/avatars.py:78 (profile picture upload, inside the request via accounts/forms.py:396) and jobs/logos.py:97 (logo upload, and Find logo in CompanyLogoActionView).
  • What happens: measured on the snapshot with a synthetic 4000×3000 photograph (3.3 MB JPEG, under the 5 MB upload cap): square=True took 10.3 s and 8 PNG encodes and stored 628×628; square=False took 13.8 s and 9 encodes. A 1600×1200 photo took 1.4–1.9 s and 4 encodes. The other reviewer's figures for real-sized JPEGs were of the same order (6–17 s).
  • Why it matters: the image runs gunicorn with three synchronous workers (docker/Dockerfile:354-362). A person setting a picture, retrying because it seems stuck, or two people doing it at once, leaves the instance with one worker or none for everyone else for that time. The result is no better for the wait: the stored picture is the size a single resize would have produced.
  • Fix: bound the work before encoding. Cap the longest edge first (Image.thumbnail to, say, 2048, with Image.draft for JPEG so the decode itself is cheaper); estimate the next scale from the first attempt (about sqrt(budget / written) with a margin) instead of 20% steps; encode trial passes without optimize and only the final one with it. Correct the comment on DEFAULT_BUDGET.

Done when

  • A test in a new tests/test_pictures.py feeds as_stored a 4000×3000 photographic JPEG and asserts that at most two PNG encodes happen (counted by patching Image.Image.save) and that the result fits the budget; it fails today.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading `core/pictures.py` and timing `as_stored` on camera-sized JPEGs. Related to #265. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `accounts/forms.py:397` (the profile form's call to `avatars.process`); `core/pictures.py`, `accounts/avatars.py:78`, `jobs/logos.py:97` and the `Dockerfile` have not changed. `as_stored` keeps the image at full resolution, converts it to RGBA and calls `encode_within`, which writes a complete PNG with `optimize=True`, and when that is over the 1 MB budget shrinks the picture by only 20% and encodes it again, until it fits. A photograph never compresses to 1 MB as PNG at camera resolution, so an ordinary phone photo goes through eight or nine full optimising encodes before it is stored at around 600 pixels. The module's comment says the reduction "effectively never runs"; that holds for flat artwork only. - **Where:** `core/pictures.py:88-112`, `encode_within` (`REDUCTION = 0.8` at `:57`); `core/pictures.py:115-131`, `as_stored`; called from `accounts/avatars.py:78` (profile picture upload, inside the request via `accounts/forms.py:396`) and `jobs/logos.py:97` (logo upload, and *Find logo* in `CompanyLogoActionView`). - **What happens:** measured on the snapshot with a synthetic 4000×3000 photograph (3.3 MB JPEG, under the 5 MB upload cap): `square=True` took 10.3 s and 8 PNG encodes and stored 628×628; `square=False` took 13.8 s and 9 encodes. A 1600×1200 photo took 1.4–1.9 s and 4 encodes. The other reviewer's figures for real-sized JPEGs were of the same order (6–17 s). - **Why it matters:** the image runs gunicorn with three synchronous workers (`docker/Dockerfile:354-362`). A person setting a picture, retrying because it seems stuck, or two people doing it at once, leaves the instance with one worker or none for everyone else for that time. The result is no better for the wait: the stored picture is the size a single resize would have produced. - **Fix:** bound the work before encoding. Cap the longest edge first (`Image.thumbnail` to, say, 2048, with `Image.draft` for JPEG so the decode itself is cheaper); estimate the next scale from the first attempt (about `sqrt(budget / written)` with a margin) instead of 20% steps; encode trial passes without `optimize` and only the final one with it. Correct the comment on `DEFAULT_BUDGET`. ## Done when - A test in a new `tests/test_pictures.py` feeds `as_stored` a 4000×3000 photographic JPEG and asserts that at most two PNG encodes happen (counted by patching `Image.Image.save`) and that the result fits the budget; it fails today.
tiagoagueda added this to the 0.6.0 milestone 2026-10-01 10:47:00 +00:00
Author
Owner

Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#482).

Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#482).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#482
No description provided.