color_utils.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. """Color comparison utilities for RFID/firmware color matching."""
  2. import math
  3. # Alpha byte that means "fully opaque". Bambu's firmware reports every opaque
  4. # spool as RRGGBBFF, so this is the overwhelmingly common value.
  5. _OPAQUE_ALPHA = "FF"
  6. def spoolman_color_hex(rgba: str | None) -> str | None:
  7. """Normalise an RRGGBB(AA) value to what Spoolman's ``color_hex`` should hold.
  8. Eight characters only when the spool is genuinely translucent. Bambuddy used
  9. to truncate to six unconditionally, which turned a clear spool's ``00000000``
  10. into opaque black (#2912); passing everything through instead would rewrite
  11. the ``color_hex`` of every opaque spool on its next touch, churning records in
  12. people's Spoolman for no benefit. Keeping the opaque case at six characters
  13. leaves existing data byte-identical.
  14. Returns ``None`` for a missing value. A value shorter than six characters is
  15. passed through unchanged so a malformed colour is not reshaped into something
  16. that looks valid; a value between six and eight is truncated to six, which is
  17. what the pre-#2912 behaviour did and what the six-character path still means.
  18. Neither is reachable through ``_validate_rgba``, which admits only 6 or 8.
  19. """
  20. if not rgba:
  21. return None
  22. clean = rgba.strip().removeprefix("#").upper()
  23. if len(clean) < 6:
  24. return clean or None
  25. if len(clean) >= 8 and clean[6:8] != _OPAQUE_ALPHA:
  26. return clean[:8]
  27. return clean[:6]
  28. def color_match_key(color_hex: str | None) -> str:
  29. """Return the key two colours are compared on: **the shape they would be stored as**.
  30. Deliberately the same rule as :func:`spoolman_color_hex`, so two colours match
  31. exactly when storing them would produce the same value. That settles both
  32. directions of the alpha question at once (#2912):
  33. ============== ============ ==================================================
  34. value key consequence
  35. ============== ============ ==================================================
  36. ``000000`` ``000000`` existing six-character data
  37. ``000000FF`` ``000000`` still matches it — the upgrade guard, without which
  38. the next AMS sync mints a duplicate filament for
  39. every spool on the instance
  40. ``00000000`` ``00000000`` a clear spool gets its own filament and is never
  41. conflated with the black one, in either direction
  42. ============== ============ ==================================================
  43. Returns ``""`` rather than ``None`` for a missing value so callers can compare
  44. without guarding, which is the only reason this is not simply an alias.
  45. """
  46. return spoolman_color_hex(color_hex) or ""
  47. def colors_similar(hex_a: str, hex_b: str, threshold: int = 50) -> bool:
  48. """Compare two RRGGBB(AA) hex colors with tolerance for RFID/firmware variations.
  49. Uses Euclidean RGB distance. Alpha channel (bytes 7-8) is ignored.
  50. Default threshold of 50 accommodates typical RFID read variations
  51. (e.g. 7CC4D5 vs 56B7E6 = distance ~43.6) while rejecting clearly
  52. different colors (e.g. red vs blue = distance ~360).
  53. """
  54. a = hex_a.strip().upper()
  55. b = hex_b.strip().upper()
  56. if a == b:
  57. return True
  58. if len(a) < 6 or len(b) < 6:
  59. return False
  60. try:
  61. ra, ga, ba = int(a[0:2], 16), int(a[2:4], 16), int(a[4:6], 16)
  62. rb, gb, bb = int(b[0:2], 16), int(b[2:4], 16), int(b[4:6], 16)
  63. except ValueError:
  64. return False
  65. dist = ((ra - rb) ** 2 + (ga - gb) ** 2 + (ba - bb) ** 2) ** 0.5
  66. return dist <= threshold
  67. # --- Perceptual colour difference (CIEDE2000) ---------------------------------
  68. #
  69. # Ranking spools by RGB distance rates a colour by how far apart the numbers
  70. # are, which is not how far apart they look: RGB overweights blue badly, so a
  71. # required green could take a purple over a green that was numerically further
  72. # away. CIEDE2000 is the CIE's perceptual metric, and small differences — which
  73. # is all this ever sees, since candidates are already inside a narrow tolerance
  74. # — are exactly the regime its predecessors handle worst.
  75. #
  76. # Mirrored in `frontend/src/utils/amsHelpers.ts` (`colorDistance`). The two must
  77. # agree: the dialog must not promise a spool the scheduler would not pick.
  78. _D65_WHITE = (0.95047, 1.0, 1.08883)
  79. _DELTA = 6.0 / 29.0
  80. def _hex_to_lab(hex_color: str) -> tuple[float, float, float] | None:
  81. """Convert ``RRGGBB(AA)`` to CIE L*a*b* under D65, or None if unusable.
  82. Alpha is ignored: the alpha a slicer writes for a transparent filament is
  83. not a colour the user chose, and counting it would stop a transparent
  84. filament matching itself.
  85. """
  86. cleaned = hex_color.replace("#", "").strip().lower()
  87. if len(cleaned) < 6:
  88. return None
  89. try:
  90. channels = [int(cleaned[i : i + 2], 16) / 255.0 for i in (0, 2, 4)]
  91. except ValueError:
  92. return None
  93. # sRGB gamma -> linear light.
  94. r, g, b = (c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in channels)
  95. x = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b
  96. y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b
  97. z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b
  98. def f(t: float) -> float:
  99. return t ** (1.0 / 3.0) if t > _DELTA**3 else t / (3 * _DELTA * _DELTA) + 4.0 / 29.0
  100. fx, fy, fz = (f(v / w) for v, w in zip((x, y, z), _D65_WHITE, strict=True))
  101. return 116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)
  102. def _ciede2000(lab1: tuple[float, float, float], lab2: tuple[float, float, float]) -> float:
  103. """CIEDE2000 colour difference between two L*a*b* triples.
  104. Straight transcription of the CIE formulation, with the parametric weights
  105. kL = kC = kH = 1. Verified against the Sharma/Wu/Dalal published test set,
  106. including the hue-discontinuity pairs that catch sign errors.
  107. """
  108. l1, a1, b1 = lab1
  109. l2, a2, b2 = lab2
  110. c1 = math.hypot(a1, b1)
  111. c2 = math.hypot(a2, b2)
  112. c_bar7 = ((c1 + c2) / 2.0) ** 7
  113. g = 0.5 * (1.0 - math.sqrt(c_bar7 / (c_bar7 + 25.0**7)))
  114. a1p = (1.0 + g) * a1
  115. a2p = (1.0 + g) * a2
  116. c1p = math.hypot(a1p, b1)
  117. c2p = math.hypot(a2p, b2)
  118. def hue(ap: float, bp: float) -> float:
  119. if ap == 0.0 and bp == 0.0:
  120. return 0.0
  121. deg = math.degrees(math.atan2(bp, ap))
  122. return deg + 360.0 if deg < 0 else deg
  123. h1p = hue(a1p, b1)
  124. h2p = hue(a2p, b2)
  125. dlp = l2 - l1
  126. dcp = c2p - c1p
  127. chroma_product = c1p * c2p
  128. if chroma_product == 0.0:
  129. dhp = 0.0
  130. else:
  131. dhp = h2p - h1p
  132. if dhp > 180.0:
  133. dhp -= 360.0
  134. elif dhp < -180.0:
  135. dhp += 360.0
  136. dhp_big = 2.0 * math.sqrt(chroma_product) * math.sin(math.radians(dhp) / 2.0)
  137. l_bar = (l1 + l2) / 2.0
  138. c_bar = (c1p + c2p) / 2.0
  139. if chroma_product == 0.0:
  140. h_bar = h1p + h2p
  141. elif abs(h1p - h2p) <= 180.0:
  142. h_bar = (h1p + h2p) / 2.0
  143. elif h1p + h2p < 360.0:
  144. h_bar = (h1p + h2p + 360.0) / 2.0
  145. else:
  146. h_bar = (h1p + h2p - 360.0) / 2.0
  147. t = (
  148. 1.0
  149. - 0.17 * math.cos(math.radians(h_bar - 30.0))
  150. + 0.24 * math.cos(math.radians(2.0 * h_bar))
  151. + 0.32 * math.cos(math.radians(3.0 * h_bar + 6.0))
  152. - 0.20 * math.cos(math.radians(4.0 * h_bar - 63.0))
  153. )
  154. c_bar_p7 = c_bar**7
  155. rc = 2.0 * math.sqrt(c_bar_p7 / (c_bar_p7 + 25.0**7))
  156. sl = 1.0 + (0.015 * (l_bar - 50.0) ** 2) / math.sqrt(20.0 + (l_bar - 50.0) ** 2)
  157. sc = 1.0 + 0.045 * c_bar
  158. sh = 1.0 + 0.015 * c_bar * t
  159. rt = -math.sin(math.radians(2.0 * (30.0 * math.exp(-(((h_bar - 275.0) / 25.0) ** 2))))) * rc
  160. dl_term = dlp / sl
  161. dc_term = dcp / sc
  162. dh_term = dhp_big / sh
  163. return math.sqrt(dl_term**2 + dc_term**2 + dh_term**2 + rt * dc_term * dh_term)
  164. def perceptual_color_distance(color1: str | None, color2: str | None) -> float | None:
  165. """Perceptual distance between two hex colours, or None if either is unusable.
  166. Returns a CIEDE2000 delta-E: ~1.0 is the threshold of a just-noticeable
  167. difference, so the numbers are far smaller than the RGB distances they
  168. replaced and cannot be compared against an RGB threshold.
  169. """
  170. if not color1 or not color2:
  171. return None
  172. lab1 = _hex_to_lab(color1)
  173. lab2 = _hex_to_lab(color2)
  174. if lab1 is None or lab2 is None:
  175. return None
  176. return _ciede2000(lab1, lab2)