fix(pdf): handle non-zero viewport origins and improve layout model ordering
Update normalizeTextItemsForLayout to correctly apply viewport transforms, including non-zero page origins, ensuring accurate mapping of PDF text items to top-left coordinates. Refactor runLayoutModel to implement a custom order sequence builder for layout regions using model order logits, improving region ordering consistency with model semantics. Update related tests to cover viewport transform edge cases.
This commit is contained in:
parent
cb430d5db0
commit
529f42528a
4 changed files with 132 additions and 37 deletions
|
|
@ -1,7 +1,29 @@
|
||||||
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||||
import type { PdfTextItem } from './types';
|
import type { PdfTextItem } from './types';
|
||||||
|
|
||||||
export function normalizeTextItemsForLayout(items: TextItem[], pageHeight: number): PdfTextItem[] {
|
interface ViewportLike {
|
||||||
|
height: number;
|
||||||
|
transform: readonly number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyViewportTransform(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
transform: readonly number[],
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const a = Number(transform[0] ?? 1);
|
||||||
|
const b = Number(transform[1] ?? 0);
|
||||||
|
const c = Number(transform[2] ?? 0);
|
||||||
|
const d = Number(transform[3] ?? 1);
|
||||||
|
const e = Number(transform[4] ?? 0);
|
||||||
|
const f = Number(transform[5] ?? 0);
|
||||||
|
return {
|
||||||
|
x: (a * x) + (c * y) + e,
|
||||||
|
y: (b * x) + (d * y) + f,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTextItemsForLayout(items: TextItem[], viewport: ViewportLike): PdfTextItem[] {
|
||||||
return items
|
return items
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
if (!(typeof item.str === 'string' && item.str.trim().length > 0)) return false;
|
if (!(typeof item.str === 'string' && item.str.trim().length > 0)) return false;
|
||||||
|
|
@ -17,13 +39,18 @@ export function normalizeTextItemsForLayout(items: TextItem[], pageHeight: numbe
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const x = Number(item.transform[4] ?? 0);
|
const origin = applyViewportTransform(
|
||||||
|
Number(item.transform[4] ?? 0),
|
||||||
|
Number(item.transform[5] ?? 0),
|
||||||
|
viewport.transform,
|
||||||
|
);
|
||||||
const width = Math.max(0, Number(item.width ?? 0));
|
const width = Math.max(0, Number(item.width ?? 0));
|
||||||
const height = Math.max(1, Math.abs(Number(item.transform[3] ?? 1)));
|
const height = Math.max(1, Math.abs(Number(item.transform[3] ?? 1)));
|
||||||
const baselineY = Number(item.transform[5] ?? 0);
|
// pdf.js text transforms are in PDF user-space and may include non-zero
|
||||||
// pdf.js text transforms are in PDF user-space (origin bottom-left).
|
// page origins via the viewport transform. Map the text baseline into
|
||||||
// Normalize into top-left page coordinates to match rendered image/model boxes.
|
// viewport coordinates first, then convert baseline to top-left.
|
||||||
const y = Math.max(0, pageHeight - baselineY - height);
|
const x = Math.max(0, origin.x);
|
||||||
|
const y = Math.max(0, origin.y - height);
|
||||||
return {
|
return {
|
||||||
text: item.str,
|
text: item.str,
|
||||||
x,
|
x,
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ export async function parsePdf(input: ParsePdfInput): Promise<ParsedPdfDocument>
|
||||||
const textContent = await page.getTextContent();
|
const textContent = await page.getTextContent();
|
||||||
const textItems = normalizeTextItemsForLayout(
|
const textItems = normalizeTextItemsForLayout(
|
||||||
textContent.items.filter((item): item is TextItem => 'str' in item && 'transform' in item),
|
textContent.items.filter((item): item is TextItem => 'str' in item && 'transform' in item),
|
||||||
viewport.height,
|
viewport,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (textItems.length > 0) sawText = true;
|
if (textItems.length > 0) sawText = true;
|
||||||
|
|
|
||||||
|
|
@ -196,8 +196,10 @@ function preprocessResized(
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.fillRect(0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
|
ctx.fillRect(0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
|
||||||
ctx.imageSmoothingEnabled = true;
|
// Match the upstream image processor more closely. The official
|
||||||
ctx.imageSmoothingQuality = 'high';
|
// implementation explicitly disables antialiasing during resize to stay
|
||||||
|
// close to OpenCV semantics for PP-DocLayoutV3 inputs.
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
ctx.drawImage(image, 0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
|
ctx.drawImage(image, 0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
|
||||||
|
|
||||||
const imageData = ctx.getImageData(0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
|
const imageData = ctx.getImageData(0, 0, preprocessor.inputWidth, preprocessor.inputHeight);
|
||||||
|
|
@ -232,24 +234,40 @@ function clampBox(
|
||||||
return [x0, y0, x1, y1];
|
return [x0, y0, x1, y1];
|
||||||
}
|
}
|
||||||
|
|
||||||
function softmaxMax(logits: Float32Array, offset: number, count: number): { index: number; score: number } {
|
function sigmoid(value: number): number {
|
||||||
let maxLogit = Number.NEGATIVE_INFINITY;
|
if (value >= 0) {
|
||||||
let maxIndex = 0;
|
const z = Math.exp(-value);
|
||||||
for (let i = 0; i < count; i += 1) {
|
return 1 / (1 + z);
|
||||||
const value = logits[offset + i];
|
}
|
||||||
if (value > maxLogit) {
|
const z = Math.exp(value);
|
||||||
maxLogit = value;
|
return z / (1 + z);
|
||||||
maxIndex = i;
|
}
|
||||||
|
|
||||||
|
function buildOrderSequence(orderLogits: Float32Array, numQueries: number): number[] {
|
||||||
|
const orderVotes = new Float32Array(numQueries);
|
||||||
|
|
||||||
|
for (let col = 0; col < numQueries; col += 1) {
|
||||||
|
let votes = 0;
|
||||||
|
|
||||||
|
for (let row = 0; row < col; row += 1) {
|
||||||
|
votes += sigmoid(orderLogits[row * numQueries + col] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (let row = col + 1; row < numQueries; row += 1) {
|
||||||
|
votes += 1 - sigmoid(orderLogits[col * numQueries + row] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
orderVotes[col] = votes;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sum = 0;
|
const pointers = Array.from({ length: numQueries }, (_, index) => index)
|
||||||
for (let i = 0; i < count; i += 1) {
|
.sort((a, b) => orderVotes[a]! - orderVotes[b]!);
|
||||||
sum += Math.exp(logits[offset + i] - maxLogit);
|
const orderSeq = new Array<number>(numQueries);
|
||||||
|
for (let rank = 0; rank < pointers.length; rank += 1) {
|
||||||
|
const queryIndex = pointers[rank]!;
|
||||||
|
orderSeq[queryIndex] = rank;
|
||||||
}
|
}
|
||||||
|
return orderSeq;
|
||||||
const score = sum > 0 ? (1 / sum) : 0;
|
|
||||||
return { index: maxIndex, score };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeModelLabel(rawLabel: string): string {
|
function normalizeModelLabel(rawLabel: string): string {
|
||||||
|
|
@ -283,25 +301,26 @@ export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegio
|
||||||
|
|
||||||
const logits = output.logits?.data as Float32Array | undefined;
|
const logits = output.logits?.data as Float32Array | undefined;
|
||||||
const predBoxes = output.pred_boxes?.data as Float32Array | undefined;
|
const predBoxes = output.pred_boxes?.data as Float32Array | undefined;
|
||||||
|
const orderLogits = output.order_logits?.data as Float32Array | undefined;
|
||||||
if (!logits || !predBoxes) return [];
|
if (!logits || !predBoxes) return [];
|
||||||
|
|
||||||
const numQueries = Math.floor(predBoxes.length / 4);
|
const numQueries = Math.floor(predBoxes.length / 4);
|
||||||
if (numQueries <= 0) return [];
|
if (numQueries <= 0) return [];
|
||||||
const classCount = Math.floor(logits.length / numQueries);
|
const classCount = Math.floor(logits.length / numQueries);
|
||||||
if (classCount <= 0) return [];
|
if (classCount <= 0) return [];
|
||||||
|
const orderSeq = orderLogits && orderLogits.length >= numQueries * numQueries
|
||||||
|
? buildOrderSequence(orderLogits, numQueries)
|
||||||
|
: null;
|
||||||
|
|
||||||
const regions: LayoutRegion[] = [];
|
const detections: Array<{
|
||||||
|
queryIdx: number;
|
||||||
|
classIdx: number;
|
||||||
|
score: number;
|
||||||
|
order: number;
|
||||||
|
rawBox: [number, number, number, number];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
for (let queryIdx = 0; queryIdx < numQueries; queryIdx += 1) {
|
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 cx = predBoxes[queryIdx * 4 + 0] * pageWidth;
|
||||||
const cy = predBoxes[queryIdx * 4 + 1] * pageHeight;
|
const cy = predBoxes[queryIdx * 4 + 1] * pageHeight;
|
||||||
const w = predBoxes[queryIdx * 4 + 2] * pageWidth;
|
const w = predBoxes[queryIdx * 4 + 2] * pageWidth;
|
||||||
|
|
@ -313,7 +332,33 @@ export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegio
|
||||||
cy + h / 2,
|
cy + h / 2,
|
||||||
];
|
];
|
||||||
|
|
||||||
const clamped = clampBox(rawBox, pageWidth, pageHeight);
|
for (let classIdx = 0; classIdx < classCount; classIdx += 1) {
|
||||||
|
detections.push({
|
||||||
|
queryIdx,
|
||||||
|
classIdx,
|
||||||
|
score: sigmoid(logits[queryIdx * classCount + classIdx] ?? 0),
|
||||||
|
order: orderSeq?.[queryIdx] ?? queryIdx,
|
||||||
|
rawBox,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions: LayoutRegion[] = [];
|
||||||
|
detections.sort((a, b) => {
|
||||||
|
if (Math.abs(b.score - a.score) > 1e-6) return b.score - a.score;
|
||||||
|
return a.order - b.order;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const detection of detections.slice(0, numQueries)) {
|
||||||
|
const rawLabel = idToLabel[detection.classIdx];
|
||||||
|
if (!rawLabel) continue;
|
||||||
|
const mapped = LABEL_MAP[normalizeModelLabel(rawLabel)];
|
||||||
|
if (!mapped) continue;
|
||||||
|
|
||||||
|
const minScore = CLASS_MIN_SCORE[mapped] ?? MIN_SCORE;
|
||||||
|
if (!Number.isFinite(detection.score) || detection.score < minScore) continue;
|
||||||
|
|
||||||
|
const clamped = clampBox(detection.rawBox, pageWidth, pageHeight);
|
||||||
if (!clamped) continue;
|
if (!clamped) continue;
|
||||||
|
|
||||||
const sizeRule = MIN_REGION_SIZE[mapped];
|
const sizeRule = MIN_REGION_SIZE[mapped];
|
||||||
|
|
@ -326,7 +371,7 @@ export async function runLayoutModel(input: RunLayoutInput): Promise<LayoutRegio
|
||||||
regions.push({
|
regions.push({
|
||||||
bbox: clamped,
|
bbox: clamped,
|
||||||
label: mapped,
|
label: mapped,
|
||||||
confidence: cls.score,
|
confidence: detection.score,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,10 @@ describe('normalizeTextItemsForLayout', () => {
|
||||||
[0, 10, -10, 0, 30, 400],
|
[0, 10, -10, 0, 30, 400],
|
||||||
);
|
);
|
||||||
|
|
||||||
const normalized = normalizeTextItemsForLayout([horizontal, rotated], 800);
|
const normalized = normalizeTextItemsForLayout([horizontal, rotated], {
|
||||||
|
height: 800,
|
||||||
|
transform: [1, 0, 0, -1, 0, 800],
|
||||||
|
});
|
||||||
expect(normalized).toHaveLength(1);
|
expect(normalized).toHaveLength(1);
|
||||||
expect(normalized[0]?.text).toBe('Powered by large language models');
|
expect(normalized[0]?.text).toBe('Powered by large language models');
|
||||||
});
|
});
|
||||||
|
|
@ -41,7 +44,27 @@ describe('normalizeTextItemsForLayout', () => {
|
||||||
const vertical = makeTextItem('Side label', [0, 10, -10, 0, 30, 200]);
|
const vertical = makeTextItem('Side label', [0, 10, -10, 0, 30, 200]);
|
||||||
const skewed = makeTextItem('Watermark', [10, 5, 2, 10, 200, 500]);
|
const skewed = makeTextItem('Watermark', [10, 5, 2, 10, 200, 500]);
|
||||||
|
|
||||||
const normalized = normalizeTextItemsForLayout([vertical, skewed], 800);
|
const normalized = normalizeTextItemsForLayout([vertical, skewed], {
|
||||||
|
height: 800,
|
||||||
|
transform: [1, 0, 0, -1, 0, 800],
|
||||||
|
});
|
||||||
expect(normalized).toEqual([]);
|
expect(normalized).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('accounts for non-zero page origins in the viewport transform', () => {
|
||||||
|
const croppedPageLine = makeTextItem(
|
||||||
|
'Vasher turned away.',
|
||||||
|
[11.2, 0, 0, 11.2, 127.5, 644.4128],
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalized = normalizeTextItemsForLayout([croppedPageLine], {
|
||||||
|
height: 666.0074,
|
||||||
|
transform: [1, 0, 0, -1, -53.4352, 720.565],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized).toHaveLength(1);
|
||||||
|
expect(normalized[0]?.x).toBeCloseTo(74.0648, 4);
|
||||||
|
expect(normalized[0]?.y).toBeCloseTo(64.9522, 4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue