Skip to content

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

Box = tuple[float, float, float, float]

A box as (x_min, y_min, x_max, y_max), normalized 0-1 relative to the uncropped source.

PixelBox module-attribute

PixelBox = tuple[int, int, int, int]

A box in whole source pixels, as Pillow's crop expects it.

FULL_FRAME module-attribute

FULL_FRAME: Box = (0.0, 0.0, 1.0, 1.0)

The whole image: the crop applied when a caller passes none.

AxisOrder module-attribute

AxisOrder = Literal['xy', 'yx']

Whether a model writes each pair x, y or y, x (Gemini uses yx).

Coordinates module-attribute

Coordinates = Sequence[Sequence[float]]

Entries of flat x, y, x, y, ... values: one point, box, or polygon per entry.

RotatedBox

Bases: BaseModel

A rotated box: normalized centre and size plus a clockwise rotation about the centre.

Examples:

>>> from annotools import RotatedBox
>>> RotatedBox(cx=0.5, cy=0.5, w=0.4, h=0.2, theta=30).theta
30.0

validate_normalized_box

validate_normalized_box(
    box: Sequence[float], name: str = "box"
) -> Box

Return box as a tuple after checking range and ordering.

Parameters:

Name Type Description Default
box Sequence[float]

Four values (x_min, y_min, x_max, y_max) normalized to [0, 1].

required
name str

Prefix used in error messages (crop, objects[2].bbox).

'box'

Returns:

Type Description
Box

The box as a tuple of floats.

Raises:

Type Description
ValueError

With name in the message when there are not exactly 4 values, a value is outside [0, 1], or min >= max on either axis.

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
def validate_normalized_box(box: Sequence[float], name: str = "box") -> Box:
    """Return ``box`` as a tuple after checking range and ordering.

    Args:
        box: Four values ``(x_min, y_min, x_max, y_max)`` normalized to [0, 1].
        name: Prefix used in error messages (``crop``, ``objects[2].bbox``).

    Returns:
        The box as a tuple of floats.

    Raises:
        ValueError: With ``name`` in the message when there are not exactly 4 values, a value is
            outside [0, 1], or ``min >= max`` on either axis.

    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)
    """
    if len(box) != 4:
        raise ValueError(f"{name}: expected 4 values (x_min, y_min, x_max, y_max), got {len(box)}")
    x_min, y_min, x_max, y_max = (float(v) for v in box)
    for label, value in (("x_min", x_min), ("y_min", y_min), ("x_max", x_max), ("y_max", y_max)):
        if not 0.0 <= value <= 1.0:
            raise ValueError(f"{name}: {label}={value} is outside [0, 1]")
    if x_min >= x_max or y_min >= y_max:
        raise ValueError(f"{name}: min must be smaller than max, got {(x_min, y_min, x_max, y_max)}")
    return (x_min, y_min, x_max, y_max)

validate_normalized_point

validate_normalized_point(
    point: Sequence[float], name: str = "point"
) -> tuple[float, float]

Return point as a tuple after checking both values are within [0, 1].

Parameters:

Name Type Description Default
point Sequence[float]

Two values (x, y) normalized to [0, 1].

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 name in the message when there are not exactly 2 values or one is outside [0, 1].

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
def validate_normalized_point(point: Sequence[float], name: str = "point") -> tuple[float, float]:
    """Return ``point`` as a tuple after checking both values are within [0, 1].

    Args:
        point: Two values ``(x, y)`` normalized to [0, 1].
        name: Prefix used in error messages.

    Returns:
        The point as a tuple of floats.

    Raises:
        ValueError: With ``name`` in the message when there are not exactly 2 values or one is outside [0, 1].

    Examples:
        >>> from annotools import validate_normalized_point
        >>> validate_normalized_point([0.5, 0.25])
        (0.5, 0.25)
    """
    if len(point) != 2:
        raise ValueError(f"{name}: expected 2 values (x, y), got {len(point)}")
    x, y = float(point[0]), float(point[1])
    if not (0.0 <= x <= 1.0 and 0.0 <= y <= 1.0):
        raise ValueError(f"{name}: ({x}, {y}) is outside [0, 1]")
    return (x, y)

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]

(out_width, out_height), each at least 1.

Raises:

Type Description
ValueError

