mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
2b7b98f685
Pack text into a continuous sentinel-delimited stream (↵ = U+21B5) so
wrapLines fills every row to `cols` instead of leaving dead right-margin.
Adds reflow/dereflow, renderTextToPngsReflow{,MultiCol} variants, a full
test suite, and an A/B eval harness with L1 OCR + L2 session results.
134 lines
4.1 KiB
JavaScript
134 lines
4.1 KiB
JavaScript
/**
|
||
* eval/lib/diff.mjs
|
||
*
|
||
* Character-level accuracy / edit-distance utilities for the L1 OCR eval.
|
||
*
|
||
* Uses Wagner–Fischer dynamic programming for Levenshtein distance.
|
||
* We operate on Unicode codepoints (not UTF-16 code units) so that
|
||
* multi-byte characters like ↵ are counted as single edits.
|
||
*/
|
||
|
||
/**
|
||
* Convert a string to an array of Unicode codepoints.
|
||
* @param {string} s
|
||
* @returns {number[]}
|
||
*/
|
||
function codepoints(s) {
|
||
return [...s].map(c => c.codePointAt(0));
|
||
}
|
||
|
||
/**
|
||
* Levenshtein edit distance between two strings, operating at the
|
||
* Unicode codepoint level.
|
||
*
|
||
* Space-optimised: O(min(|a|,|b|)) memory.
|
||
*
|
||
* @param {string} a
|
||
* @param {string} b
|
||
* @returns {number}
|
||
*/
|
||
export function levenshtein(a, b) {
|
||
const sa = codepoints(a);
|
||
const sb = codepoints(b);
|
||
if (sa.length === 0) return sb.length;
|
||
if (sb.length === 0) return sa.length;
|
||
|
||
// Keep shorter string in the inner dimension for cache efficiency
|
||
const [long, short] = sa.length >= sb.length ? [sa, sb] : [sb, sa];
|
||
|
||
let prev = Array.from({ length: short.length + 1 }, (_, i) => i);
|
||
for (let i = 1; i <= long.length; i++) {
|
||
const curr = [i];
|
||
for (let j = 1; j <= short.length; j++) {
|
||
const cost = long[i - 1] === short[j - 1] ? 0 : 1;
|
||
curr[j] = Math.min(
|
||
curr[j - 1] + 1, // insert
|
||
prev[j] + 1, // delete
|
||
prev[j - 1] + cost, // substitute
|
||
);
|
||
}
|
||
prev = curr;
|
||
}
|
||
return prev[short.length];
|
||
}
|
||
|
||
/**
|
||
* Character-level accuracy (0–1) where 1 = perfect transcription.
|
||
*
|
||
* accuracy = 1 − (editDistance / max(|ref|, |hyp|))
|
||
*
|
||
* Clamped to [0, 1].
|
||
*
|
||
* @param {string} reference The source / ground-truth text
|
||
* @param {string} hypothesis The OCR / model transcription
|
||
* @returns {number}
|
||
*/
|
||
export function charAccuracy(reference, hypothesis) {
|
||
const refLen = codepoints(reference).length;
|
||
const hypLen = codepoints(hypothesis).length;
|
||
const maxLen = Math.max(refLen, hypLen, 1);
|
||
const dist = levenshtein(reference, hypothesis);
|
||
return Math.max(0, 1 - dist / maxLen);
|
||
}
|
||
|
||
/**
|
||
* Normalise text for comparison: collapse whitespace runs and trim.
|
||
* Used before scoring so minor whitespace artefacts from OCR don't
|
||
* unfairly penalise the reflow path.
|
||
*
|
||
* @param {string} text
|
||
* @returns {string}
|
||
*/
|
||
export function normaliseForDiff(text) {
|
||
return text
|
||
.replace(/\r\n/g, '\n') // CRLF → LF
|
||
.replace(/[ \t]+/g, ' ') // collapse horizontal whitespace
|
||
.replace(/\n{3,}/g, '\n\n') // collapse 3+ blank lines
|
||
.trim();
|
||
}
|
||
|
||
/**
|
||
* Score a single block transcription.
|
||
*
|
||
* @param {{ reference: string, hypothesis: string }} params
|
||
* @returns {{ editDistance: number, charAccuracy: number, refLen: number, hypLen: number }}
|
||
*/
|
||
export function scoreTranscription({ reference, hypothesis }) {
|
||
const ref = normaliseForDiff(reference);
|
||
const hyp = normaliseForDiff(hypothesis);
|
||
const dist = levenshtein(ref, hyp);
|
||
const acc = charAccuracy(ref, hyp);
|
||
return {
|
||
editDistance: dist,
|
||
charAccuracy: acc,
|
||
refLen: codepoints(ref).length,
|
||
hypLen: codepoints(hyp).length,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Aggregate an array of per-block scores into summary stats.
|
||
*
|
||
* @param {Array<{ editDistance: number, charAccuracy: number, refLen: number }>} scores
|
||
* @returns {{ meanAccuracy: number, medianAccuracy: number, minAccuracy: number, totalEdits: number, totalChars: number, macroAccuracy: number }}
|
||
*/
|
||
export function aggregateScores(scores) {
|
||
if (scores.length === 0) {
|
||
return { meanAccuracy: 0, medianAccuracy: 0, minAccuracy: 0, totalEdits: 0, totalChars: 0, macroAccuracy: 0 };
|
||
}
|
||
|
||
const accs = scores.map(s => s.charAccuracy).sort((a, b) => a - b);
|
||
const totalEdits = scores.reduce((s, r) => s + r.editDistance, 0);
|
||
const totalChars = scores.reduce((s, r) => s + r.refLen, 0);
|
||
|
||
return {
|
||
meanAccuracy: accs.reduce((s, v) => s + v, 0) / accs.length,
|
||
medianAccuracy: accs[Math.floor(accs.length / 2)],
|
||
minAccuracy: accs[0],
|
||
totalEdits,
|
||
totalChars,
|
||
/** Micro-averaged: treats all chars equally regardless of block size. */
|
||
macroAccuracy: totalChars > 0 ? Math.max(0, 1 - totalEdits / totalChars) : 0,
|
||
};
|
||
}
|