diff --git a/js/bgRemover.js b/js/bgRemover.js index b316d09..1231ccc 100644 --- a/js/bgRemover.js +++ b/js/bgRemover.js @@ -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} [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); + }); }