from __future__ import annotations from pathlib import Path from typing import Dict, Tuple import numpy as np from PIL import Image, ImageFilter, ImageStat, UnidentifiedImageError from config import ( ALMOST_BLACK_MEAN, ALMOST_WHITE_MEAN, LOW_STDDEV, MIN_IMAGE_SIZE, ) def _laplacian_like_sharpness(gray: Image.Image) -> float: edges = gray.filter(ImageFilter.FIND_EDGES) arr = np.asarray(edges, dtype=np.float32) return float(arr.var()) def _edge_density(gray: Image.Image) -> float: edges = gray.filter(ImageFilter.FIND_EDGES) arr = np.asarray(edges, dtype=np.float32) threshold = arr.mean() + arr.std() if threshold <= 0: return 0.0 density = (arr > threshold).mean() return float(density) def _dominant_colors_count(img: Image.Image, max_colors: int = 12) -> int: small = img.convert("RGB").resize((64, 64)) palette_img = small.quantize(colors=max_colors, method=Image.MEDIANCUT) colors = palette_img.getcolors() return len(colors) if colors else 0 def _whitespace_ratio(gray: Image.Image) -> float: arr = np.asarray(gray, dtype=np.float32) near_white = (arr > 240).mean() near_black = (arr < 15).mean() return float(max(near_white, near_black)) def _layout_density(gray: Image.Image) -> float: edges = gray.filter(ImageFilter.FIND_EDGES) arr = np.asarray(edges, dtype=np.float32) active = (arr > 30).mean() return float(active) def _center_activity(gray: Image.Image) -> float: arr = np.asarray(gray.filter(ImageFilter.FIND_EDGES), dtype=np.float32) h, w = arr.shape y1, y2 = int(h * 0.25), int(h * 0.75) x1, x2 = int(w * 0.25), int(w * 0.75) center = arr[y1:y2, x1:x2] if center.size == 0: return 0.0 return float((center > 30).mean()) def _grid_balance_3x3(gray: Image.Image) -> float: arr = np.asarray(gray.filter(ImageFilter.FIND_EDGES), dtype=np.float32) h, w = arr.shape ys = np.linspace(0, h, 4, dtype=int) xs = np.linspace(0, w, 4, dtype=int) cells = [] for i in range(3): for j in range(3): cell = arr[ys[i]:ys[i + 1], xs[j]:xs[j + 1]] if cell.size == 0: cells.append(0.0) else: cells.append(float((cell > 30).mean())) mean_val = float(np.mean(cells)) std_val = float(np.std(cells)) balance = max(0.0, 1.0 - std_val / (mean_val + 1e-6)) return balance def inspect_image_content(image_path: Path) -> Tuple[bool, str]: try: with Image.open(image_path) as img: img.load() width, height = img.size if width < MIN_IMAGE_SIZE or height < MIN_IMAGE_SIZE: return False, f"too_small_{width}x{height}" gray = img.convert("L") extrema = gray.getextrema() if extrema is None: return False, "failed_extrema_check" if extrema[0] == extrema[1]: return False, "blank_uniform_image" stat = ImageStat.Stat(gray) mean_val = stat.mean[0] stddev = stat.stddev[0] if mean_val > ALMOST_WHITE_MEAN and stddev < LOW_STDDEV: return False, "almost_blank_white_image" if mean_val < ALMOST_BLACK_MEAN and stddev < LOW_STDDEV: return False, "almost_blank_black_image" return True, "ok" except UnidentifiedImageError: return False, "unidentified_image" except Exception as e: return False, f"image_inspection_error: {e}" def extract_features(image_path: Path) -> Dict[str, float | int | bool | str]: has_content, reason = inspect_image_content(image_path) if not has_content: return { "content_present_rule": False, "blank_reason": reason, "mean_brightness": 0.0, "contrast": 0.0, "saturation_mean": 0.0, "dominant_colors_count": 0, "sharpness": 0.0, "edge_density": 0.0, "whitespace_ratio": 1.0, "layout_density": 0.0, "center_activity": 0.0, "grid_balance_3x3": 0.0, } with Image.open(image_path) as img: img = img.convert("RGB") gray = img.convert("L") hsv = img.convert("HSV") gray_stat = ImageStat.Stat(gray) hsv_stat = ImageStat.Stat(hsv) mean_brightness = float(gray_stat.mean[0]) / 255.0 contrast = float(gray_stat.stddev[0]) / 64.0 saturation_mean = float(hsv_stat.mean[1]) / 255.0 dominant_colors_count = _dominant_colors_count(img) sharpness = _laplacian_like_sharpness(gray) / 1000.0 edge_density = _edge_density(gray) whitespace_ratio = _whitespace_ratio(gray) layout_density = _layout_density(gray) center_activity = _center_activity(gray) grid_balance_3x3 = _grid_balance_3x3(gray) return { "content_present_rule": True, "blank_reason": "ok", "mean_brightness": mean_brightness, "contrast": contrast, "saturation_mean": saturation_mean, "dominant_colors_count": dominant_colors_count, "sharpness": sharpness, "edge_density": edge_density, "whitespace_ratio": whitespace_ratio, "layout_density": layout_density, "center_activity": center_activity, "grid_balance_3x3": grid_balance_3x3, }