/** * 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) { 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) { 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) { 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) { 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); } } /** * 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) { 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); }); }