tooltrace-tracer / trace-server.cjs
pavanhgfcid's picture
Upload 5 files
95e5c44 verified
Raw
History Blame Contribute Delete
7.7 kB
/**
* ToolTrace BiRefNet tracer backend (stateless image → mask).
* POST /trace body = crop image bytes (PNG/JPEG) → returns grayscale mask PNG
* GET /health → { ok, model, threads }
*
* BiRefNet captures chrome/reflective tools (the hammer shaft, caliper jaws) that
* IS-Net drops, but fp32/1024² OOMs the browser — so it runs here. Session is
* loaded once and warmed; threading + graph-opt minimise per-request latency.
*
* Run: node server/trace-server.cjs (local: uses root node_modules + local model)
* Env: PORT (8787) · BIREFNET_MODEL (path) · BIREFNET_MODEL_URL (fetch if missing) ·
* ORT_THREADS · TRACE_KEY (if set, /trace requires header X-Trace-Key to match)
*/
const http = require('http');
const os = require('os');
const fs = require('fs');
const path = require('path');
const https = require('https');
const httpc = require('http');
const ort = require('onnxruntime-node');
const sharp = require('sharp');
const PORT = Number(process.env.PORT) || 8787;
const MODEL = process.env.BIREFNET_MODEL || 'public/models/birefnet_lite.onnx';
const MODEL_URL = process.env.BIREFNET_MODEL_URL || ''; // download here if MODEL absent (Railway etc.)
const TRACE_KEY = process.env.TRACE_KEY || ''; // shared secret; empty = open (local dev)
const THREADS = Number(process.env.ORT_THREADS) || Math.max(1, os.cpus().length);
const SIZE = 1024;
const MEAN = [0.485, 0.456, 0.406], STD = [0.229, 0.224, 0.225];
let session = null;
// The 224MB model isn't in git. Locally it's a file on disk; on a host (Railway)
// set BIREFNET_MODEL_URL (e.g. a Cloudflare R2 / S3 object) and it's fetched once
// at startup. Follows redirects (object stores 302 to a signed URL).
function downloadModel(url, dest) {
return new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(dest), { recursive: true });
const tmp = `${dest}.part`;
const file = fs.createWriteStream(tmp);
const get = (u, depth) => {
if (depth > 5) { reject(new Error('too many redirects')); return; }
(u.startsWith('https') ? https : httpc).get(u, (res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) { res.resume(); get(res.headers.location, depth + 1); return; }
if (res.statusCode !== 200) { res.resume(); reject(new Error(`model download HTTP ${res.statusCode}`)); return; }
res.pipe(file);
file.on('finish', () => file.close(() => { fs.renameSync(tmp, dest); resolve(); }));
}).on('error', reject);
};
get(url, 0);
});
}
async function init() {
const t0 = Date.now();
if (!fs.existsSync(MODEL)) {
if (!MODEL_URL) throw new Error(`Model not found at ${MODEL} and BIREFNET_MODEL_URL is not set`);
console.log(`model missing at ${MODEL} — downloading from ${MODEL_URL} …`);
await downloadModel(MODEL_URL, MODEL);
console.log(`model downloaded (${(fs.statSync(MODEL).size / 1e6).toFixed(0)}MB)`);
}
// Sequential + single inter-op thread, all intra-op threads on the one operator:
// fastest config in benchmarking (parallel/inter-op only added overhead here).
// Latency scales with physical cores — ~8s on a 4-core dev box, ~3-4s on a server.
session = await ort.InferenceSession.create(MODEL, {
intraOpNumThreads: THREADS,
interOpNumThreads: 1,
graphOptimizationLevel: 'all',
executionMode: 'sequential',
});
// Warm-up: first run JITs/allocates, so real requests don't pay that cost.
const warm = {}; warm[session.inputNames[0]] = new ort.Tensor('float32', new Float32Array(3 * SIZE * SIZE), [1, 3, SIZE, SIZE]);
const tw = Date.now(); await session.run(warm);
console.log(`BiRefNet ready: load ${((tw - t0) / 1000).toFixed(1)}s · warmup ${((Date.now() - tw) / 1000).toFixed(1)}s · threads=${THREADS}`);
}
async function trace(imgBuf) {
const meta = await sharp(imgBuf).metadata();
const W = meta.width, H = meta.height;
const { data } = await sharp(imgBuf).resize(SIZE, SIZE, { fit: 'fill' }).removeAlpha().raw().toBuffer({ resolveWithObject: true });
const plane = SIZE * SIZE;
const input = new Float32Array(3 * plane);
for (let i = 0; i < plane; i++) {
input[i] = (data[i * 3] / 255 - MEAN[0]) / STD[0];
input[plane + i] = (data[i * 3 + 1] / 255 - MEAN[1]) / STD[1];
input[2 * plane + i] = (data[i * 3 + 2] / 255 - MEAN[2]) / STD[2];
}
const feeds = {}; feeds[session.inputNames[0]] = new ort.Tensor('float32', input, [1, 3, SIZE, SIZE]);
const out = await session.run(feeds);
const sal = out[session.outputNames[0]].data;
let mn = Infinity, mx = -Infinity;
for (let i = 0; i < plane; i++) { const v = sal[i]; if (v < mn) mn = v; if (v > mx) mx = v; }
const range = mx - mn || 1;
const gray = Buffer.allocUnsafe(plane);
for (let i = 0; i < plane; i++) { const v = ((sal[i] - mn) / range) * 255; gray[i] = v < 0 ? 0 : v > 255 ? 255 : v; }
// resize the 1024² mask back to the crop's size; client thresholds + traces it
return sharp(gray, { raw: { width: SIZE, height: SIZE, channels: 1 } }).resize(W, H, { fit: 'fill' }).png().toBuffer();
}
const server = http.createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Trace-Key');
// The app is cross-origin isolated (COOP/COEP require-corp for WASM threads);
// this lets its fetch() consume our cross-origin response.
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: !!session, model: 'BiRefNet Lite', threads: THREADS }));
return;
}
// Shared-secret gate (only when TRACE_KEY is configured, so local dev stays open).
if (TRACE_KEY && req.headers['x-trace-key'] !== TRACE_KEY) { res.writeHead(401); res.end('unauthorized'); return; }
if (req.method === 'POST' && req.url === '/trace') {
if (!session) { res.writeHead(503); res.end('model not ready'); return; }
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', async () => {
const t0 = Date.now();
try {
const maskPng = await trace(Buffer.concat(chunks));
res.writeHead(200, { 'Content-Type': 'image/png', 'X-Trace-Ms': String(Date.now() - t0) });
res.end(maskPng);
console.log(`/trace ${Date.now() - t0}ms (${Buffer.concat(chunks).length}b in)`);
} catch (e) { console.error('/trace error', e); res.writeHead(500); res.end(String(e.message || e)); }
});
return;
}
res.writeHead(404); res.end('not found');
});
// Bind the port FIRST (cheap), then load the model — so a port clash fails in
// milliseconds with a clear message instead of after the ~18s model warm-up.
// /health reports ok:false and /trace returns 503 until the session is ready.
server.on('error', (e) => {
if (e.code === 'EADDRINUSE') {
console.error(`Port ${PORT} is already in use — a tracer is probably already running.\n` +
`Stop the other instance first, or start this one on another port: PORT=8788 node server/trace-server.cjs`);
process.exit(1);
}
throw e;
});
server.listen(PORT, () => {
console.log(`ToolTrace tracer listening on http://localhost:${PORT} — loading model…`);
init().catch((e) => { console.error('init failed', e); process.exit(1); });
});