OCR-Rohtext additiv zurückgeben, je Eintrag speichern und anzeigen

recognize() (pipeline.js) liefert zusätzlich den ungefilterten OCR-Rohtext
(rawText) zurück - leer, wenn keine Texterkennung lief. Rein additiv: die
Stapelzuordnung (session.js/spec.js, unangetastet) stützt sich weiterhin
ausschließlich auf die daraus abgeleiteten, verstandenen Spec-Felder.

main.js hängt den Rohtext nach dem Buchen als zusätzliche Eigenschaft an den
Eintrag (analog zu den bereits vorhandenen Barcode-Inhalten je Eintrag).
storage.js sichert/prüft/stellt entry.rawText nach demselben optionalen
Muster wie entry.codes wieder her - ein gesicherter Stand ohne dieses Feld
wird nicht verworfen.

In Funktion "Text erkennen" bleibt die Treffer-Rückmeldung jetzt stehen, bis
der Nutzer sie wegtippt, und zeigt den vollen erkannten Text (lesbar über
dasselbe Scroll-Muster wie die übrigen Vollbild-Overlays); in den beiden
Barcode-Funktionen bleibt es beim automatischen Ausblenden. Das
zurückgegebene Versprechen von showResult löst in jedem Fall - Zeitgeber,
Wegtippen, verdrängende neue Rückmeldung - genau einmal ein, damit die
Bedienung nie dauerhaft gesperrt bleibt. Die Sitzungsliste zeigt den Rohtext
zusätzlich je Eintrag zum Nachlesen.

