feat(heal): add server-side AI inpainting endpoint via ONNX Runtime

This commit is contained in:
2026-10-16 20:23:24 +07:00
parent a5031c76d5
commit 8aa59bcfb2
5 changed files with 321 additions and 2 deletions
+74
View File
@@ -0,0 +1,74 @@
import * as ort from 'onnxruntime-node';
import { existsSync } from 'node:fs';
const MODEL_PATH = process.env.INPAINT_MODEL_PATH || '/models/migan_pipeline_v2.onnx';
let sessionPromise: Promise<ort.InferenceSession | null> | null = null;
export async function getInpaintSession(): Promise<ort.InferenceSession | null> {
if (!sessionPromise) {
sessionPromise = (async () => {
const candidates = [
MODEL_PATH,
'/home/locpham/RecipesCam/docker/frontend/public/models/migan_pipeline_v2.onnx',
'../frontend/public/models/migan_pipeline_v2.onnx',
'./models/migan_pipeline_v2.onnx',
];
let foundPath: string | null = null;
for (const p of candidates) {
if (existsSync(p)) {
foundPath = p;
break;
}
}
if (!foundPath) {
console.warn('[inpaintService] Model file not found in candidates:', candidates);
return null;
}
console.log('[inpaintService] Loading ONNX model from:', foundPath);
const t0 = Date.now();
const session = await ort.InferenceSession.create(foundPath, {
executionProviders: ['cpu'],
graphOptimizationLevel: 'all',
});
console.log('[inpaintService] Model loaded in ' + (Date.now() - t0) + 'ms');
return session;
})().catch((err) => {
console.error('[inpaintService] Failed to load ONNX model:', err);
sessionPromise = null;
return null;
});
}
return sessionPromise;
}
export async function runServerInpaint(
srcBytes: Uint8Array,
maskBytes: Uint8Array,
w: number,
h: number
): Promise<Uint8Array | null> {
const session = await getInpaintSession();
if (!session) return null;
const in0 = new ort.Tensor('uint8', srcBytes, [1, 3, h, w]);
const in1 = new ort.Tensor('uint8', maskBytes, [1, 1, h, w]);
const out = await session.run({
[session.inputNames[0]]: in0,
[session.inputNames[1]]: in1,
});
const tensor = out[session.outputNames[0]];
const data = tensor.data as Uint8Array;
const patch = new Uint8Array(4 * w * h);
const plane = w * h;
for (let i = 0; i < plane; i++) {
patch[i * 4] = data[i];
patch[i * 4 + 1] = data[plane + i];
patch[i * 4 + 2] = data[2 * plane + i];
patch[i * 4 + 3] = 255;
}
return patch;
}
+38
View File
@@ -1,3 +1,4 @@
import { runServerInpaint } from './inpaintService';
import { recipeFile } from './recipeFile';
import { sendVerificationMail } from './mailer';
import { placeName } from './place';
@@ -425,6 +426,43 @@ async function lookupGeo(ip: string): Promise<GeoPlace | null> {
// ---- routes ----
app.get('/api/health', async () => ({ ok: true }));
// AI Inpainting endpoint (Hybrid Option 2)
app.post('/api/inpaint', { bodyLimit: 10 * 1024 * 1024 }, async (req, reply) => {
const b = bodyOf(req) as {
src?: string;
mask?: string;
w?: number;
h?: number;
};
if (!b || !b.src || !b.mask || typeof b.w !== 'number' || typeof b.h !== 'number') {
return reply.status(400).send({ ok: false, error: 'Missing src, mask, w, or h' });
}
const { w, h } = b;
const srcBuf = Buffer.from(b.src, 'base64');
const maskBuf = Buffer.from(b.mask, 'base64');
if (srcBuf.length !== 3 * w * h || maskBuf.length !== w * h) {
return reply.status(400).send({ ok: false, error: 'Dimension mismatch' });
}
try {
const patch = await runServerInpaint(new Uint8Array(srcBuf), new Uint8Array(maskBuf), w, h);
if (!patch) {
return reply.status(503).send({ ok: false, error: 'Inpaint model unavailable' });
}
return reply.send({
ok: true,
patch: Buffer.from(patch).toString('base64'),
w,
h,
});
} catch (err: any) {
req.log.error(err, 'Inpaint inference error');
return reply.status(500).send({ ok: false, error: String(err?.message || err) });
}
});
// Native file manager launcher (Open in window explorer)
app.post('/api/open-explorer', async (req, reply) => {
const b = bodyOf(req);