feat: Add image upload and background removal
Enables users to upload custom avatar assets and automatically remove the background from the generated image. New features: - Avatar creation now supports uploading base, blink, and talk textures. - Added ability to define the main body bounding box during rigging. - Vision service now includes image segmentation for background removal. - Studio component dynamically processes the avatar image for background removal if chroma key is enabled.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { Rect } from '../types';
|
||||
|
||||
export const fileToDataUrl = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target?.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
export const loadImage = (src: string): Promise<HTMLImageElement> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
};
|
||||
|
||||
export const stitchAssets = async (
|
||||
base: File,
|
||||
blink?: File,
|
||||
talk?: File
|
||||
): Promise<{ imageUrl: string; mainBody: Rect; textureClosedEye?: Rect; textureOpenMouth?: Rect }> => {
|
||||
// Load images
|
||||
const baseData = await fileToDataUrl(base);
|
||||
const baseImg = await loadImage(baseData);
|
||||
|
||||
const blinkImg = blink ? await loadImage(await fileToDataUrl(blink)) : null;
|
||||
const talkImg = talk ? await loadImage(await fileToDataUrl(talk)) : null;
|
||||
|
||||
// Layout: Base on Left. Sidebar on Right containing Blink (top) and Talk (bottom).
|
||||
// Sidebar width = max(blink.width, talk.width)
|
||||
const sidebarWidth = Math.max(blinkImg?.width || 0, talkImg?.width || 0);
|
||||
|
||||
// If there are no variants, just return the base image as is
|
||||
if (sidebarWidth === 0) {
|
||||
return {
|
||||
imageUrl: baseData,
|
||||
mainBody: { x: 0, y: 0, w: 1, h: 1 }
|
||||
};
|
||||
}
|
||||
|
||||
const totalWidth = baseImg.width + sidebarWidth;
|
||||
const totalHeight = Math.max(baseImg.height, (blinkImg?.height || 0) + (talkImg?.height || 0));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = totalWidth;
|
||||
canvas.height = totalHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error("Could not get canvas context");
|
||||
|
||||
// Draw Base
|
||||
ctx.drawImage(baseImg, 0, 0);
|
||||
|
||||
// Calculate normalized rects
|
||||
const mainBody: Rect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: baseImg.width / totalWidth,
|
||||
h: baseImg.height / totalHeight
|
||||
};
|
||||
|
||||
let textureClosedEye: Rect | undefined;
|
||||
if (blinkImg) {
|
||||
ctx.drawImage(blinkImg, baseImg.width, 0);
|
||||
textureClosedEye = {
|
||||
x: baseImg.width / totalWidth,
|
||||
y: 0,
|
||||
w: blinkImg.width / totalWidth,
|
||||
h: blinkImg.height / totalHeight
|
||||
};
|
||||
}
|
||||
|
||||
let textureOpenMouth: Rect | undefined;
|
||||
if (talkImg) {
|
||||
const yPos = blinkImg ? blinkImg.height : 0;
|
||||
ctx.drawImage(talkImg, baseImg.width, yPos);
|
||||
textureOpenMouth = {
|
||||
x: baseImg.width / totalWidth,
|
||||
y: yPos / totalHeight,
|
||||
w: talkImg.width / totalWidth,
|
||||
h: talkImg.height / totalHeight
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
imageUrl: canvas.toDataURL('image/png'),
|
||||
mainBody,
|
||||
textureClosedEye,
|
||||
textureOpenMouth
|
||||
};
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
|
||||
import { FaceLandmarker, FilesetResolver } from '@mediapipe/tasks-vision';
|
||||
import { FaceLandmarker, FilesetResolver, ImageSegmenter } from '@mediapipe/tasks-vision';
|
||||
import { Rect } from '../types';
|
||||
|
||||
let faceLandmarker: FaceLandmarker | null = null;
|
||||
let imageSegmenter: ImageSegmenter | null = null;
|
||||
|
||||
// Initialize the vision model for static image analysis
|
||||
const initVision = async () => {
|
||||
@@ -26,6 +27,29 @@ const initVision = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize the segmenter for background removal
|
||||
const initSegmenter = async () => {
|
||||
if (imageSegmenter) return;
|
||||
|
||||
try {
|
||||
const filesetResolver = await FilesetResolver.forVisionTasks(
|
||||
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.18/wasm"
|
||||
);
|
||||
|
||||
imageSegmenter = await ImageSegmenter.createFromOptions(filesetResolver, {
|
||||
baseOptions: {
|
||||
modelAssetPath: "https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite",
|
||||
delegate: "GPU"
|
||||
},
|
||||
runningMode: "IMAGE",
|
||||
outputCategoryMask: false,
|
||||
outputConfidenceMasks: true
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to initialize segmenter:", e);
|
||||
}
|
||||
};
|
||||
|
||||
export const analyzeAvatarImage = async (imageUrl: string): Promise<{ leftEye: Rect, rightEye: Rect, mouth: Rect, skinColor: string } | null> => {
|
||||
try {
|
||||
await initVision();
|
||||
@@ -126,3 +150,71 @@ export const analyzeAvatarImage = async (imageUrl: string): Promise<{ leftEye: R
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeBackground = async (imageUrl: string): Promise<string> => {
|
||||
try {
|
||||
await initSegmenter();
|
||||
if (!imageSegmenter) return imageUrl;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
try {
|
||||
// 1. Segment the image
|
||||
const segmentResult = imageSegmenter!.segment(img);
|
||||
const confidenceMasks = segmentResult.confidenceMasks;
|
||||
|
||||
if (!confidenceMasks || confidenceMasks.length === 0) {
|
||||
resolve(imageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Create canvas and context
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
resolve(imageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Draw original image
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
||||
const pixels = imageData.data;
|
||||
|
||||
// 4. Apply mask
|
||||
// The selfie_segmenter output mask is a Float32Array where values
|
||||
// indicate confidence of being a person (0.0 to 1.0).
|
||||
const mask = confidenceMasks[0].getAsFloat32Array();
|
||||
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
// Threshold for person confidence (0.3 is usually a good balance for hair details)
|
||||
const confidence = mask[i];
|
||||
if (confidence < 0.3) {
|
||||
pixels[i * 4 + 3] = 0; // Set Alpha to 0
|
||||
} else {
|
||||
// Optional: Soft edges
|
||||
// pixels[i * 4 + 3] = Math.floor(confidence * 255);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
resolve(canvas.toDataURL('image/png'));
|
||||
|
||||
} catch (e) {
|
||||
console.error("Segmentation error", e);
|
||||
resolve(imageUrl);
|
||||
}
|
||||
};
|
||||
img.onerror = () => resolve(imageUrl);
|
||||
img.src = imageUrl;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Background removal failed", e);
|
||||
return imageUrl;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user