Add Box-Grid-Review step (Step 11) to OCR pipeline
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 44s
CI / test-go-edu-search (push) Successful in 43s
CI / test-python-klausur (push) Failing after 2m52s
CI / test-python-agent-core (push) Successful in 36s
CI / test-nodejs-website (push) Successful in 37s
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 44s
CI / test-go-edu-search (push) Successful in 43s
CI / test-python-klausur (push) Failing after 2m52s
CI / test-python-agent-core (push) Successful in 36s
CI / test-nodejs-website (push) Successful in 37s
New pipeline step between Gutter Repair and Ground Truth that processes
embedded boxes (grammar tips, exercises) independently from the main grid.
Backend:
- cv_box_layout.py: classify_box_layout() detects flowing/columnar/
bullet_list/header_only layout types per box
- build_box_zone_grid(): layout-aware grid building (single-column for
flowing text, independent columns for tabular content)
- POST /sessions/{id}/build-box-grids endpoint with SmartSpellChecker
- Layout type overridable per box via request body
Frontend:
- StepBoxGridReview.tsx: shows each box with cropped image + editable
GridTable. Layout type dropdown per box. Auto-builds on first load.
- Auto-skip when no boxes detected on page
- Pipeline steps updated: 13 steps (0-12), Ground Truth moved to 12
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import { StepStructure } from '@/components/ocr-kombi/StepStructure'
|
|||||||
import { StepGridBuild } from '@/components/ocr-kombi/StepGridBuild'
|
import { StepGridBuild } from '@/components/ocr-kombi/StepGridBuild'
|
||||||
import { StepGridReview } from '@/components/ocr-kombi/StepGridReview'
|
import { StepGridReview } from '@/components/ocr-kombi/StepGridReview'
|
||||||
import { StepGutterRepair } from '@/components/ocr-kombi/StepGutterRepair'
|
import { StepGutterRepair } from '@/components/ocr-kombi/StepGutterRepair'
|
||||||
|
import { StepBoxGridReview } from '@/components/ocr-kombi/StepBoxGridReview'
|
||||||
import { StepGroundTruth } from '@/components/ocr-kombi/StepGroundTruth'
|
import { StepGroundTruth } from '@/components/ocr-kombi/StepGroundTruth'
|
||||||
import { useKombiPipeline } from './useKombiPipeline'
|
import { useKombiPipeline } from './useKombiPipeline'
|
||||||
|
|
||||||
@@ -97,6 +98,8 @@ function OcrKombiContent() {
|
|||||||
case 10:
|
case 10:
|
||||||
return <StepGutterRepair sessionId={sessionId} onNext={handleNext} />
|
return <StepGutterRepair sessionId={sessionId} onNext={handleNext} />
|
||||||
case 11:
|
case 11:
|
||||||
|
return <StepBoxGridReview sessionId={sessionId} onNext={handleNext} />
|
||||||
|
case 12:
|
||||||
return (
|
return (
|
||||||
<StepGroundTruth
|
<StepGroundTruth
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const KOMBI_V2_STEPS: PipelineStep[] = [
|
|||||||
{ id: 'grid-build', name: 'Grid-Aufbau', icon: '🧱', status: 'pending' },
|
{ id: 'grid-build', name: 'Grid-Aufbau', icon: '🧱', status: 'pending' },
|
||||||
{ id: 'grid-review', name: 'Grid-Review', icon: '📊', status: 'pending' },
|
{ id: 'grid-review', name: 'Grid-Review', icon: '📊', status: 'pending' },
|
||||||
{ id: 'gutter-repair', name: 'Wortkorrektur', icon: '🩹', status: 'pending' },
|
{ id: 'gutter-repair', name: 'Wortkorrektur', icon: '🩹', status: 'pending' },
|
||||||
|
{ id: 'box-review', name: 'Box-Review', icon: '📦', status: 'pending' },
|
||||||
{ id: 'ground-truth', name: 'Ground Truth', icon: '✅', status: 'pending' },
|
{ id: 'ground-truth', name: 'Ground Truth', icon: '✅', status: 'pending' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -56,7 +57,8 @@ export const KOMBI_V2_UI_TO_DB: Record<number, number> = {
|
|||||||
8: 10, // grid-build
|
8: 10, // grid-build
|
||||||
9: 11, // grid-review
|
9: 11, // grid-review
|
||||||
10: 11, // gutter-repair (shares DB step with grid-review)
|
10: 11, // gutter-repair (shares DB step with grid-review)
|
||||||
11: 12, // ground-truth
|
11: 11, // box-review (shares DB step with grid-review)
|
||||||
|
12: 12, // ground-truth
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map from DB step to Kombi V2 UI step index */
|
/** Map from DB step to Kombi V2 UI step index */
|
||||||
@@ -70,7 +72,7 @@ export function dbStepToKombiV2Ui(dbStep: number): number {
|
|||||||
if (dbStep === 9) return 7 // structure
|
if (dbStep === 9) return 7 // structure
|
||||||
if (dbStep === 10) return 8 // grid-build
|
if (dbStep === 10) return 8 // grid-build
|
||||||
if (dbStep === 11) return 9 // grid-review
|
if (dbStep === 11) return 9 // grid-review
|
||||||
return 11 // ground-truth
|
return 12 // ground-truth
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Document group: groups multiple sessions from a multi-page upload */
|
/** Document group: groups multiple sessions from a multi-page upload */
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export interface GridZone {
|
|||||||
header_rows: number[]
|
header_rows: number[]
|
||||||
layout_hint?: 'left_of_vsplit' | 'right_of_vsplit' | 'middle_of_vsplit'
|
layout_hint?: 'left_of_vsplit' | 'right_of_vsplit' | 'middle_of_vsplit'
|
||||||
vsplit_group?: number
|
vsplit_group?: number
|
||||||
|
box_layout_type?: 'flowing' | 'columnar' | 'bullet_list' | 'header_only'
|
||||||
|
box_grid_reviewed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BBox {
|
export interface BBox {
|
||||||
|
|||||||
282
admin-lehrer/components/ocr-kombi/StepBoxGridReview.tsx
Normal file
282
admin-lehrer/components/ocr-kombi/StepBoxGridReview.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useGridEditor } from '@/components/grid-editor/useGridEditor'
|
||||||
|
import type { GridZone } from '@/components/grid-editor/types'
|
||||||
|
import { GridTable } from '@/components/grid-editor/GridTable'
|
||||||
|
|
||||||
|
const KLAUSUR_API = '/klausur-api'
|
||||||
|
|
||||||
|
type BoxLayoutType = 'flowing' | 'columnar' | 'bullet_list' | 'header_only'
|
||||||
|
|
||||||
|
const LAYOUT_LABELS: Record<BoxLayoutType, string> = {
|
||||||
|
flowing: 'Fließtext',
|
||||||
|
columnar: 'Tabelle/Spalten',
|
||||||
|
bullet_list: 'Aufzählung',
|
||||||
|
header_only: 'Überschrift',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepBoxGridReviewProps {
|
||||||
|
sessionId: string | null
|
||||||
|
onNext: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepBoxGridReview({ sessionId, onNext }: StepBoxGridReviewProps) {
|
||||||
|
const {
|
||||||
|
grid,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
error,
|
||||||
|
dirty,
|
||||||
|
selectedCell,
|
||||||
|
setSelectedCell,
|
||||||
|
loadGrid,
|
||||||
|
saveGrid,
|
||||||
|
updateCellText,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
getAdjacentCell,
|
||||||
|
commitUndoPoint,
|
||||||
|
selectedCells,
|
||||||
|
toggleCellSelection,
|
||||||
|
clearCellSelection,
|
||||||
|
toggleSelectedBold,
|
||||||
|
setCellColor,
|
||||||
|
} = useGridEditor(sessionId)
|
||||||
|
|
||||||
|
const [building, setBuilding] = useState(false)
|
||||||
|
const [buildError, setBuildError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load grid on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionId) loadGrid()
|
||||||
|
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Get box zones
|
||||||
|
const boxZones: GridZone[] = (grid?.zones || []).filter(
|
||||||
|
(z: GridZone) => z.zone_type === 'box'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build box grids via backend
|
||||||
|
const buildBoxGrids = useCallback(async (overrides?: Record<string, string>) => {
|
||||||
|
if (!sessionId) return
|
||||||
|
setBuilding(true)
|
||||||
|
setBuildError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-box-grids`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ overrides: overrides || {} }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(data.detail || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
// Reload grid to see updated box zones
|
||||||
|
await loadGrid()
|
||||||
|
} catch (e) {
|
||||||
|
setBuildError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setBuilding(false)
|
||||||
|
}
|
||||||
|
}, [sessionId, loadGrid])
|
||||||
|
|
||||||
|
// Handle layout type change for a specific box zone
|
||||||
|
const changeLayoutType = useCallback(async (zoneIndex: number, layoutType: string) => {
|
||||||
|
await buildBoxGrids({ [String(zoneIndex)]: layoutType })
|
||||||
|
}, [buildBoxGrids])
|
||||||
|
|
||||||
|
// Auto-build on first load if box zones have no cells
|
||||||
|
useEffect(() => {
|
||||||
|
if (!grid || loading || building) return
|
||||||
|
const needsBuild = boxZones.some(z => !z.cells || z.cells.length === 0)
|
||||||
|
if (needsBuild && boxZones.length > 0) {
|
||||||
|
buildBoxGrids()
|
||||||
|
}
|
||||||
|
}, [grid, loading]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="w-8 h-8 border-4 border-teal-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="ml-3 text-gray-500">Lade Grid...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No boxes detected — skip step
|
||||||
|
if (boxZones.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-8 text-center">
|
||||||
|
<div className="text-4xl mb-3">📦</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
Keine Boxen erkannt
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
Auf dieser Seite wurden keine eingebetteten Boxen (Grammatik-Tipps, Übungen etc.) erkannt.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
className="px-6 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Weiter →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
📦 Box-Review ({boxZones.length} {boxZones.length === 1 ? 'Box' : 'Boxen'})
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Eingebettete Boxen prüfen und korrigieren. Layout-Typ kann pro Box angepasst werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
onClick={saveGrid}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichere...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => buildBoxGrids()}
|
||||||
|
disabled={building}
|
||||||
|
className="px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{building ? 'Verarbeite...' : 'Alle Boxen neu aufbauen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (dirty) await saveGrid()
|
||||||
|
onNext()
|
||||||
|
}}
|
||||||
|
className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Weiter →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Errors */}
|
||||||
|
{(error || buildError) && (
|
||||||
|
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">
|
||||||
|
{error || buildError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{building && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||||
|
<div className="w-5 h-5 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="text-amber-700 dark:text-amber-300 text-sm">Box-Grids werden aufgebaut...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Box zones */}
|
||||||
|
{boxZones.map((zone) => (
|
||||||
|
<div
|
||||||
|
key={zone.zone_index}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-xl border-2 border-amber-300 dark:border-amber-700 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Box header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-amber-50 dark:bg-amber-900/30 border-b border-amber-200 dark:border-amber-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-lg">📦</span>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
Box {zone.zone_index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||||
|
{zone.bbox_px.w}×{zone.bbox_px.h}px
|
||||||
|
{zone.cells?.length ? ` • ${zone.cells.length} Zellen` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-gray-500 dark:text-gray-400">Layout:</label>
|
||||||
|
<select
|
||||||
|
value={zone.box_layout_type || 'flowing'}
|
||||||
|
onChange={(e) => changeLayoutType(zone.zone_index, e.target.value)}
|
||||||
|
disabled={building}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200"
|
||||||
|
>
|
||||||
|
{Object.entries(LAYOUT_LABELS).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Box content — image + grid side by side */}
|
||||||
|
<div className="flex gap-0">
|
||||||
|
{/* Box image crop */}
|
||||||
|
{sessionId && (
|
||||||
|
<div className="w-1/3 border-r border-gray-200 dark:border-gray-700 p-2 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="relative overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<img
|
||||||
|
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`}
|
||||||
|
alt={`Box ${zone.zone_index + 1}`}
|
||||||
|
className="w-full h-auto"
|
||||||
|
style={{
|
||||||
|
objectFit: 'none',
|
||||||
|
objectPosition: `-${zone.bbox_pct?.x ?? 0}% -${zone.bbox_pct?.y ?? 0}%`,
|
||||||
|
// Use clip-path to show only the box region
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
// Fallback: hide image if endpoint doesn't exist
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Box grid table */}
|
||||||
|
<div className="flex-1 p-2 overflow-x-auto">
|
||||||
|
{zone.cells && zone.cells.length > 0 ? (
|
||||||
|
<GridTable
|
||||||
|
zones={[zone]}
|
||||||
|
selectedCell={selectedCell}
|
||||||
|
onCellSelect={setSelectedCell}
|
||||||
|
onCellChange={updateCellText}
|
||||||
|
onGetAdjacentCell={getAdjacentCell}
|
||||||
|
imageWidth={grid?.image_width || 0}
|
||||||
|
imageHeight={grid?.image_height || 0}
|
||||||
|
commitUndoPoint={commitUndoPoint}
|
||||||
|
selectedCells={selectedCells}
|
||||||
|
onToggleCellSelection={toggleCellSelection}
|
||||||
|
onClearCellSelection={clearCellSelection}
|
||||||
|
onToggleSelectedBold={toggleSelectedBold}
|
||||||
|
onSetCellColor={setCellColor}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-400">
|
||||||
|
<p className="text-sm">Keine Zellen erkannt.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => buildBoxGrids({ [String(zone.zone_index)]: 'flowing' })}
|
||||||
|
className="mt-2 text-xs text-amber-600 hover:text-amber-700"
|
||||||
|
>
|
||||||
|
Als Fließtext verarbeiten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
256
klausur-service/backend/cv_box_layout.py
Normal file
256
klausur-service/backend/cv_box_layout.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
"""
|
||||||
|
Box layout classifier — detects internal layout type of embedded boxes.
|
||||||
|
|
||||||
|
Classifies each box as: flowing | columnar | bullet_list | header_only
|
||||||
|
and provides layout-appropriate grid building.
|
||||||
|
|
||||||
|
Used by the Box-Grid-Review step to rebuild box zones with correct structure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import statistics
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Bullet / list-item patterns at the start of a line
|
||||||
|
_BULLET_RE = re.compile(
|
||||||
|
r'^[\-\u2022\u2013\u2014\u25CF\u25CB\u25AA\u25A0•·]\s' # dash, bullet chars
|
||||||
|
r'|^\d{1,2}[.)]\s' # numbered: "1) " or "1. "
|
||||||
|
r'|^[a-z][.)]\s' # lettered: "a) " or "a. "
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_box_layout(
|
||||||
|
words: List[Dict],
|
||||||
|
box_w: int,
|
||||||
|
box_h: int,
|
||||||
|
) -> str:
|
||||||
|
"""Classify the internal layout of a detected box.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
words: OCR word dicts within the box (with top, left, width, height, text)
|
||||||
|
box_w: Box width in pixels
|
||||||
|
box_h: Box height in pixels
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
'header_only' | 'bullet_list' | 'columnar' | 'flowing'
|
||||||
|
"""
|
||||||
|
if not words:
|
||||||
|
return "header_only"
|
||||||
|
|
||||||
|
# Group words into lines by y-proximity
|
||||||
|
lines = _group_into_lines(words)
|
||||||
|
|
||||||
|
# Header only: very few words or single line
|
||||||
|
total_words = sum(len(line) for line in lines)
|
||||||
|
if total_words <= 5 or len(lines) <= 1:
|
||||||
|
return "header_only"
|
||||||
|
|
||||||
|
# Bullet list: check if majority of lines start with bullet patterns
|
||||||
|
bullet_count = 0
|
||||||
|
for line in lines:
|
||||||
|
first_text = line[0].get("text", "") if line else ""
|
||||||
|
if _BULLET_RE.match(first_text):
|
||||||
|
bullet_count += 1
|
||||||
|
# Also check if first word IS a bullet char
|
||||||
|
elif first_text.strip() in ("-", "–", "—", "•", "·", "▪", "▸"):
|
||||||
|
bullet_count += 1
|
||||||
|
if bullet_count >= len(lines) * 0.4 and bullet_count >= 2:
|
||||||
|
return "bullet_list"
|
||||||
|
|
||||||
|
# Columnar: check for multiple distinct x-clusters
|
||||||
|
if len(lines) >= 3 and _has_column_structure(words, box_w):
|
||||||
|
return "columnar"
|
||||||
|
|
||||||
|
# Default: flowing text
|
||||||
|
return "flowing"
|
||||||
|
|
||||||
|
|
||||||
|
def _group_into_lines(words: List[Dict]) -> List[List[Dict]]:
|
||||||
|
"""Group words into lines by y-proximity."""
|
||||||
|
if not words:
|
||||||
|
return []
|
||||||
|
|
||||||
|
sorted_words = sorted(words, key=lambda w: (w["top"], w["left"]))
|
||||||
|
heights = [w["height"] for w in sorted_words if w.get("height", 0) > 0]
|
||||||
|
median_h = statistics.median(heights) if heights else 20
|
||||||
|
y_tolerance = max(median_h * 0.5, 5)
|
||||||
|
|
||||||
|
lines: List[List[Dict]] = []
|
||||||
|
current_line: List[Dict] = [sorted_words[0]]
|
||||||
|
current_y = sorted_words[0]["top"]
|
||||||
|
|
||||||
|
for w in sorted_words[1:]:
|
||||||
|
if abs(w["top"] - current_y) <= y_tolerance:
|
||||||
|
current_line.append(w)
|
||||||
|
else:
|
||||||
|
lines.append(sorted(current_line, key=lambda ww: ww["left"]))
|
||||||
|
current_line = [w]
|
||||||
|
current_y = w["top"]
|
||||||
|
|
||||||
|
if current_line:
|
||||||
|
lines.append(sorted(current_line, key=lambda ww: ww["left"]))
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column_structure(words: List[Dict], box_w: int) -> bool:
|
||||||
|
"""Check if words have multiple distinct left-edge clusters (columns)."""
|
||||||
|
if box_w <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
lines = _group_into_lines(words)
|
||||||
|
if len(lines) < 3:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Collect left-edges of non-first words in each line
|
||||||
|
# (first word of each line often aligns regardless of columns)
|
||||||
|
left_edges = []
|
||||||
|
for line in lines:
|
||||||
|
for w in line[1:]: # skip first word
|
||||||
|
left_edges.append(w["left"])
|
||||||
|
|
||||||
|
if len(left_edges) < 4:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if left edges cluster into 2+ distinct groups
|
||||||
|
left_edges.sort()
|
||||||
|
gaps = [left_edges[i + 1] - left_edges[i] for i in range(len(left_edges) - 1)]
|
||||||
|
if not gaps:
|
||||||
|
return False
|
||||||
|
|
||||||
|
median_gap = statistics.median(gaps)
|
||||||
|
# A column gap is typically > 15% of box width
|
||||||
|
column_gap_threshold = box_w * 0.15
|
||||||
|
large_gaps = [g for g in gaps if g > column_gap_threshold]
|
||||||
|
|
||||||
|
return len(large_gaps) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def build_box_zone_grid(
|
||||||
|
zone_words: List[Dict],
|
||||||
|
box_x: int,
|
||||||
|
box_y: int,
|
||||||
|
box_w: int,
|
||||||
|
box_h: int,
|
||||||
|
zone_index: int,
|
||||||
|
img_w: int,
|
||||||
|
img_h: int,
|
||||||
|
layout_type: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Build a grid for a box zone with layout-aware processing.
|
||||||
|
|
||||||
|
If layout_type is None, auto-detects it.
|
||||||
|
For 'flowing' and 'bullet_list', forces single-column layout.
|
||||||
|
For 'columnar', uses the standard multi-column detection.
|
||||||
|
For 'header_only', creates a single cell.
|
||||||
|
|
||||||
|
Returns the same format as _build_zone_grid (columns, rows, cells, header_rows).
|
||||||
|
"""
|
||||||
|
from grid_editor_helpers import _build_zone_grid, _cluster_rows
|
||||||
|
|
||||||
|
if not zone_words:
|
||||||
|
return {
|
||||||
|
"columns": [],
|
||||||
|
"rows": [],
|
||||||
|
"cells": [],
|
||||||
|
"header_rows": [],
|
||||||
|
"box_layout_type": layout_type or "header_only",
|
||||||
|
"box_grid_reviewed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-detect layout if not specified
|
||||||
|
if not layout_type:
|
||||||
|
layout_type = classify_box_layout(zone_words, box_w, box_h)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Box zone %d: layout_type=%s, %d words, %dx%d",
|
||||||
|
zone_index, layout_type, len(zone_words), box_w, box_h,
|
||||||
|
)
|
||||||
|
|
||||||
|
if layout_type == "header_only":
|
||||||
|
# Single cell with all text concatenated
|
||||||
|
all_text = " ".join(
|
||||||
|
w.get("text", "") for w in sorted(zone_words, key=lambda ww: (ww["top"], ww["left"]))
|
||||||
|
).strip()
|
||||||
|
return {
|
||||||
|
"columns": [{"col_index": 0, "index": 0, "label": "column_text", "col_type": "column_1"}],
|
||||||
|
"rows": [{"index": 0, "row_index": 0, "y_min": box_y, "y_max": box_y + box_h, "y_center": box_y + box_h / 2}],
|
||||||
|
"cells": [{
|
||||||
|
"cell_id": f"Z{zone_index}_R0C0",
|
||||||
|
"row_index": 0,
|
||||||
|
"col_index": 0,
|
||||||
|
"col_type": "column_1",
|
||||||
|
"text": all_text,
|
||||||
|
"word_boxes": zone_words,
|
||||||
|
}],
|
||||||
|
"header_rows": [0],
|
||||||
|
"box_layout_type": layout_type,
|
||||||
|
"box_grid_reviewed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if layout_type in ("flowing", "bullet_list"):
|
||||||
|
# Force single column — each line becomes one row with one cell
|
||||||
|
lines = _group_into_lines(zone_words)
|
||||||
|
column = {"col_index": 0, "index": 0, "label": "column_text", "col_type": "column_1"}
|
||||||
|
rows = []
|
||||||
|
cells = []
|
||||||
|
|
||||||
|
for row_idx, line_words in enumerate(lines):
|
||||||
|
if not line_words:
|
||||||
|
continue
|
||||||
|
y_min = min(w["top"] for w in line_words)
|
||||||
|
y_max = max(w["top"] + w["height"] for w in line_words)
|
||||||
|
y_center = (y_min + y_max) / 2
|
||||||
|
|
||||||
|
row = {
|
||||||
|
"index": row_idx,
|
||||||
|
"row_index": row_idx,
|
||||||
|
"y_min": y_min,
|
||||||
|
"y_max": y_max,
|
||||||
|
"y_center": y_center,
|
||||||
|
}
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
line_text = " ".join(w.get("text", "") for w in line_words).strip()
|
||||||
|
cell = {
|
||||||
|
"cell_id": f"Z{zone_index}_R{row_idx}C0",
|
||||||
|
"row_index": row_idx,
|
||||||
|
"col_index": 0,
|
||||||
|
"col_type": "column_1",
|
||||||
|
"text": line_text,
|
||||||
|
"word_boxes": line_words,
|
||||||
|
}
|
||||||
|
cells.append(cell)
|
||||||
|
|
||||||
|
# Detect header: first row if it's notably different (bold, larger, or short)
|
||||||
|
header_rows = []
|
||||||
|
if len(lines) >= 2:
|
||||||
|
first_line = lines[0]
|
||||||
|
first_text = " ".join(w.get("text", "") for w in first_line).strip()
|
||||||
|
# Header heuristic: short text, or all-caps, or ends with ':'
|
||||||
|
if (len(first_text) < 40
|
||||||
|
or first_text.isupper()
|
||||||
|
or first_text.rstrip().endswith(':')):
|
||||||
|
header_rows = [0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"columns": [column],
|
||||||
|
"rows": rows,
|
||||||
|
"cells": cells,
|
||||||
|
"header_rows": header_rows,
|
||||||
|
"box_layout_type": layout_type,
|
||||||
|
"box_grid_reviewed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Columnar: use standard grid builder with independent column detection
|
||||||
|
result = _build_zone_grid(
|
||||||
|
zone_words, box_x, box_y, box_w, box_h,
|
||||||
|
zone_index, img_w, img_h,
|
||||||
|
global_columns=None, # detect columns independently
|
||||||
|
)
|
||||||
|
result["box_layout_type"] = layout_type
|
||||||
|
result["box_grid_reviewed"] = False
|
||||||
|
return result
|
||||||
@@ -2181,3 +2181,117 @@ async def gutter_repair_apply(session_id: str, request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Box-Grid-Review endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/build-box-grids")
|
||||||
|
async def build_box_grids(session_id: str, request: Request):
|
||||||
|
"""Rebuild grid structure for all box zones with layout-aware detection.
|
||||||
|
|
||||||
|
For each zone with zone_type='box':
|
||||||
|
1. Auto-detect layout type (flowing / columnar / bullet_list / header_only)
|
||||||
|
2. Build grid with layout-appropriate parameters
|
||||||
|
3. Apply SmartSpellChecker corrections
|
||||||
|
4. Store results back in grid_editor_result.zones[]
|
||||||
|
|
||||||
|
Optional body: { "overrides": { "2": "bullet_list" } }
|
||||||
|
Maps zone_index → forced layout_type.
|
||||||
|
"""
|
||||||
|
session = await get_session_db(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||||
|
|
||||||
|
grid_data = session.get("grid_editor_result")
|
||||||
|
if not grid_data:
|
||||||
|
raise HTTPException(status_code=400, detail="No grid data. Run build-grid first.")
|
||||||
|
|
||||||
|
word_result = session.get("word_result") or {}
|
||||||
|
all_words = word_result.get("cells") or word_result.get("words") or []
|
||||||
|
|
||||||
|
body = {}
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
layout_overrides = body.get("overrides", {})
|
||||||
|
|
||||||
|
from cv_box_layout import classify_box_layout, build_box_zone_grid, _group_into_lines
|
||||||
|
from grid_editor_helpers import _words_in_zone
|
||||||
|
|
||||||
|
img_w = grid_data.get("image_width", 0)
|
||||||
|
img_h = grid_data.get("image_height", 0)
|
||||||
|
|
||||||
|
zones = grid_data.get("zones", [])
|
||||||
|
box_count = 0
|
||||||
|
spell_fixes = 0
|
||||||
|
|
||||||
|
for z in zones:
|
||||||
|
if z.get("zone_type") != "box":
|
||||||
|
continue
|
||||||
|
|
||||||
|
bbox = z.get("bbox_px", {})
|
||||||
|
bx, by = bbox.get("x", 0), bbox.get("y", 0)
|
||||||
|
bw, bh = bbox.get("w", 0), bbox.get("h", 0)
|
||||||
|
|
||||||
|
if bw <= 0 or bh <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
zone_idx = z.get("zone_index", 0)
|
||||||
|
|
||||||
|
# Filter words inside this box
|
||||||
|
zone_words = _words_in_zone(all_words, by, bh, bx, bw)
|
||||||
|
if not zone_words:
|
||||||
|
logger.info("Box zone %d: no words found in bbox", zone_idx)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get layout override or auto-detect
|
||||||
|
forced_layout = layout_overrides.get(str(zone_idx))
|
||||||
|
|
||||||
|
# Build box grid
|
||||||
|
box_grid = build_box_zone_grid(
|
||||||
|
zone_words, bx, by, bw, bh,
|
||||||
|
zone_idx, img_w, img_h,
|
||||||
|
layout_type=forced_layout,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply SmartSpellChecker to all box cells
|
||||||
|
try:
|
||||||
|
from smart_spell import SmartSpellChecker
|
||||||
|
ssc = SmartSpellChecker()
|
||||||
|
for cell in box_grid.get("cells", []):
|
||||||
|
text = cell.get("text", "")
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
result = ssc.correct_text(text, lang="auto")
|
||||||
|
if result.changed:
|
||||||
|
cell["text"] = result.corrected
|
||||||
|
spell_fixes += 1
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Update zone data with new grid
|
||||||
|
z["columns"] = box_grid["columns"]
|
||||||
|
z["rows"] = box_grid["rows"]
|
||||||
|
z["cells"] = box_grid["cells"]
|
||||||
|
z["header_rows"] = box_grid.get("header_rows", [])
|
||||||
|
z["box_layout_type"] = box_grid.get("box_layout_type", "flowing")
|
||||||
|
z["box_grid_reviewed"] = False
|
||||||
|
box_count += 1
|
||||||
|
|
||||||
|
# Save updated grid back
|
||||||
|
await update_session_db(session_id, grid_editor_result=grid_data)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"build-box-grids session %s: %d box zones rebuilt, %d spell fixes",
|
||||||
|
session_id, box_count, spell_fixes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"box_zones_rebuilt": box_count,
|
||||||
|
"spell_fixes": spell_fixes,
|
||||||
|
"zones": zones,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user