Skip to content

annotools.image

The preview pipeline: crop and downscale a source into a token-bounded view, then draw grids and annotations on it. Overlays take coordinates in source space and map them through the preview's crop and scale, so the same objects can be drawn on a full view and on a zoomed crop.

preview

Crop and resize a source image into a token-bounded preview.

PreviewResult dataclass

PreviewResult(
    image: Image,
    metadata: dict[str, Any] = dict(),
    crop_pixels: tuple[int, int, int, int] | None = None,
)

A rendered preview plus the metadata agents need to map it back to the source.

Attributes:

Name Type Description
image Image

The rendered PIL image (what the model will see).

metadata dict[str, Any]

original_size / original_width / original_height, the applied crop (normalized), output_size / output_width / output_height, and scale (output pixels per source pixel of the cropped view); overlays add their own keys (grid, objects, ids, legend, image_size).

crop_pixels tuple[int, int, int, int] | None

The applied crop in source pixels (None = full frame); exact, unlike re-deriving it from crop.

Examples:

>>> from PIL import Image
>>> from annotools import preview
>>> preview(Image.new("RGB", (1600, 1200)), max_width=384, max_height=384).metadata[
...     "output_size"
... ]
[384, 288]

crop_pixels class-attribute instance-attribute

crop_pixels: tuple[int, int, int, int] | None = None

The applied crop in source pixels (None = full frame); exact, unlike re-deriving it from crop.

preview

preview(
    image: Image,
    *,
    crop: Box | None = None,
    target_pixels: int | None = None,
    max_width: int | None = None,
    max_height: int | None = None,
    allow_upscale: bool = False,
) -> PreviewResult

Crop image to a normalized box and shrink it to fit a token budget.

The result is what an MLLM will see: agents localize on this view, so the returned metadata carries everything needed to map their answers back to the uncropped source (see normalize_coordinates). Downscaling uses LANCZOS; upscaling (only with allow_upscale) uses BICUBIC. EXIF orientation must already be applied (load_image does).

Parameters:

Name Type Description Default
image Image

Source image; not modified.

required
crop Box | None

(x_min, y_min, x_max, y_max) normalized to [0, 1] relative to image; None keeps the full frame. The applied crop is rounded outward to whole pixels and reported back.

None
target_pixels int | None

Cap on the output area in pixels; combined with the size limits (smallest wins).

None
max_width int | None

Maximum output width in pixels; None uses Settings.max_width (384).

None
max_height int | None

Maximum output height in pixels; None uses Settings.max_height (384).

None
allow_upscale bool

Enlarge small images or crops up to the limits instead of returning them as is.

False

Returns:

Type Description
PreviewResult

A PreviewResult whose metadata has original_size / original_width /

PreviewResult

original_height, the applied crop, output_size / output_width /

PreviewResult

output_height, and scale; crop_pixels holds the exact source-pixel crop or None.

Raises:

Type Description
ValueError

crop has a value outside [0, 1] or min >= max (message starts with crop:), or a limit is smaller than 1.

Examples:

>>> from PIL import Image
>>> from annotools import preview
>>> result = preview(
...     Image.new("RGB", (1600, 1200)),
...     crop=(0.5, 0.5, 1.0, 1.0),
...     max_width=384,
...     max_height=384,
... )
>>> result.image.size, result.metadata["scale"]
((384, 288), 0.48)
References
  • Spec: .agents/knowledge/spec/preview-image.md and mcp-overview.md (annotools repository).
  • Default 384 px: the largest size Gemini bills as one 258-token unit, https://ai.google.dev/gemini-api/docs/image-understanding (verified 2026-08-27); Claude and GPT bill by area, so pass larger limits for them (ARCHITECTURE.md, Decisions).