When a limit or target_pixels is smaller than 1.

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
Source code in src/annotools/geometry.py
def 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.

    Args:
        width: Source width in pixels (> 0).
        height: Source height in pixels (> 0).
        max_width: Maximum output width in pixels (>= 1).
        max_height: Maximum output height in pixels (>= 1).
        target_pixels: Optional cap on the output area; combined with the size limits (smallest wins).
        allow_upscale: Enlarge small inputs up to the limits instead of returning them unchanged.

    Returns:
        ``(out_width, out_height)``, each at least 1.

    Raises:
        ValueError: When a limit or ``target_pixels`` is smaller than 1.

    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).
    """
    if max_width < 1 or max_height < 1:
        raise ValueError(f"max_width/max_height must be >= 1, got {max_width}x{max_height}")
    if target_pixels is not None and target_pixels < 1:
        raise ValueError(f"target_pixels must be >= 1, got {target_pixels}")
    scale = min(max_width / width, max_height / height)
    if target_pixels is not None:
        scale = min(scale, math.sqrt(target_pixels / (width * height)))
    if not allow_upscale:
        scale = min(scale, 1.0)
    # Round to the nearest pixel (floor undershoots binding limits by 1 px through floating point),
    # then fall back to floor if rounding would break a cap.
    out_w, out_h = max(1, round(width * scale)), max(1, round(height * scale))
    if out_w > max_width or out_h > max_height or (target_pixels is not None and out_w * out_h > target_pixels):
        out_w, out_h = max(1, math.floor(width * scale)), max(1, math.floor(height * scale))
    if not allow_upscale:
        out_w, out_h = min(out_w, width), min(out_h, height)
    return (out_w, out_h)

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" (default) or "radians" for box.theta.

'degrees'
aspect_ratio float

Source width / height; 1.0 for square images.

1.0
name str

Prefix used in error messages.

'box'

Returns:

Type Description
list[float]

Eight floats [x1, y1, x2, y2, x3, y3, x4, y4] in normalized source coordinates.

Raises:

Type Description
ValueError

Naming name for a non-positive size or a centre outside [0, 1], or aspect_ratio when it is not positive.

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.md Decisions (DOTA-style 8 numbers, theta in degrees).
Source code in src/annotools/geometry.py
def 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.

    Args:
        box: Centre, size, and rotation, all normalized to the source.
        angle_unit: ``"degrees"`` (default) or ``"radians"`` for ``box.theta``.
        aspect_ratio: Source ``width / height``; 1.0 for square images.
        name: Prefix used in error messages.

    Returns:
        Eight floats ``[x1, y1, x2, y2, x3, y3, x4, y4]`` in normalized source coordinates.

    Raises:
        ValueError: Naming ``name`` for a non-positive size or a centre outside [0, 1], or
            ``aspect_ratio`` when it is not positive.

    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.md`` Decisions (DOTA-style 8 numbers, ``theta`` in degrees).
    """
    if aspect_ratio <= 0:
        raise ValueError(f"aspect_ratio must be > 0, got {aspect_ratio}")
    if box.w <= 0:
        raise ValueError(f"{name}.w must be > 0, got {box.w}")
    if box.h <= 0:
        raise ValueError(f"{name}.h must be > 0, got {box.h}")
    if not 0.0 <= box.cx <= 1.0:
        raise ValueError(f"{name}.cx={box.cx} is outside [0, 1]")
    if not 0.0 <= box.cy <= 1.0:
        raise ValueError(f"{name}.cy={box.cy} is outside [0, 1]")
    theta = box.theta if angle_unit == "radians" else math.radians(box.theta)
    cos_t, sin_t = math.cos(theta), math.sin(theta)
    half_w, half_h = box.w * aspect_ratio / 2, box.h / 2
    corners: list[float] = []
    for dx, dy in ((-half_w, -half_h), (half_w, -half_h), (half_w, half_h), (-half_w, half_h)):
        # clockwise rotation with y pointing down is the standard rotation matrix
        rx, ry = dx * cos_t - dy * sin_t, dx * sin_t + dy * cos_t
        corners.extend((box.cx + rx / aspect_ratio, box.cy + ry))
    return corners

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 [x1, y1, ..., x4, y4] in one consistent space, pixels for preference; anything other than 4 points returns False.

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

