color.py 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. """AS7341 spektri -> ligikaudne värvus (CIE 1931 XYZ -> sRGB).
  2. Kasutab Wyman, Sloan & Shirley (2013) analüütilist CIE 1931 2° CMF
  3. lähendust 8 kanali kesk lainepikkustel.
  4. NB! See EI ole kalibreeritud kolorimeetria — absoluutse värvuse jaoks on
  5. vaja valge referentsi ja fikseeritud valgustust (vt LOEMIND.md).
  6. """
  7. from __future__ import annotations
  8. import math
  9. # ESPHome as7341 kanalite F1..F8 kesk lainepikkused (nm)
  10. CHANNEL_WAVELENGTHS: dict[str, int] = {
  11. "f1": 415,
  12. "f2": 445,
  13. "f3": 480,
  14. "f4": 515,
  15. "f5": 555,
  16. "f6": 590,
  17. "f7": 630,
  18. "f8": 680,
  19. }
  20. def _piecewise_gaussian(x: float, mu: float, sigma1: float, sigma2: float) -> float:
  21. sigma = sigma1 if x < mu else sigma2
  22. return math.exp(-0.5 * ((x - mu) / sigma) ** 2)
  23. def cie_xyz_cmf(wavelength_nm: float) -> tuple[float, float, float]:
  24. """Wyman et al. (2013) lähendus CIE 1931 2° värvusfunktsioonidele."""
  25. x_bar = (
  26. 1.056 * _piecewise_gaussian(wavelength_nm, 599.8, 37.9, 31.0)
  27. + 0.362 * _piecewise_gaussian(wavelength_nm, 442.0, 16.0, 26.7)
  28. - 0.065 * _piecewise_gaussian(wavelength_nm, 501.1, 20.4, 26.2)
  29. )
  30. y_bar = (
  31. 0.821 * _piecewise_gaussian(wavelength_nm, 568.8, 46.9, 40.5)
  32. + 0.286 * _piecewise_gaussian(wavelength_nm, 530.9, 16.3, 31.1)
  33. )
  34. z_bar = (
  35. 1.217 * _piecewise_gaussian(wavelength_nm, 437.0, 11.8, 36.0)
  36. + 0.681 * _piecewise_gaussian(wavelength_nm, 459.0, 26.0, 13.8)
  37. )
  38. return x_bar, y_bar, z_bar
  39. def channels_to_xyz(channels: dict[str, float]) -> tuple[float, float, float]:
  40. """Ligikaudne XYZ: iga kanali väärtus kaalutakse CMF-iga kesk lainepikkusel."""
  41. x = y = z = 0.0
  42. for name, wavelength in CHANNEL_WAVELENGTHS.items():
  43. value = channels.get(name)
  44. if value is None:
  45. continue
  46. cx, cy, cz = cie_xyz_cmf(wavelength)
  47. x += value * cx
  48. y += value * cy
  49. z += value * cz
  50. return x, y, z
  51. def xyz_to_srgb(x: float, y: float, z: float) -> tuple[int, int, int]:
  52. """XYZ -> lineaarne sRGB (D65) -> gamma -> 8-bit. Heledus normaliseeritakse."""
  53. r_lin = 3.2406 * x - 1.5372 * y - 0.4986 * z
  54. g_lin = -0.9689 * x + 1.8758 * y + 0.0415 * z
  55. b_lin = 0.0557 * x - 0.2040 * y + 1.0570 * z
  56. def gamma(c: float) -> float:
  57. c = max(0.0, c)
  58. return 12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055
  59. r, g, b = gamma(r_lin), gamma(g_lin), gamma(b_lin)
  60. scale = max(r, g, b, 1e-9)
  61. return (
  62. int(round(255 * r / scale)),
  63. int(round(255 * g / scale)),
  64. int(round(255 * b / scale)),
  65. )
  66. def chromaticity_xy(x: float, y: float, z: float) -> tuple[float, float]:
  67. total = x + y + z
  68. if total <= 0:
  69. return 0.0, 0.0
  70. return x / total, y / total
  71. def analyze(channels: dict[str, float]) -> dict:
  72. """Tagastab tuletatud värviväljad ClickHouse'i rea jaoks."""
  73. x, y, z = channels_to_xyz(channels)
  74. cx, cy = chromaticity_xy(x, y, z)
  75. r, g, b = xyz_to_srgb(x, y, z)
  76. return {
  77. "x_chrom": round(cx, 5),
  78. "y_chrom": round(cy, 5),
  79. "r": r,
  80. "g": g,
  81. "b": b,
  82. "rgb_hex": f"#{r:02X}{g:02X}{b:02X}",
  83. }