File size: 3,473 Bytes
41fae03
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
"""Geometry primitives and context for the canonical model.

Convention (non-negotiable, see AGENTS.md):
  - bbox: [x, y, width, height]  — x = left edge, y = top edge
  - coordinate origin: top_left
  - unit: px
  - polygon: list of (x, y) pairs, or None
"""

from __future__ import annotations

from typing import Annotated

from pydantic import BaseModel, ConfigDict, Field, model_validator

from src.app.domain.models.status import CoordinateOrigin, GeometryStatus, Unit

# -- Primitives --------------------------------------------------------------


class Point(BaseModel):
    """A 2D point in image space."""

    model_config = ConfigDict(frozen=True)

    x: float = Field(ge=0)
    y: float = Field(ge=0)


class BBox(BaseModel):
    """Axis-aligned bounding box: [x, y, width, height].

    x, y = top-left corner.  width, height > 0.
    """

    model_config = ConfigDict(frozen=True)

    x: float = Field(ge=0)
    y: float = Field(ge=0)
    width: float = Field(gt=0)
    height: float = Field(gt=0)

    @property
    def x2(self) -> float:
        return self.x + self.width

    @property
    def y2(self) -> float:
        return self.y + self.height

    def as_tuple(self) -> tuple[float, float, float, float]:
        return (self.x, self.y, self.width, self.height)


PolygonPoints = list[tuple[float, float]]
"""Ordered list of (x, y) vertex pairs forming a closed polygon."""


class Baseline(BaseModel):
    """A baseline defined by two endpoints."""

    model_config = ConfigDict(frozen=True)

    start: Point
    end: Point


class ClipRect(BaseModel):
    """Clipping rectangle — same semantics as BBox but used for clipping ops."""

    model_config = ConfigDict(frozen=True)

    x: float = Field(ge=0)
    y: float = Field(ge=0)
    width: float = Field(gt=0)
    height: float = Field(gt=0)


# -- Geometry (the canonical geometry object on every node) ------------------


class Geometry(BaseModel):
    """Standardised geometry attached to every canonical node.

    bbox is always required (at least for ALTO export).
    polygon is optional — preserved when the provider supplies it.
    status indicates how the geometry was obtained.
    """

    model_config = ConfigDict(frozen=True)

    bbox: Annotated[
        tuple[float, float, float, float],
        Field(description="(x, y, width, height)"),
    ]
    polygon: PolygonPoints | None = None
    status: GeometryStatus

    @model_validator(mode="after")
    def _validate_bbox_dimensions(self) -> Geometry:
        _x, _y, w, h = self.bbox
        if w <= 0 or h <= 0:
            raise ValueError(f"bbox width and height must be > 0, got w={w}, h={h}")
        if _x < 0 or _y < 0:
            raise ValueError(f"bbox x and y must be >= 0, got x={_x}, y={_y}")
        return self


# -- GeometryContext (describes the coordinate space of a provider output) ---


class GeometryContext(BaseModel):
    """Describes the geometric context of a provider's output.

    Used by adapters to normalise provider coordinates into canonical space.
    """

    model_config = ConfigDict(frozen=True)

    source_width: int = Field(gt=0)
    source_height: int = Field(gt=0)
    provided_width: int | None = Field(default=None, gt=0)
    provided_height: int | None = Field(default=None, gt=0)
    resize_factor: float | None = Field(default=None, gt=0)
    rotation: float = 0.0
    coordinate_origin: CoordinateOrigin = CoordinateOrigin.TOP_LEFT
    unit: Unit = Unit.PX