Source code in src/annotools/image/preview.py
def preview(
    image: Image.Image,
    *,
    crop: Box | None = None,
    target_pixels: int | None = None,
    max_width: int | None = None,
    max_height: int | None = None,
    allow_upscale: bool = False,
) -> PreviewResult:
    """Crop ``image`` to a normalized box and shrink it to fit a token budget.

    The result is what an MLLM will see: agents localize on this view, so the returned metadata carries
    everything needed to map their answers back to the uncropped source (see
    [`normalize_coordinates`][annotools.normalize_coordinates]). Downscaling uses LANCZOS; upscaling (only with
    ``allow_upscale``) uses BICUBIC. EXIF orientation must already be applied ([`load_image`][annotools.load_image]
    does).

    Args:
        image: Source image; not modified.
        crop: ``(x_min, y_min, x_max, y_max)`` normalized to [0, 1] relative to ``image``; ``None``
            keeps the full frame. The applied crop is rounded outward to whole pixels and reported back.
        target_pixels: Cap on the output area in pixels; combined with the size limits (smallest wins).
        max_width: Maximum output width in pixels; ``None`` uses ``Settings.max_width`` (384).
        max_height: Maximum output height in pixels; ``None`` uses ``Settings.max_height`` (384).
        allow_upscale: Enlarge small images or crops up to the limits instead of returning them as is.

    Returns:
        A [`PreviewResult`][annotools.PreviewResult] whose ``metadata`` has ``original_size`` / ``original_width`` /
        ``original_height``, the applied ``crop``, ``output_size`` / ``output_width`` /
        ``output_height``, and ``scale``; ``crop_pixels`` holds the exact source-pixel crop or ``None``.

    Raises:
        ValueError: ``crop`` has a value outside [0, 1] or ``min >= max`` (message starts with
            ``crop:``), or a limit is smaller than 1.

    Examples:
        >>> from PIL import Image
        >>> from annotools import preview
        >>> result = preview(
        ...     Image.new("RGB", (1600, 1200)),
        ...     crop=(0.5, 0.5, 1.0, 1.0),
        ...     max_width=384,
        ...     max_height=384,
        ... )
        >>> result.image.size, result.metadata["scale"]
        ((384, 288), 0.48)

    References:
        - Spec: ``.agents/knowledge/spec/preview-image.md`` and ``mcp-overview.md`` (annotools repository).
        - Default 384 px: the largest size Gemini bills as one 258-token unit,
          https://ai.google.dev/gemini-api/docs/image-understanding (verified 2026-08-27); Claude and GPT
          bill by area, so pass larger limits for them (``ARCHITECTURE.md``, Decisions).
    """
    settings = get_settings()
    max_width = settings.max_width if max_width is None else max_width
    max_height = settings.max_height if max_height is None else max_height
    original_size = image.size
    box = validate_normalized_box(crop, name="crop") if crop is not None else FULL_FRAME
    px_box: tuple[int, int, int, int] | None = None
    if box != FULL_FRAME:
        px_box = normalized_box_to_pixels(box, *original_size)
        image = image.crop(px_box)
        # Report the box that was actually applied (rounded outward to whole source pixels).
        width, height = original_size
        box = (px_box[0] / width, px_box[1] / height, px_box[2] / width, px_box[3] / height)
    cropped_size = image.size
    out_size = fit_size(
        *cropped_size,
        max_width=max_width,
        max_height=max_height,
        target_pixels=target_pixels,
        allow_upscale=allow_upscale,
    )
    if out_size != cropped_size:
        resample = Image.Resampling.LANCZOS if out_size[0] < cropped_size[0] else Image.Resampling.BICUBIC
        image = image.resize(out_size, resample)
    metadata = size_metadata(original_size, box, out_size, out_size[0] / cropped_size[0])
    return PreviewResult(image=image, metadata=metadata, crop_pixels=px_box)

encode

encode(
    image: Image, output_format: str | None = None
) -> bytes

Encode image as jpeg, png, or webp bytes.

JPEG is the default because it is the cheapest to send; alpha is flattened onto white for JPEG and the quality comes from Settings.jpeg_quality (90).

Parameters:

Name Type Description Default
image Image

The image to encode (any mode; converted as the format requires).

required
output_format str | None

"jpeg", "png", or "webp"; None uses Settings.output_format.

None

Returns:

Type Description
bytes

The encoded bytes.

Raises:

Type Description
ValueError

For an unknown output_format.

Examples:

>>> from PIL import Image
>>> from annotools import encode
>>> encode(Image.new("RGB", (10, 10)), "png")[:4]
b'\x89PNG'
References
  • Spec: .agents/knowledge/spec/mcp-overview.md (annotools repository), output_format.
Source code in src/annotools/image/preview.py
def encode(image: Image.Image, output_format: str | None = None) -> bytes:
    r"""Encode ``image`` as ``jpeg``, ``png``, or ``webp`` bytes.

    JPEG is the default because it is the cheapest to send; alpha is flattened onto white for JPEG and
    the quality comes from ``Settings.jpeg_quality`` (90).

    Args:
        image: The image to encode (any mode; converted as the format requires).
        output_format: ``"jpeg"``, ``"png"``, or ``"webp"``; ``None`` uses ``Settings.output_format``.

    Returns:
        The encoded bytes.

    Raises:
        ValueError: For an unknown ``output_format``.

    Examples:
        >>> from PIL import Image
        >>> from annotools import encode
        >>> encode(Image.new("RGB", (10, 10)), "png")[:4]
        b'\x89PNG'

    References:
        - Spec: ``.agents/knowledge/spec/mcp-overview.md`` (annotools repository), ``output_format``.
    """
    settings = get_settings()
    output_format = settings.output_format if output_format is None else output_format
    if output_format not in FORMATS:
        raise ValueError(f"output_format must be one of {sorted(FORMATS)}, got {output_format!r}")
    if output_format == "jpeg" and image.mode not in ("RGB", "L"):
        rgba = image.convert("RGBA")
        background = Image.new("RGBA", rgba.size, "white")
        image = Image.alpha_composite(background, rgba).convert("RGB")
    buffer = io.BytesIO()
    kwargs: dict[str, Any] = {"quality": settings.jpeg_quality} if output_format == "jpeg" else {}
    image.save(buffer, format=FORMATS[output_format], **kwargs)
    return buffer.getvalue()

grid

Semi-transparent grid overlay that helps an MLLM anchor positions.

GridOptions

Bases: BaseModel

Grid parameters shared by every tool that accepts grid.

None fields take their value from Settings when the grid is drawn (resolved). A 10x10 ratio grid is the default because MLLMs anchor positions far better against visible cells than on a bare image, and 10 cells keep the labels legible at 384 px.

Examples:

>>> from annotools import GridOptions
>>> GridOptions(columns=4).resolved().rows
10
References
  • Spec: .agents/knowledge/spec/mcp-overview.md (annotools repository), GridOptions table.

resolved

resolved() -> GridOptions

Return a copy with every None filled from Settings.

Source code in src/annotools/image/grid.py
def resolved(self) -> "GridOptions":
    """Return a copy with every ``None`` filled from ``Settings``."""
    s = get_settings()
    out = self.model_copy(
        update={
            "columns": s.grid_columns if self.columns is None else self.columns,
            "rows": s.grid_rows if self.rows is None else self.rows,
            "mode": s.grid_mode if self.mode is None else self.mode,
            "column_width": s.grid_column_width if self.column_width is None else self.column_width,
            "row_width": s.grid_row_width if self.row_width is None else self.row_width,
            "opacity": s.grid_opacity if self.opacity is None else self.opacity,
            "line_width": s.grid_line_width if self.line_width is None else self.line_width,
        }
    )
    if out.mode == "fixed" and (out.column_width is None or out.row_width is None):
        raise ValueError("column_width/row_width are required when mode='fixed'")
    return out

draw_grid

draw_grid(
    image: Image, options: GridOptions
) -> PreviewResult

Blend grid lines onto image and return it with grid metadata.

Lines are blended at options.opacity in white, black, or the inverted underlying pixels so the grid stays visible on any background without hiding it. Metadata reports the cell layout so coordinates can be reasoned about in cells and converted back.

Parameters:

Name Type Description Default
image Image

The (already previewed) PIL image; RGBA input keeps its alpha channel.

required
options GridOptions

Grid layout; None fields resolve from Settings.

required

Returns:

Type Description
PreviewResult

A PreviewResult whose metadata["grid"] holds columns, rows, step_x

PreviewResult

/ step_y (normalized cell step) and cell_width / cell_height (output pixels).

Raises:

Type Description
ValueError

Via GridOptions.resolved when mode="fixed" has no

Examples:

>>> from PIL import Image
>>> from annotools import GridOptions, draw_grid
>>> draw_grid(
...     Image.new("RGB", (200, 100)), GridOptions(columns=4, rows=2)
... ).metadata["grid"]["columns"]
4
References
  • Spec: .agents/knowledge/spec/preview-image-grid.md (annotools repository).
Source code in src/annotools/image/grid.py
def draw_grid(image: Image.Image, options: GridOptions) -> PreviewResult:
    """Blend grid lines onto ``image`` and return it with ``grid`` metadata.

    Lines are blended at ``options.opacity`` in white, black, or the inverted underlying pixels so the
    grid stays visible on any background without hiding it. Metadata reports the cell layout so
    coordinates can be reasoned about in cells and converted back.

    Args:
        image: The (already previewed) PIL image; RGBA input keeps its alpha channel.
        options: Grid layout; ``None`` fields resolve from ``Settings``.

    Returns:
        A [`PreviewResult`][annotools.PreviewResult] whose ``metadata["grid"]`` holds ``columns``, ``rows``, ``step_x``
        / ``step_y`` (normalized cell step) and ``cell_width`` / ``cell_height`` (output pixels).

    Raises:
        ValueError: Via [`GridOptions.resolved`][annotools.image.grid.GridOptions.resolved] when ``mode="fixed"`` has no
        cell widths.

    Examples:
        >>> from PIL import Image
        >>> from annotools import GridOptions, draw_grid
        >>> draw_grid(
        ...     Image.new("RGB", (200, 100)), GridOptions(columns=4, rows=2)
        ... ).metadata["grid"]["columns"]
        4

    References:
        - Spec: ``.agents/knowledge/spec/preview-image-grid.md`` (annotools repository).
    """
    options = options.resolved()
    assert options.columns is not None and options.rows is not None and options.mode is not None
    assert options.opacity is not None and options.line_width is not None
    width, height = image.size
    xs, columns = _line_positions(width, options.columns, options.column_width, options.mode)
    ys, rows = _line_positions(height, options.rows, options.row_width, options.mode)
    if options.mode == "fixed" and options.column_width is not None and options.row_width is not None:
        cell_w, cell_h = float(options.column_width), float(options.row_width)
        step_x, step_y = cell_w / width, cell_h / height
    else:
        step_x, step_y = 1 / columns, 1 / rows
        cell_w, cell_h = width / columns, height / rows
    grid_meta = {
        "columns": columns,
        "rows": rows,
        "step_x": step_x,
        "step_y": step_y,
        "cell_width": cell_w,
        "cell_height": cell_h,
    }
    if options.opacity == 0 or (not xs and not ys):
        return PreviewResult(image=image, metadata={"grid": grid_meta})
    rgb = np.asarray(image.convert("RGB"), dtype=np.float32)
    mask = np.zeros((height, width), dtype=bool)
    lw = options.line_width
    for x in xs:  # a band exactly line_width px wide, centred on the line position
        start = max(0, math.floor(x - lw / 2 + 0.5))  # centred band; avoids banker's rounding
        mask[:, start : min(width, start + lw)] = True
    for y in ys:
        start = max(0, math.floor(y - lw / 2 + 0.5))
        mask[start : min(height, start + lw), :] = True
    fill = {"white": 255.0, "black": 0.0}
    target = 255.0 - rgb if options.color == "invert" else np.full_like(rgb, fill[options.color])
    blended = rgb.copy()
    blended[mask] = rgb[mask] * (1 - options.opacity) + target[mask] * options.opacity
    out = Image.fromarray(np.clip(np.rint(blended), 0, 255).astype(np.uint8), "RGB")
    if image.mode == "RGBA":
        out.putalpha(image.getchannel("A"))
    return PreviewResult(image=out, metadata={"grid": grid_meta})

overlay

Annotation overlays (bounding boxes, keypoints, polygons) drawn on a rendered preview.

BBoxObject

Bases: BaseModel

A bounding box in normalized coordinates of the uncropped source.

Examples:

>>> from annotools import BBoxObject
>>> BBoxObject(bbox=(0.1, 0.1, 0.5, 0.5), label="cat").color is None
True

KeypointObject

Bases: BaseModel

A single point in normalized coordinates of the uncropped source.

Examples:

>>> from annotools import KeypointObject
>>> KeypointObject(point=(0.5, 0.5), label="nose").label
'nose'

PolygonObject

Bases: BaseModel

A closed polygon as a flat list of normalized coordinates of the uncropped source.

Examples:

>>> from annotools import PolygonObject
>>> len(PolygonObject(points=[0.1, 0.1, 0.5, 0.1, 0.1, 0.5]).points)
6

draw_bboxes

draw_bboxes(
    result: PreviewResult,
    objects: Sequence[BBoxObject],
    line_width: int | None = None,
) -> PreviewResult

Draw objects on result.image (in place) and add the objects count to the metadata.

Boxes are given in source coordinates and mapped through the preview's crop and scale, so the same objects can be drawn on the full view and on a zoomed crop. Boxes entirely outside the view are skipped but still counted; partially visible ones are clipped.

Parameters:

Name Type Description Default
result PreviewResult

A preview from preview (with or without a grid); RGB/RGBA images are drawn on in place, other modes are converted to a new RGB image.

required
objects Sequence[BBoxObject]

At least one box; label is drawn as a tag above the box, color defaults to Settings.color.

required
line_width int | None

Outline width in output pixels; None uses Settings.line_width (2).

None

Returns:

Type Description
PreviewResult

The same image wrapped with metadata["objects"] = number of boxes.

Raises:

Type Description
ValueError

For empty objects, an invalid box or color (naming objects[i]), or line_width < 1.

Examples:

>>> from PIL import Image
>>> from annotools import BBoxObject, draw_bboxes, preview
>>> result = preview(
...     Image.new("RGB", (400, 300), "white"), max_width=400, max_height=400
... )
>>> draw_bboxes(
...     result, [BBoxObject(bbox=(0.1, 0.1, 0.5, 0.5), label="cat")]
... ).metadata["objects"]
1
References
  • Spec: .agents/knowledge/spec/preview-image-bboxes.md (annotools repository).
Source code in src/annotools/image/overlay.py
def draw_bboxes(result: PreviewResult, objects: Sequence[BBoxObject], line_width: int | None = None) -> PreviewResult:
    """Draw ``objects`` on ``result.image`` (in place) and add the ``objects`` count to the metadata.

    Boxes are given in source coordinates and mapped through the preview's ``crop`` and ``scale``, so
    the same objects can be drawn on the full view and on a zoomed crop. Boxes entirely outside the
    view are skipped but still counted; partially visible ones are clipped.

    Args:
        result: A preview from [`preview`][annotools.preview] (with or without a grid); RGB/RGBA images are drawn on in
            place, other modes are converted to a new RGB image.
        objects: At least one box; ``label`` is drawn as a tag above the box, ``color`` defaults to
            ``Settings.color``.
        line_width: Outline width in output pixels; ``None`` uses ``Settings.line_width`` (2).

    Returns:
        The same image wrapped with ``metadata["objects"]`` = number of boxes.

    Raises:
        ValueError: For empty ``objects``, an invalid box or color (naming ``objects[i]``), or
            ``line_width < 1``.

    Examples:
        >>> from PIL import Image
        >>> from annotools import BBoxObject, draw_bboxes, preview
        >>> result = preview(
        ...     Image.new("RGB", (400, 300), "white"), max_width=400, max_height=400
        ... )
        >>> draw_bboxes(
        ...     result, [BBoxObject(bbox=(0.1, 0.1, 0.5, 0.5), label="cat")]
        ... ).metadata["objects"]
        1

    References:
        - Spec: ``.agents/knowledge/spec/preview-image-bboxes.md`` (annotools repository).
    """
    if not objects:
        raise ValueError("objects: at least one bounding box is required")
    settings = get_settings()
    line_width = settings.line_width if line_width is None else line_width
    if line_width < 1:
        raise ValueError(f"line_width must be >= 1, got {line_width}")
    mapper = _Mapper(result)
    image, draw = canvas(result)
    for index, obj in enumerate(objects):
        box = validate_normalized_box(obj.bbox, name=f"objects[{index}].bbox")
        color = parse_color(settings.color if obj.color is None else obj.color, name=f"objects[{index}].color")
        x0, y0 = mapper.to_pixels(box[0], box[1])
        x1, y1 = mapper.to_pixels(box[2], box[3])
        if x1 < 0 or y1 < 0 or x0 > mapper.width or y0 > mapper.height:
            continue
        # The outline band starts at the mapped edge and extends inward by line_width.
        draw.rectangle((round(x0), round(y0), round(x1) - 1, round(y1) - 1), outline=color, width=line_width)
        if obj.label:
            draw_label(draw, obj.label, round(x0), round(y0), color, image.size)
    return PreviewResult(image=image, metadata={**result.metadata, "objects": len(objects)})

