mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Rank near-colour matches by how they look, and share one filament type table (#2804)
Three follow-ups to #2804, all bearing on one decision: which spool a print uses when the exact colour is not loaded. Colour ranking is now perceptual. The ranking added in #2804 measured RGB distance, which rates a colour by how far apart the numbers are rather than how far apart they look, and it overweights blue badly enough to invert the answer: against a required #1E4821 green, a purple #38202F is the nearer of two eligible spools by RGB and four times the further once measured properly. Both sides now use CIEDE2000 -- perceptual_color_distance in backend/app/utils/color_utils.py and colorDistance in amsHelpers.ts, kept structurally identical so they can be read side by side. Verified against the Sharma/Wu/Dalal published reference set, all 31 pairs to 1e-4, and the two implementations agree to within 1e-9 across 800 sampled pairs. Eligibility is untouched, still the per-channel RGB box, so this only reorders spools that already qualified. Type matching now agrees between the interface and the scheduler. Bambu firmware treats PA-CF, PA12-CF and PAHT-CF as one material and the scheduler has always matched them accordingly, but the interface compared raw type strings and called that same pairing a mismatch. The badge contradicted what the printer was about to do, and the manual override picker, which groups by canonical type, offered the very spool the badge then rejected. The fifteen comparison sites in useFilamentMapping.ts, useMultiPrinterFilamentMapping.ts and PrinterSelector.tsx now call filamentTypesCompatible. The pipeline pre-flight reads the matcher's table instead of its own copy. That copy had drifted into disagreeing in both directions: it aliased PLA Basic to PLA where the matcher never has, so a run could clear the check and then fail to map its slots, and it lacked the nylon grouping, so it flagged runs the matcher handles without complaint. A check whose job is to predict dispatch is wrong whenever it disagrees with dispatch, whichever way it leans, so it and the scheduler now both read backend/app/utils/filament_types.py. That canonicaliser deliberately does not strip surrounding whitespace. It looks like a free improvement, but it would collapse a junk tray_type to "" just as a 3MF declaring no filament type yields "", and a typeless requirement would start matching a junk-typed tray instead of reporting the slot unmapped. Padded type strings are worth handling on their own terms, with that case addressed. One behaviour change outside the ranking: the pre-flight is stricter for a printer reporting a product name such as "PLA Basic" where the generic material belongs, which it now flags rather than passes. Rare in practice, since the printer reports material and product name in separate fields, and it is the answer the matcher would give. Nothing about which spool a print actually uses changed outside the colour ranking itself. Adds 203 backend and 6 frontend tests. The #2804 tie-break test now uses identical colours: two colours at equal RGB distance are not perceptually tied, which is rather the point.
This commit is contained in:
@@ -36,6 +36,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from backend.app.models.local_preset import LocalPreset
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.slicer_pipeline import SlicerPipeline
|
||||
from backend.app.utils.filament_types import canonical_filament_type
|
||||
|
||||
IssueKind = Literal[
|
||||
"printer_not_set",
|
||||
@@ -80,36 +81,13 @@ class EligibilityReport:
|
||||
printer_reports: tuple[PerPrinterReport, ...] = ()
|
||||
|
||||
|
||||
# Same equivalence map as print_scheduler._canonical_filament_type but kept
|
||||
# local so this module has no upward dependency on the scheduler. Mirrors the
|
||||
# scheduler's behaviour: BBL-prefixed product names normalise to the base type
|
||||
# (e.g. "PLA Basic" → "PLA"). When the scheduler's map gets a new alias, this
|
||||
# one needs the same entry.
|
||||
_FILAMENT_EQUIV_MAP = {
|
||||
"PLA": "PLA",
|
||||
"PLA BASIC": "PLA",
|
||||
"PLA MATTE": "PLA",
|
||||
"PLA SILK": "PLA",
|
||||
"PLA PRO": "PLA",
|
||||
"PLA TOUGH": "PLA",
|
||||
"PETG": "PETG",
|
||||
"PETG HF": "PETG",
|
||||
"PETG BASIC": "PETG",
|
||||
"PETG TRANSLUCENT": "PETG",
|
||||
"ABS": "ABS",
|
||||
"ASA": "ASA",
|
||||
"TPU": "TPU",
|
||||
"TPU 95A": "TPU",
|
||||
"PC": "PC",
|
||||
"PA": "PA",
|
||||
"PA-CF": "PA",
|
||||
"PVA": "PVA",
|
||||
}
|
||||
|
||||
|
||||
def _canonical(ftype: str) -> str:
|
||||
upper = (ftype or "").strip().upper()
|
||||
return _FILAMENT_EQUIV_MAP.get(upper, upper)
|
||||
# This module's whole job is to predict what the dispatch matcher will do, so
|
||||
# it reads type equivalence from the same table the matcher does rather than
|
||||
# keeping a copy. The copy it used to keep had drifted into disagreeing in both
|
||||
# directions — it aliased "PLA Basic" to "PLA" where the matcher does not, so a
|
||||
# job could pass here and then fail on type; and it lacked the PA12-CF/PAHT-CF
|
||||
# grouping the matcher has, so a job the matcher handles fine was flagged.
|
||||
_canonical = canonical_filament_type
|
||||
|
||||
|
||||
def _normalise_colour(colour: str | None) -> str:
|
||||
|
||||
@@ -55,6 +55,8 @@ from backend.app.services.printer_manager import (
|
||||
supports_drying_while_printing,
|
||||
)
|
||||
from backend.app.services.smart_plug_manager import smart_plug_manager
|
||||
from backend.app.utils.color_utils import perceptual_color_distance
|
||||
from backend.app.utils.filament_types import canonical_filament_type
|
||||
from backend.app.utils.filename import derive_remote_filename
|
||||
from backend.app.utils.printer_models import (
|
||||
is_gcode_compatible,
|
||||
@@ -227,23 +229,6 @@ _ACTIVE_PRINT_STATES: frozenset[str] = frozenset({"PREPARE", "SLICING", "RUNNING
|
||||
# force-reconnect on the very next attempt — while still bounding the loop.
|
||||
DISPATCH_MAX_ATTEMPTS = 3
|
||||
|
||||
# Filament type equivalence groups — types within the same group are
|
||||
# interchangeable on the printer side (Bambu Lab firmware treats them as compatible).
|
||||
_FILAMENT_TYPE_GROUPS: list[list[str]] = [
|
||||
["PA-CF", "PA12-CF", "PAHT-CF"],
|
||||
]
|
||||
_FILAMENT_EQUIV_MAP: dict[str, str] = {}
|
||||
for _group in _FILAMENT_TYPE_GROUPS:
|
||||
_canonical = _group[0].upper()
|
||||
for _t in _group:
|
||||
_FILAMENT_EQUIV_MAP[_t.upper()] = _canonical
|
||||
|
||||
|
||||
def _canonical_filament_type(ftype: str) -> str:
|
||||
"""Return canonical type for equivalence matching."""
|
||||
upper = ftype.upper()
|
||||
return _FILAMENT_EQUIV_MAP.get(upper, upper)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ModelCandidate:
|
||||
@@ -2141,18 +2126,16 @@ class PrintScheduler:
|
||||
tray_type = tray.get("tray_type")
|
||||
if tray_type:
|
||||
color_norm = (tray.get("tray_color", "") or "").replace("#", "").lower()[:6]
|
||||
loaded.append(
|
||||
(_canonical_filament_type(tray_type), color_norm, tray.get("tray_info_idx", "") or "")
|
||||
)
|
||||
loaded.append((canonical_filament_type(tray_type), color_norm, tray.get("tray_info_idx", "") or ""))
|
||||
for vt in status.raw_data.get("vt_tray") or []:
|
||||
vt_type = vt.get("tray_type")
|
||||
if vt_type:
|
||||
color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
|
||||
loaded.append((_canonical_filament_type(vt_type), color_norm, vt.get("tray_info_idx", "") or ""))
|
||||
loaded.append((canonical_filament_type(vt_type), color_norm, vt.get("tray_info_idx", "") or ""))
|
||||
|
||||
missing = []
|
||||
for o in force_overrides:
|
||||
o_type = _canonical_filament_type(o.get("type") or "")
|
||||
o_type = canonical_filament_type(o.get("type") or "")
|
||||
o_color = (o.get("color") or "").replace("#", "").lower()[:6]
|
||||
o_idx = o.get("tray_info_idx") or ""
|
||||
satisfied = any(
|
||||
@@ -2189,18 +2172,18 @@ class PrintScheduler:
|
||||
for tray in ams_unit.get("tray", []):
|
||||
tray_type = tray.get("tray_type")
|
||||
if tray_type:
|
||||
loaded_types.add(_canonical_filament_type(tray_type))
|
||||
loaded_types.add(canonical_filament_type(tray_type))
|
||||
|
||||
# Check external spool(s) (virtual tray, stored in raw_data["vt_tray"] as list)
|
||||
for vt in status.raw_data.get("vt_tray") or []:
|
||||
vt_type = vt.get("tray_type")
|
||||
if vt_type:
|
||||
loaded_types.add(_canonical_filament_type(vt_type))
|
||||
loaded_types.add(canonical_filament_type(vt_type))
|
||||
|
||||
# Find which required types are missing (using canonical type for equivalence)
|
||||
missing = []
|
||||
for req_type in required_types:
|
||||
if _canonical_filament_type(req_type) not in loaded_types:
|
||||
if canonical_filament_type(req_type) not in loaded_types:
|
||||
missing.append(req_type)
|
||||
|
||||
return missing
|
||||
@@ -2741,29 +2724,24 @@ class PrintScheduler:
|
||||
return color.replace("#", "").lower()[:6]
|
||||
|
||||
def _color_distance(self, color1: str | None, color2: str | None) -> float | None:
|
||||
"""Euclidean RGB distance, or None when either colour is unusable.
|
||||
"""Perceptual (CIEDE2000) distance, or None when either colour is unusable.
|
||||
|
||||
Ranks the candidates ``_colors_are_similar`` admits (#2804). Eligibility
|
||||
stays the per-channel box that shipped — this only decides which of
|
||||
several eligible spools is closest, so nothing becomes usable or
|
||||
unusable because of it.
|
||||
|
||||
Alpha is dropped by ``_normalize_color_for_compare``, deliberately: 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.
|
||||
It ranks by how far apart the colours *look*, not how far apart their
|
||||
numbers are. RGB distance overweights blue badly enough to invert the
|
||||
answer: against a required ``#1E4821`` green, a purple ``#38202F`` is
|
||||
the nearer of two eligible spools by RGB and the further by a factor of
|
||||
four once measured perceptually.
|
||||
|
||||
Alpha is ignored, deliberately: 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.
|
||||
"""
|
||||
hex1 = self._normalize_color_for_compare(color1)
|
||||
hex2 = self._normalize_color_for_compare(color2)
|
||||
if not hex1 or not hex2 or len(hex1) < 6 or len(hex2) < 6:
|
||||
return None
|
||||
try:
|
||||
dr = int(hex1[0:2], 16) - int(hex2[0:2], 16)
|
||||
dg = int(hex1[2:4], 16) - int(hex2[2:4], 16)
|
||||
db = int(hex1[4:6], 16) - int(hex2[4:6], 16)
|
||||
except ValueError:
|
||||
return None
|
||||
return (dr * dr + dg * dg + db * db) ** 0.5
|
||||
return perceptual_color_distance(color1, color2)
|
||||
|
||||
def _colors_are_similar(self, color1: str | None, color2: str | None, threshold: int = 40) -> bool:
|
||||
"""Check if two colors are visually similar within a threshold."""
|
||||
@@ -3055,7 +3033,7 @@ class PrintScheduler:
|
||||
if not idx_match and not exact_match and not similar_match and not type_only_match:
|
||||
for f in available:
|
||||
f_type = (f.get("type") or "").upper()
|
||||
if _canonical_filament_type(f_type) != _canonical_filament_type(req_type):
|
||||
if canonical_filament_type(f_type) != canonical_filament_type(req_type):
|
||||
continue
|
||||
|
||||
# Type matches - check color
|
||||
@@ -3104,7 +3082,7 @@ class PrintScheduler:
|
||||
match["global_tray_id"],
|
||||
bucket,
|
||||
req.get("slot_id"),
|
||||
f" (colour distance {similar_distance:.1f})" if bucket == "similar_color" else "",
|
||||
f" (deltaE {similar_distance:.2f})" if bucket == "similar_color" else "",
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Color comparison utilities for RFID/firmware color matching."""
|
||||
|
||||
import math
|
||||
|
||||
|
||||
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.
|
||||
@@ -22,3 +24,140 @@ def colors_similar(hex_a: str, hex_b: str, threshold: int = 50) -> bool:
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Filament type equivalence — one answer to "will this spool do for that
|
||||
requirement?", shared by everyone who asks it.
|
||||
|
||||
``print_scheduler`` decides that question at dispatch; ``pipeline_eligibility``
|
||||
predicts the same answer before the queue runs. They used to carry a copy each,
|
||||
and the copies drifted until they disagreed in both directions: eligibility
|
||||
passed jobs the matcher then failed on type (it aliased ``PLA Basic`` to
|
||||
``PLA``, the matcher did not) and flagged jobs the matcher handled fine (the
|
||||
matcher groups ``PA12-CF`` with ``PA-CF``, eligibility did not). A predictor
|
||||
that disagrees with the thing it predicts is wrong whichever way it leans, so
|
||||
both now read from here.
|
||||
|
||||
Deliberately *not* shared with ``PrintScheduler._normalize_filament_type``,
|
||||
which reduces a tray type to a drying-preset key. "Which drying profile?" is a
|
||||
genuinely different question with a different answer — PLA Silk dries like PLA
|
||||
but does not print like it — so folding those together would be the wrong kind
|
||||
of tidy.
|
||||
"""
|
||||
|
||||
# Types within a group are interchangeable on the printer side; Bambu Lab
|
||||
# firmware treats them as the same material. The first entry is canonical.
|
||||
#
|
||||
# Product variants are deliberately absent. "PLA Silk" is not substitutable
|
||||
# for "PLA Basic" the way PA12-CF is for PA-CF — different temperature, flow
|
||||
# and finish, so standing one in for the other hands back a print nobody asked
|
||||
# for. It rarely arises anyway: the printer reports the generic material in
|
||||
# ``tray_type`` and the product name in ``tray_sub_brands``, so what reaches
|
||||
# this function is a bare "PLA".
|
||||
FILAMENT_TYPE_GROUPS: list[list[str]] = [
|
||||
["PA-CF", "PA12-CF", "PAHT-CF"],
|
||||
]
|
||||
|
||||
_EQUIV_MAP: dict[str, str] = {}
|
||||
for _group in FILAMENT_TYPE_GROUPS:
|
||||
_canonical = _group[0].upper()
|
||||
for _type in _group:
|
||||
_EQUIV_MAP[_type.upper()] = _canonical
|
||||
|
||||
|
||||
def canonical_filament_type(ftype: str | None) -> str:
|
||||
"""Return the canonical type name used for equivalence matching.
|
||||
|
||||
Deliberately does *not* strip surrounding whitespace, so this is byte-for-byte
|
||||
the rule the dispatch matcher already applied, and the same one
|
||||
``canonicalFilamentType`` applies in ``frontend/src/utils/amsHelpers.ts``.
|
||||
|
||||
Stripping looks like an improvement — it would let a padded " PETG " match
|
||||
"PETG" — but it also collapses a whitespace-only ``tray_type`` to "", and a
|
||||
3MF whose filament element carries no ``type`` attribute yields "" as well
|
||||
(``filament_requirements``). The two would then compare equal, so a
|
||||
requirement with no declared type would start matching a tray whose type is
|
||||
junk, where today it correctly reports the slot unmapped. Handling padded
|
||||
types is worth doing on its own terms, with that case addressed; it is not
|
||||
worth smuggling in here.
|
||||
"""
|
||||
upper = (ftype or "").upper()
|
||||
return _EQUIV_MAP.get(upper, upper)
|
||||
|
||||
|
||||
def filament_types_compatible(a: str | None, b: str | None) -> bool:
|
||||
"""Whether two filament types may stand in for one another."""
|
||||
return canonical_filament_type(a) == canonical_filament_type(b)
|
||||
Reference in New Issue
Block a user