feat: add border ghost filter + graphic detection tests + structure overlay

- Add _filter_border_ghost_words() to remove OCR artefacts from box borders
  (vertical + horizontal edge detection, column cleanup, re-indexing)
- Add 20 tests for border ghost filter (basic filtering + column cleanup)
- Add 24 tests for cv_graphic_detect (color detection, word overlap, boxes)
- Clean up cv_graphic_detect.py logging (per-candidate → DEBUG)
- Add structure overlay layer to StepReconstruction (boxes + graphics toggle)
- Show border_ghosts_removed badge in StepStructureDetection
- Update MkDocs with structure detection documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-16 18:28:53 +01:00
parent 6668661895
commit 729ebff63c
8 changed files with 1006 additions and 29 deletions

View File

@@ -219,7 +219,7 @@ export interface StructureGraphic {
w: number
h: number
area: number
shape: string // arrow, circle, line, exclamation, dot, icon, illustration
shape: string // image, illustration
color_name: string
color_hex: string
confidence: number
@@ -235,6 +235,7 @@ export interface StructureResult {
color_pixel_counts: Record<string, number>
has_words: boolean
word_count: number
border_ghosts_removed?: number
duration_seconds: number
}

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import type { GridResult, GridCell, ColumnResult, RowResult, PageZone, PageRegion, RowItem } from '@/app/(admin)/ai/ocr-pipeline/types'
import type { GridResult, GridCell, ColumnResult, RowResult, PageZone, PageRegion, RowItem, StructureResult, StructureBox, StructureGraphic } from '@/app/(admin)/ai/ocr-pipeline/types'
import { usePixelWordPositions } from './usePixelWordPositions'
const KLAUSUR_API = '/klausur-api'
@@ -60,6 +60,9 @@ export function StepReconstruction({ sessionId, onNext }: StepReconstructionProp
const [fontScale, setFontScale] = useState(0.7)
const [globalBold, setGlobalBold] = useState(false)
const [imageRotation, setImageRotation] = useState<0 | 180>(0)
const [structureBoxes, setStructureBoxes] = useState<StructureBox[]>([])
const [structureGraphics, setStructureGraphics] = useState<StructureGraphic[]>([])
const [showStructure, setShowStructure] = useState(true)
const reconRef = useRef<HTMLDivElement>(null)
const [reconWidth, setReconWidth] = useState(0)
@@ -92,12 +95,15 @@ export function StepReconstruction({ sessionId, onNext }: StepReconstructionProp
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
// Track image natural height for font scaling
// Track image natural dimensions for font scaling and structure layer
const handleImageLoad = useCallback(() => {
if (imageRef.current) {
setImageNaturalH(imageRef.current.naturalHeight)
if (!imageNaturalSize) {
setImageNaturalSize({ w: imageRef.current.naturalWidth, h: imageRef.current.naturalHeight })
}
}
}, [])
}, [imageNaturalSize])
const loadSessionData = async () => {
if (!sessionId) return
@@ -132,6 +138,13 @@ export function StepReconstruction({ sessionId, onNext }: StepReconstructionProp
setUndoStack([])
setRedoStack([])
// Load structure result (boxes, graphics, colors)
const structureResult: StructureResult | undefined = data.structure_result
if (structureResult) {
setStructureBoxes(structureResult.boxes || [])
setStructureGraphics(structureResult.graphics || [])
}
// Check for parent with boxes (sub-sessions + zones)
const columnResult: ColumnResult | undefined = data.column_result
const rowResult: RowResult | undefined = data.row_result
@@ -517,6 +530,65 @@ export function StepReconstruction({ sessionId, onNext }: StepReconstructionProp
return bboxPct
}
// Structure layer: boxes and graphic elements as background
const renderStructureLayer = (imgW: number, imgH: number) => {
if (!showStructure) return null
const hasElements = structureBoxes.length > 0 || structureGraphics.length > 0
if (!hasElements) return null
return (
<>
{/* Structure boxes */}
{structureBoxes.map((box, i) => {
const bgColor = box.bg_color_hex || '#6b7280'
return (
<div
key={`sbox-${i}`}
className="absolute pointer-events-none"
style={{
left: `${(box.x / imgW) * 100}%`,
top: `${(box.y / imgH) * 100}%`,
width: `${(box.w / imgW) * 100}%`,
height: `${(box.h / imgH) * 100}%`,
border: `${Math.max(1, box.border_thickness)}px solid ${bgColor}40`,
backgroundColor: `${bgColor}0a`,
borderRadius: '2px',
}}
/>
)
})}
{/* Graphic elements */}
{structureGraphics.map((g, i) => (
<div
key={`sgfx-${i}`}
className="absolute pointer-events-none"
style={{
left: `${(g.x / imgW) * 100}%`,
top: `${(g.y / imgH) * 100}%`,
width: `${(g.w / imgW) * 100}%`,
height: `${(g.h / imgH) * 100}%`,
border: `1px dashed ${g.color_hex}60`,
backgroundColor: `${g.color_hex}08`,
borderRadius: '2px',
}}
>
<span
className="absolute text-[8px] leading-none opacity-50"
style={{
top: '1px',
left: '2px',
color: g.color_hex,
}}
>
{g.shape === 'illustration' ? 'Illust' : 'Bild'}
</span>
</div>
))}
</>
)
}
// Overlay rendering helper
const renderOverlayMode = () => {
const imgW = imageNaturalSize?.w || 1
@@ -597,6 +669,9 @@ export function StepReconstruction({ sessionId, onNext }: StepReconstructionProp
)
})}
{/* Structure elements (boxes, graphics) */}
{renderStructureLayer(imgW, imgH)}
{/* Pixel-positioned words / editable inputs */}
{cells.map((cell) => {
const displayText = getDisplayText(cell)
@@ -831,6 +906,19 @@ export function StepReconstruction({ sessionId, onNext }: StepReconstructionProp
>
180°
</button>
{(structureBoxes.length > 0 || structureGraphics.length > 0) && (
<button
onClick={() => setShowStructure(v => !v)}
className={`px-2 py-1 text-xs border rounded transition-colors ${
showStructure
? 'border-violet-300 bg-violet-50 text-violet-600 dark:border-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
title="Strukturelemente anzeigen"
>
Struktur
</button>
)}
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
</>
)}
@@ -851,6 +939,21 @@ export function StepReconstruction({ sessionId, onNext }: StepReconstructionProp
Leer
</button>
{/* Structure toggle */}
{(structureBoxes.length > 0 || structureGraphics.length > 0) && (
<button
onClick={() => setShowStructure(v => !v)}
className={`px-2 py-1 text-xs border rounded transition-colors ${
showStructure
? 'border-violet-300 bg-violet-50 text-violet-600 dark:border-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
title="Strukturelemente anzeigen"
>
Struktur
</button>
)}
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Zoom controls */}
@@ -915,6 +1018,9 @@ export function StepReconstruction({ sessionId, onNext }: StepReconstructionProp
onLoad={handleImageLoad}
/>
{/* Structure elements (boxes, graphics) */}
{imageNaturalSize && renderStructureLayer(imageNaturalSize.w, imageNaturalSize.h)}
{/* Empty field markers */}
{showEmptyHighlight && cells
.filter(c => emptyCellIds.has(c.cellId))

View File

@@ -165,6 +165,11 @@ export function StepStructureDetection({ sessionId, onNext }: StepStructureDetec
{result.word_count} Woerter
</span>
)}
{(result.border_ghosts_removed ?? 0) > 0 && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-xs font-medium">
{result.border_ghosts_removed} Rahmenlinien entfernt
</span>
)}
<span className="text-gray-400 text-xs ml-auto">
{result.image_width}x{result.image_height}px | {result.duration_seconds}s
</span>