PaddyLiteX / browser-runtime.js
Wizzas's picture
Improve JPG upload compatibility
a0877e4 verified
Raw History Blame Contribute Delete
8.1 kB
/* Local adapter for the existing UI. There are no Python or Render requests. */
(function () {
'use strict';
const root = new URL('./', document.currentScript.src);
const nativeFetch = window.fetch.bind(window);
let manifestPromise;
let worker;
let workerFailure;
let sequence = 0;
let busy = false;
const pending = new Map();
const manifest = () => manifestPromise ??= nativeFetch(new URL('browser-models.json', root))
.then(response => { if (!response.ok) throw new Error('Browser model manifest is missing.'); return response.json(); });
const reply = (data, status = 200) => new Response(JSON.stringify(data), {
status, headers: { 'Content-Type': 'application/json' },
});
function getWorker() {
if (workerFailure) throw new Error(workerFailure);
if (worker) return worker;
worker = new Worker(new URL('detection-worker.js', root));
worker.onmessage = ({ data }) => {
const task = pending.get(data.requestId);
if (!task) return;
if (data.stage) {
const message = document.getElementById('message');
if (message) message.textContent = data.stage;
return;
}
pending.delete(data.requestId);
if (data.error) task.reject(new Error(data.error));
else task.resolve(data.detections);
};
worker.onerror = () => {
workerFailure = 'Browser runtime could not start. Check that the vendor runtime files were uploaded.';
for (const task of pending.values()) task.reject(new Error(workerFailure));
pending.clear();
worker.terminate();
worker = null;
};
return worker;
}
function runModel(model, size, input, transform, confidence) {
const detector = getWorker();
const requestId = ++sequence;
return new Promise((resolve, reject) => {
pending.set(requestId, { resolve, reject });
// Transfer a separate tensor for each model; the original stays reusable.
const copy = input.slice();
detector.postMessage({ requestId, model, size, input: copy.buffer, transform, confidence }, [copy.buffer]);
});
}
function prepareImage(image, size) {
const ratio = Math.min(size / image.width, size / image.height);
const resizedWidth = Math.round(image.width * ratio);
const resizedHeight = Math.round(image.height * ratio);
const left = Math.round((size - resizedWidth) / 2 - 0.1);
const top = Math.round((size - resizedHeight) / 2 - 0.1);
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.fillStyle = 'rgb(114, 114, 114)';
ctx.fillRect(0, 0, size, size);
ctx.drawImage(image, left, top, resizedWidth, resizedHeight);
const pixels = ctx.getImageData(0, 0, size, size).data;
const plane = size * size;
const input = new Float32Array(3 * plane);
for (let pixel = 0; pixel < plane; pixel += 1) {
for (let channel = 0; channel < 3; channel += 1) {
input[channel * plane + pixel] = pixels[pixel * 4 + channel] / 255;
}
}
return { input, transform: { ratio, left, top, width: image.width, height: image.height } };
}
function annotate(image, detections) {
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0);
const fontSize = Math.max(12, Math.round(Math.max(image.width, image.height) / 45));
ctx.font = '600 ' + fontSize + 'px system-ui, sans-serif';
ctx.lineWidth = Math.max(2, Math.round(fontSize / 6));
const colors = ['#ff4848', '#ee9635', '#2aab65', '#2a98dd', '#8861d6', '#dc5697', '#b58530', '#278d84', '#cc6144'];
for (const detection of detections) {
const [x1, y1, x2, y2] = detection.box;
const color = colors[detection.class_id % colors.length];
ctx.strokeStyle = color;
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
const label = detection.class_name + ' ' + (detection.confidence * 100).toFixed(1) + '%';
const labelWidth = Math.min(canvas.width, ctx.measureText(label).width + 10);
const labelX = Math.max(0, Math.min(x1, canvas.width - labelWidth));
const labelY = Math.max(0, y1 - fontSize - 8);
ctx.fillStyle = color;
ctx.fillRect(labelX, labelY, labelWidth, fontSize + 8);
ctx.fillStyle = '#ffffff';
ctx.fillText(label, labelX + 5, labelY + fontSize + 1, labelWidth - 10);
}
return canvas.toDataURL('image/png');
}
// JPG upload compatibility: some files have an alias or no MIME type.
function isSupportedImageFile(file) {
if (!file) return false;
const imageType = (file.type || '').toLowerCase();
const supportedTypes = [
'image/jpeg',
'image/jpg',
'image/pjpeg',
'image/png',
'image/webp',
];
if (supportedTypes.includes(imageType)) return true;
// The existing image decoder still verifies that this is an actual image.
return (
['', 'application/octet-stream'].includes(imageType) &&
/\.(jpg|jpeg|png|webp)$/i.test(file.name || '')
);
}
async function compare(form) {
if (busy) return reply({ detail: 'A comparison is already running on this device.' }, 429);
busy = true;
let image;
try {
const models = await manifest();
if (!models.ready || models.models.length !== 3) {
return reply({ detail: models.message || 'Export and upload the three ONNX models first.' }, 503);
}
const file = form.get('file');
const confidence = Number(form.get('confidence'));
const size = Number(form.get('image_size'));
if (!isSupportedImageFile(file)) {
throw new Error('Choose a JPG, JPEG, PNG, or WebP image.');
}
if (file.size > 10 * 1024 * 1024) throw new Error('Image exceeds 10 MB.');
if (![320, 512, 640].includes(size) || !Number.isFinite(confidence) || confidence < 0.05 || confidence > 0.95) throw new Error('Invalid detection settings.');
image = await createImageBitmap(file);
if (image.width * image.height > 12000000) throw new Error('Image exceeds 12 megapixels.');
const { input, transform } = prepareImage(image, size);
const results = [];
for (const model of models.models) {
try {
if (!model.files[String(size)]) throw new Error('This input size has not been exported.');
const detections = await runModel(model, size, input, transform, confidence);
results.push({ id: model.id, status: 'success', detections,
image: annotate(image, detections),
class_names: Object.fromEntries(model.names.map((name, id) => [String(id), name])) });
} catch (error) {
results.push({ id: model.id, status: 'error', message: error.message });
}
}
document.getElementById('message').textContent = results.some(result => result.status === 'error')
? 'Some browser models could not run. See the model cards for details.'
: 'Comparison completed on your device.';
return reply({ results, image_size: [image.width, image.height], settings: { confidence, imgsz: size, iou: 0.7 } });
} catch (error) {
return reply({ detail: error.message }, 400);
} finally {
image?.close();
busy = false;
}
}
window.PaddyLiteXBrowser = {
async fetch(url, options = {}) {
if (url === '/api/benchmarks') return nativeFetch(new URL('results.json', root));
if (url === '/api/status') {
const models = await manifest();
const benchmarks = await (await nativeFetch(new URL('results.json', root))).json();
return reply({ inference_enabled: Boolean(models.ready), models: benchmarks.main.map(model => ({
id: model.id, checkpoint_present: Boolean(models.ready && models.models.some(item => item.id === model.id)),
})) });
}
if (url === '/api/compare') return compare(options.body);
throw new Error('Unsupported browser API route: ' + url);
},
};
})();