feat(processing): normalise BMP, ICO, TGA, JPEG 2000, and AVIF to WebP

Extends the image-normalisation pipeline to cover the realistic set
of "operator drags an unusual image format into the upload modal"
cases, all handled by Pillow's built-in decoders without a new apt
or wheel dependency:

  ┌──────────┬────────────────────────────────────────────────────┐
  │ Format   │ Why we want it converted                           │
  ├──────────┼────────────────────────────────────────────────────┤
  │ BMP      │ Uncompressed; a 4K BMP is ~30 MB vs ~1 MB as WebP. │
  │ ICO      │ Multi-frame Windows icon; pick the largest, flatten│
  │ TGA      │ Screenshot tools / game asset exports; no browser  │
  │          │ support.                                           │
  │ JPEG2000 │ .jp2/.j2k/.jpx/.jpc/.jpf — scanner output; no      │
  │          │ browser support.                                   │
  │ AVIF     │ Modern phone exports. Chromium 85+ renders AVIF,   │
  │          │ but the legacy Pi 2/3 Qt5 WebEngine predates it,   │
  │          │ so converting on upload means one playback path    │
  │          │ across the fleet.                                  │
  └──────────┴────────────────────────────────────────────────────┘

JPEG / PNG / WebP / GIF / SVG remain untouched — already
viewer-friendly *and* well-compressed.

Implementation:
* Extend ``NORMALIZE_IMAGE_EXTS``; the rest of the pipeline already
  accepts any extension in this set (RGBA conversion happens inside
  ``_convert_image_to_webp`` regardless of source format).
* Replace the duplicate extension set in ``assets_upload`` with a
  call to ``processing.needs_image_normalisation`` so the source of
  truth lives in one place.
* Widen the upload modal's <input accept> attribute.

Tests:
* ``test_image_normalises_to_lossless_webp_across_formats`` is a
  parametrised matrix that round-trips each new format end-to-end:
  source synthesised via Pillow, runs through
  ``_run_image_normalisation``, asserts the WebP output decodes
  cleanly back to a 16x16 image. Catches both decoder-side
  regressions (Pillow drops a format) and writer-side regressions
  (RGBA convert mode breaks one source).
* ``test_needs_image_normalisation`` extended to cover every entry
  in the new set plus negative cases (.jpg/.png/.webp/.gif/.svg
  stay False). Total: 109 image-format assertions.
This commit is contained in:
Viktor Petersson
2026-05-07 08:48:53 +00:00
parent f270f19b0f
commit 8602faff97
4 changed files with 120 additions and 22 deletions

View File

@@ -99,7 +99,7 @@
WebP, exotic video codecs → H.264 MP4) makes the wider
accept range safe to surface here. #}
<input type="file" class="hidden" id="add-file" name="file_upload"
accept="image/*,video/*,.heic,.heif,.tif,.tiff,.mov,.m4v,.mkv,.webm,.avi,.mpg,.mpeg,.ts,.flv"
accept="image/*,video/*,.heic,.heif,.tif,.tiff,.bmp,.ico,.tga,.jp2,.j2k,.jpx,.jpc,.jpf,.avif,.mov,.m4v,.mkv,.webm,.avi,.mpg,.mpeg,.ts,.flv"
@change="if ($event.target.files.length) { uploadFileName = $event.target.files[0].name; $event.target.form.requestSubmit() }">
</label>

View File

@@ -342,16 +342,17 @@ def assets_upload(request: HttpRequest) -> HttpResponse:
# "ffprobe + write duration" the old probe_video_duration
# did, and the transcode branch covers the non-H.264 case
# this issue exists to fix.
# * HEIC / HEIF / TIFF images are converted to lossless WebP.
# Other images (JPEG / PNG / WebP / GIF) skip the pipeline
# entirely and land ready-to-play.
# * HEIC / HEIF / TIFF / BMP images are converted to lossless
# WebP. Other images (JPEG / PNG / WebP / GIF) skip the
# pipeline entirely and land ready-to-play. The exact set
# lives in anthias_server.processing.NORMALIZE_IMAGE_EXTS so
# adding a new format only touches one place.
from anthias_server.processing import needs_image_normalisation
is_video = mimetype == 'video'
needs_image_normalize = mimetype == 'image' and src_ext.lower() in {
'.heic',
'.heif',
'.tif',
'.tiff',
}
needs_image_normalize = mimetype == 'image' and needs_image_normalisation(
final_path
)
is_processing = is_video or needs_image_normalize
duration = settings['default_duration']

View File

@@ -225,10 +225,51 @@ def _resolve_board_profile() -> _BoardProfile:
return _BOARD_PROFILES.get(device_type, _DEFAULT_PROFILE)
# Image extensions we route through the conversion task. Anything not
# in this set is left as-is — the existing pipeline already handles
# JPEG/PNG/WebP/GIF/BMP via direct Qt webview rendering.
NORMALIZE_IMAGE_EXTS = frozenset({'.heic', '.heif', '.tif', '.tiff'})
# Image extensions we route through the conversion task. The
# motivation differs by format:
#
# * HEIC / HEIF — Qt webview can't render them at all on most
# boards (libheif binding is server-side only via pillow-heif).
# * TIFF — patchy browser support; a multi-page TIFF flattens
# awkwardly. Normalising flattens it once, deterministically.
# * BMP — uncompressed; a 4K BMP is ~30 MB vs ~1 MB as WebP.
# Browsers do render BMP, but the on-disk size matters on a Pi
# and BMP is a one-shot convert (Pillow built-in, no apt dep).
# * ICO — Windows icons. Often multi-frame; the largest frame is
# what we want, flattened to a single WebP.
# * TGA — Truevision Targa (screenshot tools, game assets). No
# browser support.
# * JPEG 2000 (.jp2/.j2k/.jpx/.jpc/.jpf) — scanner output. No
# browser support.
# * AVIF — modern phone exports / Android camera output. Modern
# Chromium renders AVIF, but the Qt5 WebEngine on legacy Pi 2/3
# predates the AVIF support in Chromium 85, so converting on
# upload guarantees the viewer renders correctly across the
# fleet without per-board branching.
#
# JPEG / PNG / WebP / GIF / SVG stay untouched — already
# viewer-friendly *and* well-compressed.
#
# All formats above are handled by Pillow's built-in decoders (no
# extra apt or wheel dependency beyond pillow-heif, which is
# already required for HEIC/HEIF).
NORMALIZE_IMAGE_EXTS = frozenset(
{
'.heic',
'.heif',
'.tif',
'.tiff',
'.bmp',
'.ico',
'.tga',
'.jp2',
'.j2k',
'.jpx',
'.jpc',
'.jpf',
'.avif',
}
)
def needs_image_normalisation(uri_or_filename: str) -> bool:

View File

@@ -220,19 +220,55 @@ def _make_video(
@pytest.mark.parametrize(
('fmt', 'ext'),
[
# TIFF — both extension spellings must route through the
# same path; covers the multi-page-flatten case implicitly
# (Pillow opens the first frame).
('TIFF', '.tif'),
('TIFF', '.tiff'),
# BMP — uncompressed source, dramatic size win after WebP.
('BMP', '.bmp'),
# ICO — Windows icon. Pillow saves+reloads as a single
# frame, which is what we want for a signage asset.
('ICO', '.ico'),
# TGA — Truevision Targa, common in screenshot tools.
('TGA', '.tga'),
# JPEG 2000 family — every Pillow-recognised extension we
# accept must round-trip.
('JPEG2000', '.jp2'),
('JPEG2000', '.j2k'),
('JPEG2000', '.jpx'),
# AVIF — modern phone camera output. Pillow 12+ supports
# AVIF natively; round-trip proves the libavif binding is
# actually linked at runtime, not just registered.
('AVIF', '.avif'),
],
)
def test_image_tiff_converts_to_lossless_webp(
def test_image_normalises_to_lossless_webp_across_formats(
asset_dir: str, fmt: str, ext: str
) -> None:
"""TIFF → WebP, original removed, row's URI swapped, metadata
populated. Both ``.tif`` and ``.tiff`` extensions route through
the same path."""
"""Every entry in ``NORMALIZE_IMAGE_EXTS`` produces a valid WebP
output: the original is removed, the row's URI is swapped to the
new ``.webp`` path, ``metadata.original_ext`` carries the source
extension, and the resulting file actually decodes back as WebP
(not a stub or a bytewise-renamed source).
Parametrising over the full grid catches a future change to
``_convert_image_to_webp`` that breaks one decoder while leaving
the others intact — e.g. a move to a non-RGBA convert mode would
crash JPEG2000 (which Pillow opens in mode 'RGB' by default for
some files), and a Pillow version drop that loses libavif would
fail AVIF specifically. Each case is one assertion per
invariant; failures point at one row in the matrix."""
src = path.join(asset_dir, f'fixture{ext}')
_write_image(src, fmt)
asset = _make_processing_asset('img-tiff', src)
# AVIF needs RGB (Pillow's libavif binding doesn't accept RGBA on
# write); ICO/TGA/JP2/BMP all accept RGBA. The conversion target
# (``_convert_image_to_webp``'s internal ``.convert('RGBA')``) is
# what unifies — just make sure the SOURCE encodes happily.
if fmt in ('AVIF', 'JPEG2000'):
_write_image(src, fmt, mode='RGB', colour=(0, 200, 0))
else:
_write_image(src, fmt)
asset = _make_processing_asset(f'img-{fmt.lower()}', src)
with mock.patch.object(processing, '_notify') as notify:
processing._run_image_normalisation(asset)
@@ -241,12 +277,12 @@ def test_image_tiff_converts_to_lossless_webp(
expected_uri = path.join(asset_dir, 'fixture.webp')
assert asset.uri == expected_uri
assert path.isfile(expected_uri)
assert not path.exists(src), 'original tiff must be removed'
assert not path.exists(src), f'original {ext} must be removed'
assert asset.is_processing is False
assert asset.mimetype == 'image'
assert asset.metadata['original_ext'] == ext
assert asset.metadata['converted'] is True
notify.assert_called_once_with('img-tiff')
notify.assert_called_once_with(f'img-{fmt.lower()}')
# Round-trip the WebP — proves the file isn't a stub.
with Image.open(expected_uri) as im:
@@ -1212,15 +1248,35 @@ def test_normalize_on_failure_no_args_is_safe() -> None:
@pytest.mark.parametrize(
('filename', 'expected'),
[
# Every entry in NORMALIZE_IMAGE_EXTS routes through; case
# variants exercise the case-insensitive lstrip-and-lower
# path in ``_ext``.
('foo.heic', True),
('FOO.HEIC', True),
('foo.heif', True),
('foo.tif', True),
('foo.tiff', True),
('foo.bmp', True),
('foo.BMP', True),
('foo.ico', True),
('foo.tga', True),
('foo.jp2', True),
('foo.j2k', True),
('foo.jpx', True),
('foo.jpc', True),
('foo.jpf', True),
('foo.avif', True),
# Already-friendly formats stay untouched — no Celery hop.
('foo.jpg', False),
('foo.jpeg', False),
('foo.png', False),
('foo.webp', False),
('foo.gif', False),
('foo.svg', False),
# No extension and unknown extensions also fall through to
# the no-op branch.
('foo', False),
('foo.psd', False),
],
)
def test_needs_image_normalisation(filename: str, expected: bool) -> None: