A shop that prints t-shirts sells them in twenty colours, but it has one photo: a white tee on a hanger. Photographing every colour isn't realistic, so the product page has to make the other nineteen from that one photo. And the folds have to stay, or the shirt looks like a flat sticker.
Here's the approach I use. It's about 90 lines, a pure function over RGBA bytes, so the same code runs on a canvas in the browser and in Node at build time.
1. Decide which pixels are fabric
The photo also has a wooden hanger, maybe a label, maybe a background. Those must keep their colour. The test is simple: a fabric pixel is opaque and nearly colourless.
const FABRIC_MAX_SATURATION = 0.14; function isFabric(data, i) { if (data[i + 3] < 5) return false; const r = data[i], g = data[i + 1], b = data[i + 2]; const max = Math.max(r, g, b); const min = Math.min(r, g, b); return max > 0 && (max - min) / max < FABRIC_MAX_SATURATION; } (max - min) / max is HSV saturation. White and grey cloth sits near 0; the hanger's wood is far above 0.14, so it's left alone.
2. Find the shirt's "true colour" level
A white shirt in a photo isn't one white. Highlights are near 255, folds are much darker. To map white to the target colour I need one reference level: the brightness that should come out as exactly the colour the customer picked.
The mean doesn't work well, because a big shadow pulls it down. I take the 70th percentile of the fabric's luminance from a histogram:
export function fabricReference(data, width, height) { const histogram = new Uint32Array(256); let count = 0; for (let i = 0; i < width * height * 4; i += 4) { if (isFabric(data, i)) { histogram[Math.round(luminance(data[i], data[i + 1], data[i + 2]))]++; count++; } } let seen = 0; for (let level = 0; level < 256; level++) { seen += histogram[level]; if (seen >= count * 0.7) return level / 255; } return 1; } For the photo above it's 0.937. It's computed once per photo, not per colour.
3. Shade, then put the folds back
The first idea is to multiply: each fabric pixel becomes the target colour times how bright that pixel is relative to the reference.
const shade = lum / reference; out = colour * shade; That works for light colours. For dark ones it doesn't: multiplying a near-black colour by anything gives near-black, so the folds disappear. Left is plain multiplication, right is the version below:
The fix is to add the photo's own contrast back, and more of it the darker the target colour is:
const foldStrength = 0.55 - 0.35 * ((cr + cg + cb) / 3); // per fabric pixel const lum = luminance(r, g, b) / 255; const shade = lum / reference; const fold = (lum - reference) * foldStrength; out[i] = (cr * shade + fold) * 255; out[i + 1] = (cg * shade + fold) * 255; out[i + 2] = (cb * shade + fold) * 255; lum - reference is positive on highlights and negative in folds, so it brightens one and darkens the other by the same amount on every channel. On pure black, foldStrength is 0.55 and the folds come back. On white it's 0.2, so light colours don't get over-contrasted. The output is a Uint8ClampedArray, so anything past 0 or 255 clamps for free.
4. One more thing: dark shirts need light ink
When the shirt changes to navy or black, a default black text colour stops being readable. I use WCAG's relative luminance on the shirt colour and call anything under 0.25 "dark":
export function isDarkShirt(hex) { const [r, g, b] = hexToRgb(hex).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); return 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.25; } Where it falls short
- Heather and melange fabrics: their flecks are part of the colour, and this flattens them.
- A photo of a coloured shirt: the fabric test assumes white or light grey to start with.
- Anything printed on the photographed shirt gets recoloured with it if it's grey.
Using it
Because it works on plain RGBA bytes, the browser calls it with ctx.getImageData() and draws the result back with putImageData(). In Node I read the same photo with sharp's .raw() and write each colour as a file at build time. Same function, same numbers.
I wrote this for PrintBlob, a product designer for WooCommerce, where it recolours every product photo. You can try it on a t-shirt in the live demo, or make free PNG mockups of a design in every colour with the t-shirt mockup generator, which runs the same code in your browser.
This post was written with AI assistance and checked against the code it describes. The images are that code's real output on the photo shown.