draw_keypoints

draw_keypoints(
    result: PreviewResult,
    objects: Sequence[KeypointObject],
    point_diameter: int | None = None,
) -> PreviewResult

Draw objects as filled dots (optional labels) and add the objects count to the metadata.

Parameters:

Name Type Description Default
result PreviewResult

A preview from preview; its image is modified.

required
objects Sequence[KeypointObject]

At least one point; label is drawn beside the dot, color defaults to Settings.color.

required
point_diameter int | None

Dot diameter in output pixels; None uses Settings.point_diameter (3).

None

Returns:

Type Description
PreviewResult

The same image wrapped with metadata["objects"] = number of points.

Raises:

Type Description
ValueError

For empty objects, a point outside [0, 1] or an unknown color (naming objects[i]), or point_diameter < 1.

Examples:

>>> from PIL import Image
>>> from annotools import KeypointObject, draw_keypoints, preview
>>> result = preview(
...     Image.new("RGB", (400, 300), "white"), max_width=400, max_height=400
... )
>>> draw_keypoints(result, [KeypointObject(point=(0.5, 0.5))]).metadata["objects"]
1
References
  • Spec: .agents/knowledge/spec/preview-image-keypoints.md (annotools repository).
Source code in src/annotools/image/overlay.py
def draw_keypoints(
    result: PreviewResult, objects: Sequence[KeypointObject], point_diameter: int | None = None
) -> PreviewResult:
    """Draw ``objects`` as filled dots (optional labels) and add the ``objects`` count to the metadata.

    Args:
        result: A preview from [`preview`][annotools.preview]; its image is modified.
        objects: At least one point; ``label`` is drawn beside the dot, ``color`` defaults to
            ``Settings.color``.
        point_diameter: Dot diameter in output pixels; ``None`` uses ``Settings.point_diameter`` (3).

    Returns:
        The same image wrapped with ``metadata["objects"]`` = number of points.

    Raises:
        ValueError: For empty ``objects``, a point outside [0, 1] or an unknown color (naming
            ``objects[i]``), or ``point_diameter < 1``.

    Examples:
        >>> from PIL import Image
        >>> from annotools import KeypointObject, draw_keypoints, preview
        >>> result = preview(
        ...     Image.new("RGB", (400, 300), "white"), max_width=400, max_height=400
        ... )
        >>> draw_keypoints(result, [KeypointObject(point=(0.5, 0.5))]).metadata["objects"]
        1

    References:
        - Spec: ``.agents/knowledge/spec/preview-image-keypoints.md`` (annotools repository).
    """
    if not objects:
        raise ValueError("objects: at least one keypoint is required")
    settings = get_settings()
    point_diameter = settings.point_diameter if point_diameter is None else point_diameter
    if point_diameter < 1:
        raise ValueError(f"point_diameter must be >= 1, got {point_diameter}")
    mapper = _Mapper(result)
    image, draw = canvas(result)
    for index, obj in enumerate(objects):
        x, y = validate_normalized_point(obj.point, name=f"objects[{index}].point")
        color = parse_color(settings.color if obj.color is None else obj.color, name=f"objects[{index}].color")
        px, py = mapper.to_pixels(x, y)
        if not mapper.inside(px, py):
            continue
        draw_dot(draw, px, py, point_diameter, color)
        if obj.label:
            draw_label(draw, obj.label, px + point_diameter, py, color, image.size, anchor="middle")
    return PreviewResult(image=image, metadata={**result.metadata, "objects": len(objects)})

draw_polygons

draw_polygons(
    result: PreviewResult,
    objects: Sequence[PolygonObject],
    line_width: int | None = None,
    point_diameter: int | None = None,
    show_point_index: bool = True,
) -> PreviewResult

Draw closed polygons with vertex dots and optional 1-based vertex indices; add objects to metadata.

Vertex indices let a model refer to "point 3" when correcting a polygon, which is why they are on by default. Polygons with no vertex inside the view are skipped but still counted.

Parameters:

Name Type Description Default
result PreviewResult

A preview from preview; its image is modified.

required
objects Sequence[PolygonObject]

At least one polygon (points even-length, at least 3 vertices); color defaults to Settings.color.

required
line_width int | None

Outline width in output pixels; None uses Settings.line_width (2).

None
point_diameter int | None

Vertex dot diameter; None uses Settings.point_diameter (3).

None
show_point_index bool

Draw the 1-based vertex number next to each vertex.

True

Returns:

Type Description
PreviewResult

The same image wrapped with metadata["objects"] = number of polygons.

Raises:

Type Description
ValueError

For empty objects, an invalid polygon or color (naming objects[i]), or a width or diameter smaller than 1.

Examples:

