Mindykkyan's picture
feat: Aesthetic Dissection Panel
6420a23 verified
Raw
History Blame Contribute Delete
6.45 kB
"""
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)
# High bar = well balanced (near center); low = shifted
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]
# Circular mean on hue circle
ang = h * 2 * math.pi
centroid = (math.atan2(float(np.sin(ang).mean()), float(np.cos(ang).mean())) / (2 * math.pi)) % 1.0
# Map hue: blues/greens (~0.45–0.75) = cool, reds/oranges (~0.0–0.12, 0.88–1.0) = warm
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),
]