Standardize compute job concurrency configuration by introducing a shared COMPUTE_JOB_CONCURRENCY setting, replacing separate PDF and Whisper concurrency controls. Add cross-platform CPU core detection and thread budgeting utilities, and update both ONNX model execution and concurrency limiters to use dynamic thread allocation per job. Refactor environment variable docs and examples to reflect unified concurrency management. Streamline PDF.js font path resolution for improved reliability across environments.
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
import * as ort from 'onnxruntime-node';
|
|
import { readFile } from 'fs/promises';
|
|
import type { LayoutRegion, PdfTextItem } from './types';
|
|
import { ensureModel, MODEL_CONFIG_PATH, MODEL_PREPROCESSOR_PATH } from './ensureModel';
|
|
import { getOnnxThreadsPerJob } from '../runtime/cpu-budget';
|
|
|
|
interface RunLayoutInput {
|
|
pageWidth: number;
|
|
pageHeight: number;
|
|
textItems: PdfTextItem[];
|
|
pageImage: Buffer;
|
|
}
|
|
|
|
const DEFAULT_INPUT_SIZE = 800;
|
|
const MIN_SCORE = 0.5;
|
|
const CLASS_MIN_SCORE: Partial<Record<LayoutRegion['label'], number>> = {
|
|
header: 0.4,
|
|
footer: 0.4,
|
|
figure_title: 0.45,
|
|
footnote: 0.45,
|
|
vision_footnote: 0.45,
|
|
};
|
|
|
|
const LABEL_MAP: Record<string, LayoutRegion['label'] | null> = {
|
|
// PP-DocLayoutV3 labels
|
|
abstract: 'abstract',
|
|
algorithm: 'algorithm',
|
|
aside_text: 'aside_text',
|
|
chart: 'chart',
|
|
content: 'content',
|
|
display_formula: 'formula',
|
|
doc_title: 'doc_title',
|
|
figure_title: 'figure_title',
|
|
footer: 'footer',
|
|
footer_image: 'footer',
|
|
footnote: 'footnote',
|
|
formula_number: 'formula_number',
|
|
header: 'header',
|
|
header_image: 'header',
|
|
image: 'image',
|
|
inline_formula: 'formula',
|
|
number: 'number',
|
|
paragraph_title: 'paragraph_title',
|
|
reference: 'reference',
|
|
reference_content: 'reference_content',
|
|
seal: 'seal',
|
|
table: 'table',
|
|
text: 'text',
|
|
vertical_text: 'text',
|
|
vision_footnote: 'vision_footnote',
|
|
};
|
|
|
|
const MIN_REGION_SIZE: Partial<Record<LayoutRegion['label'], { minWidth: number; minHeight: number }>> = {
|
|
abstract: { minWidth: 24, minHeight: 14 },
|
|
algorithm: { minWidth: 24, minHeight: 14 },
|
|
aside_text: { minWidth: 24, minHeight: 14 },
|
|
content: { minWidth: 24, minHeight: 14 },
|
|
text: { minWidth: 24, minHeight: 14 },
|
|
reference: { minWidth: 24, minHeight: 14 },
|
|
reference_content: { minWidth: 24, minHeight: 14 },
|
|
paragraph_title: { minWidth: 24, minHeight: 14 },
|
|
doc_title: { minWidth: 24, minHeight: 14 },
|
|
number: { minWidth: 18, minHeight: 12 },
|
|
figure_title: { minWidth: 18, minHeight: 10 },
|
|
footnote: { minWidth: 18, minHeight: 10 },
|
|
vision_footnote: { minWidth: 18, minHeight: 10 },
|
|
header: { minWidth: 18, minHeight: 10 },
|
|
footer: { minWidth: 18, minHeight: 10 },
|
|
};
|
|
|
|
interface ModelPreprocessor {
|
|
inputWidth: number;
|
|
inputHeight: number;
|
|
rescaleFactor: number;
|
|
mean: [number, number, number];
|
|
std: [number, number, number];
|
|
}
|
|
|
|
let sessionPromise: Promise<ort.InferenceSession> | null = null;
|
|
let idToLabelPromise: Promise<Record<number, string>> | null = null;
|
|
let preprocessorPromise: Promise<ModelPreprocessor> | null = null;
|
|
let canvasFnsPromise: Promise<{
|
|
createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D };
|
|
loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>;
|
|
}> | null = null;
|
|
|
|
async function getCanvasFns(): Promise<{
|
|
createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D };
|
|
loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>;
|
|
}> {
|
|
if (!canvasFnsPromise) {
|
|
canvasFnsPromise = (async () => {
|
|
const mod = await import('@napi-rs/canvas');
|
|
const namespace = mod as Record<string, unknown>;
|
|
const fallback = (namespace.default ?? {}) as Record<string, unknown>;
|
|
const createCanvasFn = (namespace.createCanvas ?? fallback.createCanvas) as
|
|
| ((width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D })
|
|
| undefined;
|
|
const loadImageFn = (namespace.loadImage ?? fallback.loadImage) as
|
|
| ((src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>)
|
|
| undefined;
|
|
|
|
if (typeof createCanvasFn !== 'function' || typeof loadImageFn !== 'function') {
|
|
throw new Error(
|
|
`Canvas runtime missing createCanvas/loadImage exports (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`,
|
|
);
|
|
}
|
|
|
|
return { createCanvasFn, loadImageFn };
|
|
})();
|
|
}
|
|
return canvasFnsPromise;
|
|
}
|
|
|
|
async function getSession(): Promise<ort.InferenceSession> {
|
|
if (!sessionPromise) {
|
|
sessionPromise = (async () => {
|
|
const modelPath = await ensureModel();
|
|
const onnxThreadsPerJob = getOnnxThreadsPerJob();
|
|
const stableSessionOptions: ort.InferenceSession.SessionOptions = {
|
|
executionProviders: ['cpu'],
|
|
graphOptimizationLevel: 'all',
|
|
intraOpNumThreads: onnxThreadsPerJob,
|
|
interOpNumThreads: 1,
|
|
executionMode: 'sequential',
|
|
};
|
|
return ort.InferenceSession.create(modelPath, {
|
|
...stableSessionOptions,
|
|
});
|
|
})();
|
|
}
|
|
return sessionPromise;
|
|
}
|
|
|
|
async function getIdToLabel(): Promise<Record<number, string>> {
|
|
if (!idToLabelPromise) {
|
|
idToLabelPromise = (async () => {
|
|
await ensureModel();
|
|
const raw = await readFile(MODEL_CONFIG_PATH, 'utf8');
|
|
const parsed = JSON.parse(raw) as { id2label?: Record<string, string> };
|
|
const out: Record<number, string> = {};
|
|
for (const [key, value] of Object.entries(parsed.id2label ?? {})) {
|
|
const n = Number(key);
|
|
if (Number.isFinite(n)) out[n] = String(value ?? '').trim();
|
|
}
|
|
return out;
|
|
})();
|
|
}
|
|
return idToLabelPromise;
|
|
}
|
|
|
|
async function getPreprocessor(): Promise<ModelPreprocessor> {
|
|
if (!preprocessorPromise) {
|
|
preprocessorPromise = (async () => {
|
|
await ensureModel();
|
|
const raw = await readFile(MODEL_PREPROCESSOR_PATH, 'utf8');
|
|
const parsed = JSON.parse(raw) as {
|
|
size?: { width?: number; height?: number };
|
|
rescale_factor?: number;
|
|
image_mean?: number[];
|
|
image_std?: number[];
|
|
};
|
|
|
|
const inputWidth = Math.max(1, Number(parsed.size?.width ?? DEFAULT_INPUT_SIZE));
|
|
const inputHeight = Math.max(1, Number(parsed.size?.height ?? DEFAULT_INPUT_SIZE));
|
|
const rescaleFactor = Number.isFinite(parsed.rescale_factor) ? Number(parsed.rescale_factor) : (1 / 255);
|
|
const mean = [
|
|
Number(parsed.image_mean?.[0] ?? 0),
|
|
Number(parsed.image_mean?.[1] ?? 0),
|
|
Number(parsed.image_mean?.[2] ?? 0),
|
|
] as [number, number, number];
|
|
const std = [
|
|
Number(parsed.image_std?.[0] ?? 1),
|
|
Number(parsed.image_std?.[1] ?? 1),
|
|
Number(parsed.image_std?.[2] ?? 1),
|
|
] as [number, number, number];
|
|
|
|
return {
|
|
inputWidth,
|
|
inputHeight,
|
|
rescaleFactor,
|
|
mean,
|
|
std,
|
|
};
|
|
})();
|
|
}
|
|
return preprocessorPromise;
|
|
}
|
|
|
|
function preprocessResized(
|
|
image: CanvasImageSource,
|
|
preprocessor: ModelPreprocessor,
|
|
createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D },
|
|
): ort.Tensor {
|
|
const canvas = createCanvasFn(preprocessor.inputWidth, preprocessor.inputHeight);
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.imageSmoothingQuality = 'high';
|
|
ctx.drawImage(image, 0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
|
|
|
|
const imageData = ctx.getImageData(0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
|
|
const chw = new Float32Array(1 * 3 * preprocessor.inputWidth * preprocessor.inputHeight);
|
|
const channelSize = preprocessor.inputWidth * preprocessor.inputHeight;
|
|
for (let y = 0; y < preprocessor.inputHeight; y += 1) {
|
|
for (let x = 0; x < preprocessor.inputWidth; x += 1) {
|
|
const pixelIndex = (y * preprocessor.inputWidth + x) * 4;
|
|
const idx = y * preprocessor.inputWidth + x;
|
|
const r = imageData.data[pixelIndex] * preprocessor.rescaleFactor;
|
|
const g = imageData.data[pixelIndex + 1] * preprocessor.rescaleFactor;
|
|
const b = imageData.data[pixelIndex + 2] * preprocessor.rescaleFactor;
|
|
|
|
chw[idx] = (r - preprocessor.mean[0]) / Math.max(1e-8, preprocessor.std[0]);
|
|
chw[channelSize + idx] = (g - preprocessor.mean[1]) / Math.max(1e-8, preprocessor.std[1]);
|
|
chw[channelSize * 2 + idx] = (b - preprocessor.mean[2]) / Math.max(1e-8, preprocessor.std[2]);
|
|
}
|
|
}
|
|
return new ort.Tensor('float32', chw, [1, 3, preprocessor.inputHeight, preprocessor.inputWidth]);
|
|
}
|
|
|
|
function clampBox(
|
|
bbox: [number, number, number, number],
|
|
pageWidth: number,
|
|
pageHeight: number,
|
|
): [number, number, number, number] | null {
|
|
const x0 = Math.max(0, Math.min(pageWidth, bbox[0]));
|
|
const y0 = Math.max(0, Math.min(pageHeight, bbox[1]));
|
|
const x1 = Math.max(0, Math.min(pageWidth, bbox[2]));
|
|
const y1 = Math.max(0, Math.min(pageHeight, bbox[3]));
|
|
if (x1 <= x0 || y1 <= y0) return null;
|
|
return [x0, y0, x1, y1];
|
|
}
|
|
|
|
function softmaxMax(logits: Float32Array, offset: number, count: number): { index: number; score: number } {
|
|
let maxLogit = Number.NEGATIVE_INFINITY;
|
|
let maxIndex = 0;
|
|
for (let i = 0; i < count; i += 1) {
|
|
const value = logits[offset + i];
|
|
if (value > maxLogit) {
|
|
maxLogit = value;
|
|
maxIndex = i;
|
|
}
|
|
}
|
|
|
|
let sum = 0;
|
|
for (let i = 0; i < count; i += 1) {
|
|
sum += Math.exp(logits[offset + i] - maxLogit);
|
|
}
|
|
|
|
const score = sum > 0 ? (1 / sum) : 0;
|
|
return { index: maxIndex, score };
|
|
}
|
|
|
|
function normalizeModelLabel(rawLabel: string): string {
|
|
const normalized = rawLabel.trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
if (normalized.endsWith('_image')) {
|
|
const base = normalized.slice(0, -'_image'.length);
|
|
if (base === 'header' || base === 'footer') return normalized;
|
|
}
|
|
// Some exports suffix duplicate classes (e.g. header_1, footer_1, text_1).
|
|
return normalized.replace(/_\d+$/g, '');
|
|
}
|
|
|
|
export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegion[]> {
|
|
const { pageWidth, pageHeight, textItems, pageImage } = input;
|
|
if (!textItems.length) return [];
|
|
if (!pageImage || pageImage.byteLength === 0) {
|
|
throw new Error('layout-render-missing-page-image');
|
|
}
|
|
|
|
try {
|
|
const [session, idToLabel, preprocessor, canvasFns] = await Promise.all([
|
|
getSession(),
|
|
getIdToLabel(),
|
|
getPreprocessor(),
|
|
getCanvasFns(),
|
|
]);
|
|
|
|
const decodedPageImage = await canvasFns.loadImageFn(pageImage);
|
|
const pixelValues = preprocessResized(decodedPageImage, preprocessor, canvasFns.createCanvasFn);
|
|
const output = await session.run({ pixel_values: pixelValues });
|
|
|
|
const logits = output.logits?.data as Float32Array | undefined;
|
|
const predBoxes = output.pred_boxes?.data as Float32Array | undefined;
|
|
if (!logits || !predBoxes) return [];
|
|
|
|
const numQueries = Math.floor(predBoxes.length / 4);
|
|
if (numQueries <= 0) return [];
|
|
const classCount = Math.floor(logits.length / numQueries);
|
|
if (classCount <= 0) return [];
|
|
|
|
const regions: LayoutRegion[] = [];
|
|
|
|
for (let queryIdx = 0; queryIdx < numQueries; queryIdx += 1) {
|
|
const cls = softmaxMax(logits, queryIdx * classCount, classCount);
|
|
const rawLabel = idToLabel[cls.index];
|
|
if (!rawLabel) continue;
|
|
const mapped = LABEL_MAP[normalizeModelLabel(rawLabel)];
|
|
if (!mapped) continue;
|
|
|
|
const minScore = CLASS_MIN_SCORE[mapped] ?? MIN_SCORE;
|
|
if (!Number.isFinite(cls.score) || cls.score < minScore) continue;
|
|
|
|
const cx = predBoxes[queryIdx * 4 + 0] * pageWidth;
|
|
const cy = predBoxes[queryIdx * 4 + 1] * pageHeight;
|
|
const w = predBoxes[queryIdx * 4 + 2] * pageWidth;
|
|
const h = predBoxes[queryIdx * 4 + 3] * pageHeight;
|
|
const rawBox: [number, number, number, number] = [
|
|
cx - w / 2,
|
|
cy - h / 2,
|
|
cx + w / 2,
|
|
cy + h / 2,
|
|
];
|
|
|
|
const clamped = clampBox(rawBox, pageWidth, pageHeight);
|
|
if (!clamped) continue;
|
|
|
|
const sizeRule = MIN_REGION_SIZE[mapped];
|
|
if (sizeRule) {
|
|
const width = clamped[2] - clamped[0];
|
|
const height = clamped[3] - clamped[1];
|
|
if (width < sizeRule.minWidth || height < sizeRule.minHeight) continue;
|
|
}
|
|
|
|
regions.push({
|
|
bbox: clamped,
|
|
label: mapped,
|
|
confidence: cls.score,
|
|
});
|
|
}
|
|
|
|
return regions.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0));
|
|
} catch (error) {
|
|
throw new Error(
|
|
`layout-model-inference-failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|