Chad Neal meex.work

Deterministic trait compositing

How do you ship a generative image set with verifiable provenance, without per-item AI drift, and without hand-drawing hundreds of layers? Those three constraints pull against each other. Here is a way to hold all of them at once.

31 August 2026 · Technique

The obvious approaches each break one of the constraints.

Generate every item with a model and you get variety for free, but no two items are quite the same species. Line weight wanders, the palette drifts, a proportion changes, and there is nothing to verify afterwards because nobody — including you — can reproduce the output.

Draw a layer stack and you get consistency, at a cost that scales badly: every material times every colourway is an asset somebody has to draw, and adding a sixth colourway late means going back through all of them.

Pre-render the whole set and you now have thousands of files to host, pin and keep alive, forever, for a set that may never be looked at again.

What follows does none of those. One hand-made base image, thresholds that derive its materials, arithmetic that recolours them, and a committed salt that fixes every item's traits before the set opens. Nothing is generated per item, so nothing drifts. Nothing is pre-rendered, so there is nothing to host.

Twenty-four generated items in a grid, all clearly the same species, varying in body colour, accent colour and accessories.
Every tile here comes from a single 512×512 base image and one accessory. Nothing was drawn per item and nothing was generated per item. The demo art is deliberately abstract geometry, drawn from primitives, so the technique is the only thing on display.

1. Zones instead of layers

A layer stack needs one drawn asset per material per colourway. The alternative is to stop treating the artwork as layers at all, and start treating it as materials you can find.

Take one base image and threshold it in HSV space:

alpha = image.split()[3]
h, s, v = rgb_to_hsv(image)
r, g, b = channels(image)
subject = alpha > 10

# a small saturated feature: one channel clearly beats the rest
accent = subject & (g > r + 25) & (g > b + 25) & (g > 90)

# a dark, desaturated material
dark = subject & (v < 118) & (s < 60)

body = subject & ~accent & ~dark
body_shadow    = body & (v <= 178)
body_highlight = body & (v >  178)
Five panels: the base image, then each derived zone highlighted in a different colour over a desaturated copy.
One base image, four masks. The body splits by value into shadow and highlight so the two can be styled separately — that split is what keeps a recolour from going flat.

One detail is load-bearing. The accent is found in RGB, not HSV. "This channel clearly beats the other two" is a far more stable test for a small saturated feature than a hue window is. Hue wraps at zero, so a window near red needs two comparisons instead of one, and it is exactly where compression noise and antialiased edges land. The channel-dominance test has neither problem.

The thresholds themselves are not constants. They are tuned per base image, and the honest way to tune them is to measure rather than squint:

print(zones.coverage())
# {'accent': 0.038, 'dark': 0.167, 'body': 0.795, 'body_base': 0.452, ...}

The failure mode worth fearing: a zone that matches nothing. The render still succeeds. It simply never recolours that material, and you get a set where one part is stubbornly the same on every item. Assert every zone is non-empty, or you will find out from a buyer.

2. Recolour as a transform, not a colour

The instinct is to assign a colour to a zone. Don't — it destroys every highlight and fold the artist painted, and you get a flat sticker where there was modelling.

Three panels: the base image, a flat colour fill with no shading, and a transform that keeps the shading.
Middle: a flat fill. The gradient the artist painted is gone. Right: the same target colour applied as a transform over the existing saturation and value.

Instead, describe a material as a transform of what is already there — multiply and offset the existing saturation and value, and only assign the hue:

@dataclass(frozen=True)
class ZoneStyle:
    hue: int | None    # None leaves hue alone
    sat_mul: float
    sat_add: float
    sat_min: float
    sat_max: float
    val_mul: float
    val_add: float

    def apply(self, region, h, s, v):
        if self.hue is not None:
            h[region] = self.hue
        s[region] = np.clip(
            s[region] * self.sat_mul + self.sat_add,
            self.sat_min, self.sat_max)
        v[region] = np.clip(
            v[region] * self.val_mul + self.val_add, 0, 255)

A real one, for a deep navy:

ZoneStyle(hue=150,
          sat_mul=0.42, sat_add=46, sat_min=36, sat_max=98,
          val_mul=0.40, val_add=16)

hue=None matters more than it looks. Greys and blacks have no meaningful hue, and forcing one onto them drags the whole material toward some arbitrary point on the wheel. Leaving hue alone and crushing saturation gives a neutral that still holds its shading.

Five colourways of the same base image: warm brown, navy, grey, cream and near-black.
One base image, five colourways. Zones are independent axes, so they multiply: five colourways times six accent colours is thirty combinations, from one drawing.

3. Provenance: commit the salt

Traits come from a seeded generator, and the seed is a salt you are pinned to in public before anyone can buy:

salt = secrets.token_hex(16)          # secrets, never random
published = hashlib.sha256(salt.encode()).hexdigest()

def rng_for(salt, item_id):
    return random.Random(f"{salt}:{item_id}")

traits = assign(rng_for(salt, item_id), spec)

Publish published before the sale. Reveal salt after. Anyone can then recompute every item's traits and check both that they match what was delivered and that the salt hashes to the number you committed to. You cannot decide after the fact who got the rare one, because the traits were fixed by a number you were bound to before any of them existed.

