annotools.geometry¶
Coordinates are normalized to 0.0–1.0 relative to the uncropped source everywhere in annotools.
This module holds the conversions in and out of that convention, including
normalize_coordinates, which maps a model's native
answer (pixels of the image it saw, a 0–1000 space, or 0–999) back to the source.
geometry ¶
Coordinate helpers. Tool-facing coordinates are normalized to [0, 1] relative to the uncropped source.
Box
module-attribute
¶
A box as (x_min, y_min, x_max, y_max), normalized 0-1 relative to the uncropped source.
PixelBox
module-attribute
¶
A box in whole source pixels, as Pillow's crop expects it.
FULL_FRAME
module-attribute
¶
The whole image: the crop applied when a caller passes none.
AxisOrder
module-attribute
¶
Whether a model writes each pair x, y or y, x (Gemini uses yx).
Coordinates
module-attribute
¶
Entries of flat x, y, x, y, ... values: one point, box, or polygon per entry.
RotatedBox ¶
validate_normalized_box ¶
Return box as a tuple after checking range and ordering.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
box
|
Sequence[float]
|
Four values |
required |
name
|
str
|
Prefix used in error messages ( |
'box'
|
Returns:
| Type | Description |
|---|---|
Box
|
The box as a tuple of floats. |
Raises:
| Type | Description |
|---|---|
ValueError
|
With |
Examples:
>>> from annotools import validate_normalized_box
>>> validate_normalized_box([0.1, 0.2, 0.5, 0.6])
(0.1, 0.2, 0.5, 0.6)
Source code in src/annotools/geometry.py
validate_normalized_point ¶
Return point as a tuple after checking both values are within [0, 1].
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
point
|
Sequence[float]
|
Two values |
required |
name
|
str
|
Prefix used in error messages. |
'point'
|
Returns:
| Type | Description |
|---|---|
tuple[float, float]
|
The point as a tuple of floats. |
Raises:
| Type | Description |
|---|---|
ValueError
|
With |
Examples:
>>> from annotools import validate_normalized_point
>>> validate_normalized_point([0.5, 0.25])
(0.5, 0.25)
Source code in src/annotools/geometry.py
fit_size ¶
fit_size(
width: int,
height: int,
*,
max_width: int,
max_height: int,
target_pixels: int | None = None,
allow_upscale: bool = False,
) -> tuple[int, int]
Scale (width, height) to fit the limits, preserving aspect ratio.
The output never exceeds max_width x max_height nor target_pixels (area). Without
allow_upscale the output never exceeds the input size either. Rounding goes to the nearest
pixel, falling back to floor when rounding would break a cap.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
width
|
int
|
Source width in pixels (> 0). |
required |
height
|
int
|
Source height in pixels (> 0). |
required |
max_width
|
int
|
Maximum output width in pixels (>= 1). |
required |
max_height
|
int
|
Maximum output height in pixels (>= 1). |
required |
target_pixels
|
int | None
|
Optional cap on the output area; combined with the size limits (smallest wins). |
None
|
allow_upscale
|
bool
|
Enlarge small inputs up to the limits instead of returning them unchanged. |
False
|
Returns:
| Type | Description |
|---|---|
tuple[int, int]
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
When a limit or |
Examples:
>>> from annotools import fit_size
>>> fit_size(4000, 3000, max_width=384, max_height=384)
(384, 288)
>>> fit_size(200, 100, max_width=384, max_height=384)
(200, 100)
References
- Spec:
.agents/knowledge/spec/preview-image.md(annotools repository). - Why a token budget maps to a size cap:
.agents/knowledge/mllm-token-budget.md; Gemini bills each 768x768 tile at 258 tokens, https://ai.google.dev/gemini-api/docs/image-understanding (verified 2026-08-27).
Source code in src/annotools/geometry.py
rotated_box_to_corners ¶
rotated_box_to_corners(
box: RotatedBox,
*,
angle_unit: Literal["degrees", "radians"] = "degrees",
aspect_ratio: float = 1.0,
name: str = "box",
) -> list[float]
Return the 4 corners of box as [x1, y1, ..., x4, y4], clockwise from the unrotated top-left.
Rotation is performed in an isotropic frame (x scaled by aspect_ratio = source width / height)
so boxes on non-square images rotate without shear. Corners are not clipped to [0, 1]. The
8-number output is the DOTA-style exchange format used by the polygon overlay and by detection
datasets for oriented boxes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
box
|
RotatedBox
|
Centre, size, and rotation, all normalized to the source. |
required |
angle_unit
|
Literal['degrees', 'radians']
|
|
'degrees'
|
aspect_ratio
|
float
|
Source |
1.0
|
name
|
str
|
Prefix used in error messages. |
'box'
|
Returns:
| Type | Description |
|---|---|
list[float]
|
Eight floats |
Raises:
| Type | Description |
|---|---|
ValueError
|
Naming |
Examples:
>>> from annotools import RotatedBox, rotated_box_to_corners
>>> corners = rotated_box_to_corners(
... RotatedBox(cx=0.5, cy=0.5, w=0.4, h=0.2, theta=0)
... )
>>> [round(v, 3) for v in corners]
[0.3, 0.4, 0.7, 0.4, 0.7, 0.6, 0.3, 0.6]
References
- Spec:
.agents/knowledge/spec/rotated-bbox-to-polygon.md(annotools repository);ARCHITECTURE.mdDecisions (DOTA-style 8 numbers,thetain degrees).
Source code in src/annotools/geometry.py
is_rectangle ¶
is_rectangle(
points: Sequence[float],
*,
angle_tol_deg: float = 2.0,
length_tol: float = 0.02,
) -> bool
Whether the flat [x1, y1, ..., x4, y4] polygon is a rectangle within tolerances.
Adjacent edges must be perpendicular within angle_tol_deg and opposite edges equal in length
within length_tol (relative). Use it to decide whether a model's 4-point answer can be stored as
a rotated box or must stay a polygon.
Angles are measured in whatever space the points are given in. Normalizing scales x and y by different amounts on a non-square image, which shears a rotated rectangle until it no longer passes: run the test on the model's pixel answer, before normalizing. An axis-aligned rectangle is unaffected, so a negative result on normalized coordinates means "rotated on a non-square image", not necessarily "not a rectangle".
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
points
|
Sequence[float]
|
Eight numbers |
required |
angle_tol_deg
|
float
|
Allowed deviation from 90 degrees between adjacent edges. |
2.0
|
length_tol
|
float
|
Allowed relative difference between opposite edge lengths. |
0.02
|
Returns:
| Type | Description |
|---|---|
bool
|
|
Examples:
>>> from annotools import is_rectangle
>>> is_rectangle([0, 0, 1, 0, 1, 1, 0, 1]), is_rectangle([0, 0, 1, 0, 1, 1, 0, 0.5])
(True, False)
A rectangle rotated 30 degrees, in pixels of an 800x600 image and then normalized:
>>> rotated = [338.0, 161.4, 551.0, 284.4, 462.0, 438.6, 249.0, 315.6]
>>> is_rectangle(rotated)
True
>>> is_rectangle(
... [v / 800 if i % 2 == 0 else v / 600 for i, v in enumerate(rotated)]
... )
False
References
- Spec:
.agents/knowledge/spec/rotated-bbox-to-polygon.md(annotools repository).
Source code in src/annotools/geometry.py
normalize_coordinates ¶
normalize_coordinates(
coordinates: Coordinates,
base_width: float,
base_height: float,
*,
crop: Sequence[float] | None = None,
axis_order: AxisOrder = "xy",
name: str = "coordinates",
) -> list[list[float]]
Map coordinates from a model's answer frame to normalized [0, 1] coordinates of the uncropped source.
Models localize best in their native convention, so ask each model natively and convert here
rather than asking it to normalize: Claude and Qwen2.5-VL answer in pixels of the image they saw,
Gemini and Qwen3-VL in a 0-1000 space (Gemini y-first), GPT in 0-999. When the model looked at a
crop, pass the applied crop reported by the preview so the answer lands in the full image.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
coordinates
|
Coordinates
|
Entries of flat |
required |
base_width
|
float
|
Width of that frame: the preview's |
required |
base_height
|
float
|
Height of that frame, likewise. Must be > 0. |
required |
crop
|
Sequence[float] | None
|
The applied |
None
|
axis_order
|
AxisOrder
|
|
'xy'
|
name
|
str
|
Prefix used in error messages ( |
'coordinates'
|
Returns:
| Type | Description |
|---|---|
list[list[float]]
|
One flat |
list[list[float]]
|
[0, 1]; same shape as the input. |
Raises:
| Type | Description |
|---|---|
ValueError
|
An entry has an odd number of values ( |
Examples:
>>> from annotools import normalize_coordinates
>>> normalize_coordinates([[192, 144]], 384, 288)
[[0.5, 0.5]]
>>> normalize_coordinates(
... [[500, 250]], 1000, 1000, crop=(0.5, 0.5, 1.0, 1.0), axis_order="yx"
... )
[[0.625, 0.75]]
References
- Spec:
.agents/knowledge/spec/coordinates.md(annotools repository). - Claude: "Always ask for pixel coordinates and normalize in your own code", https://platform.claude.com/docs/en/build-with-claude/vision-coordinates (verified 2026-08-27).
- Gemini
box_2dis[ymin, xmin, ymax, xmax]normalized to 0-1000, https://ai.google.dev/gemini-api/docs/image-understanding (verified 2026-08-27). - GPT-5.4 tips recommend a fixed
0..999space with the origin top-left, https://developers.openai.com/cookbook/examples/multimodal/document_and_multimodal_understanding_tips (verified 2026-08-27).
Source code in src/annotools/geometry.py
denormalize_coordinates ¶
denormalize_coordinates(
coordinates: Coordinates,
base_width: float,
base_height: float,
*,
crop: Sequence[float] | None = None,
axis_order: AxisOrder = "xy",
name: str = "coordinates",
) -> list[list[float]]
Map source-normalized coordinates back into a model's own frame.
The inverse of normalize_coordinates.
Use it to draw stored annotations in the frame a model reasons in (for example to ask "is this box right?" in pixels of the preview it saw) or to feed ground truth to a model in its native space.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
coordinates
|
Coordinates
|
Entries of flat |
required |
base_width
|
float
|
Width of the target frame (preview |
required |
base_height
|
float
|
Height of the target frame. Must be > 0. |
required |
crop
|
Sequence[float] | None
|
The applied |
None
|
axis_order
|
AxisOrder
|
|
'xy'
|
name
|
str
|
Prefix used in error messages. |
'coordinates'
|
Returns:
| Type | Description |
|---|---|
list[list[float]]
|
One flat list per input entry in the target frame. Values are not rounded or clamped: a point |
list[list[float]]
|
outside |
Raises:
| Type | Description |
|---|---|
ValueError
|
Naming |
Examples:
>>> from annotools import denormalize_coordinates
>>> denormalize_coordinates([[0.625, 0.75]], 1000, 1000, crop=(0.5, 0.5, 1.0, 1.0))
[[250.0, 500.0]]
References
- Spec:
.agents/knowledge/spec/coordinates.md(annotools repository).