Files
zulassung-ocr-compare/ocr_compare/app.js
T

326 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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"), finePrint = $("fine-print");
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;
finePrint.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.");
});
finePrint.addEventListener("change", () => {
result = null;$("comparison").hidden = true;
setStatus("OCR-Detailgrad 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.",
invalid_detail: "Der OCR-Detailgrad 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),
"X-OCR-Detail":finePrint.checked ? "fine" : "standard"},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();}
});