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();
}
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();
}
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,
};
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;
});
};
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();
});
}
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
- 照片打馬賽克/模糊工具 — 繁體中文
- 照片打马赛克/模糊工具 — 简体中文
- Photo Blur & Pixelate Tool — English
- 写真モザイク・ぼかし加工ツール — 日本語
- 사진 모자이크·블러 처리 도구 — 한국어
- Outil de Flou et Pixellisation de Photo — Français
- Пикселизация и Размытие Фото — Русский
- Foto Verpixeln & Unscharf Machen — Deutsch
- Alat Sensor & Blur Foto — Bahasa Indonesia
- Pixelar y Difuminar Fotos — Español
- Công Cụ Làm Mờ/Che Pixel Ảnh — Tiếng Việt
- เครื่องมือเบลอ/พิกเซลรูปภาพ — ไทย
- Pikselizacja i Rozmycie Zdjęć — Polski
- Fotoğraf Bulanıklaştırma ve Pikselleştirme — Türkçe
- Pixelare e Sfocare Foto — Italiano
- Pixelizar e Desfocar Fotos — Português
- Foto Vervagen & Pixeleren — Nederlands
- Розмиття та Пікселізація Фото — Українська
Top comments (0)