Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ All dataclasses are serializable with `to_dict`/`from_dict` and `to_json`/`from_
| Class | Import from |
|---|---|
| `Page` | `pygexml` |
| `Page`, `TextRegion`, `TextLine`, `Coords` | `pygexml.page` |
| `Page`, `TextRegion`, `TextLine`, `Coords`, `Label` | `pygexml.page` |
| `Point`, `Box`, `Polygon` | `pygexml.geometry` |

`Page`, `TextRegion` and `TextLine` each expose `all_text()` and `all_words()` iterators. On `Page`, these respect the PAGE-XML reading order if present.
Lookups by ID are available via `lookup_region()` and `lookup_textline()`. The reading order is also accessible directly via `regions_ordered()`.
`TextLine.confidence` is read from PAGE-XML `TextEquiv/@conf`. ALTO confidence is not mapped because its `String/@WC` values are defined per word, below the granularity of this model.
`Page.labels` and `TextRegion.labels` contain semantic PAGE-XML labels as `set[Label]`. Each `Label` has a required `value` and optional `type` and `comments`.

Refer to the [online API docs][api-docs] for details.

Expand Down
31 changes: 31 additions & 0 deletions pygexml/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ def __str__(self) -> str:
ID: TypeAlias = str


@dataclass(frozen=True)
class Label(DataClassJsonMixin):
value: str
type: str | None = None
comments: str | None = None


@dataclass
class LayoutLine(DataClassJsonMixin):
id: ID
Expand Down Expand Up @@ -188,6 +195,7 @@ def words(self) -> Iterable[str]:
@dataclass
class TextRegion(LayoutRegion, DataClassJsonMixin):
textlines: Mapping[ID, TextLine] # pyright: ignore[reportIncompatibleVariableOverride] # fmt: skip
labels: set[Label] = field(default_factory=set)

@classmethod
def from_xml(cls, element: Element) -> "TextRegion":
Expand All @@ -208,6 +216,7 @@ def from_xml(cls, element: Element) -> "TextRegion":
textlines={
tl.id: tl for tl in (TextLine.from_xml(tl) for tl in text_lines)
},
labels=_parse_labels(element),
)

@classmethod
Expand Down Expand Up @@ -258,10 +267,31 @@ def _parse_reading_order_group(element: Element) -> list[ID]:
return result


def _parse_label(element: Element) -> Label | None:
value = element.attrib.get("value")
if value is None: # Can has Maybe monad and do notation in Python pls?
return None
return Label(
value=value,
type=element.attrib.get("type"),
comments=element.attrib.get("comments"),
)


def _parse_labels(element: Element) -> set[Label]:
return {
parsed_label
for labels in find_children(element, "Labels")
for label in find_children(labels, "Label")
if (parsed_label := _parse_label(label)) is not None
}


@dataclass
class Page(PageLayout, DataClassJsonMixin):
regions: Mapping[ID, TextRegion] # pyright: ignore[reportIncompatibleVariableOverride] # fmt: skip
reading_order: list[ID] | None = field(default=None)
labels: set[Label] = field(default_factory=set)

@classmethod
def from_xml(cls, element: Element) -> "Page":
Expand Down Expand Up @@ -299,6 +329,7 @@ def from_xml(cls, element: Element) -> "Page":
tr.id: tr for tr in (TextRegion.from_xml(region) for region in regions)
},
reading_order=reading_order,
labels=_parse_labels(element),
)

@classmethod
Expand Down
20 changes: 17 additions & 3 deletions pygexml/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from pygexml.geometry import Point, Box, Polygon
from pygexml.image import Image
from pygexml.page import Coords, Page, TextLine, TextRegion
from pygexml.page import Coords, Label, Page, TextLine, TextRegion

st_points = st.builds(Point, x=st.integers(min_value=0), y=st.integers(min_value=0))

Expand Down Expand Up @@ -56,13 +56,21 @@ def st_simple_text(**kwargs):
confidence=st.one_of(st.none(), st.floats(min_value=0, max_value=1)),
)

st_labels = st.builds(
Label,
value=st_simple_text(min_size=1),
type=st.one_of(st.none(), st_simple_text(min_size=1)),
comments=st.one_of(st.none(), st_simple_text(min_size=1)),
)

st_text_regions = st.builds(
TextRegion,
id=st_simple_text(),
coords=st_coords,
textlines=st.builds(
lambda lines: {l.id: l for l in lines}, st.lists(st_text_lines)
),
labels=st.sets(st_labels, max_size=3),
)

st_images = st.builds(
Expand All @@ -85,12 +93,18 @@ def st_pages(draw):
image = draw(st_images)
regions = {tr.id: tr for tr in draw(st.lists(st_text_regions))}
reading_order = draw(st.one_of(st.none(), st.permutations(list(regions.keys()))))
return Page(image=image, regions=regions, reading_order=reading_order)
labels = draw(st.sets(st_labels, max_size=3))
return Page(
image=image, regions=regions, reading_order=reading_order, labels=labels
)


@st.composite
def st_pages_with_dimensions(draw):
image = draw(st_images_with_dimensions)
regions = {tr.id: tr for tr in draw(st.lists(st_text_regions))}
reading_order = draw(st.one_of(st.none(), st.permutations(list(regions.keys()))))
return Page(image=image, regions=regions, reading_order=reading_order)
labels = draw(st.sets(st_labels, max_size=3))
return Page(
image=image, regions=regions, reading_order=reading_order, labels=labels
)
24 changes: 22 additions & 2 deletions test/test_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from pygexml.strategies import *
from pygexml.geometry import Point, Box, Polygon
from pygexml.image import Image
from pygexml.page import Coords, ID, TextLine, TextRegion, Page
from pygexml.page import Coords, ID, Label, TextLine, TextRegion, Page

############## Tests for Coords ####################

Expand Down Expand Up @@ -258,6 +258,11 @@ def test_textregion_simple_parsing_example() -> None:
tr = TextRegion.from_xml(etree.fromstring("""
<TextRegion id="tr-id">
<Coords points="1,2 8,9"/>
<Labels>
<Label value="person" type="entity" comments="a person"/>
<Label value="named"/>
</Labels>
<Labels><Label value="person" type="entity"/></Labels>
<TextLine id="tl-1">
<Coords points="17,42 1,2"/>
<TextEquiv>
Expand All @@ -275,6 +280,11 @@ def test_textregion_simple_parsing_example() -> None:
"""))
assert tr.id == "tr-id"
assert tr.coords == Coords.parse("1,2 8,9")
assert tr.labels == {
Label(value="person", type="entity", comments="a person"),
Label(value="named"),
Label(value="person", type="entity"),
}
assert tr.textlines == {
"tl-1": TextLine(
id="tl-1",
Expand Down Expand Up @@ -408,6 +418,7 @@ def test_textregion_serialization_roundtrip() -> None:
textlines={
"tl-1": TextLine(id="tl-1", coords=Coords.parse("1,2 3,4"), text="foo")
},
labels={Label(value="person", type="entity")},
)
assert TextRegion.from_dict(tr.to_dict()) == tr

Expand All @@ -418,6 +429,10 @@ def test_textregion_serialization_roundtrip() -> None:
def test_page_from_element_example() -> None:
pa = Page.from_xml(etree.fromstring("""
<Page imageFilename="7895328.jpg" imageWidth="4279" imageHeight="5315">
<Labels>
<Label value="document" type="genre"/>
<Label value="annotated" comments="manual"/>
</Labels>
<TextRegion id="tr-1">
<Coords points="1,2 8,9"/>
<TextLine id="tl-1">
Expand Down Expand Up @@ -453,6 +468,10 @@ def test_page_from_element_example() -> None:
"""))

assert pa.image == Image(filename="7895328.jpg", width=4279, height=5315)
assert pa.labels == {
Label(value="document", type="genre"),
Label(value="annotated", comments="manual"),
}
assert pa.regions == {
"tr-1": TextRegion(
id="tr-1",
Expand Down Expand Up @@ -1108,8 +1127,9 @@ def test_page_serialization_roundtrip() -> None:
id="tl-1", coords=Coords.parse("1,2 3,4"), text="foo"
)
},
)
),
},
labels={Label(value="document", type="genre")},
)
assert Page.from_dict(pa.to_dict()) == pa

Expand Down