feat: remplacer flood-fill par @imgly/background-removal (IA WebAssembly)
This commit is contained in:
+58
-164
@@ -1,178 +1,72 @@
|
||||
/**
|
||||
* 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}
|
||||
* Suppression de fond par IA — @imgly/background-removal (WebAssembly).
|
||||
* Le modèle (~40 Mo) est téléchargé depuis le CDN au premier lancement
|
||||
* puis mis en cache automatiquement dans le navigateur.
|
||||
*/
|
||||
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])
|
||||
);
|
||||
|
||||
const CDN = 'https://cdn.jsdelivr.net/npm/@imgly/background-removal/dist/';
|
||||
|
||||
// Chargement paresseux de la librairie (évite de bloquer le rendu initial)
|
||||
let _removeBg = null;
|
||||
|
||||
async function loadLib() {
|
||||
if (_removeBg) return _removeBg;
|
||||
const mod = await import(CDN + 'bundle.browser.esm.js');
|
||||
_removeBg = mod.removeBackground;
|
||||
return _removeBg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}
|
||||
* Supprime le fond d'un Blob image via IA.
|
||||
* Retourne un nouveau Blob JPEG avec fond blanc pur.
|
||||
* @param {Blob} blob — image source (JPEG)
|
||||
* @param {function} [onProgress] — callback({ key, percent })
|
||||
* @returns {Promise<Blob>} Blob JPEG fond blanc
|
||||
*/
|
||||
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 = [];
|
||||
export async function removeBackground(blob, onProgress) {
|
||||
const removeBg = await loadLib();
|
||||
|
||||
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); }
|
||||
}
|
||||
// Détourage IA → PNG avec transparence
|
||||
const pngBlob = await removeBg(blob, {
|
||||
publicPath: CDN,
|
||||
output: {
|
||||
format: 'image/png',
|
||||
type: 'foreground',
|
||||
quality: 1,
|
||||
},
|
||||
progress: (key, current, total) => {
|
||||
if (onProgress && total > 0) {
|
||||
onProgress({ key, percent: Math.round((current / total) * 100) });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
// Composite sur fond blanc → JPEG
|
||||
return compositeOnWhite(pngBlob);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remplace les pixels de fond (mask=1) par replacementColor, en place.
|
||||
* @param {{ data: Uint8ClampedArray }} imageData
|
||||
* @param {Uint8Array} mask
|
||||
* @param {number[]} replacementColor — [R, G, B]
|
||||
* Composite un PNG transparent sur fond blanc, retourne un Blob JPEG.
|
||||
* @param {Blob} pngBlob
|
||||
* @returns {Promise<Blob>}
|
||||
*/
|
||||
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<number[]>} [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);
|
||||
async function compositeOnWhite(pngBlob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
canvas.toBlob(b => {
|
||||
URL.revokeObjectURL(img.src);
|
||||
b ? resolve(b) : reject(new Error('canvas.toBlob a retourné null'));
|
||||
}, 'image/jpeg', 0.92);
|
||||
};
|
||||
img.onerror = () => reject(new Error('Impossible de charger le résultat IA'));
|
||||
img.src = URL.createObjectURL(pngBlob);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user