mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-20 14:41:05 +08:00
fix: align pdf table structure coordinates (#17016)
### What problem does this PR solve? Table structure recognition rows, columns, headers, and spans are produced in cropped table image coordinates, while OCR boxes are matched later in page-cumulative coordinates. Comparing those boxes without normalization can skip or misassign table row and column metadata. Closes #16992. ### What is changed? - Map TSR components from cropped or rotated table-image coordinates back into page-cumulative coordinates before matching OCR boxes. - Reuse one inverse rotation transform for rotated OCR boxes and TSR components. - Keep TSR layout ids in the same `table-N` form used by table OCR boxes. - Sort columns by mapped page x-coordinate after coordinate normalization. - Add focused unit coverage for page offsets, zoom scaling, and 90/180/270 degree rotated tables. ### Type of change - [x] Bug fix - [x] Test coverage ### How has this been tested? - `uv run --group test pytest test/unit_test/deepdoc/parser/test_pdf_parser_table_coordinates.py -q` - `uv run --no-sync --group test pytest --confcutdir=test/unit_test/deepdoc/parser test/unit_test/deepdoc/parser/test_pdf_parser_table_coordinates.py -q` - `uv run ruff check deepdoc/parser/pdf_parser.py test/unit_test/deepdoc/parser/test_pdf_parser_table_coordinates.py` - `uv run --no-sync python -m py_compile deepdoc/parser/pdf_parser.py test/unit_test/deepdoc/parser/test_pdf_parser_table_coordinates.py` - `git diff --check` A later dependency-sync attempt was blocked while resolving the `en-core-web-sm` wheel from GitHub, and the repository-level unit-test conftest can try to download missing NLTK `wordnet` data when it is not already present locally. The focused parser test above does not require that data fixture. --------- Co-authored-by: zq <zhouquan1511@163.com>
This commit is contained in:
@@ -108,8 +108,8 @@ func (p *Parser) processOneTable(ctx context.Context, pageImg image.Image, boxes
|
||||
p.ocrTableCells(ctx, cells, tsrImg, docAnalyzer)
|
||||
}
|
||||
for i := range cells {
|
||||
cells[i].X0, cells[i].Y0 = util.MapRotatedPointToOriginal(cells[i].X0, cells[i].Y0, bestAngle, origW, origH)
|
||||
cells[i].X1, cells[i].Y1 = util.MapRotatedPointToOriginal(cells[i].X1, cells[i].Y1, bestAngle, origW, origH)
|
||||
cells[i].X0, cells[i].Y0, cells[i].X1, cells[i].Y1 = util.MapRotatedRectToOriginal(
|
||||
cells[i].X0, cells[i].Y0, cells[i].X1, cells[i].Y1, bestAngle, origW, origH)
|
||||
}
|
||||
}
|
||||
firstCellTop := 1e9
|
||||
|
||||
97
internal/deepdoc/parser/pdf/table_extract_test.go
Normal file
97
internal/deepdoc/parser/pdf/table_extract_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
tbl "ragflow/internal/deepdoc/parser/pdf/table"
|
||||
pdf "ragflow/internal/deepdoc/parser/pdf/type"
|
||||
)
|
||||
|
||||
type orientationScoringDoc struct{}
|
||||
|
||||
func (d *orientationScoringDoc) DLA(_ context.Context, _ image.Image) ([]pdf.DLARegion, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (d *orientationScoringDoc) TSR(_ context.Context, _ image.Image) ([]pdf.TSRCell, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (d *orientationScoringDoc) OCRDetect(_ context.Context, img image.Image) ([]pdf.OCRBox, error) {
|
||||
regions := 1
|
||||
if img.Bounds().Dy() > img.Bounds().Dx() {
|
||||
regions = 5
|
||||
}
|
||||
boxes := make([]pdf.OCRBox, regions)
|
||||
for i := range boxes {
|
||||
x0 := float64((i + 1) * 10)
|
||||
boxes[i] = pdf.OCRBox{
|
||||
X0: x0, Y0: 10,
|
||||
X1: x0 + 5, Y1: 10,
|
||||
X2: x0 + 5, Y2: 30,
|
||||
X3: x0, Y3: 30,
|
||||
}
|
||||
}
|
||||
return boxes, nil
|
||||
}
|
||||
|
||||
func (d *orientationScoringDoc) OCRRecognize(_ context.Context, _ image.Image) ([]pdf.OCRText, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (d *orientationScoringDoc) Health() bool { return true }
|
||||
|
||||
type staticTableBuilder struct {
|
||||
cells []pdf.TSRCell
|
||||
}
|
||||
|
||||
func (b *staticTableBuilder) Name() string { return "static" }
|
||||
|
||||
func (b *staticTableBuilder) DetectCells(_ context.Context, _ image.Image) ([]pdf.TSRCell, error) {
|
||||
return append([]pdf.TSRCell(nil), b.cells...), nil
|
||||
}
|
||||
|
||||
func (b *staticTableBuilder) GroupCells(cells []pdf.TSRCell) [][]pdf.TSRCell {
|
||||
if len(cells) == 0 {
|
||||
return nil
|
||||
}
|
||||
return [][]pdf.TSRCell{{cells[0]}}
|
||||
}
|
||||
|
||||
func TestProcessOneTable_AutoRotateNormalizesCellBounds(t *testing.T) {
|
||||
autoRotate := true
|
||||
cfg := pdf.DefaultParserConfig()
|
||||
cfg.AutoRotateTables = &autoRotate
|
||||
cfg.SkipOCR = true
|
||||
p := NewParser(cfg)
|
||||
|
||||
pageImg := image.NewRGBA(image.Rect(0, 0, 320, 220))
|
||||
boxes := []pdf.TextBox{
|
||||
{X0: 10, X1: 60, Top: 10, Bottom: 30, Text: "cell", LayoutType: pdf.LayoutTypeTable},
|
||||
}
|
||||
match := tbl.TableMatch{
|
||||
Region: pdf.DLARegion{X0: 10, Y0: 10, X1: 210, Y1: 110, Label: pdf.LayoutTypeTable},
|
||||
BoxIdx: []int{0},
|
||||
}
|
||||
builder := &staticTableBuilder{
|
||||
cells: []pdf.TSRCell{
|
||||
{X0: 10, Y0: 20, X1: 60, Y1: 80, Label: "table row"},
|
||||
},
|
||||
}
|
||||
|
||||
item := p.processOneTable(context.Background(), pageImg, boxes, 0, &orientationScoringDoc{}, builder, match, pdf.DlaScale)
|
||||
if len(item.Cells) != 1 {
|
||||
t.Fatalf("cells = %d, want 1", len(item.Cells))
|
||||
}
|
||||
|
||||
got := item.Cells[0]
|
||||
if got.X0 != 20 || got.Y0 != 45 || got.X1 != 80 || got.Y1 != 95 {
|
||||
t.Errorf("cell bounds = (%.0f,%.0f,%.0f,%.0f), want (20,45,80,95)",
|
||||
got.X0, got.Y0, got.X1, got.Y1)
|
||||
}
|
||||
if got.X0 > got.X1 || got.Y0 > got.Y1 {
|
||||
t.Fatalf("cell bounds are inverted: (%.0f,%.0f,%.0f,%.0f)", got.X0, got.Y0, got.X1, got.Y1)
|
||||
}
|
||||
}
|
||||
@@ -535,6 +535,29 @@ func MapRotatedPointToOriginal(x, y float64, angle int, origW, origH int) (float
|
||||
}
|
||||
}
|
||||
|
||||
// MapRotatedRectToOriginal maps a rotated-image rectangle back into original
|
||||
// image coordinates and normalizes the resulting bounds. For 90°/270° rotation,
|
||||
// mapping only two diagonal corners can invert X/Y bounds; mapping all four
|
||||
// corners preserves the enclosing rectangle.
|
||||
func MapRotatedRectToOriginal(x0, y0, x1, y1 float64, angle int, origW, origH int) (float64, float64, float64, float64) {
|
||||
points := [][2]float64{
|
||||
{x0, y0},
|
||||
{x1, y0},
|
||||
{x0, y1},
|
||||
{x1, y1},
|
||||
}
|
||||
minX, minY := math.Inf(1), math.Inf(1)
|
||||
maxX, maxY := math.Inf(-1), math.Inf(-1)
|
||||
for _, p := range points {
|
||||
x, y := MapRotatedPointToOriginal(p[0], p[1], angle, origW, origH)
|
||||
minX = math.Min(minX, x)
|
||||
minY = math.Min(minY, y)
|
||||
maxX = math.Max(maxX, x)
|
||||
maxY = math.Max(maxY, y)
|
||||
}
|
||||
return minX, minY, maxX, maxY
|
||||
}
|
||||
|
||||
// CropImageRegion crops a pdf.DLARegion from an image with a 3% margin
|
||||
// (matching Python's _table_transformer_job: w*0.03, h*0.03).
|
||||
func CropImageRegion(img image.Image, r pdf.DLARegion) (image.Image, error) {
|
||||
|
||||
@@ -245,6 +245,33 @@ func TestMapRotatedPointToOriginal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapRotatedRectToOriginal_NormalizesBounds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
angle int
|
||||
wantX0, wantY0 float64
|
||||
wantX1, wantY1 float64
|
||||
}{
|
||||
{name: "zero", angle: 0, wantX0: 10, wantY0: 20, wantX1: 60, wantY1: 80},
|
||||
{name: "ninety", angle: 90, wantX0: 20, wantY0: 39, wantX1: 80, wantY1: 89},
|
||||
{name: "one-eighty", angle: 180, wantX0: 139, wantY0: 19, wantX1: 189, wantY1: 79},
|
||||
{name: "two-seventy", angle: 270, wantX0: 119, wantY0: 10, wantX1: 179, wantY1: 60},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotX0, gotY0, gotX1, gotY1 := MapRotatedRectToOriginal(10, 20, 60, 80, tt.angle, 200, 100)
|
||||
if gotX0 != tt.wantX0 || gotY0 != tt.wantY0 || gotX1 != tt.wantX1 || gotY1 != tt.wantY1 {
|
||||
t.Errorf("got (%.0f,%.0f,%.0f,%.0f), want (%.0f,%.0f,%.0f,%.0f)",
|
||||
gotX0, gotY0, gotX1, gotY1, tt.wantX0, tt.wantY0, tt.wantX1, tt.wantY1)
|
||||
}
|
||||
if gotX0 > gotX1 || gotY0 > gotY1 {
|
||||
t.Fatalf("mapped rectangle is inverted: (%.0f,%.0f,%.0f,%.0f)", gotX0, gotY0, gotX1, gotY1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func colorEqual(a, b color.Color) bool {
|
||||
ar, ag, ab, aa := a.RGBA()
|
||||
br, bg, bb, ba := b.RGBA()
|
||||
|
||||
Reference in New Issue
Block a user