DEV Community

Joe Lin for BeGoodTool.com

Posted on

I expected image pixelation to mean per-pixel math — this tool mostly just redraws the same rectangle twice

The first time I looked at a "blur or mosaic just part of a photo" tool, I assumed the hard part would be the image-processing math.

In this implementation, the more interesting part is actually not a giant manual pixel loop. The mosaic effect is mostly a neat canvas trick: draw the selected region smaller, then scale it back up with smoothing disabled. The blur path is different again — it leans on the browser's built-in canvas filter, but only inside a clipped rectangle.

Pixelation is just aggressive downscaling, then scaling back up

The mosaic path doesn't average pixel blocks manually. Instead, it converts the selected area into a tiny temporary canvas, then stretches that tiny version back over the original rectangle:

const mosaicBlockSize = (intensity) =>
  Math.max(2, Math.round(2 + (intensity / 100) * 48));

function applyMosaicEffect(ctx, img, rect, intensity) {
  const { x, y, w, h } = rect;
  const blockSize = mosaicBlockSize(intensity);
  const smallW = Math.max(1, Math.round(w / blockSize));
  const smallH = Math.max(1, Math.round(h / blockSize));
  const tmp = document.createElement("canvas");
  tmp.width = smallW;
  tmp.height = smallH;
  const tctx = tmp.getContext("2d");
  tctx.drawImage(img, x, y, w, h, 0, 0, smallW, smallH);
  ctx.save();
  ctx.imageSmoothingEnabled = false;
  ctx.drawImage(tmp, 0, 0, smallW, smallH, x, y, w, h);
  ctx.restore();
}
Enter fullscreen mode Exit fullscreen mode

That's a nice shortcut because the browser handles the resampling work for you. The important detail is ctx.imageSmoothingEnabled = false; without that, the enlarged blocks would get softened and you'd end up with a muddy blur instead of crisp square mosaic cells.

The intensity slider is also mapped to a concrete block size, not some vague "strength" value. At 100, the block size goes much larger, so the region becomes unreadable fast.

Blur uses the browser's filter pipeline, but only inside the selected box

Blur is handled very differently. There isn't a custom convolution kernel in the component at all. The code clips the canvas to the selected rectangle, turns on a blur filter, then redraws the source image:

const blurRadiusPx = (intensity) =>
  Math.max(1, Math.round(1 + (intensity / 100) * 40));

function applyBlurEffect(ctx, img, rect, intensity, naturalW, naturalH) {
  const { x, y, w, h } = rect;
  const radius = blurRadiusPx(intensity);
  ctx.save();
  ctx.beginPath();
  ctx.rect(x, y, w, h);
  ctx.clip();
  ctx.filter = `blur(${radius}px)`;
  ctx.drawImage(img, 0, 0, naturalW, naturalH);
  ctx.filter = "none";
  ctx.restore();
}
Enter fullscreen mode Exit fullscreen mode

The subtle part here is the order: the full image is redrawn while the clip is active. That means only the selected area gets the blurred redraw, and everything outside the rectangle stays untouched from the first base draw.

I like this approach because it avoids writing and maintaining a manual blur implementation. The tradeoff is that you're trusting browser canvas filter performance, which is usually fine, but can get expensive on very large images.

The selection UI is really a coordinate-conversion problem

Drawing a box over an image sounds simple until the displayed image size changes. This component keeps the visible selection boxes in stage coordinates, then converts them back to original-image coordinates right before rendering:

const scale = computed(() =>
  naturalWidth.value && stageWidth.value ? stageWidth.value / naturalWidth.value : 1
);

const rectNatural = {
  x: box.x / s,
  y: box.y / s,
  w: box.w / s,
  h: box.h / s,
};
Enter fullscreen mode Exit fullscreen mode

That only works because the stage image preserves aspect ratio (width: 100%; height: auto;), so one scale factor is enough.

The other smart bit is that the boxes are rescaled when the rendered image size changes:

const handleStageResize = (newW, newH) => {
  const fx = newW / oldW;
  const fy = newH / oldH;
  boxes.forEach((box) => {
    box.x *= fx;
    box.w *= fx;
    box.y *= fy;
    box.h *= fy;
  });
};
Enter fullscreen mode Exit fullscreen mode

Without that ResizeObserver-driven update, responsive layout changes would make the selection overlay drift away from the actual content.

Where this gets tricky: preview cost and reversibility

Dragging a box updates state constantly, so the component doesn't render immediately on every pointer event. It schedules one redraw with requestAnimationFrame:

function scheduleRender() {
  if (rafId) return;
  rafId = requestAnimationFrame(() => {
    rafId = null;
    renderNow();
  });
}
Enter fullscreen mode Exit fullscreen mode

Then renderNow() redraws the original image onto a full-resolution canvas and reapplies every box in natural-image coordinates. That's the right tradeoff for visual correctness: the downloaded image matches the preview because both come from the same canvas pipeline, not from CSS effects layered over the DOM.

The gotcha is cost. canvas.width = naturalWidth.value means preview rendering always happens at the image's original resolution. That's great for output quality, but a huge phone photo plus several blur regions can turn every drag into a lot of work. Also, blur is not the same as destruction: strong pixelation throws away more information, while blur can sometimes be partially reversed in principle. If you need something harder to recover, mosaic is the safer option.

I turned that approach into a small free tool here: Photo Blur & Pixelate Tool.


Available in other languages

Top comments (0)