"""Color comparison utilities for RFID/firmware color matching.""" import math # Alpha byte that means "fully opaque". Bambu's firmware reports every opaque # spool as RRGGBBFF, so this is the overwhelmingly common value. _OPAQUE_ALPHA = "FF" def spoolman_color_hex(rgba: str | None) -> str | None: """Normalise an RRGGBB(AA) value to what Spoolman's ``color_hex`` should hold. Eight characters only when the spool is genuinely translucent. Bambuddy used to truncate to six unconditionally, which turned a clear spool's ``00000000`` into opaque black (#2912); passing everything through instead would rewrite the ``color_hex`` of every opaque spool on its next touch, churning records in people's Spoolman for no benefit. Keeping the opaque case at six characters leaves existing data byte-identical. Returns ``None`` for a missing value. A value shorter than six characters is passed through unchanged so a malformed colour is not reshaped into something that looks valid; a value between six and eight is truncated to six, which is what the pre-#2912 behaviour did and what the six-character path still means. Neither is reachable through ``_validate_rgba``, which admits only 6 or 8. """ if not rgba: return None clean = rgba.strip().removeprefix("#").upper() if len(clean) < 6: return clean or None if len(clean) >= 8 and clean[6:8] != _OPAQUE_ALPHA: return clean[:8] return clean[:6] def color_match_key(color_hex: str | None) -> str: """Return the key two colours are compared on: **the shape they would be stored as**. Deliberately the same rule as :func:`spoolman_color_hex`, so two colours match exactly when storing them would produce the same value. That settles both directions of the alpha question at once (#2912): ============== ============ ================================================== value key consequence ============== ============ ================================================== ``000000`` ``000000`` existing six-character data ``000000FF`` ``000000`` still matches it — the upgrade guard, without which the next AMS sync mints a duplicate filament for every spool on the instance ``00000000`` ``00000000`` a clear spool gets its own filament and is never conflated with the black one, in either direction ============== ============ ================================================== Returns ``""`` rather than ``None`` for a missing value so callers can compare without guarding, which is the only reason this is not simply an alias. """ return spoolman_color_hex(color_hex) or "" def colors_similar(hex_a: str, hex_b: str, threshold: int = 50) -> bool: """Compare two RRGGBB(AA) hex colors with tolerance for RFID/firmware variations. Uses Euclidean RGB distance. Alpha channel (bytes 7-8) is ignored. Default threshold of 50 accommodates typical RFID read variations (e.g. 7CC4D5 vs 56B7E6 = distance ~43.6) while rejecting clearly different colors (e.g. red vs blue = distance ~360). """ a = hex_a.strip().upper() b = hex_b.strip().upper() if a == b: return True if len(a) < 6 or len(b) < 6: return False try: ra, ga, ba = int(a[0:2], 16), int(a[2:4], 16), int(a[4:6], 16) rb, gb, bb = int(b[0:2], 16), int(b[2:4], 16), int(b[4:6], 16) except ValueError: return False dist = ((ra - rb) ** 2 + (ga - gb) ** 2 + (ba - bb) ** 2) ** 0.5 return dist <= threshold # --- Perceptual colour difference (CIEDE2000) --------------------------------- # # Ranking spools by RGB distance rates a colour by how far apart the numbers # are, which is not how far apart they look: RGB overweights blue badly, so a # required green could take a purple over a green that was numerically further # away. CIEDE2000 is the CIE's perceptual metric, and small differences — which # is all this ever sees, since candidates are already inside a narrow tolerance # — are exactly the regime its predecessors handle worst. # # Mirrored in `frontend/src/utils/amsHelpers.ts` (`colorDistance`). The two must # agree: the dialog must not promise a spool the scheduler would not pick. _D65_WHITE = (0.95047, 1.0, 1.08883) _DELTA = 6.0 / 29.0 def _hex_to_lab(hex_color: str) -> tuple[float, float, float] | None: """Convert ``RRGGBB(AA)`` to CIE L*a*b* under D65, or None if unusable. Alpha is ignored: the alpha a slicer writes for a transparent filament is not a colour the user chose, and counting it would stop a transparent filament matching itself. """ cleaned = hex_color.replace("#", "").strip().lower() if len(cleaned) < 6: return None try: channels = [int(cleaned[i : i + 2], 16) / 255.0 for i in (0, 2, 4)] except ValueError: return None # sRGB gamma -> linear light. r, g, b = (c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in channels) x = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b def f(t: float) -> float: return t ** (1.0 / 3.0) if t > _DELTA**3 else t / (3 * _DELTA * _DELTA) + 4.0 / 29.0 fx, fy, fz = (f(v / w) for v, w in zip((x, y, z), _D65_WHITE, strict=True)) return 116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz) def _ciede2000(lab1: tuple[float, float, float], lab2: tuple[float, float, float]) -> float: """CIEDE2000 colour difference between two L*a*b* triples. Straight transcription of the CIE formulation, with the parametric weights kL = kC = kH = 1. Verified against the Sharma/Wu/Dalal published test set, including the hue-discontinuity pairs that catch sign errors. """ l1, a1, b1 = lab1 l2, a2, b2 = lab2 c1 = math.hypot(a1, b1) c2 = math.hypot(a2, b2) c_bar7 = ((c1 + c2) / 2.0) ** 7 g = 0.5 * (1.0 - math.sqrt(c_bar7 / (c_bar7 + 25.0**7))) a1p = (1.0 + g) * a1 a2p = (1.0 + g) * a2 c1p = math.hypot(a1p, b1) c2p = math.hypot(a2p, b2) def hue(ap: float, bp: float) -> float: if ap == 0.0 and bp == 0.0: return 0.0 deg = math.degrees(math.atan2(bp, ap)) return deg + 360.0 if deg < 0 else deg h1p = hue(a1p, b1) h2p = hue(a2p, b2) dlp = l2 - l1 dcp = c2p - c1p chroma_product = c1p * c2p if chroma_product == 0.0: dhp = 0.0 else: dhp = h2p - h1p if dhp > 180.0: dhp -= 360.0 elif dhp < -180.0: dhp += 360.0 dhp_big = 2.0 * math.sqrt(chroma_product) * math.sin(math.radians(dhp) / 2.0) l_bar = (l1 + l2) / 2.0 c_bar = (c1p + c2p) / 2.0 if chroma_product == 0.0: h_bar = h1p + h2p elif abs(h1p - h2p) <= 180.0: h_bar = (h1p + h2p) / 2.0 elif h1p + h2p < 360.0: h_bar = (h1p + h2p + 360.0) / 2.0 else: h_bar = (h1p + h2p - 360.0) / 2.0 t = ( 1.0 - 0.17 * math.cos(math.radians(h_bar - 30.0)) + 0.24 * math.cos(math.radians(2.0 * h_bar)) + 0.32 * math.cos(math.radians(3.0 * h_bar + 6.0)) - 0.20 * math.cos(math.radians(4.0 * h_bar - 63.0)) ) c_bar_p7 = c_bar**7 rc = 2.0 * math.sqrt(c_bar_p7 / (c_bar_p7 + 25.0**7)) sl = 1.0 + (0.015 * (l_bar - 50.0) ** 2) / math.sqrt(20.0 + (l_bar - 50.0) ** 2) sc = 1.0 + 0.045 * c_bar sh = 1.0 + 0.015 * c_bar * t rt = -math.sin(math.radians(2.0 * (30.0 * math.exp(-(((h_bar - 275.0) / 25.0) ** 2))))) * rc dl_term = dlp / sl dc_term = dcp / sc dh_term = dhp_big / sh return math.sqrt(dl_term**2 + dc_term**2 + dh_term**2 + rt * dc_term * dh_term) def perceptual_color_distance(color1: str | None, color2: str | None) -> float | None: """Perceptual distance between two hex colours, or None if either is unusable. Returns a CIEDE2000 delta-E: ~1.0 is the threshold of a just-noticeable difference, so the numbers are far smaller than the RGB distances they replaced and cannot be compared against an RGB threshold. """ if not color1 or not color2: return None lab1 = _hex_to_lab(color1) lab2 = _hex_to_lab(color2) if lab1 is None or lab2 is None: return None return _ciede2000(lab1, lab2)