diff --git a/backend/app/services/audits/__init__.py b/backend/app/services/audits/__init__.py new file mode 100644 index 0000000..96e5198 --- /dev/null +++ b/backend/app/services/audits/__init__.py @@ -0,0 +1,6 @@ +"""Audit rule modules. Each module exposes evaluate(pil_image, **params) -> bool. + +The retroactive library-cleanup tab and (future) import-time filter logic +both consume these. Importers should NOT inline rule logic going forward; +add the rule here and call from both sides. +""" diff --git a/backend/app/services/audits/transparency.py b/backend/app/services/audits/transparency.py new file mode 100644 index 0000000..f15f31f --- /dev/null +++ b/backend/app/services/audits/transparency.py @@ -0,0 +1,27 @@ +"""Transparency audit: matches images whose transparent-pixel fraction +exceeds the threshold. Animated images short-circuit (skipped) to avoid +the multi-frame PIL decode that hits Celery's hard time limit.""" + + +def evaluate(pil_image, *, threshold: float) -> bool: + """True iff the image's transparent-pixel fraction exceeds threshold. + + False for non-alpha modes and animated images. Mirrors the import-side + Importer._transparency_pct logic so retroactive enforcement matches + prospective filtering. + """ + if getattr(pil_image, "is_animated", False): + return False + if pil_image.mode not in ("RGBA", "LA") and not ( + pil_image.mode == "P" and "transparency" in pil_image.info + ): + return False + im = pil_image + if im.mode != "RGBA": + im = im.convert("RGBA") + alpha = im.getchannel("A") + histogram = alpha.histogram() + transparent = histogram[0] + total = sum(histogram) + pct = transparent / total if total else 0.0 + return pct > threshold diff --git a/tests/test_audits_transparency.py b/tests/test_audits_transparency.py new file mode 100644 index 0000000..44d6abb --- /dev/null +++ b/tests/test_audits_transparency.py @@ -0,0 +1,46 @@ +"""Tests for the transparency audit rule. + +The rule mirrors `Importer._transparency_pct` semantics for retroactive +enforcement: returns True iff the fraction of fully-transparent pixels +exceeds the threshold. Animated images short-circuit to False to avoid +the multi-frame PIL decode that triggered SoftTimeLimitExceeded +2026-05-26 against animated WebPs. +""" + +from PIL import Image + +from backend.app.services.audits import transparency + + +def test_transparency_evaluate_true_when_fully_transparent(): + im = Image.new("RGBA", (10, 10), (0, 0, 0, 0)) + assert transparency.evaluate(im, threshold=0.5) is True + + +def test_transparency_evaluate_false_when_fully_opaque(): + im = Image.new("RGBA", (10, 10), (200, 100, 50, 255)) + assert transparency.evaluate(im, threshold=0.5) is False + + +def test_transparency_evaluate_respects_threshold_boundary(): + # Half-transparent image: 50% alpha=0 pixels, 50% alpha=255. + im = Image.new("RGBA", (10, 10), (0, 0, 0, 0)) + for x in range(5): + for y in range(10): + im.putpixel((x, y), (0, 0, 0, 255)) + # 50% transparent. threshold=0.4 → True; threshold=0.6 → False. + assert transparency.evaluate(im, threshold=0.4) is True + assert transparency.evaluate(im, threshold=0.6) is False + + +def test_transparency_evaluate_false_for_rgb_image_without_alpha(): + im = Image.new("RGB", (10, 10), (128, 128, 128)) + assert transparency.evaluate(im, threshold=0.5) is False + + +def test_transparency_evaluate_false_for_animated_image(): + im = Image.new("RGBA", (10, 10), (0, 0, 0, 0)) + # Mark as animated (mimics PIL's WebP/GIF multi-frame attribute). + im.is_animated = True # type: ignore[attr-defined] + im.n_frames = 5 # type: ignore[attr-defined] + assert transparency.evaluate(im, threshold=0.5) is False