input: raw-sheet.png on flat #FF00FF · output: sheet-transparent.png, RGBA
Four passes from flat magenta to clean alpha
Chroma keying is the one place where a naive threshold visibly ruins the result: cut hard and every antialiased edge becomes a staircase with a pink fringe. This engine keeps partial coverage by solving the blend equation instead of thresholding it, and it keeps a separate pass for spill that is trapped inside the subject where alpha must not be touched at all.
- 96.0hard-cut distance ball,
KEY_THRESHOLD - 180.0in-band / out-of-band split
- 18.0minimum tint to count as a blend
- 4 pxdefault unmix reach from the keyed region
- 48 pxreach used by the halo retry
- 0.5%subject-size cap on a trapped-spill cluster
-
02a
The four passes
farmer/despill.py:65Every pixel is scored twice before anything is modified: Euclidean distance to the key colour, and a linear key-tint score that is simply the mean of the channels the key maxes out minus the mean of the channels it zeroes. For
#FF00FFthat ismean(R, B) − G, which makes the key itself score exactly 255 and a neutral grey score 0. Those two numbers partition the image.The four passes and what each one is allowed to touch Pass Selects Changes Constant 1 · hard key cut Already-transparent input, or distance to the key ≤ 96 RGBA set to zero KEY_THRESHOLD = 96.0 2 · depth map Everything, by 3×3 max-dilation out of the keyed region Nothing — it produces a Chebyshev depth, capped at the reach UNMIX_REACH = 4 3 · soft-alpha unmix Blend pixels within reach; in-band ones only within depth 2 RGB and alpha — partial coverage is recovered IN_BAND_UNMIX_KEY_DEPTH = 2 4 · trapped spill 8-connected clusters of still-tinted visible pixels, small and strongly tinted Colour only — alpha untouched, no pinholes
SPILL_MIN_TINT = 40.0, SPILL_MAX_FRACTION = 0.005 Figure 5 — the edge, pixel by pixel. A worked example computed from the constants in despill.py:27–33, not a capture. The point of the middle columns is that they end with partial alpha: the antialiased silhouette survives instead of collapsing into a binary staircase.farmer/despill.py:102–120pass 3 — soft-alpha unmix# Pass 3: soft-alpha unmix near the keyed region. if key_tint > 0 and unmix_reach > 0: eligible = (depth > 0) & (depth <= unmix_reach) & ( blend_out_of_band | (blend_in_band & (depth <= in_band_unmix_depth))) if eligible.any(): k = np.clip(tint[eligible] / key_tint, 0.0, 1.0) coverage = 1.0 - k safe = coverage > 0 ... unmixed = (rgb[eligible] - k[:, None] * key_arr) / coverage[:, None] new_rgb[safe] = np.clip(np.round(unmixed[safe]), 0, 255) new_alpha = np.where(safe, np.round(alpha[eligible] * coverage), 0)
The depth map from pass 2 is computed with a dilation that is not blocked by subject pixels (despill.py:94–96), so a blend pixel trapped in a gap between hair strands still receives a small depth and still gets unmixed.What the linear model assumesThe unmix estimates the blend fraction as
k = tint / 255. Because the tint score is linear in the blend, the observed tint is(1−k)·tintsubject + k·255— so the estimate is exact only when the subject's own key-tint score is zero. A subject that is greener than the key axis produces a slightly low estimate, and a subject leaning magenta a slightly high one. That is one reason the prompt bans coloured light on the background rather than relying on the unmix to undo it. -
remove_chroma_background() → residual_key_pixels() → retry decision
-
02b
The guarded halo retry
farmer/despill.py:212Lamps and fires break the contract. Generators paint their glow over the keyed background, producing a key-and-glow blend that sits far beyond a four-pixel reach and survives as a pink corona. The pipeline does not call the default pass and hope; it measures what is left, and decides.
The decision has a second half that matters more than the first. Raising the reach to 48 px would also eat a subject that genuinely wears magenta-adjacent colour. So before retrying, the code builds a hue histogram of the clean subject — visible pixels that are not already strongly key-tinted, because those are the halo itself and must not get a vote — and refuses the retry if more than 2% of them sit between hue 280° and 340°.
Figure 6 — the retry, and its refusal. The interesting branch is the right-hand one: a case the code knows it cannot solve with colour alone, so it declines, explains, and leaves the residual measurable rather than damaging the subject. “A subject that is both emissive and genuinely pink defeats the despill halo-retry (unresolvable with color alone); regenerate with a stricter no-glow prompt instead. The retry is guarded — it reports why it skipped.”
README.md, honest limitationsfarmer/despill.py:238–243the refusal, printed and recordedprint(f"[despill] halo retry SKIPPED: {magenta_frac:.1%} of the clean " f"subject is magenta-adjacent (genuine subject color, deep unmix " f"would eat it); residual stays {residual}") info.update({"halo_retry": "skipped", "reason": "subject contains magenta-adjacent hues", "subject_magenta_fraction": round(magenta_frac, 4)})
The saturation floor in the hue test is deliberately low (sat > 0.15): a strongly saturated pink is usually already excluded from the “clean” set by the tint cut, so what remains to vote is the low-saturation gradation of a pink garment — which is exactly the evidence that the colour is content rather than spill. The reasoning is written out in a comment atdespill.py:203–206. -
02c
What the committed runs actually show
*/validation.jsonResidual key pixels are recorded per frame and totalled per run. Across the five packed runs committed to the repository:
Residual key pixels in the repository's own runs (limit is 400 per run) Run Frames Canvas Residual total guide/idle-breathing6 256 × 394 0 guide/gesture-presenting6 256 × 394 0 janitor-idle-breathing5 226 × 426 0 receptionist/idle-breathing6 193 × 404 69 lamp/flicker3 201 × 341 0 The single non-zero figure is the interesting one: 69 tinted pixels spread over six frames of an emissive-free character, comfortably inside the limit and consistent with the skipped-column behaviour in Figure 5 — a thin rim that pass 4 declined to treat as a small trapped cluster.
AttributionThe four passes and the blend model are a port, not an invention. The module docstring credits
aldegad/sprite-gen(sprite_gen/extract.py, Apache-2.0) and the repository carries aNOTICEfile for it. What is new here is the numpy re-expression and the guarded adaptive wrapper.