color.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. """AS7341 spektri -> ligikaudne värvus (CIE 1931 XYZ -> sRGB), v2.
  2. Erinevused v1-st:
  3. * tumekorrutis (dark) ja valge referents (white) on sisseehitatud;
  4. * kanalid integreeritakse CMF-iga koos kanali ribalaiusega (FWHM), mitte
  5. delta-funktsioonina;
  6. * valge normaliseeritakse D65-le, värviesitus säilitab heleduse ja hallid;
  7. * tulemus sisaldab kehtivuslippu (pime / küllastunud / puuduv kanal).
  8. """
  9. from __future__ import annotations
  10. import math
  11. # AS7341 kanalid: (nimi, kesk-lainepikkus nm, FWHM nm)
  12. CHANNELS = (
  13. ("f1", 415.0, 26.0),
  14. ("f2", 445.0, 30.0),
  15. ("f3", 480.0, 36.0),
  16. ("f4", 515.0, 39.0),
  17. ("f5", 555.0, 39.0),
  18. ("f6", 590.0, 40.0),
  19. ("f7", 630.0, 52.0),
  20. ("f8", 680.0, 122.0),
  21. )
  22. CHANNEL_WAVELENGTHS: dict[str, int] = {n: int(w) for n, w, _ in CHANNELS}
  23. FWHM: dict[str, float] = {n: f for n, _, f in CHANNELS}
  24. # CIE D65 suhteline spektraaljaotus kanalite keskkohtadel (560 nm = 100)
  25. 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}
  26. D65_WHITE = (0.95047, 1.0, 1.08883)
  27. SATURATION = 65000.0
  28. def _piecewise_gaussian(x: float, mu: float, sigma1: float, sigma2: float) -> float:
  29. sigma = sigma1 if x < mu else sigma2
  30. return math.exp(-0.5 * ((x - mu) / sigma) ** 2)
  31. def cie_xyz_cmf(wavelength_nm: float) -> tuple[float, float, float]:
  32. """Wyman et al. (2013) lähendus CIE 1931 2° värvusfunktsioonidele."""
  33. x_bar = (
  34. 1.056 * _piecewise_gaussian(wavelength_nm, 599.8, 37.9, 31.0)
  35. + 0.362 * _piecewise_gaussian(wavelength_nm, 442.0, 16.0, 26.7)
  36. - 0.065 * _piecewise_gaussian(wavelength_nm, 501.1, 20.4, 26.2)
  37. )
  38. y_bar = (
  39. 0.821 * _piecewise_gaussian(wavelength_nm, 568.8, 46.9, 40.5)
  40. + 0.286 * _piecewise_gaussian(wavelength_nm, 530.9, 16.3, 31.1)
  41. )
  42. z_bar = (
  43. 1.217 * _piecewise_gaussian(wavelength_nm, 437.0, 11.8, 36.0)
  44. + 0.681 * _piecewise_gaussian(wavelength_nm, 459.0, 26.0, 13.8)
  45. )
  46. return x_bar, y_bar, z_bar
  47. def _weights() -> dict[str, tuple[float, float, float]]:
  48. out: dict[str, tuple[float, float, float]] = {}
  49. for name, wl, fwhm in CHANNELS:
  50. cx, cy, cz = cie_xyz_cmf(wl)
  51. k = (D65_SPD[wl] / 100.0) * (fwhm / 100.0)
  52. out[name] = (cx * k, cy * k, cz * k)
  53. return out
  54. _W = _weights()
  55. # Valge (kõik kanalid = 1) annab D65 valge: normaliseerime Y = 1.
  56. _W_Y = sum(v[1] for v in _W.values())
  57. def _white_raw() -> tuple[float, float, float]:
  58. x = sum(v[0] for v in _W.values()) / _W_Y
  59. y = 1.0
  60. z = sum(v[2] for v in _W.values()) / _W_Y
  61. return x, y, z
  62. _WHITE_RAW = _white_raw()
  63. _WB_X = D65_WHITE[0] / _WHITE_RAW[0] if _WHITE_RAW[0] else 1.0
  64. _WB_Z = D65_WHITE[2] / _WHITE_RAW[2] if _WHITE_RAW[2] else 1.0
  65. def channels_to_xyz(channels: dict[str, float]) -> tuple[float, float, float]:
  66. x = y = z = 0.0
  67. for name, (wx, wy, wz) in _W.items():
  68. value = channels.get(name)
  69. if value is None:
  70. continue
  71. x += value * wx
  72. y += value * wy
  73. z += value * wz
  74. # Kvadratuuri nihe: valge (kõik 1) peab olema täpselt D65.
  75. return (x / _W_Y) * _WB_X, y / _W_Y, (z / _W_Y) * _WB_Z
  76. def chromaticity_xy(x: float, y: float, z: float) -> tuple[float, float]:
  77. total = x + y + z
  78. if total <= 1e-12:
  79. return 0.0, 0.0
  80. return x / total, y / total
  81. def _xyz_to_srgb8(x: float, y: float, z: float) -> tuple[int, int, int]:
  82. """XYZ -> sRGB 8-bit. Y on absoluutne heledus (0..1 = täisskaala)."""
  83. if y <= 1e-9:
  84. return 0, 0, 0
  85. r = 3.2406 * x - 1.5372 * y - 0.4986 * z
  86. g = -0.9689 * x + 1.8758 * y + 0.0415 * z
  87. b = 0.0557 * x - 0.2040 * y + 1.0570 * z
  88. # Gamut: negatiivsed komponendid -> lisa kõigile (desatureeri valge suunas)
  89. m = min(r, g, b)
  90. if m < 0:
  91. r, g, b = r - m, g - m, b - m
  92. # Kui üle 1, skaleeri (heledus väheneb, toon jääb)
  93. mx = max(r, g, b)
  94. if mx > 1.0:
  95. r, g, b = r / mx, g / mx, b / mx
  96. def enc(c: float) -> int:
  97. c = max(0.0, min(1.0, c))
  98. v = 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
  99. return int(round(255 * max(0.0, min(1.0, v))))
  100. return enc(r), enc(g), enc(b)
  101. def quality(channels: dict[str, float], dark: dict | None = None) -> tuple[bool, str]:
  102. """Kas mõõtmine on kasutatav (pime / küllastunud / puuduv kanal)."""
  103. for name, _, _ in CHANNELS:
  104. if channels.get(name) is None:
  105. return False, "puuduv kanal"
  106. d = dark or {}
  107. clear = channels.get("clear")
  108. dclear = float(d.get("clear") or 0.0)
  109. if clear is not None and float(clear) < max(dclear * 3.0, dclear + 20.0):
  110. return False, "liiga tume"
  111. if clear is not None and float(clear) >= SATURATION:
  112. return False, "küllastunud"
  113. for name, _, _ in CHANNELS:
  114. if float(channels.get(name) or 0.0) >= SATURATION:
  115. return False, "küllastunud"
  116. return True, ""
  117. def analyze(channels: dict[str, float], dark: dict | None = None, white: dict | None = None,
  118. target_y: float = 1.0) -> dict:
  119. """Tagastab tuletatud värviväljad.
  120. dark = tumereferents (lahutatakse)
  121. white = valgereferents; kui antud, arvutatakse reflekteeruvus (S-D)/(W-D)
  122. """
  123. valid, reason = quality(channels, dark)
  124. vec: dict[str, float] = {}
  125. for name, _, _ in CHANNELS:
  126. v = float(channels.get(name) or 0.0)
  127. dv = float((dark or {}).get(name) or 0.0)
  128. v -= dv
  129. if white is not None:
  130. wv = float(white.get(name) or 0.0) - dv
  131. v = (v / wv) if wv > 1e-6 else 0.0
  132. else:
  133. v = v / 65535.0
  134. vec[name] = max(0.0, min(v, 1.5))
  135. x, y, z = channels_to_xyz(vec)
  136. cx, cy = chromaticity_xy(x, y, z)
  137. r, g, b = _xyz_to_srgb8(x, y, z)
  138. return {
  139. "x_chrom": round(cx, 5),
  140. "y_chrom": round(cy, 5),
  141. "r": r,
  142. "g": g,
  143. "b": b,
  144. "rgb_hex": f"#{r:02X}{g:02X}{b:02X}",
  145. "valid": valid,
  146. "reason": reason,
  147. }