319 lines
18 KiB
JavaScript
319 lines
18 KiB
JavaScript
|
|
"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();}
|
|||
|
|
});
|