Include pretty visual mode
This commit is contained in:
parent
0c5755f0b6
commit
9c2e42ace8
7 changed files with 208 additions and 26 deletions
26
document-parser/bbox.py
Normal file
26
document-parser/bbox.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Bounding box coordinate normalization for Docling output.
|
||||||
|
|
||||||
|
Docling's BoundingBox uses two possible coordinate origins:
|
||||||
|
- TOPLEFT: y=0 at top, t < b (t is smaller, closer to origin)
|
||||||
|
- BOTTOMLEFT: y=0 at bottom, t > b (t is larger, further from origin)
|
||||||
|
|
||||||
|
The frontend canvas uses TOPLEFT coordinates. This module ensures all
|
||||||
|
bboxes are normalized to TOPLEFT [left, top, right, bottom] before
|
||||||
|
being sent to the frontend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from docling_core.types.doc.base import BoundingBox, CoordOrigin
|
||||||
|
|
||||||
|
|
||||||
|
def to_topleft_list(bbox: BoundingBox, page_height: float) -> list[float]:
|
||||||
|
"""Convert a Docling BoundingBox to a [l, t, r, b] list in TOPLEFT origin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bbox: Docling BoundingBox (any origin).
|
||||||
|
page_height: Height of the page (needed for BOTTOMLEFT conversion).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[left, top, right, bottom] in TOPLEFT coordinates.
|
||||||
|
"""
|
||||||
|
normalized = bbox.to_top_left_origin(page_height)
|
||||||
|
return [normalized.l, normalized.t, normalized.r, normalized.b]
|
||||||
|
|
@ -10,6 +10,8 @@ from docling.document_converter import DocumentConverter
|
||||||
from pdf2image import convert_from_bytes
|
from pdf2image import convert_from_bytes
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from bbox import to_topleft_list
|
||||||
|
|
||||||
app = FastAPI(title="Docling Studio - Document Parser")
|
app = FastAPI(title="Docling Studio - Document Parser")
|
||||||
|
|
||||||
converter = DocumentConverter()
|
converter = DocumentConverter()
|
||||||
|
|
@ -86,11 +88,13 @@ def _process_content_item(item, pages: dict[int, PageDetail]):
|
||||||
page_number=page_no, width=612.0, height=792.0, elements=[]
|
page_number=page_no, width=612.0, height=792.0, elements=[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
page_height = pages[page_no].height
|
||||||
|
|
||||||
bbox = [0, 0, 0, 0]
|
bbox = [0, 0, 0, 0]
|
||||||
if hasattr(prov, 'bbox') and prov.bbox:
|
if hasattr(prov, 'bbox') and prov.bbox:
|
||||||
b = prov.bbox
|
b = prov.bbox
|
||||||
if hasattr(b, 'l'):
|
if hasattr(b, 'l'):
|
||||||
bbox = [b.l, b.t, b.r, b.b]
|
bbox = to_topleft_list(b, page_height)
|
||||||
elif isinstance(b, (list, tuple)) and len(b) >= 4:
|
elif isinstance(b, (list, tuple)) and len(b) >= 4:
|
||||||
bbox = list(b[:4])
|
bbox = list(b[:4])
|
||||||
|
|
||||||
|
|
|
||||||
50
document-parser/test_bbox.py
Normal file
50
document-parser/test_bbox.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""Tests for bbox coordinate normalization."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from docling_core.types.doc.base import BoundingBox, CoordOrigin
|
||||||
|
|
||||||
|
from bbox import to_topleft_list
|
||||||
|
|
||||||
|
|
||||||
|
class TestToTopleftList:
|
||||||
|
"""Tests for to_topleft_list conversion."""
|
||||||
|
|
||||||
|
def test_topleft_origin_unchanged(self):
|
||||||
|
"""TOPLEFT bbox should pass through unchanged."""
|
||||||
|
bbox = BoundingBox(l=10, t=20, r=100, b=80, coord_origin=CoordOrigin.TOPLEFT)
|
||||||
|
result = to_topleft_list(bbox, page_height=792.0)
|
||||||
|
assert result == [10, 20, 100, 80]
|
||||||
|
|
||||||
|
def test_bottomleft_origin_converted(self):
|
||||||
|
"""BOTTOMLEFT bbox should have y-coordinates flipped."""
|
||||||
|
# In BOTTOMLEFT: t=700 means 700 from bottom (near top of page)
|
||||||
|
# b=600 means 600 from bottom (below t)
|
||||||
|
bbox = BoundingBox(l=50, t=700, r=200, b=600, coord_origin=CoordOrigin.BOTTOMLEFT)
|
||||||
|
result = to_topleft_list(bbox, page_height=792.0)
|
||||||
|
|
||||||
|
# After conversion: new_t = 792 - 700 = 92, new_b = 792 - 600 = 192
|
||||||
|
assert result[0] == 50 # l unchanged
|
||||||
|
assert result[1] == pytest.approx(92.0) # t = page_height - old_t
|
||||||
|
assert result[2] == 200 # r unchanged
|
||||||
|
assert result[3] == pytest.approx(192.0) # b = page_height - old_b
|
||||||
|
|
||||||
|
def test_result_has_positive_dimensions(self):
|
||||||
|
"""Converted bbox should always have b > t (positive height)."""
|
||||||
|
bbox = BoundingBox(l=10, t=500, r=300, b=100, coord_origin=CoordOrigin.BOTTOMLEFT)
|
||||||
|
result = to_topleft_list(bbox, page_height=800.0)
|
||||||
|
|
||||||
|
l, t, r, b = result
|
||||||
|
assert r > l, "width should be positive"
|
||||||
|
assert b > t, "height should be positive"
|
||||||
|
|
||||||
|
def test_full_page_bbox_bottomleft(self):
|
||||||
|
"""A bbox covering the full page in BOTTOMLEFT origin."""
|
||||||
|
bbox = BoundingBox(l=0, t=792, r=612, b=0, coord_origin=CoordOrigin.BOTTOMLEFT)
|
||||||
|
result = to_topleft_list(bbox, page_height=792.0)
|
||||||
|
assert result == [0, 0, 612, 792]
|
||||||
|
|
||||||
|
def test_full_page_bbox_topleft(self):
|
||||||
|
"""A bbox covering the full page in TOPLEFT origin."""
|
||||||
|
bbox = BoundingBox(l=0, t=0, r=612, b=792, coord_origin=CoordOrigin.TOPLEFT)
|
||||||
|
result = to_topleft_list(bbox, page_height=792.0)
|
||||||
|
assert result == [0, 0, 612, 792]
|
||||||
54
frontend/src/features/analysis/bboxScaling.js
Normal file
54
frontend/src/features/analysis/bboxScaling.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/**
|
||||||
|
* Bbox scaling utilities for mapping Docling TOPLEFT coordinates
|
||||||
|
* to canvas pixel coordinates.
|
||||||
|
*
|
||||||
|
* Docling bbox format (after backend normalization): [l, t, r, b]
|
||||||
|
* All values are in page coordinate space (points).
|
||||||
|
* Origin: top-left (y=0 at top of page).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute scale factors from page coordinates to displayed image pixels.
|
||||||
|
*
|
||||||
|
* @param {number} displayWidth - Rendered image width in CSS pixels
|
||||||
|
* @param {number} displayHeight - Rendered image height in CSS pixels
|
||||||
|
* @param {number} pageWidth - Page width in Docling points
|
||||||
|
* @param {number} pageHeight - Page height in Docling points
|
||||||
|
* @returns {{ sx: number, sy: number }}
|
||||||
|
*/
|
||||||
|
export function computeScale(displayWidth, displayHeight, pageWidth, pageHeight) {
|
||||||
|
return {
|
||||||
|
sx: displayWidth / pageWidth,
|
||||||
|
sy: displayHeight / pageHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Docling bbox [l, t, r, b] to a canvas rect { x, y, w, h }.
|
||||||
|
*
|
||||||
|
* @param {number[]} bbox - [left, top, right, bottom] in page points
|
||||||
|
* @param {{ sx: number, sy: number }} scale - Scale factors from computeScale
|
||||||
|
* @returns {{ x: number, y: number, w: number, h: number }}
|
||||||
|
*/
|
||||||
|
export function bboxToRect(bbox, scale) {
|
||||||
|
const [l, t, r, b] = bbox
|
||||||
|
return {
|
||||||
|
x: l * scale.sx,
|
||||||
|
y: t * scale.sy,
|
||||||
|
w: (r - l) * scale.sx,
|
||||||
|
h: (b - t) * scale.sy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test if a point (px, py) falls inside a rect.
|
||||||
|
*
|
||||||
|
* @param {number} px
|
||||||
|
* @param {number} py
|
||||||
|
* @param {{ x: number, y: number, w: number, h: number }} rect
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function pointInRect(px, py, rect) {
|
||||||
|
return px >= rect.x && px <= rect.x + rect.w &&
|
||||||
|
py >= rect.y && py <= rect.y + rect.h
|
||||||
|
}
|
||||||
58
frontend/src/features/analysis/bboxScaling.test.js
Normal file
58
frontend/src/features/analysis/bboxScaling.test.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { computeScale, bboxToRect, pointInRect } from './bboxScaling.js'
|
||||||
|
|
||||||
|
describe('computeScale', () => {
|
||||||
|
it('returns 1:1 when display matches page', () => {
|
||||||
|
const s = computeScale(612, 792, 612, 792)
|
||||||
|
expect(s.sx).toBe(1)
|
||||||
|
expect(s.sy).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scales proportionally', () => {
|
||||||
|
const s = computeScale(306, 396, 612, 792)
|
||||||
|
expect(s.sx).toBeCloseTo(0.5)
|
||||||
|
expect(s.sy).toBeCloseTo(0.5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('bboxToRect', () => {
|
||||||
|
it('maps page coordinates to pixel rect at scale 1', () => {
|
||||||
|
const scale = { sx: 1, sy: 1 }
|
||||||
|
const rect = bboxToRect([10, 20, 110, 80], scale)
|
||||||
|
expect(rect).toEqual({ x: 10, y: 20, w: 100, h: 60 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scales correctly at 2x', () => {
|
||||||
|
const scale = { sx: 2, sy: 2 }
|
||||||
|
const rect = bboxToRect([10, 20, 60, 70], scale)
|
||||||
|
expect(rect).toEqual({ x: 20, y: 40, w: 100, h: 100 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles fractional scales', () => {
|
||||||
|
const scale = { sx: 0.5, sy: 0.5 }
|
||||||
|
const rect = bboxToRect([100, 200, 300, 400], scale)
|
||||||
|
expect(rect.x).toBeCloseTo(50)
|
||||||
|
expect(rect.y).toBeCloseTo(100)
|
||||||
|
expect(rect.w).toBeCloseTo(100)
|
||||||
|
expect(rect.h).toBeCloseTo(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pointInRect', () => {
|
||||||
|
const rect = { x: 10, y: 20, w: 100, h: 60 }
|
||||||
|
|
||||||
|
it('returns true for point inside', () => {
|
||||||
|
expect(pointInRect(50, 50, rect)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true for point on edge', () => {
|
||||||
|
expect(pointInRect(10, 20, rect)).toBe(true)
|
||||||
|
expect(pointInRect(110, 80, rect)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for point outside', () => {
|
||||||
|
expect(pointInRect(5, 50, rect)).toBe(false)
|
||||||
|
expect(pointInRect(50, 15, rect)).toBe(false)
|
||||||
|
expect(pointInRect(115, 50, rect)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -61,6 +61,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, nextTick, reactive } from 'vue'
|
import { ref, computed, watch, nextTick, reactive } from 'vue'
|
||||||
import { getPreviewUrl } from '../../document/api.js'
|
import { getPreviewUrl } from '../../document/api.js'
|
||||||
|
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling.js'
|
||||||
|
|
||||||
const ELEMENT_COLORS = {
|
const ELEMENT_COLORS = {
|
||||||
section_header: '#F97316',
|
section_header: '#F97316',
|
||||||
|
|
@ -132,24 +133,18 @@ function drawOverlay() {
|
||||||
const page = currentPageData.value
|
const page = currentPageData.value
|
||||||
if (!page) return
|
if (!page) return
|
||||||
|
|
||||||
const scaleX = img.clientWidth / page.width
|
const scale = computeScale(img.clientWidth, img.clientHeight, page.width, page.height)
|
||||||
const scaleY = img.clientHeight / page.height
|
|
||||||
|
|
||||||
for (const el of visibleElements.value) {
|
for (const el of visibleElements.value) {
|
||||||
const [x0, y0, x1, y1] = el.bbox
|
const rect = bboxToRect(el.bbox, scale)
|
||||||
const rx = x0 * scaleX
|
|
||||||
const ry = y0 * scaleY
|
|
||||||
const rw = (x1 - x0) * scaleX
|
|
||||||
const rh = (y1 - y0) * scaleY
|
|
||||||
|
|
||||||
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
|
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
|
||||||
|
|
||||||
ctx.strokeStyle = color
|
ctx.strokeStyle = color
|
||||||
ctx.lineWidth = 2
|
ctx.lineWidth = 2
|
||||||
ctx.strokeRect(rx, ry, rw, rh)
|
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
|
||||||
|
|
||||||
ctx.fillStyle = color + '20'
|
ctx.fillStyle = color + '20'
|
||||||
ctx.fillRect(rx, ry, rw, rh)
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,22 +154,15 @@ function onMouseMove(e) {
|
||||||
const img = imageRef.value
|
const img = imageRef.value
|
||||||
if (!canvas || !page || !img) return
|
if (!canvas || !page || !img) return
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect()
|
const canvasRect = canvas.getBoundingClientRect()
|
||||||
const mx = e.clientX - rect.left
|
const mx = e.clientX - canvasRect.left
|
||||||
const my = e.clientY - rect.top
|
const my = e.clientY - canvasRect.top
|
||||||
|
|
||||||
const scaleX = img.clientWidth / page.width
|
const scale = computeScale(img.clientWidth, img.clientHeight, page.width, page.height)
|
||||||
const scaleY = img.clientHeight / page.height
|
|
||||||
|
|
||||||
let found = null
|
let found = null
|
||||||
for (const el of visibleElements.value) {
|
for (const el of visibleElements.value) {
|
||||||
const [x0, y0, x1, y1] = el.bbox
|
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) {
|
||||||
const rx = x0 * scaleX
|
|
||||||
const ry = y0 * scaleY
|
|
||||||
const rw = (x1 - x0) * scaleX
|
|
||||||
const rh = (y1 - y0) * scaleY
|
|
||||||
|
|
||||||
if (mx >= rx && mx <= rx + rw && my >= ry && my <= ry + rh) {
|
|
||||||
found = el
|
found = el
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,11 +91,11 @@
|
||||||
:value="currentPage"
|
:value="currentPage"
|
||||||
@change="onPageInput"
|
@change="onPageInput"
|
||||||
min="1"
|
min="1"
|
||||||
:max="selectedDoc.pageCount || 999"
|
:max="selectedDoc.pageCount || 1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="pdf-page-total">/ {{ selectedDoc.pageCount || '?' }}</span>
|
<span class="pdf-page-total">/ {{ selectedDoc.pageCount || '?' }}</span>
|
||||||
<button class="pdf-nav-btn" :disabled="selectedDoc.pageCount && currentPage >= selectedDoc.pageCount" @click="currentPage++">
|
<button class="pdf-nav-btn" :disabled="!selectedDoc.pageCount || currentPage >= selectedDoc.pageCount" @click="currentPage++">
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<span class="pdf-separator" />
|
<span class="pdf-separator" />
|
||||||
|
|
@ -227,7 +227,9 @@ const previewUrl = computed(() => {
|
||||||
|
|
||||||
function onPageInput(e) {
|
function onPageInput(e) {
|
||||||
const val = parseInt(e.target.value)
|
const val = parseInt(e.target.value)
|
||||||
if (val >= 1) currentPage.value = val
|
if (!val || val < 1) return
|
||||||
|
const max = selectedDoc.value?.pageCount || val
|
||||||
|
currentPage.value = Math.min(val, max)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAnalysis() {
|
async function runAnalysis() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue