diff --git a/admin-lehrer/components/ocr-pipeline/StepReconstruction.tsx b/admin-lehrer/components/ocr-pipeline/StepReconstruction.tsx index 1a9ccb5..b86286d 100644 --- a/admin-lehrer/components/ocr-pipeline/StepReconstruction.tsx +++ b/admin-lehrer/components/ocr-pipeline/StepReconstruction.tsx @@ -59,6 +59,7 @@ export function StepReconstruction({ sessionId, onNext }: StepReconstructionProp const [imageNaturalSize, setImageNaturalSize] = useState<{ w: number; h: number } | null>(null) const [fontScale, setFontScale] = useState(0.7) const [globalBold, setGlobalBold] = useState(false) + const [imageRotation, setImageRotation] = useState<0 | 180>(0) const reconRef = useRef(null) const [reconWidth, setReconWidth] = useState(0) @@ -70,6 +71,7 @@ export function StepReconstruction({ sessionId, onNext }: StepReconstructionProp overlayImageUrl, mergedGridCells, editorMode === 'overlay', + imageRotation, ) // Track reconstruction container width for font size calculation @@ -138,6 +140,7 @@ export function StepReconstruction({ sessionId, onNext }: StepReconstructionProp const hasBoxes = subSessions.length > 0 && zones.some(z => z.zone_type === 'box') setIsParentWithBoxes(hasBoxes) + if (hasBoxes) setImageRotation(180) // Default: rotate for correct pixel matching if (columnResult?.columns) setParentColumns(columnResult.columns) if (rowResult?.rows) setParentRows(rowResult.rows) @@ -493,6 +496,7 @@ export function StepReconstruction({ sessionId, onNext }: StepReconstructionProp src={dewarpedUrl} alt="Original" className="w-full h-auto" + style={imageRotation === 180 ? { transform: 'rotate(180deg)' } : undefined} onLoad={(e) => { const img = e.target as HTMLImageElement setImageNaturalSize({ w: img.naturalWidth, h: img.naturalHeight }) @@ -775,6 +779,17 @@ export function StepReconstruction({ sessionId, onNext }: StepReconstructionProp > B +
)} diff --git a/admin-lehrer/components/ocr-pipeline/usePixelWordPositions.ts b/admin-lehrer/components/ocr-pipeline/usePixelWordPositions.ts index e801be4..ad0f11e 100644 --- a/admin-lehrer/components/ocr-pipeline/usePixelWordPositions.ts +++ b/admin-lehrer/components/ocr-pipeline/usePixelWordPositions.ts @@ -12,12 +12,17 @@ export interface WordPosition { * Shared hook: analyse dark-pixel clusters on an image to determine * the exact horizontal position & auto-font-size of word groups in each cell. * + * When rotation=180, the image is rotated 180° before pixel analysis. + * Cell coordinates are transformed to the rotated space for reading, + * and cluster positions are mirrored back to the original coordinate system. + * * Returns a Map. */ export function usePixelWordPositions( imageUrl: string, cells: GridCell[], active: boolean, + rotation: 0 | 180 = 0, ): Map { const [cellWordPositions, setCellWordPositions] = useState>(new Map()) @@ -27,12 +32,24 @@ export function usePixelWordPositions( const img = new Image() img.crossOrigin = 'anonymous' img.onload = () => { + const imgW = img.naturalWidth + const imgH = img.naturalHeight + const canvas = document.createElement('canvas') - canvas.width = img.naturalWidth - canvas.height = img.naturalHeight + canvas.width = imgW + canvas.height = imgH const ctx = canvas.getContext('2d') if (!ctx) return - ctx.drawImage(img, 0, 0) + + if (rotation === 180) { + // Draw image rotated 180° + ctx.translate(imgW, imgH) + ctx.rotate(Math.PI) + ctx.drawImage(img, 0, 0) + ctx.setTransform(1, 0, 0, 1, 0, 0) // reset transform for measureText + } else { + ctx.drawImage(img, 0, 0) + } const refFontSize = 40 const fontFam = "'Liberation Sans', Arial, sans-serif" @@ -46,14 +63,24 @@ export function usePixelWordPositions( // Split by 3+ whitespace into word-groups const groups = cell.text.split(/\s{3,}/).map(s => s.trim()).filter(Boolean) - // Pixel region for this cell - const imgW = img.naturalWidth - const imgH = img.naturalHeight - const cx = Math.round(cell.bbox_pct.x / 100 * imgW) - const cy = Math.round(cell.bbox_pct.y / 100 * imgH) + // Cell pixel region — when rotated 180°, transform coordinates + let cx: number, cy: number const cw = Math.round(cell.bbox_pct.w / 100 * imgW) const ch = Math.round(cell.bbox_pct.h / 100 * imgH) + + if (rotation === 180) { + // In rotated image: (x,y) maps to (W-x-w, H-y-h) + cx = Math.round((100 - cell.bbox_pct.x - cell.bbox_pct.w) / 100 * imgW) + cy = Math.round((100 - cell.bbox_pct.y - cell.bbox_pct.h) / 100 * imgH) + } else { + cx = Math.round(cell.bbox_pct.x / 100 * imgW) + cy = Math.round(cell.bbox_pct.y / 100 * imgH) + } if (cw <= 0 || ch <= 0) continue + // Clamp to image bounds + if (cx < 0) cx = 0 + if (cy < 0) cy = 0 + if (cx + cw > imgW || cy + ch > imgH) continue const imageData = ctx.getImageData(cx, cy, cw, ch) @@ -70,7 +97,7 @@ export function usePixelWordPositions( // Find dark-pixel clusters (word groups on the image) const threshold = Math.max(1, ch * 0.03) const minGap = Math.max(5, Math.round(cw * 0.02)) - const clusters: { start: number; end: number }[] = [] + let clusters: { start: number; end: number }[] = [] let inCluster = false let clStart = 0 let gap = 0 @@ -92,6 +119,15 @@ export function usePixelWordPositions( if (clusters.length === 0) continue + // When rotated 180°, mirror clusters back to original coordinate system + // A cluster at (start, end) in rotated space = (cw-1-end, cw-1-start) in original + if (rotation === 180) { + clusters = clusters.map(c => ({ + start: cw - 1 - c.end, + end: cw - 1 - c.start, + })).reverse() // reverse to restore left-to-right order in original space + } + const wordPos: WordPosition[] = [] if (groups.length <= 1) { @@ -156,7 +192,7 @@ export function usePixelWordPositions( setCellWordPositions(positions) } img.src = imageUrl - }, [active, cells, imageUrl]) + }, [active, cells, imageUrl, rotation]) return cellWordPositions }