>>> from PIL import Image
>>> from annotools import PolygonObject, draw_polygons, preview
>>> result = preview(
...     Image.new("RGB", (400, 300), "white"), max_width=400, max_height=400
... )
>>> draw_polygons(
...     result, [PolygonObject(points=[0.1, 0.1, 0.5, 0.1, 0.1, 0.5])]
... ).metadata["objects"]
1
References
  • Spec: .agents/knowledge/spec/preview-image-polygons.md (annotools repository).
Source code in src/annotools/image/overlay.py
def draw_polygons(
    result: PreviewResult,
    objects: Sequence[PolygonObject],
    line_width: int | None = None,
    point_diameter: int | None = None,
    show_point_index: bool = True,
) -> PreviewResult:
    """Draw closed polygons with vertex dots and optional 1-based vertex indices; add ``objects`` to metadata.

    Vertex indices let a model refer to "point 3" when correcting a polygon, which is why they are on
    by default. Polygons with no vertex inside the view are skipped but still counted.

    Args:
        result: A preview from [`preview`][annotools.preview]; its image is modified.
        objects: At least one polygon (``points`` even-length, at least 3 vertices); ``color`` defaults
            to ``Settings.color``.
        line_width: Outline width in output pixels; ``None`` uses ``Settings.line_width`` (2).
        point_diameter: Vertex dot diameter; ``None`` uses ``Settings.point_diameter`` (3).
        show_point_index: Draw the 1-based vertex number next to each vertex.

    Returns:
        The same image wrapped with ``metadata["objects"]`` = number of polygons.

    Raises:
        ValueError: For empty ``objects``, an invalid polygon or color (naming ``objects[i]``), or a
            width or diameter smaller than 1.

    Examples:
        >>> from PIL import Image
        >>> from annotools import PolygonObject, draw_polygons, preview
        >>> result = preview(
        ...     Image.new("RGB", (400, 300), "white"), max_width=400, max_height=400
        ... )
        >>> draw_polygons(
        ...     result, [PolygonObject(points=[0.1, 0.1, 0.5, 0.1, 0.1, 0.5])]
        ... ).metadata["objects"]
        1

    References:
        - Spec: ``.agents/knowledge/spec/preview-image-polygons.md`` (annotools repository).
    """
    if not objects:
        raise ValueError("objects: at least one polygon is required")
    settings = get_settings()
    line_width = settings.line_width if line_width is None else line_width
    point_diameter = settings.point_diameter if point_diameter is None else point_diameter
    if line_width < 1 or point_diameter < 1:
        raise ValueError(f"line_width and point_diameter must be >= 1, got {line_width} and {point_diameter}")
    mapper = _Mapper(result)
    image, draw = canvas(result)
    for index, obj in enumerate(objects):
        vertices = _validate_polygon(obj.points, name=f"objects[{index}].points")
        color = parse_color(settings.color if obj.color is None else obj.color, name=f"objects[{index}].color")
        pixels = [mapper.to_pixels(x, y) for x, y in vertices]
        if not any(mapper.inside(px, py) for px, py in pixels):
            continue
        draw.line([*pixels, pixels[0]], fill=color, width=line_width, joint="curve")
        cx = sum(px for px, _ in pixels) / len(pixels)
        cy = sum(py for _, py in pixels) / len(pixels)
        for number, (px, py) in enumerate(pixels, start=1):
            draw_dot(draw, px, py, point_diameter, color)
            if show_point_index:
                dx, dy = (px - cx), (py - cy)
                norm = max(1e-6, (dx * dx + dy * dy) ** 0.5)
                offset = point_diameter + 8
                draw_label(draw, str(number), px + dx / norm * offset, py + dy / norm * offset, color, image.size)
        if obj.label:
            draw_label(draw, obj.label, pixels[0][0], pixels[0][1] - point_diameter - 12, color, image.size)
    return PreviewResult(image=image, metadata={**result.metadata, "objects": len(objects)})

segmentation

ID-mask overlays for instance, panoptic, and semantic segmentation previews.

MASK_MODES module-attribute

MASK_MODES = {'L', 'P', 'I', 'I;16', 'I;16B', 'I;16L'}

Pillow modes accepted for an ID mask: single-channel integer images only.

load_mask

load_mask(uri: str) -> np.ndarray

Load a single-channel ID mask (0 = background) as an integer array.

Masks are exchanged as ID images rather than color images so instance, panoptic, and semantic outputs share one contract (ARCHITECTURE.md, Decisions); the accepted modes are MASK_MODES.

Parameters:

Name Type Description Default
uri str

Local path or fsspec URL of an L, P, I, or I;16 image.

required

Returns:

Type Description
ndarray

A 2-D int64 array of IDs, the size of the mask image.

Raises:

Type Description
ValueError

Naming mask_source when the image is not single-channel, or naming the URI when the content is not a decodable image (from load_image).

FileNotFoundError

When the URI does not exist (from open_bytes).

OSError

For other read failures (from open_bytes).

Examples:

>>> from annotools import load_mask
>>> int(load_mask("masks/000000001675.png").max())
3
References
  • Spec: .agents/knowledge/spec/preview-image-segmentation.md (annotools repository).
