"""AS7341 spektri -> ligikaudne värvus (CIE 1931 XYZ -> sRGB), v2. Erinevused v1-st: * tumekorrutis (dark) ja valge referents (white) on sisseehitatud; * kanalid integreeritakse CMF-iga koos kanali ribalaiusega (FWHM), mitte delta-funktsioonina; * valge normaliseeritakse D65-le, värviesitus säilitab heleduse ja hallid; * tulemus sisaldab kehtivuslippu (pime / küllastunud / puuduv kanal). """ from __future__ import annotations import math # AS7341 kanalid: (nimi, kesk-lainepikkus nm, FWHM nm) CHANNELS = ( ("f1", 415.0, 26.0), ("f2", 445.0, 30.0), ("f3", 480.0, 36.0), ("f4", 515.0, 39.0), ("f5", 555.0, 39.0), ("f6", 590.0, 40.0), ("f7", 630.0, 52.0), ("f8", 680.0, 122.0), ) CHANNEL_WAVELENGTHS: dict[str, int] = {n: int(w) for n, w, _ in CHANNELS} FWHM: dict[str, float] = {n: f for n, _, f in CHANNELS} # CIE D65 suhteline spektraaljaotus kanalite keskkohtadel (560 nm = 100) D65_SPD = {415: 91.0, 445: 97.2, 480: 99.0, 515: 99.9, 555: 99.8, 590: 96.0, 630: 88.0, 680: 78.2} D65_WHITE = (0.95047, 1.0, 1.08883) SATURATION = 65000.0 def _piecewise_gaussian(x: float, mu: float, sigma1: float, sigma2: float) -> float: sigma = sigma1 if x < mu else sigma2 return math.exp(-0.5 * ((x - mu) / sigma) ** 2) def cie_xyz_cmf(wavelength_nm: float) -> tuple[float, float, float]: """Wyman et al. (2013) lähendus CIE 1931 2° värvusfunktsioonidele.""" x_bar = ( 1.056 * _piecewise_gaussian(wavelength_nm, 599.8, 37.9, 31.0) + 0.362 * _piecewise_gaussian(wavelength_nm, 442.0, 16.0, 26.7) - 0.065 * _piecewise_gaussian(wavelength_nm, 501.1, 20.4, 26.2) ) y_bar = ( 0.821 * _piecewise_gaussian(wavelength_nm, 568.8, 46.9, 40.5) + 0.286 * _piecewise_gaussian(wavelength_nm, 530.9, 16.3, 31.1) ) z_bar = ( 1.217 * _piecewise_gaussian(wavelength_nm, 437.0, 11.8, 36.0) + 0.681 * _piecewise_gaussian(wavelength_nm, 459.0, 26.0, 13.8) ) return x_bar, y_bar, z_bar def _weights() -> dict[str, tuple[float, float, float]]: out: dict[str, tuple[float, float, float]] = {} for name, wl, fwhm in CHANNELS: cx, cy, cz = cie_xyz_cmf(wl) k = (D65_SPD[wl] / 100.0) * (fwhm / 100.0) out[name] = (cx * k, cy * k, cz * k) return out _W = _weights() # Valge (kõik kanalid = 1) annab D65 valge: normaliseerime Y = 1. _W_Y = sum(v[1] for v in _W.values()) def _white_raw() -> tuple[float, float, float]: x = sum(v[0] for v in _W.values()) / _W_Y y = 1.0 z = sum(v[2] for v in _W.values()) / _W_Y return x, y, z _WHITE_RAW = _white_raw() _WB_X = D65_WHITE[0] / _WHITE_RAW[0] if _WHITE_RAW[0] else 1.0 _WB_Z = D65_WHITE[2] / _WHITE_RAW[2] if _WHITE_RAW[2] else 1.0 def channels_to_xyz(channels: dict[str, float]) -> tuple[float, float, float]: x = y = z = 0.0 for name, (wx, wy, wz) in _W.items(): value = channels.get(name) if value is None: continue x += value * wx y += value * wy z += value * wz # Kvadratuuri nihe: valge (kõik 1) peab olema täpselt D65. return (x / _W_Y) * _WB_X, y / _W_Y, (z / _W_Y) * _WB_Z def chromaticity_xy(x: float, y: float, z: float) -> tuple[float, float]: total = x + y + z if total <= 1e-12: return 0.0, 0.0 return x / total, y / total def _xyz_to_srgb8(x: float, y: float, z: float) -> tuple[int, int, int]: """XYZ -> sRGB 8-bit. Y on absoluutne heledus (0..1 = täisskaala).""" if y <= 1e-9: return 0, 0, 0 r = 3.2406 * x - 1.5372 * y - 0.4986 * z g = -0.9689 * x + 1.8758 * y + 0.0415 * z b = 0.0557 * x - 0.2040 * y + 1.0570 * z # Gamut: negatiivsed komponendid -> lisa kõigile (desatureeri valge suunas) m = min(r, g, b) if m < 0: r, g, b = r - m, g - m, b - m # Kui üle 1, skaleeri (heledus väheneb, toon jääb) mx = max(r, g, b) if mx > 1.0: r, g, b = r / mx, g / mx, b / mx def enc(c: float) -> int: c = max(0.0, min(1.0, c)) v = 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055 return int(round(255 * max(0.0, min(1.0, v)))) return enc(r), enc(g), enc(b) def quality(channels: dict[str, float], dark: dict | None = None) -> tuple[bool, str]: """Kas mõõtmine on kasutatav (pime / küllastunud / puuduv kanal).""" for name, _, _ in CHANNELS: if channels.get(name) is None: return False, "puuduv kanal" d = dark or {} clear = channels.get("clear") dclear = float(d.get("clear") or 0.0) if clear is not None and float(clear) < max(dclear * 3.0, dclear + 20.0): return False, "liiga tume" if clear is not None and float(clear) >= SATURATION: return False, "küllastunud" for name, _, _ in CHANNELS: if float(channels.get(name) or 0.0) >= SATURATION: return False, "küllastunud" return True, "" def analyze(channels: dict[str, float], dark: dict | None = None, white: dict | None = None, target_y: float = 1.0) -> dict: """Tagastab tuletatud värviväljad. dark = tumereferents (lahutatakse) white = valgereferents; kui antud, arvutatakse reflekteeruvus (S-D)/(W-D) """ valid, reason = quality(channels, dark) vec: dict[str, float] = {} for name, _, _ in CHANNELS: v = float(channels.get(name) or 0.0) dv = float((dark or {}).get(name) or 0.0) v -= dv if white is not None: wv = float(white.get(name) or 0.0) - dv v = (v / wv) if wv > 1e-6 else 0.0 else: v = v / 65535.0 vec[name] = max(0.0, min(v, 1.5)) x, y, z = channels_to_xyz(vec) cx, cy = chromaticity_xy(x, y, z) r, g, b = _xyz_to_srgb8(x, y, z) return { "x_chrom": round(cx, 5), "y_chrom": round(cy, 5), "r": r, "g": g, "b": b, "rgb_hex": f"#{r:02X}{g:02X}{b:02X}", "valid": valid, "reason": reason, }