feat: implémenter bgRemover complet (flood-fill, masque, anti-aliasing, pipette)

This commit is contained in:
2026-05-27 10:06:20 +02:00
parent a3a85dfff1
commit 3ff71b0ef9
+167 -7
View File
@@ -1,18 +1,178 @@
/**
* Distance de Chebyshev entre deux couleurs RGB.
* max(|R1-R2|, |G1-G2|, |B1-B2|)
* @param {number[]} c1 — [R, G, B]
* @param {number[]} c2 — [R, G, B]
* @returns {number}
*/
export function chebyshevDistance(c1, c2) {
throw new Error('Not implemented');
return Math.max(
Math.abs(c1[0] - c2[0]),
Math.abs(c1[1] - c2[1]),
Math.abs(c1[2] - c2[2])
);
}
/**
* Flood-fill BFS depuis les 4 coins. Retourne un masque Uint8Array
* où 1 = fond, 0 = premier plan.
* @param {{ data: Uint8ClampedArray, width: number, height: number }} imageData
* @param {number[]} seedColor — [R, G, B]
* @param {number} tolerance — tolérance Chebyshev
* @returns {Uint8Array}
*/
export function createFloodFillMask(imageData, seedColor, tolerance) {
throw new Error('Not implemented');
const { data, width, height } = imageData;
const mask = new Uint8Array(width * height);
const visited = new Uint8Array(width * height);
const queue = [];
const corners = [0, width - 1, (height - 1) * width, height * width - 1];
for (const idx of corners) {
if (!visited[idx]) { visited[idx] = 1; queue.push(idx); }
}
while (queue.length > 0) {
const idx = queue.pop();
const di = idx * 4;
const px = [data[di], data[di + 1], data[di + 2]];
if (chebyshevDistance(px, seedColor) > tolerance) continue;
mask[idx] = 1;
const x = idx % width;
const y = Math.floor(idx / width);
const neighbors = [
x > 0 ? idx - 1 : -1,
x < width-1 ? idx + 1 : -1,
y > 0 ? idx - width : -1,
y < height-1 ? idx + width : -1,
];
for (const n of neighbors) {
if (n >= 0 && !visited[n]) { visited[n] = 1; queue.push(n); }
}
}
return mask;
}
/**
* Remplace les pixels de fond (mask=1) par replacementColor, en place.
* @param {{ data: Uint8ClampedArray }} imageData
* @param {Uint8Array} mask
* @param {number[]} replacementColor — [R, G, B]
*/
export function applyMaskToImageData(imageData, mask, replacementColor) {
throw new Error('Not implemented');
const { data } = imageData;
const [r, g, b] = replacementColor;
for (let i = 0; i < mask.length; i++) {
if (mask[i] === 1) {
const di = i * 4;
data[di] = r; data[di + 1] = g; data[di + 2] = b; data[di + 3] = 255;
}
}
}
/**
* Adoucit la bordure masque/premier-plan en mélangeant sur 1px.
* @param {{ data: Uint8ClampedArray, width: number, height: number }} imageData
* @param {Uint8Array} mask
*/
export function softEdges(imageData, mask) {
throw new Error('Not implemented');
const { data, width, height } = imageData;
const total = width * height;
const edgePixels = [];
for (let i = 0; i < total; i++) {
if (mask[i] !== 1) continue;
const x = i % width, y = Math.floor(i / width);
const neighbors = [
x > 0 ? i - 1 : -1,
x < width-1 ? i + 1 : -1,
y > 0 ? i - width : -1,
y < height-1 ? i + width : -1,
];
if (neighbors.some(n => n >= 0 && mask[n] === 0)) edgePixels.push(i);
}
for (const i of edgePixels) {
const x = i % width, y = Math.floor(i / width);
const fgNeighbors = [
x > 0 ? i - 1 : -1,
x < width-1 ? i + 1 : -1,
y > 0 ? i - width : -1,
y < height-1 ? i + width : -1,
].filter(n => n >= 0 && mask[n] === 0);
if (fgNeighbors.length === 0) continue;
let sR = 0, sG = 0, sB = 0;
for (const n of fgNeighbors) {
sR += data[n * 4]; sG += data[n * 4 + 1]; sB += data[n * 4 + 2];
}
const di = i * 4, c = fgNeighbors.length;
data[di] = Math.round((data[di] + sR / c) / 2);
data[di + 1] = Math.round((data[di + 1] + sG / c) / 2);
data[di + 2] = Math.round((data[di + 2] + sB / c) / 2);
}
}
export function removeBackground(canvas, seedColor, tolerance = 30) {
throw new Error('Not implemented');
/**
* Supprime le fond d'un canvas en place.
* @param {HTMLCanvasElement} canvas
* @param {number[]|null} seedColor — [R,G,B]; si null, utilise le coin haut-gauche
* @param {number} tolerance
* @throws {Error} si aucun pixel de fond n'est détecté
*/
export function removeBackground(canvas, seedColor = null, tolerance = 30) {
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const seed = seedColor ?? [imageData.data[0], imageData.data[1], imageData.data[2]];
const mask = createFloodFillMask(imageData, seed, tolerance);
const maskedCount = mask.reduce((a, b) => a + b, 0);
if (maskedCount === 0) {
throw new Error(
"Aucun fond détecté. Augmentez la tolérance ou utilisez la pipette."
);
}
applyMaskToImageData(imageData, mask, [255, 255, 255]);
softEdges(imageData, mask);
ctx.putImageData(imageData, 0, 0);
}
/**
* Laisse l'utilisateur choisir une couleur.
* Chrome/Edge : EyeDropper API (plein écran).
* Safari/Firefox : clic sur le canvas fourni.
* @param {HTMLCanvasElement|null} canvas — requis uniquement en fallback Safari
* @returns {Promise<number[]>} [R, G, B]
*/
export async function pickColorFromCanvas(canvas) {
throw new Error('Not implemented');
if (window.EyeDropper) {
const dropper = new window.EyeDropper();
const result = await dropper.open(); // { sRGBHex: "#rrggbb" }
const hex = result.sRGBHex.replace('#', '');
return [
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16),
];
}
if (!canvas) throw new Error('Canvas requis pour la pipette sur ce navigateur');
return new Promise((resolve) => {
canvas.classList.add('picking-color');
function onClick(e) {
canvas.removeEventListener('click', onClick);
canvas.classList.remove('picking-color');
const rect = canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) * (canvas.width / rect.width));
const y = Math.floor((e.clientY - rect.top) * (canvas.height / rect.height));
const pixel = canvas.getContext('2d').getImageData(x, y, 1, 1).data;
resolve([pixel[0], pixel[1], pixel[2]]);
}
canvas.addEventListener('click', onClick);
});
}