From 8a5f2aa188d0d594d0474cba172a11f926d6df8f Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 11 Mar 2026 15:39:54 +0100 Subject: [PATCH] fix: Cluster-Zuordnung per Breiten-Proportionalitaet statt Position Zwei wesentliche Verbesserungen: 1. Multi-group: Gruppen werden per Best-Fit-Breite den Clustern zugeordnet statt naiv links-nach-rechts. Damit wird z.B. "Kokosnuss" dem DE-Spalten-Cluster zugeordnet statt dem breiteren Box-Cluster. 2. Single-group Fallback: verwendet den BREITESTEN Cluster statt first-to-last Span. Verhindert dass Streupixel von benachbarten Seitenbereichen den Text nach links ziehen. Co-Authored-By: Claude Opus 4.6 --- .../ocr-overlay/usePixelWordPositions.ts | 71 +++++++++++++------ 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/admin-lehrer/components/ocr-overlay/usePixelWordPositions.ts b/admin-lehrer/components/ocr-overlay/usePixelWordPositions.ts index b4872b9..6568a35 100644 --- a/admin-lehrer/components/ocr-overlay/usePixelWordPositions.ts +++ b/admin-lehrer/components/ocr-overlay/usePixelWordPositions.ts @@ -147,43 +147,68 @@ export function usePixelWordPositions( const wordPos: WordPosition[] = [] - // When more clusters than groups, pick the N widest clusters - // (text clusters are wider than box-border clusters) - let matchClusters = clusters - if (groups.length > 1 && clusters.length > groups.length) { - matchClusters = [...clusters] - .sort((a, b) => (b.end - b.start) - (a.end - a.start)) - .slice(0, groups.length) - .sort((a, b) => a.start - b.start) - } + // Match groups to clusters using width-proportional assignment. + // Each group is assigned to the cluster whose width best matches + // the group's expected pixel width (text measurement). + if (groups.length > 1 && clusters.length >= groups.length) { + // Measure each group's expected width + const groupWidths = groups.map(g => ctx.measureText(g).width) - if (groups.length > 1 && matchClusters.length >= groups.length) { - // Multiple text groups with matching clusters - for (let i = 0; i < groups.length; i++) { - const cl = matchClusters[i] + // Greedy assignment: for each group (in order), find the best + // unassigned cluster by width ratio consistency + const totalMeasured = groupWidths.reduce((a, b) => a + b, 0) + const totalClusterW = clusters.reduce((a, c) => a + (c.end - c.start + 1), 0) + const refScale = totalClusterW / totalMeasured + const used = new Set() + + const assignments: number[] = [] + for (let gi = 0; gi < groups.length; gi++) { + const expectedW = groupWidths[gi] * refScale + let bestIdx = -1 + let bestDiff = Infinity + for (let ci = 0; ci < clusters.length; ci++) { + if (used.has(ci)) continue + const clW = clusters[ci].end - clusters[ci].start + 1 + const diff = Math.abs(clW - expectedW) + if (diff < bestDiff) { + bestDiff = diff + bestIdx = ci + } + } + used.add(bestIdx) + assignments.push(bestIdx) + } + + // Sort assignments to maintain left-to-right order + const sortedPairs = assignments + .map((ci, gi) => ({ ci, gi })) + .sort((a, b) => clusters[a.ci].start - clusters[b.ci].start) + + for (const { ci, gi } of sortedPairs) { + const cl = clusters[ci] const clusterW = cl.end - cl.start + 1 - const measured = ctx.measureText(groups[i]) - const autoFontPx = refFontSize * (clusterW / measured.width) + const autoFontPx = refFontSize * (clusterW / groupWidths[gi]) const fontRatio = Math.min(autoFontPx / ch, 1.0) wordPos.push({ xPct: cell.bbox_pct.x + (cl.start / cw) * cell.bbox_pct.w, wPct: ((cl.end - cl.start + 1) / cw) * cell.bbox_pct.w, - text: groups[i], + text: groups[gi], fontRatio, }) } } else { - // Single group OR clusters don't match groups: - // position entire text as one span from first to last cluster - const firstCl = clusters[0] - const lastCl = clusters[clusters.length - 1] - const clusterW = lastCl.end - firstCl.start + 1 + // Single group OR not enough clusters: + // use the WIDEST cluster (not first-to-last span which pulls in + // stray pixels from adjacent page areas like box borders) + const widest = clusters.reduce((best, c) => + (c.end - c.start) > (best.end - best.start) ? c : best, clusters[0]) + const clusterW = widest.end - widest.start + 1 const measured = ctx.measureText(cell.text.trim()) const autoFontPx = refFontSize * (clusterW / measured.width) const fontRatio = Math.min(autoFontPx / ch, 1.0) wordPos.push({ - xPct: cell.bbox_pct.x + (firstCl.start / cw) * cell.bbox_pct.w, - wPct: ((lastCl.end - firstCl.start + 1) / cw) * cell.bbox_pct.w, + xPct: cell.bbox_pct.x + (widest.start / cw) * cell.bbox_pct.w, + wPct: ((widest.end - widest.start + 1) / cw) * cell.bbox_pct.w, text: cell.text.trim(), fontRatio, })