Tests (pipeline.test.js, storage.test.js): rawText nur bei tatsächlich
gelaufener Texterkennung, unterschiedlicher Rohtext beeinflusst die
Stapelzuordnung nachweislich nicht, Rohtext übersteht Sichern/Laden inkl.
Abwärtskompatibilität zu einem Stand ohne dieses Feld.
This commit is contained in:
2026-07-29 15:19:13 +02:00
parent 1c10c68053
commit b903b1c7aa
7 changed files with 266 additions and 22 deletions
+14 -8
View File
@@ -60,10 +60,16 @@ function allCompatible(specs) {
* Stapel bekannt ist. Ist einer der gelesenen Codes bekannt, steht die
* Zuordnung fest und die Texterkennung wird uebersprungen. Fehlt die
* Funktion, verhaelt sich die Pipeline wie ohne diese Optimierung.
* @returns {Promise<{spec: object, source: 'barcode'|'ocr'|'none', confidence: 'green'|'yellow'|'red', codes: string[]}>}
* @returns {Promise<{spec: object, source: 'barcode'|'ocr'|'none', confidence: 'green'|'yellow'|'red', codes: string[], rawText: string}>}
* codes sind die im Bild gelesenen rohen Barcode-Zeichenketten (koennen
* neben der Teilenummer z.B. auch eine Seriennummer enthalten) - zur
* Weitergabe an proposeAssignment/commitAssignment in session.js.
* rawText ist der ungefilterte Texterkennungs-Rohtext, rein additiv: er
* fliesst nirgends in die Stapelzuordnung ein (die stuetzt sich weiterhin
* ausschliesslich auf die aus ihm abgeleiteten, verstandenen Spec-Felder,
* siehe extractFields/ocr-extract.js) und dient allein der Anzeige fuer den
* Nutzer. Leer, wenn die Texterkennung in diesem Durchlauf nicht lief (z.B.
* weil ein gruener Barcode-Treffer sie ueberfluessig gemacht hat).
*/
export async function recognize(frame, deps) {
const barcodeTimeoutMs = deps.barcodeTimeoutMs ?? DEFAULT_BARCODE_TIMEOUT_MS;
@@ -99,7 +105,7 @@ export async function recognize(frame, deps) {
}
if (usable.length === 1) {
return { spec: usable[0], source: 'barcode', confidence: 'green', codes };
return { spec: usable[0], source: 'barcode', confidence: 'green', codes, rawText: '' };
}
if (usable.length > 1) {
if (allCompatible(usable)) {
@@ -109,10 +115,10 @@ export async function recognize(frame, deps) {
// da alle uebrigen Felder aus derselben Teilenummer abgeleitet werden,
// sind vertraegliche Treffer ohnehin gleich. Es gibt nichts aufzufuellen,
// der erste Treffer genuegt.
return { spec: usable[0], source: 'barcode', confidence: 'green', codes };
return { spec: usable[0], source: 'barcode', confidence: 'green', codes, rawText: '' };
}
// Mehrdeutigkeit zwischen verwertbaren Barcodes: nicht raten, Nutzer entscheidet.
return { spec: emptySpec(), source: 'none', confidence: 'red', codes };
return { spec: emptySpec(), source: 'none', confidence: 'red', codes, rawText: '' };
}
// Ist einer der gelesenen Codes bereits einem Stapel bekannt, steht die
@@ -121,7 +127,7 @@ export async function recognize(frame, deps) {
// Bei einem tatsaechlich neuen Modul (kein Code bekannt) laeuft sie wie
// bisher, denn dort liefert sie die lesbare Beschriftung des neuen Stapels.
if (typeof deps.isKnownCode === 'function' && codes.some((code) => deps.isKnownCode(code))) {
return { spec: best, source: 'barcode', confidence: 'green', codes };
return { spec: best, source: 'barcode', confidence: 'green', codes, rawText: '' };
}
const ocrResult = await safely(() => deps.runOcr(frame), '', ocrTimeoutMs);
@@ -136,7 +142,7 @@ export async function recognize(frame, deps) {
}
if (isUsable(merged)) {
return { spec: merged, source: 'ocr', confidence: 'yellow', codes };
return { spec: merged, source: 'ocr', confidence: 'yellow', codes, rawText: text };
}
// Zum Sortieren muss keine Kapazitaet bekannt sein - eine exakt gelesene
// Teilenummer genuegt, um ein Modul wiederzuerkennen. Sie ueberlebt bis
@@ -144,7 +150,7 @@ export async function recognize(frame, deps) {
// mehrere sich einig waren (siehe 'best' oben) - bei widersprechenden
// Codes bleibt merged.partNumber null und die Vorsicht damit erhalten.
if (merged.partNumber !== null) {
return { spec: merged, source: 'barcode', confidence: 'green', codes };
return { spec: merged, source: 'barcode', confidence: 'green', codes, rawText: text };
}
return { spec: merged, source: 'none', confidence: 'red', codes };
return { spec: merged, source: 'none', confidence: 'red', codes, rawText: text };
}
+12
View File
@@ -52,6 +52,9 @@ function isUsableStack(stack) {
// dieser Erweiterung kennt das Feld nicht und gilt deshalb nicht als
// unstimmig (siehe isUsableStack). Ist es vorhanden, muss es eine Liste von
// Zeichenketten sein.
// entry.rawText (der OCR-Rohtext, siehe pipeline.js/recognize) ist nach
// demselben Muster optional: ein Stand aus einer Fassung ohne dieses Feld ist
// nicht unstimmig; ist es vorhanden, muss es eine Zeichenkette sein.
function isUsableEntry(entry) {
return (
isPlainObject(entry)
@@ -59,6 +62,7 @@ function isUsableEntry(entry) {
&& typeof entry.stackId === 'string'
&& isPlainObject(entry.spec)
&& (entry.codes === undefined || isStringArray(entry.codes))
&& (entry.rawText === undefined || typeof entry.rawText === 'string')
);
}
@@ -107,6 +111,14 @@ export function loadSession(store) {
if (entryIds.some((entryId) => entryId >= parsed.nextEntryId)) return null;
// rawText fehlt bei einem Eintrag aus einer Fassung vor dieser Erweiterung
// (siehe isUsableEntry oben) - beim Wiederherstellen wird das einheitlich
// als leerer Rohtext behandelt, damit main.js/session-list.js sich nicht
// um "undefined vs. leer" kuemmern muessen.
for (const entry of parsed.entries) {
if (entry.rawText === undefined) entry.rawText = '';
}
for (const stack of parsed.stacks) {
const stackEntries = parsed.entries.filter((entry) => entry.stackId === stack.id);
stack.count = stackEntries.length;
+15
View File
@@ -125,6 +125,21 @@ body {
.overlay .detail { font-size: 20px; line-height: 1.5; }
.overlay .pn { font-size: 15px; opacity: 0.85; word-break: break-all; }
/* Voller OCR-Rohtext in der wartenden Rueckmeldung (Funktion "Text
erkennen", siehe result-overlay.js) - linksbuendig und mit Zeilenumbruch,
damit ein langer Etikettentext lesbar bleibt statt einer einzelnen,
abgeschnittenen Zeile. Waechst nicht aus dem Bildschirm heraus, weil
`.overlay` selbst scrollbar ist (overflow-y: auto, siehe oben). */
.overlay .raw-text {
font-size: 16px;
line-height: 1.5;
text-align: left;
white-space: pre-wrap;
overflow-wrap: break-word;
max-width: 420px;
width: 100%;
}
.choices { display: grid; gap: 12px; width: 100%; max-width: 420px; }
.choices button { min-height: 64px; font-size: 20px; }
+82 -14
View File
@@ -1,38 +1,106 @@
import { describeSpec } from './describe-spec.js';
/** Haelt pro Root die aktuell sichtbare Rueckmeldung samt Zeitgeber fest. */
/** Haelt pro Root die aktuell sichtbare Rueckmeldung samt Abschlussfunktion fest. */
const currentFeedback = new WeakMap();
/**
* Blendet die Treffer-Rueckmeldung ein und nach kurzer Zeit wieder aus.
* Gruen bei Barcode, gelb bei OCR. Keine Eingabe noetig.
* Blendet die Treffer-Rueckmeldung ein. Gruen bei Barcode, gelb bei OCR.
*
* In den Barcode-Funktionen (autoHide: true, Voreinstellung) blendet sie sich
* nach kurzer Zeit von selbst wieder aus - dort zaehlt Tempo, keine Eingabe
* noetig. In Funktion 3 ("Text erkennen", autoHide: false) ist der erkannte
* Text potenziell ein ganzer Etikettentext und damit zu lang fuer eine knappe
* automatische Anzeige: die Rueckmeldung bleibt dort stehen, bis der Nutzer
* sie ueber die Schaltflaeche wegtippt, und zeigt zusaetzlich den vollen
* Rohtext (rawText). Lang werdender Text bleibt lesbar ueber dasselbe Muster,
* mit dem auch die anderen Vollbild-Overlays lange Inhalte handhaben: die
* `.overlay`-Klasse ist selbst scrollbar (overflow-y: auto), statt aus dem
* Bildschirm herauszuwachsen (siehe styles.css).
*
* Das zurueckgegebene Versprechen wird in jedem Fall genau einmal eingeloest -
* beim automatischen Ausblenden, beim Wegtippen und auch dann, wenn eine neue
* Rueckmeldung (z.B. durch einen weiteren Scan) diese hier verdraengt, bevor
* sie selbst eingeloest wurde. Die App sperrt waehrend der laufenden
* Erkennung die Bedienung (siehe processCapture() in main.js) - ein nie
* eingeloestes Versprechen wuerde sie dauerhaft lahmlegen.
*
* @param {HTMLElement} root
* @param {{stackId: string, spec: object, confidence: string, rawText?: string, autoHide?: boolean}} args
* @param {number} durationMs Anzeigedauer bei autoHide: true.
* @returns {Promise<void>}
*/
export function showResult(root, { stackId, spec, confidence }, durationMs = 1200) {
export function showResult(
root,
{ stackId, spec, confidence, rawText = '', autoHide = true },
durationMs = 1200,
) {
// Eine noch offene vorherige Rueckmeldung wird sofort abgeschlossen (nicht
// nur entfernt) - ihr Versprechen darf nicht offen bleiben, nur weil eine
// neue Rueckmeldung sie verdraengt.
const previous = currentFeedback.get(root);
if (previous) {
clearTimeout(previous.timer);
previous.overlay.remove();
previous.resolve();
currentFeedback.delete(root);
}
if (previous) previous.finish();
const overlay = document.createElement('div');
overlay.className = `overlay ${confidence}`;
if (!autoHide) {
// Nur die wartende Variante ist eine echte Eingabeaufforderung - siehe
// ambiguous-dialog.js/mode-dialog.js fuer dasselbe Muster (role="dialog",
// aria-modal, Ueberschrift referenziert per aria-labelledby).
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'result-overlay-heading');
}
overlay.innerHTML = `
<div class="stack-id">STAPEL ${stackId}</div>
<div class="stack-id" id="result-overlay-heading">STAPEL ${stackId}</div>
<div class="detail"></div>
<div class="pn"></div>
<div class="raw-text" id="raw-text" hidden></div>
`;
overlay.querySelector('.detail').textContent = describeSpec(spec);
overlay.querySelector('.pn').textContent = spec.partNumber ?? '';
if (!autoHide) {
// Der vollstaendige erkannte Text - Fremdinhalt vom Etikett, deshalb als
// Text gesetzt, nicht als Auszeichnung (siehe ambiguous-dialog.js/#read).
const rawTextEl = overlay.querySelector('#raw-text');
rawTextEl.hidden = false;
rawTextEl.textContent = rawText;
}
root.appendChild(overlay);
return new Promise((resolve) => {
const timer = setTimeout(() => {
let settled = false;
let timer = null;
// Schuetzt vor Mehrfacheinloesung: gleichzeitig ablaufender Zeitgeber und
// Tippen auf "weiter", oder ein Abschluss durch eine verdraengende neue
// Rueckmeldung, nachdem der Zeitgeber schon gelaufen ist.
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
overlay.remove();
currentFeedback.delete(root);
resolve();
}, durationMs);
currentFeedback.set(root, { overlay, timer, resolve });
};
if (autoHide) {
timer = setTimeout(finish, durationMs);
} else {
// Bedienflaeche mindestens 56px hoch (siehe Vorgaben) - der einzige Weg,
// diese Rueckmeldung zu schliessen, solange sie nicht autoHide ist.
const dismissButton = document.createElement('button');
dismissButton.className = 'action';
dismissButton.style.minHeight = '56px';
dismissButton.textContent = 'weiter';
dismissButton.addEventListener('click', finish);
overlay.appendChild(dismissButton);
dismissButton.focus();
}
currentFeedback.set(root, { finish });
});
}
+16
View File
@@ -90,6 +90,22 @@ export function renderSessionList(root, session, { onMove, onRemove, onEndSessio
row.appendChild(removeButton);
body.appendChild(row);
// entry.rawText ist additiv (siehe pipeline.js/recognize) und nur bei
// per Texterkennung erfassten Eintraegen nicht leer - zum Nachlesen des
// vollstaendigen Etikettentexts, unabhaengig von den daraus verstandenen
// Feldern oben in derselben Zeile.
if (entry.rawText) {
const rawTextRow = document.createElement('div');
rawTextRow.style.fontSize = '12px';
rawTextRow.style.opacity = '0.7';
rawTextRow.style.textAlign = 'left';
rawTextRow.style.whiteSpace = 'pre-wrap';
rawTextRow.style.overflowWrap = 'break-word';
rawTextRow.style.marginBottom = '6px';
rawTextRow.textContent = `Erkannter Text: ${entry.rawText}`;
body.appendChild(rawTextRow);
}
}
}
+91
View File
@@ -2,6 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { recognize } from '../src/pipeline.js';
import { emptySpec } from '../src/spec.js';
import { createSession, proposeAssignment, commitAssignment } from '../src/session.js';
const OCR_TEXT = '64GB 4DRx4 PC4-2400T-LD1-11-MC0 M386A8K40BM1-CRC4Y 1908';
@@ -281,3 +282,93 @@ test('ohne isKnownCode-Abhaengigkeit bleibt die Pipeline ohne sie lauffaehig (Te
assert.equal(ocrCalled, true);
assert.equal(result.confidence, 'green');
});
// --- Erweiterung: Rohtext als additives Feld (Funktion "Text erkennen") ---
//
// Der Rohtext ist reine Anzeige-Information fuer den Nutzer - er verlaesst
// die Erkennung, wird aber nirgends zur Stapelzuordnung herangezogen (siehe
// den letzten Test unten, der recognize() mit session.js zusammenspielen
// laesst).
test('recognize liefert den Rohtext, wenn die Texterkennung gelaufen ist (gelbes Ergebnis)', async () => {
const result = await recognize({}, deps({ codes: [], text: OCR_TEXT }));
assert.equal(result.source, 'ocr');
assert.equal(result.rawText, OCR_TEXT);
});
test('recognize liefert den Rohtext auch, wenn die Texterkennung lief, aber nichts Verwertbares fand (rot)', async () => {
const result = await recognize({}, deps({ codes: [], text: 'Made in Philippines' }));
assert.equal(result.confidence, 'red');
assert.equal(result.rawText, 'Made in Philippines');
});
test('recognize liefert den Rohtext, wenn OCR keine Kapazitaet findet, aber eine unverwertbare Barcode-Teilenummer bestaetigt (gruen)', async () => {
// '7325773' hat kein bekanntes Nummernschema (decodePartNumber liefert
// keine Kapazitaet) - die Texterkennung laeuft trotzdem und liefert Text
// ohne jede Kapazitaetsangabe. Die Teilenummer aus dem Barcode traegt
// (siehe recognize()), das Ergebnis bleibt gruen - der gelaufene Rohtext
// wird trotzdem zurueckgegeben.
const result = await recognize({}, {
decodeBarcodes: async () => ['7325773'],
runOcr: async () => 'Made in Philippines',
});
assert.equal(result.source, 'barcode');
assert.equal(result.confidence, 'green');
assert.equal(result.spec.partNumber, '7325773');
assert.equal(result.rawText, 'Made in Philippines');
});
test('recognize liefert eine leere Zeichenkette als Rohtext, wenn die Texterkennung nicht lief (gruener Barcode-Treffer)', async () => {
const result = await recognize({}, {
decodeBarcodes: async () => ['M386A8K40BM1-CRC4Y'],
runOcr: async () => { throw new Error('darf nicht aufgerufen werden'); },
});
assert.equal(result.source, 'barcode');
assert.equal(result.rawText, '');
});
test('recognize liefert eine leere Zeichenkette als Rohtext, wenn ein bekannter Code die Texterkennung ueberfluessig macht', async () => {
const result = await recognize({}, {
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH'],
runOcr: async () => { throw new Error('darf nicht aufgerufen werden'); },
isKnownCode: (code) => code === 'HMA84GL7AFR4N-UH',
});
assert.equal(result.rawText, '');
});
test('recognize liefert eine leere Zeichenkette als Rohtext bei mehrdeutigen verwertbaren Barcodes (rot, ohne OCR)', async () => {
const result = await recognize({}, {
decodeBarcodes: async () => ['M386A8K40BM1-CRC4Y', 'M386A8K40BM1-CWE4Y'],
runOcr: async () => { throw new Error('darf nicht aufgerufen werden'); },
});
assert.equal(result.confidence, 'red');
assert.equal(result.rawText, '');
});
test('der Rohtext beeinflusst die Stapelzuordnung nicht: zwei Aufnahmen mit unterschiedlichem Rohtext, aber gleichen verstandenen Feldern, landen auf demselben Stapel', async () => {
const firstText = '64GB 4DRx4 PC4-2400T-LD1-11-MC0 M386A8K40BM1-CRC4Y 1908';
// Anderer Rohtext (andere Reihenfolge, zusaetzliches "Made in Korea",
// andere Gross-/Kleinschreibung) - dieselben verstandenen Felder.
const secondText = 'made in korea m386a8k40bm1-crc4y 64gb 4drx4 PC4-2400 1908';
const first = await recognize({}, deps({ codes: [], text: firstText }));
const second = await recognize({}, deps({ codes: [], text: secondText }));
assert.notEqual(first.rawText, second.rawText, 'Vorbedingung: die Rohtexte unterscheiden sich tatsaechlich');
assert.deepEqual(first.spec, second.spec, 'Vorbedingung: dieselben verstandenen Felder');
const session = createSession();
const plan1 = proposeAssignment(session, first.spec, first.codes);
const entry1 = commitAssignment(session, first.spec, first.source, plan1.stackId, first.codes);
// Der Rohtext haengt (wie main.js es tut) rein additiv am Eintrag, ohne
// dass session.js davon weiss - siehe main.js/processCapture().
entry1.rawText = first.rawText;
const plan2 = proposeAssignment(session, second.spec, second.codes);
assert.equal(plan2.kind, 'match', 'das zweite Modul muss denselben Stapel treffen');
const entry2 = commitAssignment(session, second.spec, second.source, plan2.stackId, second.codes);
entry2.rawText = second.rawText;
assert.equal(entry1.stackId, entry2.stackId, 'unterschiedlicher Rohtext darf keinen eigenen Stapel erzeugen');
assert.notEqual(entry1.rawText, entry2.rawText, 'die Eintraege fuehren trotzdem je ihren eigenen Rohtext');
});
+36
View File
@@ -308,3 +308,39 @@ test('Eintrag mit gebrochener entryId liefert null', () => {
}));
assert.equal(loadSession(store), null);
});
// --- Erweiterung: der OCR-Rohtext eines Eintrags (entry.rawText) ---
test('Rohtext eines Eintrags ueberlebt Sichern und Laden', () => {
const store = fakeStore();
const session = createSession();
const entry = commitAssignment(session, { ...emptySpec(), capacityGb: 32 }, 'ocr', 'A');
entry.rawText = '32GB 2Rx4 PC4-2666 Vollständiges Etikett Made in Korea';
saveSession(session, store);
const reloaded = loadSession(store);
assert.notEqual(reloaded, null);
assert.equal(reloaded.entries[0].rawText, '32GB 2Rx4 PC4-2666 Vollständiges Etikett Made in Korea');
});
test('ein Stand ohne rawText-Feld (aeltere Fassung) wird nicht verworfen, der Eintrag gilt als ohne Rohtext', () => {
const store = fakeStore();
store.setItem('ram-sortierhilfe:session', JSON.stringify({
stacks: [{ id: 'A', count: 1, spec: { ...emptySpec(), capacityGb: 64 } }],
entries: [{ entryId: 1, stackId: 'A', spec: { ...emptySpec(), capacityGb: 64 }, source: 'barcode' }],
nextEntryId: 2,
}));
const reloaded = loadSession(store);
assert.notEqual(reloaded, null, 'ein Stand ohne rawText-Feld darf nicht verworfen werden');
assert.equal(reloaded.entries[0].rawText, '');
});
test('Eintrag mit rawText als Nicht-Zeichenkette liefert null', () => {
const store = fakeStore();
store.setItem('ram-sortierhilfe:session', JSON.stringify({
stacks: [{ id: 'A', count: 1, spec: emptySpec() }],
entries: [{ entryId: 1, stackId: 'A', spec: emptySpec(), source: 'barcode', rawText: 123 }],
nextEntryId: 2,
}));
assert.equal(loadSession(store), null);
});