The linesolid node = trunkdashed node = fork

assetfarmer Stage 02 · despill despill.py — 263 lines, ported from Apache-2.0 sprite-gen

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.

  1. 02a

    The four passes

    farmer/despill.py:65

    Every 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 #FF00FF that is mean(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
    PassSelectsChangesConstant
    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
    A worked cross-section through an antialiased silhouette edge Eight pixel columns run from pure key on the left to pure subject on the right, for a neutral grey subject blended with magenta at coverages of 100, 70, 55, 40, 25, 10, 3 and 0 percent key. For each column the diagram gives the observed RGB triple, the Euclidean distance to the key colour, the key-tint score, the resulting classification, the Chebyshev depth from the keyed region, whether pass three considers it eligible, and the alpha value it ends with. The first two columns are hard-cut to zero. Columns three and four are unmixed to partial alpha of 115 and 153. Column five is in band but sits at depth three, past the in-band guard, so it is skipped and keeps full alpha with a magenta tint. Column six is out of band at depth four and is unmixed to alpha 229. The last two columns score below the fringe delta and are treated as subject. worked example — neutral grey subject (128,128,128) blended with #FF00FF observed = (1 − k)·subject + k·key, for the eight blend fractions k below k (true blend) observed RGB dist to key tint score class depth pass 3 eligible alpha out 1.000.700.55 0.400.250.10 0.030.00 255,0,255217,38,217 198,58,198179,77,179 160,96,160141,115,141 132,124,132128,128,128 066 99132 165198 213220 255179 140102 6426 80 keyed keyed blendin-band blendin-band blendin-band blendout-of-band subject_like subject_like 001 234 n/a (cut) n/a (cut) yes yes no — depth 3 > in-band guard yes no no 00 115 153 255 229 255255 resulting alpha ramp left full The one column that stays tinted At k = 0.25 the pixel is close enough to the key to be in-band, but sits at depth 3 — past IN_BAND_UNMIX_KEY_DEPTH. It keeps full alpha and a tint of 64, so residual_key_pixels() counts it: alpha > 32 and tint > 40. Pass 4 will only rescue it if its connected tinted cluster is small enough. Where the thresholds actually land For this mid-grey subject, distance 96 corresponds to k ≈ 0.56 — anything more than 56% key is hard-cut. The fringe delta of 18 corresponds to k ≈ 0.07, so blends under about 7% key are never treated as blends at all. Both boundaries move with the subject's own colour; they are not fixed coverage fractions.
    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 assumes

    The 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.

  2. remove_chroma_background() → residual_key_pixels() → retry decision
  3. 02b

    The guarded halo retry

    farmer/despill.py:212

    Lamps 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°.

    The adaptive halo retry decision tree in remove_chroma_background_adaptive A decision flow beginning with the default four-pass despill. Its output is measured by residual_key_pixels. If the residual is at or below 400, the result is returned with mode default. Above 400, the subject magenta fraction is measured over clean pixels only. If more than two percent of clean subject pixels fall in the magenta hue band from 280 to 340 degrees, the retry is skipped, a message is printed explaining why, and the info dictionary records the skip reason and the measured fraction. Otherwise the despill is rerun with an unmix reach of 48 and the in-band depth guard also set to 48; if that lowers the residual, the retried image is returned with mode halo-retry, otherwise the original result stands. Every branch records what it did. remove_chroma_background(image, key) the default four passes, reach = 4 residual_key_pixels(out) > residual_limit (400)? no return out mode: "default" yes subject_magenta_fraction(out) > 0.02? measured on clean pixels: alpha > 128 and tint < 40 yes retry SKIPPED the subject genuinely wears magenta-adjacent hues; a deep unmix would eat it. Residual stays, and the reason is printed. no remove_chroma_background(image, key, unmix_reach=48, in_band_unmix_depth=48) retry residual < original? no keep the default mode: "default" yes return retry mode: "halo-retry" both residuals recorded The info dict is always returned Whatever branch runs, its outcome lands in the run report as report["despill"] — never silent.
    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 limitations
    farmer/despill.py:238–243the refusal, printed and recorded
    print(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 at despill.py:203–206.
  4. 02c

    What the committed runs actually show

    */validation.json

    Residual 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)
    RunFramesCanvasResidual total
    guide/idle-breathing6256 × 3940
    guide/gesture-presenting6256 × 3940
    janitor-idle-breathing5226 × 4260
    receptionist/idle-breathing6193 × 40469
    lamp/flicker3201 × 3410

    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.

    Attribution

    The 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 a NOTICE file for it. What is new here is the numpy re-expression and the guarded adaptive wrapper.