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