feat(fc-cleanup): audits/single_color.py + tests — Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user