| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 |
- """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,
- }
|