Source code in src/annotools/image/segmentation.py
def load_mask(uri: str) -> np.ndarray:
    """Load a single-channel ID mask (0 = background) as an integer array.

    Masks are exchanged as ID images rather than color images so instance, panoptic, and semantic
    outputs share one contract (``ARCHITECTURE.md``, Decisions); the accepted modes are ``MASK_MODES``.

    Args:
        uri: Local path or fsspec URL of an ``L``, ``P``, ``I``, or ``I;16`` image.

    Returns:
        A 2-D ``int64`` array of IDs, the size of the mask image.

    Raises:
        ValueError: Naming ``mask_source`` when the image is not single-channel, or naming the URI when
            the content is not a decodable image (from [`load_image`][annotools.load_image]).
        FileNotFoundError: When the URI does not exist (from [`open_bytes`][annotools.open_bytes]).
        OSError: For other read failures (from [`open_bytes`][annotools.open_bytes]).

    Examples:
        >>> from annotools import load_mask
        >>> int(load_mask("masks/000000001675.png").max())  # doctest: +SKIP
        3

    References:
        - Spec: ``.agents/knowledge/spec/preview-image-segmentation.md`` (annotools repository).
    """
    image = load_image(uri)
    if image.mode not in MASK_MODES:
        raise ValueError(
            f"mask_source: {uri} must be a single-channel ID image (L, P, I, or I;16), got mode {image.mode!r}"
        )
    return np.asarray(image).astype(np.int64)

overlay_mask

overlay_mask(
    result: PreviewResult,
    mask: ndarray,
    *,
    annotation: Literal["label", "legend"] | str = "label",
    id_names: dict[int, str] | None = None,
    alpha: float = 0.5,
    line_width: int | None = None,
    max_width: int | None = None,
    max_height: int | None = None,
    target_pixels: int | None = None,
) -> PreviewResult

Blend the ID mask over result.image and annotate regions with labels or a legend.

mask is in source pixel space (any size; resized to the source with nearest neighbour), then follows the preview's crop and scale. Each ID gets a stable color from color_from_text, so the same instance keeps its color across previews. annotation="legend" appends a color strip below the image and re-fits the composite to the size limits.

Parameters:

Name Type Description Default
result PreviewResult

A preview from preview; not modified (a new image is returned).

required
mask ndarray

2-D integer array of IDs, 0 = background (from load_mask).

required
annotation Literal['label', 'legend'] | str

"label" draws each ID's name at the region centre; "legend" lists IDs and colors in a strip and in metadata["legend"].

'label'
id_names dict[int, str] | None

Optional {id: name} for labels and legend entries; missing IDs use the number.

None
alpha float

Blend strength of the region color in [0, 1].

0.5
line_width int | None

Boundary width in output pixels (0 = no boundary); None uses Settings.line_width.

None
max_width int | None

Size limit for the legend composite, which is re-fitted after the strip is appended; None uses Settings.max_width (384) rather than the preview's own size, so a 768 px preview comes back at 384 px unless you pass the limits you previewed with. Ignored by annotation="label".

None
max_height int | None

Likewise; None uses Settings.max_height.

None
target_pixels int | None

Optional area cap for the legend composite.

None

Returns:

Type Description
PreviewResult

A new PreviewResult with metadata["ids"] (count of visible IDs) and, for the

PreviewResult

legend, metadata["legend"] (entries {"id", "name", "color"}), image_size (the area the

PreviewResult

inverse mapping applies to, above the strip) and size keys refreshed for the re-fitted composite —

PreviewResult

output_size covers image plus strip, so map coordinates through image_size, not it.

Raises:

Type Description
ValueError

For alpha outside [0, 1], line_width < 0, or an unknown annotation.

Examples:

>>> import numpy as np
>>> from PIL import Image
>>> from annotools import overlay_mask, preview
>>> result = preview(
...     Image.new("RGB", (100, 100), "white"), max_width=100, max_height=100
... )
>>> mask = np.zeros((100, 100), dtype=np.uint8)
>>> mask[20:60, 20:60] = 1
>>> overlay_mask(result, mask).metadata["ids"]
1
References
  • Spec: .agents/knowledge/spec/preview-image-segmentation.md (annotools repository).
