"Fixing What You're Comparing, Not What You're Comparing It To"

Two colors, two moods, one lesson learned independently

Photography AI·Advanced·9 min read · July 16, 2026

Color matching in this system started from a comparison that looked reasonable and was quietly wrong at the model level, not the parameter level.

The controlled color vocabulary maps a query token like "blue" to one fixed reference hex — #0000FF. A photo's dominant colors are a separate, independently-generated list of real pixel hexes extracted by Claude Vision — something like #2C5F8A. The original matcher compared these by exact string equality, which meant a visually obvious blue photo and a query for "blue" almost never matched, because two hex strings representing genuinely similar colors are essentially never byte-identical. This isn't a threshold problem. It's a category error: color is a geometric, perceptual property, and exact-string equality was never a valid way to compare two points in that space, regardless of what threshold you'd have wrapped around it.

The fix changed the comparison model, not a number inside it. Both the query hex and every candidate palette hex get converted to OKLab — a perceptually uniform color space published by Björn Ottosson in 2020 — and compared by Euclidean distance in that space. OKLab was chosen over the more familiar CIELAB because it converts directly from linear sRGB through two fixed matrices with no white-point or chromatic-adaptation parameter to choose, and over raw RGB distance because RGB distances don't correspond to perceived differences — which was the deeper defect that exact-hex equality was only a degenerate case of. The matcher was validated against known-good and known-bad pairs computed this way: #0000FF against #2C5F8A (0.2308) and #7EA6C1 (0.3643) had to register as matches; #0000FF against #FF0000 (0.5371) and #008000 against #FF00FF (0.5309) had to register as non-matches. This is pure math — no AI, no embeddings, no classifier, entirely deterministic, entirely local.

Fixing the comparison model exposed the next thing that was wrong, which wasn't the math — it was the representation. Each color name was still backed by exactly one saturated reference swatch, and a production audit found that real photographs almost never contain a pure primary. They contain sky blue, steel blue, slate blue, navy-adjacent tones — a whole neighborhood of things a person would call "blue," represented by one arbitrary point in it. The fix was to replace each single reference hex with a curated color family: 17 families, 4 to 8 representative hexes each, drawn from the CSS Color Module's extended keyword list plus a handful of photography-specific names — Rust, Charcoal, Jet, Gunmetal — flagged explicitly as such in the source. Matching now checks every family member against every palette entry and keeps the closest pair anywhere in that expanded set. Because a family's first member is always identical to the old single-anchor hex, this change can only ever add candidate matches relative to the old version — it structurally cannot lose a match the single-hex version used to find, only gain ones it used to miss. The confirmed fix: a photo with dominant color #4A4A4A, previously rejected by "black"'s lone #000000 anchor at a distance of 0.4091, now matches through the family's Charcoal representative at 0.038.

Mood needed the identical correction, arrived at independently, for a structurally identical reason. Mood retrieval was still asking a general-purpose text embedding "which word is closest in language-embedding space," when what it actually needed was a curated model of affective closeness — and a live similarity audit run before any code changed found that near-perfect human synonyms of "serene" failed the production threshold, that the single strongest same-feeling pair the entire audit measured — "moody" and "melancholic" — still failed it, and that lowering the threshold to compensate was rejected outright, because a pair with no real conceptual relationship ("contemplative" and "tranquil") sat above a pair that clearly did ("dramatic" and "moody"). No single global threshold could admit the true synonyms without admitting comparable noise alongside them. The fix that followed was the same fix as color, translated into a different modality: replace the single point with a curated family. MOOD_FAMILIES groups a small set of related feelings — calm, reflective, dramatic, energetic — each grouping backed by an inline citation to either a real production co-occurrence or a measured audit similarity, not asserted from intuition. This wasn't a coincidence of naming. It was one architecture, proven correct for color, deliberately reapplied to a completely different kind of data because the underlying shape of the problem — one point standing in for a whole neighborhood of related meaning — was the same shape both times.

What both corrections share is the order they happened in. In color, the comparison model (OKLab) was fixed before the representation (families) was fixed, and the representation was fixed before anyone touched the threshold gating it. In mood, the same ordering held: architecture — what should even count as a candidate match — got settled before any number describing how close is close enough. Doing it the other way around — tuning a threshold against a broken comparison, or against an under-representative point — produces a number that's precisely calibrated to the wrong question.

The transferable lesson: before spending any effort calibrating a threshold, confirm that what the threshold is measuring is actually the right thing to measure. A perfectly-tuned cutoff on top of a wrong comparison model, or an unrepresentative point standing in for a whole category, isn't a partial fix. It's a precisely-wrong one, and no amount of threshold tuning corrects for the layer underneath it being broken.

Fixing the representation, though, created a problem of its own. Adding more reference points per color mechanically narrows the gap between what should match and what shouldn't — more candidate pairs mean more chances for two different colors' representatives to sit close together by coincidence. That effect was measured directly during the family work and explicitly left unaddressed, out of scope for that phase by its own stated rule: don't tune a threshold unless the representation is proven structurally incorrect first, and even then, defer it. The representation was now correct. The threshold that had been calibrated for the old, sparser representation was not. Something had to actually measure the new gap, rather than assume the old number still fit.