feat: implémenter bgRemover complet (flood-fill, masque, anti-aliasing, pipette)
This commit is contained in:
+167
-7
@@ -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) {
|
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) {
|
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) {
|
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) {
|
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) {
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user