| """ |
| Dissection proxies — defensible, computable aesthetic dimensions (0–1 bars). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
|
|
| import numpy as np |
| from PIL import Image |
|
|
| try: |
| import cv2 |
|
|
| _HAS_CV2 = True |
| except ImportError: |
| _HAS_CV2 = False |
|
|
|
|
| def _rgb_array(img: Image.Image) -> np.ndarray: |
| return np.array(img.convert("RGB"), dtype=np.float32) / 255.0 |
|
|
|
|
| def _hsv_arrays(img: Image.Image): |
| arr = _rgb_array(img) |
| h, w, _ = arr.shape |
| maxc = np.maximum(np.maximum(arr[:, :, 0], arr[:, :, 1]), arr[:, :, 2]) |
| minc = np.minimum(np.minimum(arr[:, :, 0], arr[:, :, 1]), arr[:, :, 2]) |
| delta = maxc - minc |
| sat = delta / (maxc + 1e-6) |
| hue = np.zeros_like(maxc) |
| mask = delta > 1e-6 |
| r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2] |
| hue[mask & (maxc == r)] = ((g - b) / (delta + 1e-6))[mask & (maxc == r)] % 6 |
| hue[mask & (maxc == g)] = ((b - r) / (delta + 1e-6) + 2)[mask & (maxc == g)] |
| hue[mask & (maxc == b)] = ((r - g) / (delta + 1e-6) + 4)[mask & (maxc == b)] |
| hue = hue / 6.0 |
| val = maxc |
| return hue, sat, val, arr |
|
|
|
|
| def _clip01(x: float) -> float: |
| return float(max(0.0, min(1.0, x))) |
|
|
|
|
| def _color_harmony(img: Image.Image) -> dict: |
| """HSV hue histogram spread (entropy) × luminance contrast ratio proxy.""" |
| hue, sat, val, arr = _hsv_arrays(img) |
| active = sat > 0.08 |
| if active.sum() < 10: |
| spread = 0.3 |
| else: |
| h_active = hue[active] |
| hist, _ = np.histogram(h_active, bins=36, range=(0, 1), density=True) |
| hist = hist + 1e-8 |
| entropy = -float(np.sum(hist * np.log(hist))) / math.log(36) |
| spread = _clip01(entropy / 0.85) |
|
|
| lum = 0.2126 * arr[:, :, 0] + 0.7152 * arr[:, :, 1] + 0.0722 * arr[:, :, 2] |
| p95, p05 = float(np.percentile(lum, 95)), float(np.percentile(lum, 5)) |
| contrast = _clip01((p95 - p05) / 0.75) |
|
|
| value = _clip01(0.55 * spread + 0.45 * contrast) |
| return { |
| "id": "color_harmony", |
| "label": "Color Harmony", |
| "value": round(value, 3), |
| "raw": { |
| "hue_entropy_norm": round(spread, 4), |
| "luminance_contrast": round(contrast, 4), |
| }, |
| "detail": f"hue spread {spread:.2f}, luminance contrast {contrast:.2f}", |
| } |
|
|
|
|
| def _saliency_com(img: Image.Image) -> tuple[float, float, float]: |
| """Return (cx, cy, offset) in 0–1 coords; offset = distance from center.""" |
| arr = _rgb_array(img) |
| h, w, _ = arr.shape |
|
|
| if _HAS_CV2: |
| bgr = (arr * 255).astype(np.uint8)[:, :, ::-1] |
| try: |
| sal = cv2.saliency.StaticSaliencySpectralResidual_create() |
| ok, sal_map = sal.computeSaliency(bgr) |
| if ok: |
| mass = sal_map.astype(np.float32) |
| mass = mass / (mass.sum() + 1e-6) |
| ys = np.arange(h)[:, None] |
| xs = np.arange(w)[None, :] |
| cy = float((ys * mass).sum()) |
| cx = float((xs * mass).sum()) |
| offset = math.sqrt((cx / w - 0.5) ** 2 + (cy / h - 0.5) ** 2) |
| return cx / w, cy / h, offset |
| except Exception: |
| pass |
|
|
| lum = 0.2126 * arr[:, :, 0] + 0.7152 * arr[:, :, 1] + 0.0722 * arr[:, :, 2] |
| mass = lum / (lum.sum() + 1e-6) |
| ys = np.arange(h)[:, None] |
| xs = np.arange(w)[None, :] |
| cy = float((ys * mass).sum()) |
| cx = float((xs * mass).sum()) |
| offset = math.sqrt((cx / w - 0.5) ** 2 + (cy / h - 0.5) ** 2) |
| return cx / w, cy / h, offset |
|
|
|
|
| def _composition_balance(img: Image.Image) -> dict: |
| cx, cy, offset = _saliency_com(img) |
| |
| value = _clip01(1.0 - offset / 0.45) |
| return { |
| "id": "composition_balance", |
| "label": "Composition Balance", |
| "value": round(value, 3), |
| "raw": {"center_x": round(cx, 4), "center_y": round(cy, 4), "offset": round(offset, 4)}, |
| "detail": f"visual mass at ({cx:.0%}, {cy:.0%}), offset {offset:.2f} from center", |
| } |
|
|
|
|
| def _saturation_intensity(img: Image.Image) -> dict: |
| _, sat, _, _ = _hsv_arrays(img) |
| mean_sat = float(sat.mean()) |
| value = _clip01(mean_sat / 0.65) |
| return { |
| "id": "saturation_intensity", |
| "label": "Saturation Intensity", |
| "value": round(value, 3), |
| "raw": {"mean_saturation": round(mean_sat, 4)}, |
| "detail": f"mean saturation {mean_sat:.2f}", |
| } |
|
|
|
|
| def _edge_complexity(img: Image.Image) -> dict: |
| arr = _rgb_array(img) |
| gray = (0.2126 * arr[:, :, 0] + 0.7152 * arr[:, :, 1] + 0.0722 * arr[:, :, 2] * 255).astype( |
| np.uint8 |
| ) |
| if _HAS_CV2: |
| edges = cv2.Canny(gray, 80, 160) |
| density = float(edges.mean()) / 255.0 |
| else: |
| gx = np.abs(np.diff(gray.astype(np.float32), axis=1)).mean() |
| gy = np.abs(np.diff(gray.astype(np.float32), axis=0)).mean() |
| density = _clip01((gx + gy) / 120.0) |
|
|
| value = _clip01(density / 0.35) |
| return { |
| "id": "edge_complexity", |
| "label": "Edge Complexity", |
| "value": round(value, 3), |
| "raw": {"edge_density": round(density, 4)}, |
| "detail": f"edge density {density:.3f}", |
| } |
|
|
|
|
| def _warm_cool(img: Image.Image) -> dict: |
| """Hue centroid mapped: 0=cool, 1=warm.""" |
| hue, sat, _, _ = _hsv_arrays(img) |
| active = sat > 0.06 |
| if active.sum() < 10: |
| centroid = 0.5 |
| else: |
| h = hue[active] |
| |
| ang = h * 2 * math.pi |
| centroid = (math.atan2(float(np.sin(ang).mean()), float(np.cos(ang).mean())) / (2 * math.pi)) % 1.0 |
|
|
| |
| warm_score = 1.0 - abs(((centroid + 0.5) % 1.0) - 0.5) * 2 |
| warm_score = _clip01(warm_score * 0.6 + 0.2 if centroid < 0.2 or centroid > 0.85 else 0.35) |
| return { |
| "id": "warm_cool", |
| "label": "Warm / Cool", |
| "value": round(warm_score, 3), |
| "raw": {"hue_centroid": round(centroid, 4)}, |
| "detail": f"hue centroid {centroid:.2f} ({'warm-leaning' if warm_score > 0.55 else 'cool-leaning' if warm_score < 0.45 else 'neutral'})", |
| } |
|
|
|
|
| def compute_dissection(img: Image.Image) -> list[dict]: |
| """Return ordered list of dimension dicts with value in [0, 1].""" |
| img = img.convert("RGB") |
| return [ |
| _color_harmony(img), |
| _composition_balance(img), |
| _saturation_intensity(img), |
| _edge_complexity(img), |
| _warm_cool(img), |
| ] |
|
|