"""orientationpy-app — 2D structure-tensor orientation analysis. Uses [orientationpy](https://gitlab.com/epfl-center-for-imaging/orientationpy) to compute per-pixel orientation of a 2D grayscale image, then renders it as an HSV map where: Hue = orientation angle (theta), mapped from [-90°, 90°] to [0, 1] Saturation = coherency (how directed the structure is) Value = intensity (trace of the structure tensor), normalized Single `process(image, sigma)` — sigma is the Gaussian window for the structure tensor. """ from __future__ import annotations import colorsys import numpy as np import orientationpy as op from skimage import color def _to_gray_float(image: np.ndarray) -> np.ndarray: arr = np.asarray(image) if arr.ndim == 3 and arr.shape[-1] in (3, 4): arr = color.rgb2gray(arr[..., :3]) elif arr.ndim == 3: arr = arr.mean(axis=-1) return arr.astype(np.float32) def _normalize(a: np.ndarray) -> np.ndarray: mn, mx = float(a.min()), float(a.max()) rng = max(mx - mn, 1e-8) return np.clip((a - mn) / rng, 0.0, 1.0) def _hsv_to_rgb(hsv: np.ndarray) -> np.ndarray: flat = hsv.reshape(-1, 3) out = np.array([colorsys.hsv_to_rgb(*row) for row in flat]) return out.reshape(hsv.shape) def process(image: np.ndarray, sigma: float = 2.0) -> np.ndarray: gray = _to_gray_float(image) gradients = op.computeGradient(gray, mode="gaussian") structure_tensor = op.computeStructureTensor(gradients, sigma=float(sigma)) orientation = op.computeOrientation(structure_tensor) theta = orientation["theta"] # in degrees, range ~[-90, 90] intensity = op.computeIntensity(structure_tensor) directionality = op.computeStructureDirectionality(structure_tensor) h = ((theta + 90.0) / 180.0) % 1.0 s = _normalize(directionality) v = _normalize(intensity) hsv = np.stack([h, s, v], axis=-1) rgb = _hsv_to_rgb(hsv) return (rgb * 255).astype(np.uint8) def stats(image: np.ndarray, sigma: float = 2.0) -> dict: """Summary orientation statistics (no image render) — handy over the API.""" gray = _to_gray_float(image) gradients = op.computeGradient(gray, mode="gaussian") structure_tensor = op.computeStructureTensor(gradients, sigma=float(sigma)) theta = op.computeOrientation(structure_tensor)["theta"] directionality = op.computeStructureDirectionality(structure_tensor) # theta is axial (period 180°): take the directionality-weighted double-angle mean. w = np.clip(directionality, 0, None) ang = np.deg2rad(theta * 2.0) dom = float(np.rad2deg(np.arctan2((w * np.sin(ang)).sum(), (w * np.cos(ang)).sum())) / 2.0) return { "dims": [int(d) for d in gray.shape], "sigma": float(sigma), "dominant_angle_deg": round(dom, 2), "mean_coherency": round(float(_normalize(directionality).mean()), 4), }