Seed per item, not once for a long stream. It means item 2,913 is verifiable on its own without replaying the 2,912 draws in front of it, and inserting or removing an item does not renumber everything after it.

A commitment published alongside its own salt was never a commitment. The script I pulled this out of wrote salt and provenanceHash into the same JSON file. Both halves existed, the scheme was implemented correctly, and there was still never a single moment when anything was binding — because the reveal shipped with the commit.

Make it two functions that write to two places, and have the secret one refuse to write anywhere that looks like a deploy directory. The discipline has to be in the code, because it is invisible in the output: a voided commitment and a sound one produce byte-identical sets.

Budgets, not independent rolls

One more piece worth stealing. If every optional feature rolls independently, the top of your set is a long tail of items wearing six things at once, which reads as noisy rather than rare. Give each tier a budget instead: features roll for candidacy, then get truncated.

hits = [f for f, pct in tier.features.items()
        if rng.random() * 100 < pct]
rng.shuffle(hits)
hits = hits[:tier.slots]          # the budget

# A tier above the floor that rolled nothing is a "sleeper":
# nominally rare, visually identical to a common. Force one
# real feature so the tier is readable from the item itself.
if tier.name != floor and not hits:
    hits = [weighted(rng, tier.features)]

Two things that cost me real time

An accessory sharing a material with a recoloured zone cannot be a fixed asset

This is the one I would most like to have known in advance.

The set had an accessory drawn in the same material as the body. It was shipped as a fixed PNG and composited on top. Against the colourways near the paint it was drawn in, it looked completely correct — so it shipped.

Six panels in two columns. Left column: an accessory that stays its original colour across three body colourways. Right column: the same accessory recoloured to match each body.
Left: shipped as a fixed asset. Right: recoloured with the body. Look at the top row first — on a colourway near the original paint the two are nearly identical, which is exactly why this ships. Then look down.

What made it survive review was not subtlety, it was plausibility. The accessory was in the right place and the right shape and merely the wrong colour, so it read as a shading artefact rather than as a bug. Nobody looks at a rendering artefact twice.

Generalised: any accessory sharing a material with a recoloured zone has to be recoloured by the same transform. The fix is not to remember to do it — it is to make the accessory carry its own zones, so the same styles run through it automatically:

class Accessory:
    def __init__(self, name, image, shares_material=True):
        self.name, self.image = name, image
        self.zones = derive_zones(image)   # knows its own materials
        self.shares_material = shares_material

def accessory_for(acc, styles, cache):
    if not acc.shares_material:      # a metal buckle on cloth
        return acc.image
    key = (acc.name, style_signature(styles))
    if key not in cache:
        cache[key] = recolor(acc.image, acc.zones, styles)
    return cache[key]

The cache is keyed by accessory and style, so it holds at most accessories × styles entries regardless of set size. Twenty-four items needed nine recolours. Three thousand items would still need nine.

RGB → HSV → RGB does not round-trip

Both conversions quantise to 8 bits. So if you convert the whole frame, edit one zone, and convert all of it back, you have just moved pixels that no style ever touched.

Three panels: whole-frame write-back, masked write-back, and a heat map showing drift concentrated in bands across the untouched region.
Right panel is the difference between the two, amplified 60×. In this image 43% of the untouched subject moved, by a unit or two each.

One pass is invisible, which is the problem — it is invisible right up until a pipeline recolours in several passes, or re-reads its own output, and the error compounds over artwork nobody edited.

The fix is one line and costs nothing: track which pixels a style actually claimed, and composite the result back over only those.

touched = np.zeros(h.shape, dtype=bool)
for name, style in styles.items():
    mask = getattr(zones, name)
    style.apply(mask, h2, s2, v2)
    touched |= mask               # remember what we claimed

out = Image.composite(
    recoloured,                   # the edited frame
    img.convert("RGB"),           # the original, untouched
    Image.fromarray((touched * 255).astype("uint8"), "L"),
)

Untouched pixels now come out bit-identical, which is also a property you can assert in a test — and a test that asserts it will fail the moment someone refactors the masking away.


On tests, briefly

The original script had no tests. It had a tests/ directory containing four reference PNGs, which is not the same thing and is arguably worse, because the directory exists and looks reassuring.

The extracted version has fifty. More usefully, it has a script that breaks the code on purpose and checks that the specific test claiming to guard each behaviour actually goes red:

RED  accessory shipped as a fixed asset
     -> test_shared_material_accessory_tracks_the_body
RED  recolour writes back the whole frame
     -> test_recolor_only_touches_its_zone
RED  tier budget not enforced
     -> test_feature_count_never_exceeds_the_tier_budget
RED  sleeper guarantee removed
     -> test_no_sleeper_rares
RED  salt leaked into the public commitment
     -> test_public_commitment_never_contains_the_salt
RED  per-item rng ignores the salt
     -> test_rng_differs_across_items_and_salts

A green suite on its own is not evidence. A test that asserts nothing looks exactly like a test that passes, and you cannot tell which you have until you have watched it fail.

Two of those mutations correspond to bugs that were real in the original. The tests exist because the bugs did.