Open 9am opened 1 year ago
This is the printing method we use for 1.5 centuries. And you still see it in newspapers, magazines, and wrappers. If look closely, there're just layered dots in only 4 colors, it's amazing and I gotta know how it works. So let's code a CMYK Halftone (it's harder than I thought).
CMYK halftone paint under a microscope
The idea is pretty straightforward for mono-color:
This is easy to do since we have canvas APIs to get the RGBA data of the image.
const cells = [];
const cellSize = 8;
const [cw, ch] = [cellSize, cellSize];
const column = Math.ceil(w / cw);
const row = Math.ceil(h / ch);
for (let j = 0; j < row; j++) {
for (let i = 0; i < column; i++) {
const cell = greyCtx.getImageData(i * cw, j * ch, cw, ch);
const avg = getAvg(cell.data);
const size = 1 - avg / 255;
cells.push(size);
}
}
We know that the CMYK stands for 4 colors: cyan, magenta, yellow, key(black). If we find a way to separate those colors from the origin image like the greyscale
process, then we could use the mono methods to halftone the separated color layer.
The image data we have from canvas is in RGBA color space. The equation to turn it to CMYK is this:
key = 1 - max(r, g, b)
cyan = (1 - r - key) / (1 - key)
magenta = (1 - g - key) / (1 - key)
yellow = (1 - b - key) / (1 - key)
Now we have the CMYK layers, simply stacking them together will not get us there, cuz to simulate the print effect, we need to find a way to blend colors, or the dots on top will cover lower ones. We'll lose a lot of details of that.
There are 2 ways to do this in the browser.
mix-blend-mode
if we render in HTML elements.globalCompositeOperation
if we render in canvas
.We'll try canvas first:
outCtx.globalCompositeOperation = 'multiply'
It's not done because the tiles for each color layer are the same size, so if we look closely, the dots in each color are stacked together, and not putting their max effort into the final results. The way to resolve this is to angle different layers.
dots overlayed
Typical CMYK halftone screen angles
To be honest, this cost me some time to figure out (partly because of my poor geometry knowledge). First, I thought of a 2d matrix converting for each pixelData(i, j)
, but if go this way, the origin points need to be pre-calculated, and I still want to take advantage of the getImageData
which only works in (x, y, width, height)
way.
At last, I took a way less complex for me. Rotate the origin image before dividing tiles, and draw it into a larger hidden canvas, after that, everything else goes as usual. Then rotate and translate the output canvas to fit the hidden canvas and draw it.
Pros:
- The codes are less complex to understand.
- No need to implement a customized version of
getImageData
.Cons:
- Calculation waste for the space outside the origin image size.
// calculate the viewport size after rotation
const cos = Math.cos(this.angle);
const sin = Math.sin(this.angle);
const [vw, vh] = [Math.ceil(w * cos + h * sin), Math.ceil(w * sin + h * cos)];
...
...
// rotate and translate the origin before drawing
ctx.translate(h * sin, 0);
ctx.rotate(this.angle);
ctx.drawImage(source, 0, 0, w, h);
ctx.resetTransform();
not overlayed
after angled
with smaller cell size, we'll get a vivid picture
After the experiments, I made a custom element <img-halftone>
.
It's not a perfect implementation. I have to re-sample the image to limit calculation, and still couldn't find a way to solve the Moiré pattern problem. And due to the resolution of our display screen, it'll never be as sophisticated as the real print. But I really enjoy the coding! Give it a try.
Well, hope you enjoy it. I'll see you next time.
Description | Live demo |
---|---|
Default setting with a zoom-in-out animation | codepen |
Render with different varient |
codepen |
Render with different cellsize |
codepen |
Render with different shape |
codepen |
Concurrent processing | codepen |
@9am 🕘