True for a rectangle (any rotation), False otherwise, including degenerate polygons.

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
def 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".

    Args:
        points: Eight numbers ``[x1, y1, ..., x4, y4]`` in one consistent space, pixels for preference;
            anything other than 4 points returns ``False``.
        angle_tol_deg: Allowed deviation from 90 degrees between adjacent edges.
        length_tol: Allowed relative difference between opposite edge lengths.

    Returns:
        ``True`` for a rectangle (any rotation), ``False`` otherwise, including degenerate polygons.

    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).
    """
    if len(points) != 8:
        return False
    pts = [(points[i], points[i + 1]) for i in range(0, 8, 2)]
    edges = [(pts[(i + 1) % 4][0] - pts[i][0], pts[(i + 1) % 4][1] - pts[i][1]) for i in range(4)]
    lengths = [math.hypot(*e) for e in edges]
    if min(lengths) == 0:
        return False
    for i in range(4):
        a, b = edges[i], edges[(i + 1) % 4]
        cos_angle = (a[0] * b[0] + a[1] * b[1]) / (lengths[i] * lengths[(i + 1) % 4])
        if abs(math.degrees(math.acos(max(-1.0, min(1.0, cos_angle)))) - 90) > angle_tol_deg:
            return False
    return all(abs(lengths[i] - lengths[i + 2]) / max(lengths[i], lengths[i + 2]) <= length_tol for i in range(2))

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 x, y, x, y, ... values (a point, a box, or a polygon) in the model's frame; each entry needs an even number of values.

required
base_width float

Width of that frame: the preview's output_width for pixel answers, or 1000 / 999 for fixed-space answers. Must be > 0.

required
base_height float

Height of that frame, likewise. Must be > 0.

required
crop Sequence[float] | None

The applied crop from the preview metadata, (x_min, y_min, x_max, y_max) in [0, 1]; None means the model saw the full frame.

None
axis_order AxisOrder

"xy" (default) or "yx" when the model writes y, x pairs (Gemini's [ymin, xmin, ymax, xmax]). Applies to the input only; output is always x, y.

'xy'
name str

Prefix used in error messages (coordinates[3]: ...).

'coordinates'

Returns:

Type Description
list[list[float]]

One flat x, y, ... list per input entry, normalized to the uncropped source and clamped to

list[list[float]]

[0, 1]; same shape as the input.

Raises:

Type Description
ValueError

An entry has an odd number of values (name[i]), a base is not positive (name), or crop is invalid (crop).

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
Source code in src/annotools/geometry.py
def 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.

    Args:
        coordinates: Entries of flat ``x, y, x, y, ...`` values (a point, a box, or a polygon) in the
            model's frame; each entry needs an even number of values.
        base_width: Width of that frame: the preview's ``output_width`` for pixel answers, or 1000 /
            999 for fixed-space answers. Must be > 0.
        base_height: Height of that frame, likewise. Must be > 0.
        crop: The applied ``crop`` from the preview metadata, ``(x_min, y_min, x_max, y_max)`` in
            [0, 1]; ``None`` means the model saw the full frame.
        axis_order: ``"xy"`` (default) or ``"yx"`` when the model writes ``y, x`` pairs (Gemini's
            ``[ymin, xmin, ymax, xmax]``). Applies to the input only; output is always ``x, y``.
        name: Prefix used in error messages (``coordinates[3]: ...``).

    Returns:
        One flat ``x, y, ...`` list per input entry, normalized to the uncropped source and clamped to
        [0, 1]; same shape as the input.

    Raises:
        ValueError: An entry has an odd number of values (``name[i]``), a base is not positive
            (``name``), or ``crop`` is invalid (``crop``).

    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_2d`` is ``[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..999`` space with the origin top-left,
          https://developers.openai.com/cookbook/examples/multimodal/document_and_multimodal_understanding_tips
          (verified 2026-08-27).
    """
    _check_base(base_width, base_height, name)
    x0, y0, x1, y1 = validate_normalized_box(crop, name="crop") if crop is not None else FULL_FRAME
    span_x, span_y = x1 - x0, y1 - y0
    result: list[list[float]] = []
    for index, entry in enumerate(coordinates):
        flat: list[float] = []
        for x, y in _pairs(entry, index, name, axis_order):
            nx = x0 + x / base_width * span_x
            ny = y0 + y / base_height * span_y
            flat += [min(1.0, max(0.0, nx)), min(1.0, max(0.0, ny))]
        result.append(flat)
    return result

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 x, y, ... values in [0, 1] relative to the uncropped source.

required
base_width float

Width of the target frame (preview output_width, or 1000 / 999). Must be > 0.

required
base_height float

Height of the target frame. Must be > 0.

required
crop Sequence[float] | None

The applied crop of the view the frame belongs to; None for the full frame.

None
axis_order AxisOrder

"xy" (default) or "yx" to write y, x pairs (Gemini).

'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 crop maps outside the frame.

Raises:

Type Description
ValueError

Naming name[i] for an odd-length entry or a value outside [0, 1], name for a non-positive base, or crop for an invalid crop.

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).
Source code in src/annotools/geometry.py
def 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`][annotools.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.

    Args:
        coordinates: Entries of flat ``x, y, ...`` values in [0, 1] relative to the uncropped source.
        base_width: Width of the target frame (preview ``output_width``, or 1000 / 999). Must be > 0.
        base_height: Height of the target frame. Must be > 0.
        crop: The applied ``crop`` of the view the frame belongs to; ``None`` for the full frame.
        axis_order: ``"xy"`` (default) or ``"yx"`` to write ``y, x`` pairs (Gemini).
        name: Prefix used in error messages.

    Returns:
        One flat list per input entry in the target frame. Values are not rounded or clamped: a point
        outside ``crop`` maps outside the frame.

    Raises:
        ValueError: Naming ``name[i]`` for an odd-length entry or a value outside [0, 1], ``name`` for
            a non-positive base, or ``crop`` for an invalid crop.

    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).
    """
    _check_base(base_width, base_height, name)
    x0, y0, x1, y1 = validate_normalized_box(crop, name="crop") if crop is not None else FULL_FRAME
    span_x, span_y = x1 - x0, y1 - y0
    result: list[list[float]] = []
    for index, entry in enumerate(coordinates):
        flat: list[float] = []
        for x, y in _pairs(entry, index, name, "xy"):
            if not (0.0 <= x <= 1.0 and 0.0 <= y <= 1.0):
                raise ValueError(f"{name}[{index}]: ({x}, {y}) is outside [0, 1]")
            bx = (x - x0) / span_x * base_width
            by = (y - y0) / span_y * base_height
            flat += [by, bx] if axis_order == "yx" else [bx, by]
        result.append(flat)
    return result