Add private local OCR comparison package

This commit is contained in:
OCR Team
2026-10-07 21:00:21 +02:00
commit 87a1d114da
15 changed files with 2044 additions and 0 deletions
+318
View File
@@ -0,0 +1,318 @@
"use strict";
const $ = id => document.getElementById(id);
const input = $("image-file"), original = $("original"), cornersCanvas = $("corners");
const processed = $("processed"), evidenceCanvas = $("evidence");
const run = $("run"), status = $("status"), rotation = $("rotation");
const initialCorners = () => [[0, 0], [1, 0], [1, 1], [0, 1]];
let corners = initialCorners(), edited = false, busy = false, ready = false;
let objectUrl = null, drag = null, result = null, selectedEvidence = null;
const manual = {classic: new Map(), vision: new Map()};
const suppressed = {classic: new Set(), vision: new Set()};
const priorityCodes = ["C.1.1", "C.1.2", "C.1.3", "B", "2.1", "2.2"];
const reviewMethods = new Set(["merged_code", "vl_table_context_code", "caption_region_next_line", "surname_above_forename"]);
const fieldNames = {"A":"Kennzeichen","C.1.1":"Name / Firmenname","C.1.2":"Vorname",
"C.1.3":"Anschrift","B":"Erstzulassung","2.1":"Herstellerschlüssel (HSN)",
"2.2":"Typschlüssel (TSN)","2":"Hersteller","D.1":"Marke","D.2":"Typ / Variante",
"D.3":"Handelsbezeichnung","E":"Fahrzeug-Identifizierungsnummer","P.3":"Kraftstoffart",
"R":"Farbe","17":"Merkmal zur Betriebserlaubnis"};
function setStatus(message, kind = "") {
status.textContent = message;
status.className = `status ${kind}`;
}
function validCorners() {
if (!ready) return false;
if (!edited) return true;
const w = original.naturalWidth, h = original.naturalHeight;
const points = corners.map(([x, y]) => [x * w, y * h]);
const edges = points.map((p, i) => [points[(i + 1) % 4][0] - p[0], points[(i + 1) % 4][1] - p[1]]);
const crosses = edges.map((a, i) => a[0] * edges[(i + 1) % 4][1] - a[1] * edges[(i + 1) % 4][0]);
const area = Math.abs(points.reduce((sum, p, i) => sum + p[0] * points[(i + 1) % 4][1] - p[1] * points[(i + 1) % 4][0], 0)) / 2;
return crosses.every(value => value > 0) && area > .02 * w * h &&
edges.every(([x, y]) => Math.hypot(x, y) >= 30);
}
function updateControls() {
input.disabled = busy;
$("reset-corners").disabled = !ready || busy;
rotation.disabled = busy;
run.disabled = busy || !validCorners();
}
function drawCorners() {
const w = original.naturalWidth, h = original.naturalHeight;
if (!w || !h) return;
cornersCanvas.width = w;
cornersCanvas.height = h;
const ctx = cornersCanvas.getContext("2d");
const pts = corners.map(([x, y]) => [x * (w - 1), y * (h - 1)]);
ctx.strokeStyle = "#1459dd";
ctx.lineWidth = Math.max(5, w / 550);
ctx.beginPath();
pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
ctx.closePath();ctx.stroke();
const radius = Math.max(25, w / 110);
pts.forEach(([x, y], i) => {
ctx.beginPath();ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fillStyle = "#fff";ctx.fill();ctx.stroke();
ctx.fillStyle = "#1547a5";ctx.font = `bold ${Math.max(22, w / 125)}px system-ui`;
ctx.fillText(String(i + 1), Math.min(w - radius, x + radius * .9), Math.max(radius, y - radius * .65));
});
}
function pointInCanvas(event) {
const b = cornersCanvas.getBoundingClientRect();
return [(event.clientX - b.left) / b.width, (event.clientY - b.top) / b.height];
}
cornersCanvas.addEventListener("pointerdown", event => {
if (!ready || busy) return;
const b = cornersCanvas.getBoundingClientRect();
const [x, y] = pointInCanvas(event);
const distances = corners.map(([cx, cy]) => Math.hypot((cx - x) * b.width, (cy - y) * b.height));
const index = distances.indexOf(Math.min(...distances));
if (distances[index] > 32) return;
drag = index;
cornersCanvas.setPointerCapture(event.pointerId);
event.preventDefault();
});
cornersCanvas.addEventListener("pointermove", event => {
if (drag === null || busy) return;
const [x, y] = pointInCanvas(event);
corners[drag] = [Math.max(0, Math.min(1, x)), Math.max(0, Math.min(1, y))];
edited = true; result = null; $("comparison").hidden = true;
drawCorners();updateControls();
setStatus(validCorners() ? "Ecken geändert. Bitte beide Verfahren neu starten." :
"Ecken müssen ein nicht gekreuztes Viereck um das Dokument bilden.", validCorners() ? "" : "error");
});
function finishDrag() {drag = null;}
cornersCanvas.addEventListener("pointerup", finishDrag);
cornersCanvas.addEventListener("pointercancel", finishDrag);
$("reset-corners").addEventListener("click", () => {
corners = initialCorners(); edited = false; result = null;
$("comparison").hidden = true;drawCorners();updateControls();setStatus("Ganzes Bild ausgewählt.");
});
rotation.addEventListener("change", () => {
result = null;$("comparison").hidden = true;setStatus("Leserichtung geändert. Bitte erneut vergleichen.");
});
input.addEventListener("change", () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
objectUrl = null; ready = false; edited = false; corners = initialCorners();
drag = null; result = null; selectedEvidence = null;
manual.classic.clear();manual.vision.clear();
suppressed.classic.clear();suppressed.vision.clear();
$("comparison").hidden = true;rotation.value = "0";
const file = input.files?.[0];
$("original-wrap").hidden = !file;
$("file-name").textContent = file ? `${file.name} · ${(file.size / 1048576).toFixed(2)} MiB` : "JPEG oder PNG, maximal 16 MiB.";
if (file) {objectUrl = URL.createObjectURL(file);original.src = objectUrl;}
else original.removeAttribute("src");
setStatus("");updateControls();
});
original.addEventListener("load", () => {
ready = true;drawCorners();updateControls();
$("file-name").textContent += ` · ${original.naturalWidth} × ${original.naturalHeight} Pixel`;
});
original.addEventListener("error", () => {
ready = false;updateControls();setStatus("Das Bild kann nicht geöffnet werden. Bitte JPEG oder PNG verwenden.", "error");
});
window.addEventListener("pagehide", () => {if (objectUrl) URL.revokeObjectURL(objectUrl);});
const errorText = {
busy: "Ein Vergleich läuft bereits.", unsupported_media_type: "Nur JPEG und PNG sind erlaubt.",
upload_too_large: "Das Bild ist größer als 16 MiB.", upload_timeout: "Der Upload hat zu lange gedauert.",
invalid_image: "Die Bilddatei lässt sich nicht vollständig lesen.",
unsupported_encoding: "Der Bildinhalt ist kein unterstütztes JPEG oder PNG.",
image_too_large: "Die Auflösung überschreitet 24 Megapixel oder 10.000 Pixel pro Seite.",
invalid_corners: "Die vier Ecken bilden kein gültiges Dokumentviereck.",
invalid_rotation: "Die gewählte Drehung ist ungültig.",
classic_timeout: "Die klassische OCR hat das Zeitlimit überschritten.",
classic_models_missing: "Die lokalen OCR-Modelle fehlen. Zuerst die Modell-Einrichtung aus der README ausführen.",
classic_failed: "Die klassische OCR ist lokal fehlgeschlagen.",
classic_invalid: "Die klassische OCR hat ein ungültiges Ergebnis geliefert.",
vision_timeout: "PaddleOCR-VL hat das Zeitlimit überschritten.",
vision_failed: "PaddleOCR-VL konnte auf der konfigurierten eigenen Hardware nicht ausgeführt werden.",
vision_disabled: "Vision ist in dieser Installation deaktiviert.",
vision_config_error: "Die Vision-Konfiguration ist unvollständig oder ungültig.",
vision_models_missing: "Die lokalen Vision-Modelle fehlen. Zuerst die Modell-Einrichtung aus der README ausführen.",
vision_invalid: "PaddleOCR-VL hat ein ungültiges Ergebnis geliefert.",
};
function element(tag, value, className = "") {
const node = document.createElement(tag);
node.textContent = value;
if (className) node.className = className;
return node;
}
function drawEvidence() {
if (!result || !processed.naturalWidth) return;
const w = processed.naturalWidth, h = processed.naturalHeight;
evidenceCanvas.width = w;evidenceCanvas.height = h;
const ctx = evidenceCanvas.getContext("2d");
if ($( "all-boxes" ).checked) {
for (const [engine, branch] of Object.entries(result.results)) {
if (branch.status !== "ok") continue;
ctx.strokeStyle = engine === "classic" ? "#008778" : "#425edf";
ctx.lineWidth = Math.max(2, w / 900);
for (const line of branch.lines) if (line.box) {
const [x1, y1, x2, y2] = line.box;
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
}
}
}
if (selectedEvidence?.anchor && selectedEvidence.anchor.join() !== selectedEvidence.box.join()) {
const [x1, y1, x2, y2] = selectedEvidence.anchor;
ctx.strokeStyle = "#d88614";ctx.fillStyle = "rgba(216,134,20,.13)";
ctx.lineWidth = Math.max(4, w / 400);
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
}
if (selectedEvidence?.box) {
const [x1, y1, x2, y2] = selectedEvidence.box;
ctx.strokeStyle = "#d72b4a";ctx.fillStyle = "rgba(215,43,74,.16)";
ctx.lineWidth = Math.max(4, w / 400);
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
}
}
function focusEvidence(box, anchor = null) {
if (!box) return;
selectedEvidence = {box, anchor};drawEvidence();
$("comparison").scrollIntoView({behavior:"smooth",block:"start"});
}
processed.addEventListener("load", drawEvidence);
$("all-boxes").addEventListener("change", drawEvidence);
function renderCard(engine) {
const card = $(`${engine}-card`), branch = result.results[engine];
card.replaceChildren(element("h3", engine === "classic" ? "A · Klassische OCR" : "B · Vision-Dokumentmodell"));
if (branch.status !== "ok") {
card.append(element("p", errorText[branch.error] || "Dieses Verfahren ist fehlgeschlagen.", "error"),
element("p", `${(branch.elapsed_ms / 1000).toFixed(1)} s`, "meta"));
return;
}
const automatic = branch.fields.filter(field => !suppressed[engine].has(field.code));
card.append(element("p", `${branch.model} · ${(branch.elapsed_ms / 1000).toFixed(1)} s · ${branch.lines.length} Textbereiche · ${automatic.length} automatische Feldvorschläge`, "meta"));
if (branch.layout_note) card.append(element("p", branch.layout_note, "note"));
card.append(element("p", "Die Zuordnung ist ein Vorschlag. Orange markiert den gelesenen Feldcode, Rot den Wert. Falsche Vorschläge lassen sich verwerfen; der Rohtext bleibt erhalten.", "assignment-note"));
card.append(element("h4", "Strukturierte Feldvorschläge"));
const merged = new Map(automatic.map(field => [field.code, field]));
for (const [code, field] of manual[engine]) merged.set(code, field);
const priority = element("div", "", "priority-fields");
priority.append(element("h5", "Wichtige Angaben"));
for (const code of priorityCodes) {
const field = merged.get(code);
priority.append(element("span", `${code} ${fieldNames[code]}: ${field ? (field.manual ? "manuell" : reviewMethods.has(field.method) ? "prüfen" : "Vorschlag") : "offen"}`,
`priority-item${field ? "" : " missing"}`));
}
card.append(priority);
if (!merged.size) card.append(element("p", "Keine sicher belegbaren Felder. Rohtext prüfen und bei Bedarf manuell zuordnen.", "meta"));
const fieldList = element("div", "", "field-list");
for (const field of merged.values()) {
const row = element("div", "", `field-item${reviewMethods.has(field.method) ? " needs-review" : ""}`);
const show = element("button", "", "field-show");show.type = "button";
const heading = element("span", "", "field-heading");
heading.append(element("strong", field.code), element("small", fieldNames[field.code] || "Feldcode"));
const method = field.manual ? "Manuell aus OCR-Text" :
field.method === "adjacent_code" ? "Code und Wert in Nachbarboxen" :
field.method === "below_caption" ? "Wert unter gedruckter Beschriftung" :
field.method === "caption_region_next_line" ? "Nächste Textzeile derselben Layoutregion · bitte prüfen" :
field.method === "surname_above_forename" ? "C.1.1-Code nicht gelesen · Wert oberhalb von C.1.2 · bitte prüfen" :
field.method === "vl_table_adjacent_cell" ? "Benachbarte Modellzellen · grober Tabellenbeleg" :
field.method === "vl_table_context_code" ? "Punkt im Feldcode fehlt · Tabellenfolge B/2.1/2.2 · bitte prüfen" :
field.method === "merged_code" ? "Code und Wert verschmolzen · bitte prüfen" :
"Code und Wert in einer Textzeile";
show.append(heading, element("span", field.value, "field-value"), element("small", method, "field-method"));
show.disabled = !field.box;
show.addEventListener("click", () => focusEvidence(field.box, field.anchor_box));
const dismiss = element("button", field.manual ? "Entfernen" : "Verwerfen", "field-dismiss");
dismiss.type = "button";
dismiss.setAttribute("aria-label", `${field.code} ${field.manual ? "entfernen" : "verwerfen"}`);
dismiss.addEventListener("click", () => {
if (field.manual) manual[engine].delete(field.code);
else suppressed[engine].add(field.code);
renderCard(engine);
});
row.append(show, dismiss);
fieldList.append(row);
}
card.append(fieldList, element("h4", "Rohtext mit Bildbelegen"));
if (!branch.lines.length) card.append(element("p", "Kein Text erkannt.", "meta"));
const raw = element("div", "", "raw-lines");
let code, lineSelect, value, form;
branch.lines.forEach((line, index) => {
const entry = element("div", "", "raw-entry");
const row = element("button", "", "raw-line");row.type = "button";
row.append(element("span", `${index + 1}.`), element("span", line.text));
row.disabled = !line.box;
row.title = line.source || "Bildbereich markieren";
row.addEventListener("click", () => {
lineSelect.value = String(index);value.value = line.text;
focusEvidence(line.box);
});
const pick = element("button", "Zuordnen", "raw-pick");pick.type = "button";
pick.disabled = !line.box;
pick.addEventListener("click", () => {
lineSelect.value = String(index);value.value = line.text;
selectedEvidence = {box:line.box,anchor:null};drawEvidence();
form.scrollIntoView({behavior:"smooth",block:"center"});code.focus();
});
entry.append(row,pick);raw.append(entry);
});
card.append(raw);
if (!branch.lines.length) return;
card.append(element("h4", "Zeile manuell zuordnen"));
form = element("div", "", "manual");
code = document.createElement("select");lineSelect = document.createElement("select");
code.setAttribute("aria-label", "Feldcode");lineSelect.setAttribute("aria-label", "OCR-Zeile");
code.append(new Option("Feldcode", ""));
result.field_codes.forEach(fieldCode => code.append(new Option(`${fieldCode} · ${fieldNames[fieldCode] || "Feldcode"}`, fieldCode)));
lineSelect.append(new Option("OCR-Zeile wählen", ""));
branch.lines.forEach((row, index) => lineSelect.append(new Option(`${index + 1}: ${row.text.slice(0, 55)}`, String(index))));
value = document.createElement("input");value.type = "text";value.maxLength = 500;
value.setAttribute("aria-label", "Wörtlicher Ausschnitt aus der OCR-Zeile");
value.placeholder = "OCR-Zeile wählen, dann bei Bedarf auf den Wert kürzen";
lineSelect.addEventListener("change", () => {value.value = lineSelect.value === "" ? "" : branch.lines[Number(lineSelect.value)].text;});
const add = element("button", "Zuordnen");add.type = "button";
const hint = element("p", "Nur wörtlich erkannter Text mit Bildbereich ist zulässig.", "manual-hint");
add.addEventListener("click", () => {
const row = lineSelect.value === "" ? null : branch.lines[Number(lineSelect.value)];
const chosen = value.value.trim();
if (!code.value || !chosen || !row?.box || !row.text.includes(chosen)) {
hint.textContent = "Feldcode und wörtlichen Ausschnitt aus der gewählten Zeile angeben.";
hint.className = "manual-hint error";return;
}
manual[engine].set(code.value, {code:code.value,value:chosen,box:row.box,manual:true});
suppressed[engine].delete(code.value);
renderCard(engine);
});
const clear = element("button", "Manuelle Zuordnungen löschen");clear.type = "button";
clear.addEventListener("click", () => {manual[engine].clear();renderCard(engine);});
form.append(code,lineSelect,value,add,clear,hint);card.append(form);
}
run.addEventListener("click", async () => {
const file = input.files?.[0];
if (!file || !validCorners() || busy) return;
if (file.size > 16 * 1048576) {setStatus(errorText.upload_too_large, "error");return;}
const mime = file.type || (/\.jpe?g$/i.test(file.name) ? "image/jpeg" : /\.png$/i.test(file.name) ? "image/png" : "");
if (!["image/jpeg", "image/png"].includes(mime)) {setStatus(errorText.unsupported_media_type, "error");return;}
busy = true;updateControls();result = null;selectedEvidence = null;
manual.classic.clear();manual.vision.clear();$("comparison").hidden = true;
suppressed.classic.clear();suppressed.vision.clear();
setStatus("Die lokalen Verfahren verarbeiten dasselbe Bild …");
try {
const geometry = {rotation:Number(rotation.value)};
if (edited) geometry.corners = corners;
const response = await fetch("/api/compare", {method:"POST",body:file,
headers:{"Content-Type":mime,"X-OCR-Geometry":JSON.stringify(geometry)},cache:"no-store"});
const data = await response.json();
if (!response.ok) {setStatus(errorText[data.error] || `Vergleich fehlgeschlagen (${response.status}).`,"error");return;}
result = data;processed.src = data.image;
$("image-size").textContent = `Gemeinsames OCR-Bild: ${data.image_size.width} × ${data.image_size.height} Pixel. Klick auf Wert oder Rohtext markiert seinen Bildbeleg.`;
renderCard("classic");renderCard("vision");$("comparison").hidden = false;
const both = Object.values(data.results).every(branch => branch.status === "ok");
setStatus(both ? "Beide Ergebnisse bereit. Feldvorschläge anhand der Bildbelege prüfen." :
"Ein Verfahren ist fehlgeschlagen; das andere Ergebnis und der Fehler sind sichtbar.",both ? "ok" : "error");
} catch {
setStatus(result ? "Die Antwort des lokalen Vergleichs konnte nicht angezeigt werden. Seite neu laden." :
"Der lokale Dienst antwortet nicht. Prozess und /api/health prüfen.","error");
} finally {busy = false;updateControls();}
});