color_utils.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. """Color comparison utilities for RFID/firmware color matching."""
  2. import math
  3. def colors_similar(hex_a: str, hex_b: str, threshold: int = 50) -> bool:
  4. """Compare two RRGGBB(AA) hex colors with tolerance for RFID/firmware variations.
  5. Uses Euclidean RGB distance. Alpha channel (bytes 7-8) is ignored.
  6. Default threshold of 50 accommodates typical RFID read variations
  7. (e.g. 7CC4D5 vs 56B7E6 = distance ~43.6) while rejecting clearly
  8. different colors (e.g. red vs blue = distance ~360).
  9. """
  10. a = hex_a.strip().upper()
  11. b = hex_b.strip().upper()
  12. if a == b:
  13. return True
  14. if len(a) < 6 or len(b) < 6:
  15. return False
  16. try:
  17. ra, ga, ba = int(a[0:2], 16), int(a[2:4], 16), int(a[4:6], 16)
  18. rb, gb, bb = int(b[0:2], 16), int(b[2:4], 16), int(b[4:6], 16)
  19. except ValueError:
  20. return False
  21. dist = ((ra - rb) ** 2 + (ga - gb) ** 2 + (ba - bb) ** 2) ** 0.5
  22. return dist <= threshold
  23. # --- Perceptual colour difference (CIEDE2000) ---------------------------------
  24. #
  25. # Ranking spools by RGB distance rates a colour by how far apart the numbers
  26. # are, which is not how far apart they look: RGB overweights blue badly, so a
  27. # required green could take a purple over a green that was numerically further
  28. # away. CIEDE2000 is the CIE's perceptual metric, and small differences — which
  29. # is all this ever sees, since candidates are already inside a narrow tolerance
  30. # — are exactly the regime its predecessors handle worst.
  31. #
  32. # Mirrored in `frontend/src/utils/amsHelpers.ts` (`colorDistance`). The two must
  33. # agree: the dialog must not promise a spool the scheduler would not pick.
  34. _D65_WHITE = (0.95047, 1.0, 1.08883)
  35. _DELTA = 6.0 / 29.0
  36. def _hex_to_lab(hex_color: str) -> tuple[float, float, float] | None:
  37. """Convert ``RRGGBB(AA)`` to CIE L*a*b* under D65, or None if unusable.
  38. Alpha is ignored: the alpha a slicer writes for a transparent filament is
  39. not a colour the user chose, and counting it would stop a transparent
  40. filament matching itself.
  41. """
  42. cleaned = hex_color.replace("#", "").strip().lower()
  43. if len(cleaned) < 6:
  44. return None
  45. try:
  46. channels = [int(cleaned[i : i + 2], 16) / 255.0 for i in (0, 2, 4)]
  47. except ValueError:
  48. return None
  49. # sRGB gamma -> linear light.
  50. r, g, b = (c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in channels)
  51. x = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b
  52. y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b
  53. z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b
  54. def f(t: float) -> float:
  55. return t ** (1.0 / 3.0) if t > _DELTA**3 else t / (3 * _DELTA * _DELTA) + 4.0 / 29.0
  56. fx, fy, fz = (f(v / w) for v, w in zip((x, y, z), _D65_WHITE, strict=True))
  57. return 116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)
  58. def _ciede2000(lab1: tuple[float, float, float], lab2: tuple[float, float, float]) -> float:
  59. """CIEDE2000 colour difference between two L*a*b* triples.
  60. Straight transcription of the CIE formulation, with the parametric weights
  61. kL = kC = kH = 1. Verified against the Sharma/Wu/Dalal published test set,
  62. including the hue-discontinuity pairs that catch sign errors.
  63. """
  64. l1, a1, b1 = lab1
  65. l2, a2, b2 = lab2
  66. c1 = math.hypot(a1, b1)
  67. c2 = math.hypot(a2, b2)
  68. c_bar7 = ((c1 + c2) / 2.0) ** 7
  69. g = 0.5 * (1.0 - math.sqrt(c_bar7 / (c_bar7 + 25.0**7)))
  70. a1p = (1.0 + g) * a1
  71. a2p = (1.0 + g) * a2
  72. c1p = math.hypot(a1p, b1)
  73. c2p = math.hypot(a2p, b2)
  74. def hue(ap: float, bp: float) -> float:
  75. if ap == 0.0 and bp == 0.0:
  76. return 0.0
  77. deg = math.degrees(math.atan2(bp, ap))
  78. return deg + 360.0 if deg < 0 else deg
  79. h1p = hue(a1p, b1)
  80. h2p = hue(a2p, b2)
  81. dlp = l2 - l1
  82. dcp = c2p - c1p
  83. chroma_product = c1p * c2p
  84. if chroma_product == 0.0:
  85. dhp = 0.0
  86. else:
  87. dhp = h2p - h1p
  88. if dhp > 180.0:
  89. dhp -= 360.0
  90. elif dhp < -180.0:
  91. dhp += 360.0
  92. dhp_big = 2.0 * math.sqrt(chroma_product) * math.sin(math.radians(dhp) / 2.0)
  93. l_bar = (l1 + l2) / 2.0
  94. c_bar = (c1p + c2p) / 2.0
  95. if chroma_product == 0.0:
  96. h_bar = h1p + h2p
  97. elif abs(h1p - h2p) <= 180.0:
  98. h_bar = (h1p + h2p) / 2.0
  99. elif h1p + h2p < 360.0:
  100. h_bar = (h1p + h2p + 360.0) / 2.0
  101. else:
  102. h_bar = (h1p + h2p - 360.0) / 2.0
  103. t = (
  104. 1.0
  105. - 0.17 * math.cos(math.radians(h_bar - 30.0))
  106. + 0.24 * math.cos(math.radians(2.0 * h_bar))
  107. + 0.32 * math.cos(math.radians(3.0 * h_bar + 6.0))
  108. - 0.20 * math.cos(math.radians(4.0 * h_bar - 63.0))
  109. )
  110. c_bar_p7 = c_bar**7
  111. rc = 2.0 * math.sqrt(c_bar_p7 / (c_bar_p7 + 25.0**7))
  112. sl = 1.0 + (0.015 * (l_bar - 50.0) ** 2) / math.sqrt(20.0 + (l_bar - 50.0) ** 2)
  113. sc = 1.0 + 0.045 * c_bar
  114. sh = 1.0 + 0.015 * c_bar * t
  115. rt = -math.sin(math.radians(2.0 * (30.0 * math.exp(-(((h_bar - 275.0) / 25.0) ** 2))))) * rc
  116. dl_term = dlp / sl
  117. dc_term = dcp / sc
  118. dh_term = dhp_big / sh
  119. return math.sqrt(dl_term**2 + dc_term**2 + dh_term**2 + rt * dc_term * dh_term)
  120. def perceptual_color_distance(color1: str | None, color2: str | None) -> float | None:
  121. """Perceptual distance between two hex colours, or None if either is unusable.
  122. Returns a CIEDE2000 delta-E: ~1.0 is the threshold of a just-noticeable
  123. difference, so the numbers are far smaller than the RGB distances they
  124. replaced and cannot be compared against an RGB threshold.
  125. """
  126. if not color1 or not color2:
  127. return None
  128. lab1 = _hex_to_lab(color1)
  129. lab2 = _hex_to_lab(color2)
  130. if lab1 is None or lab2 is None:
  131. return None
  132. return _ciede2000(lab1, lab2)