diff --git a/backend/app/services/audits/single_color.py b/backend/app/services/audits/single_color.py new file mode 100644 index 0000000..167e078 --- /dev/null +++ b/backend/app/services/audits/single_color.py @@ -0,0 +1,53 @@ +"""Single-color audit: matches images where one color dominates beyond +the threshold (within the given Euclidean RGB tolerance). The first +canonical implementation — the import-side filter (SkipReason.single_color) +was never wired; FC-Cleanup's audit module is the source of truth and a +future spec can adopt it on the import path too. +""" + +from PIL import Image + +_THUMB_SIZE = (64, 64) + + +def evaluate( + pil_image, + *, + threshold: float, + tolerance: int, +) -> bool: + """True iff the fraction of pixels within `tolerance` (Euclidean RGB + distance) of the dominant color exceeds `threshold`. + + Downsamples to 64x64 for speed (~4ms regardless of source size). + Alpha channels are stripped; only RGB is considered. Animated images + use frame 0 (PIL's default after Image.open without seek). + """ + im = pil_image + if im.mode == "RGBA": + im = im.convert("RGB") + elif im.mode not in ("RGB", "L"): + im = im.convert("RGB") + if im.size != _THUMB_SIZE: + im = im.resize(_THUMB_SIZE, Image.Resampling.BILINEAR) + pixels = list(im.getdata()) + if not pixels: + return False + # Normalize L-mode pixels to RGB tuples for distance math. + if isinstance(pixels[0], int): + pixels = [(p, p, p) for p in pixels] + # Dominant color = mean RGB. + n = len(pixels) + sum_r = sum(p[0] for p in pixels) + sum_g = sum(p[1] for p in pixels) + sum_b = sum(p[2] for p in pixels) + dom = (sum_r / n, sum_g / n, sum_b / n) + tol_sq = tolerance * tolerance + within = 0 + for r, g, b in pixels: + dr = r - dom[0] + dg = g - dom[1] + db = b - dom[2] + if dr * dr + dg * dg + db * db <= tol_sq: + within += 1 + return (within / n) > threshold diff --git a/tests/test_audits_single_color.py b/tests/test_audits_single_color.py new file mode 100644 index 0000000..e133bab --- /dev/null +++ b/tests/test_audits_single_color.py @@ -0,0 +1,42 @@ +"""Tests for the single-color audit rule. + +The rule downsamples + measures the fraction of pixels within `tolerance` +(Euclidean RGB distance) of the dominant color. Matches if that fraction +exceeds `threshold`. Single-color content is typically uploaded by +mistake (placeholder/error/preview images) and should be flagged. +""" + +from PIL import Image + +from backend.app.services.audits import single_color + + +def test_single_color_evaluate_true_for_uniform_image(): + im = Image.new("RGB", (50, 50), (128, 64, 200)) + assert single_color.evaluate(im, threshold=0.9, tolerance=10) is True + + +def test_single_color_evaluate_false_for_diverse_image(): + # Half black, half white — no single color dominates. + im = Image.new("RGB", (50, 50), (0, 0, 0)) + for x in range(25): + for y in range(50): + im.putpixel((x, y), (255, 255, 255)) + assert single_color.evaluate(im, threshold=0.9, tolerance=10) is False + + +def test_single_color_evaluate_respects_tolerance_widening(): + # Gradient image: pixels span 0..50 in R channel. Tight tolerance + # rejects (no concentration), wide tolerance accepts (all near 25). + im = Image.new("RGB", (50, 50), (0, 0, 0)) + for x in range(50): + for y in range(50): + im.putpixel((x, y), (x, 0, 0)) + assert single_color.evaluate(im, threshold=0.9, tolerance=5) is False + assert single_color.evaluate(im, threshold=0.9, tolerance=50) is True + + +def test_single_color_evaluate_handles_rgba_input(): + # Alpha channel should be ignored — only RGB matters for the rule. + im = Image.new("RGBA", (50, 50), (100, 100, 100, 128)) + assert single_color.evaluate(im, threshold=0.9, tolerance=10) is True