fix: stale __version__, scheduler import order, quality test coverage
- __init__.py: derive __version__ from importlib.metadata instead of a hardcoded "0.1.0" that was six releases out of date - scheduler.py: move winnow.cli import to module top (no more noqa); clean up redundant bool variables in check_models - tests/test_quality.py: 17 tests covering all five quality check functions individually plus assess_quality integration cases Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+6
-8
@@ -11,6 +11,10 @@ except ImportError:
|
|||||||
print("croniter not installed. Run: uv add croniter")
|
print("croniter not installed. Run: uv add croniter")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Imported at module level so models loaded during the first run stay
|
||||||
|
# resident in memory across all subsequent scheduled runs.
|
||||||
|
from winnow.cli import main
|
||||||
|
|
||||||
SCHEDULE = os.environ["CRON_SCHEDULE"]
|
SCHEDULE = os.environ["CRON_SCHEDULE"]
|
||||||
MODELS_DIR = os.environ.get("HF_HOME", "/models/huggingface")
|
MODELS_DIR = os.environ.get("HF_HOME", "/models/huggingface")
|
||||||
INSIGHTFACE_BASE = os.environ.get("INSIGHTFACE_HOME", "/models")
|
INSIGHTFACE_BASE = os.environ.get("INSIGHTFACE_HOME", "/models")
|
||||||
@@ -21,18 +25,12 @@ logger = logging.getLogger(__name__)
|
|||||||
def check_models() -> None:
|
def check_models() -> None:
|
||||||
buffalo = Path(INSIGHTFACE_BASE) / ".insightface" / "models" / "buffalo_l"
|
buffalo = Path(INSIGHTFACE_BASE) / ".insightface" / "models" / "buffalo_l"
|
||||||
hf_hub = Path(MODELS_DIR) / "hub"
|
hf_hub = Path(MODELS_DIR) / "hub"
|
||||||
buffalo_ok = buffalo.exists()
|
if not buffalo.exists():
|
||||||
hf_ok = hf_hub.exists() and any(hf_hub.iterdir())
|
|
||||||
if not buffalo_ok:
|
|
||||||
print(" InsightFace Buffalo_L not found — will download on first run", flush=True)
|
print(" InsightFace Buffalo_L not found — will download on first run", flush=True)
|
||||||
if not hf_ok:
|
if not (hf_hub.exists() and any(hf_hub.iterdir())):
|
||||||
print(" HuggingFace models not found — will download on first run", flush=True)
|
print(" HuggingFace models not found — will download on first run", flush=True)
|
||||||
|
|
||||||
|
|
||||||
# Import once — models loaded during the first run stay resident in memory
|
|
||||||
# for all subsequent scheduled runs, avoiding repeated multi-GB load times.
|
|
||||||
from winnow.cli import main # noqa: E402
|
|
||||||
|
|
||||||
NOW = time.time()
|
NOW = time.time()
|
||||||
cron = croniter(SCHEDULE, NOW)
|
cron = croniter(SCHEDULE, NOW)
|
||||||
next_run = cron.get_next(float)
|
next_run = cron.get_next(float)
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Tests for image quality filtering functions."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_image(r, g, b, size=(100, 100)) -> Image.Image:
|
||||||
|
arr = np.full((*size, 3), [r, g, b], dtype=np.uint8)
|
||||||
|
return Image.fromarray(arr, "RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def _noisy_color_image(size=(100, 100)) -> Image.Image:
|
||||||
|
"""Noisy image with a strong red channel so grayscale check passes."""
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
arr = rng.integers(0, 256, (*size, 3), dtype=np.uint8)
|
||||||
|
arr[:, :, 0] = np.clip(arr[:, :, 0].astype(int) + 80, 0, 255).astype(np.uint8)
|
||||||
|
arr[:, :, 2] = np.clip(arr[:, :, 2].astype(int) - 80, 0, 255).astype(np.uint8)
|
||||||
|
return Image.fromarray(arr, "RGB")
|
||||||
|
|
||||||
|
|
||||||
|
# ── check_blur ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_blur_rejects_flat_image():
|
||||||
|
from winnow.quality import check_blur
|
||||||
|
flat = np.full((100, 100, 3), 128, dtype=np.uint8)
|
||||||
|
passed, reason = check_blur(flat, threshold=100.0)
|
||||||
|
assert not passed
|
||||||
|
assert "Blurry" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_blur_passes_noisy_color_image():
|
||||||
|
from winnow.quality import check_blur
|
||||||
|
img = _noisy_color_image()
|
||||||
|
passed, _ = check_blur(np.asarray(img), threshold=100.0)
|
||||||
|
assert passed
|
||||||
|
|
||||||
|
|
||||||
|
# ── check_grayscale ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_grayscale_rejects_ir_image():
|
||||||
|
from winnow.quality import check_grayscale
|
||||||
|
gray = np.full((100, 100, 3), 128, dtype=np.uint8)
|
||||||
|
passed, reason = check_grayscale(gray)
|
||||||
|
assert not passed
|
||||||
|
assert "Grayscale" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_grayscale_passes_color_image():
|
||||||
|
from winnow.quality import check_grayscale
|
||||||
|
color = np.zeros((100, 100, 3), dtype=np.uint8)
|
||||||
|
color[:, :, 0] = 200 # strong red channel
|
||||||
|
passed, _ = check_grayscale(color)
|
||||||
|
assert passed
|
||||||
|
|
||||||
|
|
||||||
|
def test_grayscale_rejects_single_channel():
|
||||||
|
from winnow.quality import check_grayscale
|
||||||
|
single = np.full((100, 100, 1), 128, dtype=np.uint8)
|
||||||
|
passed, reason = check_grayscale(single)
|
||||||
|
assert not passed
|
||||||
|
|
||||||
|
|
||||||
|
# ── check_exposure ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_exposure_rejects_black_image():
|
||||||
|
from winnow.quality import check_exposure
|
||||||
|
black = np.zeros((100, 100, 3), dtype=np.uint8)
|
||||||
|
passed, reason = check_exposure(black)
|
||||||
|
assert not passed
|
||||||
|
assert "Underexposed" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_exposure_rejects_white_image():
|
||||||
|
from winnow.quality import check_exposure
|
||||||
|
white = np.full((100, 100, 3), 255, dtype=np.uint8)
|
||||||
|
passed, reason = check_exposure(white)
|
||||||
|
assert not passed
|
||||||
|
assert "Overexposed" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_exposure_passes_normal_image():
|
||||||
|
from winnow.quality import check_exposure
|
||||||
|
mid = np.full((100, 100, 3), 128, dtype=np.uint8)
|
||||||
|
passed, _ = check_exposure(mid)
|
||||||
|
assert passed
|
||||||
|
|
||||||
|
|
||||||
|
# ── check_face_size ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_face_size_rejects_small_face():
|
||||||
|
from winnow.quality import check_face_size
|
||||||
|
passed, reason = check_face_size(30, 30, min_px=50)
|
||||||
|
assert not passed
|
||||||
|
assert "small" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_face_size_passes_adequate_face():
|
||||||
|
from winnow.quality import check_face_size
|
||||||
|
passed, _ = check_face_size(100, 100, min_px=50)
|
||||||
|
assert passed
|
||||||
|
|
||||||
|
|
||||||
|
def test_face_size_rejects_if_either_dimension_small():
|
||||||
|
from winnow.quality import check_face_size
|
||||||
|
passed, _ = check_face_size(100, 30, min_px=50)
|
||||||
|
assert not passed
|
||||||
|
|
||||||
|
|
||||||
|
# ── check_confidence ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_confidence_rejects_low_score():
|
||||||
|
from winnow.quality import check_confidence
|
||||||
|
passed, reason = check_confidence(0.5, min_conf=0.7)
|
||||||
|
assert not passed
|
||||||
|
assert "confidence" in reason.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_passes_high_score():
|
||||||
|
from winnow.quality import check_confidence
|
||||||
|
passed, _ = check_confidence(0.95, min_conf=0.7)
|
||||||
|
assert passed
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_passes_none_score():
|
||||||
|
from winnow.quality import check_confidence
|
||||||
|
passed, _ = check_confidence(None, min_conf=0.7)
|
||||||
|
assert passed
|
||||||
|
|
||||||
|
|
||||||
|
# ── assess_quality (integration) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_assess_quality_passes_good_image():
|
||||||
|
from winnow.quality import assess_quality
|
||||||
|
img = _noisy_color_image()
|
||||||
|
result = assess_quality(img, face_bbox=(10, 10, 110, 110), confidence=0.9)
|
||||||
|
assert result.passed
|
||||||
|
|
||||||
|
|
||||||
|
def test_assess_quality_collects_multiple_failures():
|
||||||
|
from winnow.quality import assess_quality
|
||||||
|
black = _rgb_image(0, 0, 0)
|
||||||
|
result = assess_quality(black, face_bbox=(0, 0, 10, 10), confidence=0.3)
|
||||||
|
assert not result.passed
|
||||||
|
assert len(result.reasons) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_assess_quality_skips_face_size_without_bbox():
|
||||||
|
from winnow.quality import assess_quality
|
||||||
|
img = _noisy_color_image()
|
||||||
|
result = assess_quality(img, face_bbox=None, confidence=0.9)
|
||||||
|
assert result.passed
|
||||||
+6
-1
@@ -5,4 +5,9 @@ Immich library for Frigate's Face Recognition (ArcFace) and Object/State
|
|||||||
Classification models.
|
Classification models.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
|
||||||
|
try:
|
||||||
|
__version__ = version("winnow")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
__version__ = "unknown"
|
||||||
|
|||||||
Reference in New Issue
Block a user