Source code in src/annotools/image/segmentation.py
def overlay_mask(
    result: PreviewResult,
    mask: np.ndarray,
    *,
    annotation: Literal["label", "legend"] | str = "label",
    id_names: dict[int, str] | None = None,
    alpha: float = 0.5,
    line_width: int | None = None,
    max_width: int | None = None,
    max_height: int | None = None,
    target_pixels: int | None = None,
) -> PreviewResult:
    """Blend the ID ``mask`` over ``result.image`` and annotate regions with labels or a legend.

    ``mask`` is in source pixel space (any size; resized to the source with nearest neighbour), then
    follows the preview's crop and scale. Each ID gets a stable color from
    [`color_from_text`][annotools.color_from_text], so the same instance keeps its color across previews.
    ``annotation="legend"`` appends a color strip below the image and re-fits the composite to the size limits.

    Args:
        result: A preview from [`preview`][annotools.preview]; not modified (a new image is returned).
        mask: 2-D integer array of IDs, 0 = background (from [`load_mask`][annotools.load_mask]).
        annotation: ``"label"`` draws each ID's name at the region centre; ``"legend"`` lists IDs and
            colors in a strip and in ``metadata["legend"]``.
        id_names: Optional ``{id: name}`` for labels and legend entries; missing IDs use the number.
        alpha: Blend strength of the region color in [0, 1].
        line_width: Boundary width in output pixels (0 = no boundary); ``None`` uses
            ``Settings.line_width``.
        max_width: Size limit for the legend composite, which is re-fitted after the strip is appended;
            ``None`` uses ``Settings.max_width`` (384) rather than the preview's own size, so a 768 px
            preview comes back at 384 px unless you pass the limits you previewed with. Ignored by
            ``annotation="label"``.
        max_height: Likewise; ``None`` uses ``Settings.max_height``.
        target_pixels: Optional area cap for the legend composite.

    Returns:
        A new [`PreviewResult`][annotools.PreviewResult] with ``metadata["ids"]`` (count of visible IDs) and, for the
        legend, ``metadata["legend"]`` (entries ``{"id", "name", "color"}``), ``image_size`` (the area the
        inverse mapping applies to, above the strip) and size keys refreshed for the re-fitted composite —
        ``output_size`` covers image plus strip, so map coordinates through ``image_size``, not it.

    Raises:
        ValueError: For ``alpha`` outside [0, 1], ``line_width < 0``, or an unknown ``annotation``.

    Examples:
        >>> import numpy as np
        >>> from PIL import Image
        >>> from annotools import overlay_mask, preview
        >>> result = preview(
        ...     Image.new("RGB", (100, 100), "white"), max_width=100, max_height=100
        ... )
        >>> mask = np.zeros((100, 100), dtype=np.uint8)
        >>> mask[20:60, 20:60] = 1
        >>> overlay_mask(result, mask).metadata["ids"]
        1

    References:
        - Spec: ``.agents/knowledge/spec/preview-image-segmentation.md`` (annotools repository).
    """
    settings = get_settings()
    line_width = settings.line_width if line_width is None else line_width
    max_width = settings.max_width if max_width is None else max_width
    max_height = settings.max_height if max_height is None else max_height
    if not 0.0 <= alpha <= 1.0:
        raise ValueError(f"alpha must be within [0, 1], got {alpha}")
    if line_width < 0:
        raise ValueError(f"line_width must be >= 0, got {line_width}")
    if annotation not in ("label", "legend"):
        raise ValueError(f"annotation must be 'label' or 'legend', got {annotation!r}")
    names = {int(k): v for k, v in (id_names or {}).items()}
    original_w, original_h = result.metadata["original_size"]
    full = _resize_ids(mask, (original_w, original_h))
    x0, y0, x1, y1 = result.crop_pixels or (0, 0, original_w, original_h)
    cropped = full[y0:y1, x0:x1]
    view = _resize_ids(cropped, result.image.size)
    ids = [int(v) for v in np.unique(view) if v != 0]

    if result.image.mode == "RGBA":
        image = Image.alpha_composite(Image.new("RGBA", result.image.size, "white"), result.image).convert("RGB")
    else:
        image = result.image.convert("RGB")
    rgb = np.asarray(image, dtype=np.float32)
    colors = {i: color_from_text(str(i)) for i in ids}
    for i in ids:
        region = view == i
        rgb[region] = rgb[region] * (1 - alpha) + np.array(colors[i], dtype=np.float32) * alpha
    if line_width > 0:
        edges = _thicken(_boundary(view), line_width)
        for i in ids:
            sel = edges & (view == i)
            rgb[sel] = np.array(colors[i], dtype=np.float32)
    image = Image.fromarray(np.clip(np.rint(rgb), 0, 255).astype(np.uint8), "RGB")
    metadata: dict[str, Any] = {**result.metadata, "ids": len(ids)}

    if annotation == "label":
        draw = ImageDraw.Draw(image)
        for i in ids:
            ys, xs = np.nonzero(view == i)
            draw_label(
                draw, names.get(i, str(i)), float(xs.mean()), float(ys.mean()), colors[i], image.size, anchor="middle"
            )
    else:
        entries = [{"id": i, "name": names.get(i, str(i)), "color": to_hex(colors[i])} for i in ids]
        metadata["legend"] = entries
        strip = _legend_strip(entries, image.width)
        combined = Image.new("RGB", (image.width, image.height + strip.height), "white")
        combined.paste(image, (0, 0))
        combined.paste(strip, (0, image.height))
        size = fit_size(
            combined.width, combined.height, max_width=max_width, max_height=max_height, target_pixels=target_pixels
        )
        factor = size[0] / combined.width
        if size != combined.size:
            combined = combined.resize(size, Image.Resampling.LANCZOS)
        # output_size is the composite; image_size is the area the inverse mapping applies to.
        metadata.update(
            size_metadata((original_w, original_h), result.metadata["crop"], size, result.metadata["scale"] * factor)
        )
        metadata["image_size"] = [round(image.width * factor), round(image.height * factor)]
        if "grid" in metadata:  # the grid was drawn before the re-fit: its pixel cell sizes shrink with it
            metadata["grid"] = {
                **metadata["grid"],
                "cell_width": metadata["grid"]["cell_width"] * factor,
                "cell_height": metadata["grid"]["cell_height"] * factor,
            }
        image = combined
    return PreviewResult(image=image, metadata=metadata)