Files
sejong-office/packages/frontend/src/integrations/bgremove/removeBg.ts
T
2026-07-23 11:28:43 +09:00

255 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export type BgRemoveMode = 'threshold' | 'flood';
export interface BgRemoveOptions {
/** 0–100: how far from sampled background still counts as background */
tolerance: number;
/** 0–100: soft edge width beyond hard threshold */
feather: number;
mode: BgRemoveMode;
}
export interface Rgb {
r: number;
g: number;
b: number;
}
export const DEFAULT_BG_REMOVE_OPTIONS: BgRemoveOptions = {
tolerance: 28,
feather: 12,
mode: 'threshold',
};
function clampByte(value: number) {
return Math.min(255, Math.max(0, Math.round(value)));
}
function colorDistance(a: Rgb, r: number, g: number, b: number) {
return Math.max(Math.abs(r - a.r), Math.abs(g - a.g), Math.abs(b - a.b));
}
function median(values: number[]) {
if (!values.length) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2
? sorted[mid]!
: Math.round((sorted[mid - 1]! + sorted[mid]!) / 2);
}
/** Sample corner neighborhoods and take per-channel median as background. */
export function sampleCornerBackground(
data: Uint8ClampedArray,
width: number,
height: number,
radius = 2,
): Rgb {
const rs: number[] = [];
const gs: number[] = [];
const bs: number[] = [];
const corners: Array<[number, number]> = [
[0, 0],
[width - 1, 0],
[0, height - 1],
[width - 1, height - 1],
];
for (const [cx, cy] of corners) {
for (let dy = -radius; dy <= radius; dy += 1) {
for (let dx = -radius; dx <= radius; dx += 1) {
const x = Math.min(width - 1, Math.max(0, cx + dx));
const y = Math.min(height - 1, Math.max(0, cy + dy));
const i = (y * width + x) * 4;
rs.push(data[i]!);
gs.push(data[i + 1]!);
bs.push(data[i + 2]!);
}
}
}
return { r: median(rs), g: median(gs), b: median(bs) };
}
function hardLimit(tolerance: number) {
return Math.round((Math.min(100, Math.max(0, tolerance)) / 100) * 180);
}
function featherWidth(feather: number) {
return Math.round((Math.min(100, Math.max(0, feather)) / 100) * 80);
}
function alphaForDistance(
distance: number,
hard: number,
soft: number,
originalAlpha: number,
) {
if (distance <= hard) return 0;
if (soft <= 0 || distance >= hard + soft) return originalAlpha;
const t = (distance - hard) / soft;
return clampByte(originalAlpha * t);
}
function loadImage(source: string | Blob | HTMLImageElement | HTMLCanvasElement) {
if (source instanceof HTMLCanvasElement) {
return Promise.resolve(source);
}
if (source instanceof HTMLImageElement) {
if (source.complete && source.naturalWidth) return Promise.resolve(source);
return new Promise<HTMLImageElement>((resolve, reject) => {
source.onload = () => resolve(source);
source.onerror = () =>
reject(new Error('이미지를 불러오지 못했습니다.'));
});
}
const objectUrl =
typeof source === 'string' ? null : URL.createObjectURL(source);
const src = typeof source === 'string' ? source : objectUrl!;
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
resolve(image);
};
image.onerror = () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
reject(new Error('이미지를 불러오지 못했습니다.'));
};
image.src = src;
});
}
function drawToCanvas(
image: HTMLImageElement | HTMLCanvasElement,
): HTMLCanvasElement {
if (image instanceof HTMLCanvasElement) {
const copy = document.createElement('canvas');
copy.width = image.width;
copy.height = image.height;
const ctx = copy.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
ctx.drawImage(image, 0, 0);
return copy;
}
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth || image.width;
canvas.height = image.naturalHeight || image.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
ctx.drawImage(image, 0, 0);
return canvas;
}
function applyThreshold(
data: Uint8ClampedArray,
bg: Rgb,
hard: number,
soft: number,
) {
for (let i = 0; i < data.length; i += 4) {
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
data[i + 3] = alphaForDistance(d, hard, soft, data[i + 3]!);
}
}
function applyFlood(
data: Uint8ClampedArray,
width: number,
height: number,
bg: Rgb,
hard: number,
soft: number,
) {
const total = width * height;
const match = new Uint8Array(total);
const limit = hard + soft;
for (let p = 0; p < total; p += 1) {
const i = p * 4;
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
if (d <= limit) match[p] = 1;
}
const visited = new Uint8Array(total);
const queue = new Int32Array(total);
let head = 0;
let tail = 0;
const seed = (x: number, y: number) => {
const p = y * width + x;
if (!match[p] || visited[p]) return;
visited[p] = 1;
queue[tail++] = p;
};
seed(0, 0);
seed(width - 1, 0);
seed(0, height - 1);
seed(width - 1, height - 1);
while (head < tail) {
const p = queue[head++]!;
const x = p % width;
const y = (p / width) | 0;
if (x > 0) seed(x - 1, y);
if (x + 1 < width) seed(x + 1, y);
if (y > 0) seed(x, y - 1);
if (y + 1 < height) seed(x, y + 1);
}
for (let p = 0; p < total; p += 1) {
if (!visited[p]) continue;
const i = p * 4;
const d = colorDistance(bg, data[i]!, data[i + 1]!, data[i + 2]!);
data[i + 3] = alphaForDistance(d, hard, soft, data[i + 3]!);
}
}
export async function removeBackgroundBySample(
source: string | Blob | HTMLImageElement | HTMLCanvasElement,
options: Partial<BgRemoveOptions> = {},
): Promise<{
blob: Blob;
url: string;
width: number;
height: number;
background: Rgb;
}> {
const opts = { ...DEFAULT_BG_REMOVE_OPTIONS, ...options };
const image = await loadImage(source);
const canvas = drawToCanvas(image);
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) throw new Error('Canvas를 초기화하지 못했습니다.');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const { data, width, height } = imageData;
const background = sampleCornerBackground(data, width, height);
const hard = hardLimit(opts.tolerance);
const soft = featherWidth(opts.feather);
if (opts.mode === 'flood') {
applyFlood(data, width, height, background, hard, soft);
} else {
applyThreshold(data, background, hard, soft);
}
ctx.putImageData(imageData, 0, 0);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(value) =>
value
? resolve(value)
: reject(new Error('투명 PNG를 만들지 못했습니다.')),
'image/png',
);
});
return {
blob,
url: URL.createObjectURL(blob),
width: canvas.width,
height: canvas.height,
background,
};
}