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
12 changes: 9 additions & 3 deletions docs/backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,10 @@ styles (`\r\n`, `\r`, `\n`) differ, and Unicode is not re-normalized. It never u
What changes the key: the state (including key order), question names, each question's type,
`instructions`, and `criteria` (Score level order and Choice option order matter; Noul criteria
with all-`None` values equal no criteria). What does not: question order. The key version `v` is
bumped if this rule ever changes.
bumped if this rule changes after a fixture has been recorded under it. The image `assets`
member (below) was redefined once, before any image fixture existed (its digest moved from the
base64 text to the decoded file bytes), so `v` stayed 1 and no recorded key moved. Any later
change that would alter an existing key, text-only or image, bumps `v`.

The state, a question's `instructions`, and each `criteria` value (each Score level, Choice
option description, Noul outcome) each nest at most **64 levels** of list/object, counting the
Expand Down Expand Up @@ -400,8 +403,11 @@ Keep image files under `assets/` beside `fixtures/`, and put their relative path

The text-only replay envelope is unchanged: omitted, `None` and empty assets produce exactly
the existing keys. A request with images adds an `assets` object sorted by reference, holding
each asset's `type`, canonical `format` (`png` or `jpeg`) and SHA-256 of its base64 ASCII text.
That means changed image
each asset's `type`, canonical `format` (`png` or `jpeg`) and `sha256`, the lowercase hex
SHA-256 of the **decoded file bytes** (the same digest `image_reference` embeds), not of the
base64 text. Base64 that decodes to the same bytes but is spelled differently (for example
non-canonical trailing bits, `QQ==` and `QR==`) therefore keys once. This is the envelope
every image fixture binds to; text-only keys are untouched and pinned by a test. Changed image
bytes or references change the key, while reordering the assets map does not. No image data
is stored in a response fixture. The scripted random seed includes this key; scripts retain
their existing `(state, questions, rng)` interface.
Expand Down
25 changes: 20 additions & 5 deletions docs/vision.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,12 @@ contained real path for code that opens files itself.
decoded, still-encoded PNG/JPEG file bytes. Neither function changes those bytes.

The accepted local files are PNG or JPEG stills, at most 16 MiB, with each
dimension 256–1568 and total area 78,400–2,458,624 pixels. A 384 × 320 fixture fits;
a 256 × 256 fixture does not. The loader rejects orientation metadata requiring a
dimension 280–1568 and total area 78,400–2,458,624 pixels (all ranges include both ends;
the same numbers are `vision.IMAGE_LIMITS`). A 384 × 320 or a 280 × 280 fixture fits; a
256 × 256 or a 279 × 400 fixture does not, and neither does 400 × 279: the API rejects an
image with either side under 280 pixels, never resizing it, and rejects it only after the request
is sent, so the loader refuses it first. Scale up or pad a smaller image before saving it.
The loader rejects orientation metadata requiring a
rotation: prepare upright pixels explicitly with Pillow's `ImageOps.exif_transpose`
before saving. Limits follow the experimental [Images documentation](https://docs.typesafe.ai/images).

Expand Down Expand Up @@ -115,15 +119,20 @@ backgrounds and ink text from the cookbook palette.

`box_iou(first, second)` computes intersection divided by union, using continuous
normalized geometry before pixel rounding. It returns zero for disjoint or merely
touching rectangles and one for identical rectangles.
touching rectangles and one for identical rectangles. A valid but vanishingly small box
(for example 1e-200 wide) has an area that underflows to zero; when both do, the union is
zero and `box_iou` (and `localization_metrics`) raise `ValueError` rather than invent a score.
Because the union is at least as large as each area, this happens only when both boxes
underflow, so in `localization_metrics` it means a degenerate gold label: fix the fixture.

## Presence, confidence and evaluation

A Box returns one rectangle even when its target is absent. Determine presence
with a companion question before treating a rectangle as a detection.
[Box documentation](https://docs.typesafe.ai/primitives/box)

`box_confidence(answer)` returns the supplied confidence or `None` if missing/null;
`box_confidence(answer)` returns the supplied confidence, including a reported `0.0`
or `1.0`, or `None` if missing/null (absence is never turned into a number);
invalid confidence values raise. A missing value cannot pass a confidence gate:

```python
Expand Down Expand Up @@ -183,7 +192,13 @@ with Image.open("assets/screenshot.png") as image:
`.bounds` in the original image, in row-major order. `overlap` is an integer number
of pixels smaller than both tile dimensions. The last row/column anchors to the
bottom/right edge, which can increase overlap; there are no uncovered gaps or
partial sliver tiles. A source smaller than a requested tile keeps its full extent.
partial sliver tiles. A source smaller than a requested tile keeps its full extent, so a
tile (or a source) under 280 pixels on a side must be scaled up or padded before
`image_asset` accepts it. The tile count is computed before any tile is cropped and
`max_tiles` (default 256) bounds it: the count grows with the inverse square of the
stride (tile size minus overlap), so an overlap near the tile size makes it explode, and a
configuration above the cap raises `ValueError`. Each tile is usually one API call, so
`max_tiles` also caps cost, not only memory. Pass a larger `max_tiles=` only deliberately.

`tile_to_image` applies an affine transform using the actual pixel bounds, so a
rectangle found inside a tile maps back to the whole image. It also handles an
Expand Down
16 changes: 11 additions & 5 deletions src/jev_cookbook/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,15 @@ def _check_assets(
raise ValueError(
f"asset {ref!r} is not referenced in a state/instructions/criteria value"
)
validate_image_asset(asset)
data = validate_image_asset(asset)
# Canonical format ("jpg" -> "jpeg", lower case) so the replay key and every consumer
# see one spelling per set of bytes.
checked[ref] = {**asset, "format": canonical_image_format(asset["format"])}
# see one spelling per set of bytes. The digest is of the decoded file bytes, so
# non-canonical base64 of the same bytes ("QQ==" and "QR==") keys identically.
checked[ref] = {
**asset,
"format": canonical_image_format(asset["format"]),
"sha256": hashlib.sha256(data).hexdigest(),
}
if reference_bytes > 65536:
raise ValueError("all asset references together must be at most 65536 UTF-8 bytes")
return checked
Expand All @@ -158,7 +163,8 @@ def replay_key(
Choice options) and every array in the order written; no whitespace, ASCII escapes,
shortest-repr floats. Non-empty ``assets`` adds a sorted map of reference to asset type,
canonical format (``png`` or ``jpeg``; ``jpg`` and case variants are the same key) and
SHA-256 of its base64 text. Text-only requests retain their original keys.
SHA-256 of its decoded file bytes (the digest inside ``vision.image_reference``).
Text-only requests retain their original keys.
"""
plain_state, checked = _check_request(state, questions)
questions_json = {}
Expand All @@ -181,7 +187,7 @@ def replay_key(
ref: {
"type": asset["type"],
"format": asset["format"],
"sha256": hashlib.sha256(asset["data_base64"].encode("ascii")).hexdigest(),
"sha256": asset["sha256"],
}
for ref, asset in sorted(checked_assets.items())
}
Expand Down
81 changes: 67 additions & 14 deletions src/jev_cookbook/vision.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
from .style import INK, PAPER, PINK

__all__ = [
"DEFAULT_MAX_TILES",
"IMAGE_LIMITS",
"AssetPathError",
"ImageTile",
"LocalizationMetrics",
Expand All @@ -46,7 +48,23 @@
"validate_image_asset",
]

_MAX_IMAGE_BYTES = 16 * 1024 * 1024
# The Images API limits this module enforces locally. Source of truth: the "Limits" table at
# https://docs.typesafe.ai/images (fetched 2026-10-20): "Width and height: 280 to 1568 pixels
# each", "Total pixels (width x height): 78,400 to 2,458,624" and "16 MiB per image file". All
# ranges include both ends. tests/test_vision.py pins these numbers against docs/vision.md.
IMAGE_LIMITS = {
"min_side": 280,
"max_side": 1568,
"min_area": 78_400,
"max_area": 2_458_624,
"max_bytes": 16 * 1024 * 1024,
}
_MIN_SIDE = IMAGE_LIMITS["min_side"]
_MAX_SIDE = IMAGE_LIMITS["max_side"]
_MIN_AREA = IMAGE_LIMITS["min_area"]
_MAX_AREA = IMAGE_LIMITS["max_area"]
_MAX_IMAGE_BYTES = IMAGE_LIMITS["max_bytes"]
DEFAULT_MAX_TILES = 256
_FORMATS = {"PNG": "png", "JPEG": "jpeg"}
_EDGES = ("top", "bottom", "left", "right")
PixelBounds = tuple[int, int, int, int]
Expand All @@ -63,10 +81,12 @@ def _image_format(data: bytes) -> str:
if getattr(image, "is_animated", False):
raise ValueError("image must be a still image, not an animation")
width, height = image.size
if not (256 <= width <= 1568 and 256 <= height <= 1568):
raise ValueError("image width and height must each be 256 to 1568 pixels")
if not 78_400 <= width * height <= 2_458_624:
raise ValueError("image area must be 78,400 to 2,458,624 pixels")
if not (_MIN_SIDE <= width <= _MAX_SIDE and _MIN_SIDE <= height <= _MAX_SIDE):
raise ValueError(
f"image width and height must each be {_MIN_SIDE} to {_MAX_SIDE} pixels"
)
if not _MIN_AREA <= width * height <= _MAX_AREA:
raise ValueError(f"image area must be {_MIN_AREA:,} to {_MAX_AREA:,} pixels")
image.verify()
# verify() checks file integrity; load() also catches truncated pixel data.
with Image.open(io.BytesIO(data)) as image:
Expand All @@ -84,8 +104,9 @@ def _image_format(data: bytes) -> str:
def validate_image_asset(asset: Mapping[str, Any]) -> bytes:
"""Validate a raw image asset and return its original encoded bytes.

Checks actual PNG/JPEG content, stillness, orientation, dimensions and the 16 MiB
file limit. This does not certify aggregate request limits or API acceptance.
Checks actual PNG/JPEG content, stillness, orientation, dimensions (each side 280 to
1568 pixels, area 78,400 to 2,458,624; a side under 280 is rejected by the API) and the
16 MiB file limit. This does not certify aggregate request limits or API acceptance.
"""
if not isinstance(asset, Mapping) or set(asset) != {"type", "format", "data_base64"}:
raise ValueError("image asset needs exactly type, format and data_base64")
Expand Down Expand Up @@ -275,14 +296,22 @@ def normalized_to_pixels(box: Any, width: int, height: int) -> PixelBounds:


def box_iou(first: Any, second: Any) -> float:
"""Intersection area divided by union area of two valid normalized rectangles."""
"""Intersection area divided by union area of two valid normalized rectangles.

Raises ``ValueError`` when both areas are so small that the union underflows to zero.
"""
a, b = normalized_box(first), normalized_box(second)
intersection = max(0.0, min(a["right"], b["right"]) - max(a["left"], b["left"])) * max(
0.0, min(a["bottom"], b["bottom"]) - max(a["top"], b["top"])
)
area_a = (a["right"] - a["left"]) * (a["bottom"] - a["top"])
area_b = (b["right"] - b["left"]) * (b["bottom"] - b["top"])
return intersection / (area_a + area_b - intersection)
union = area_a + area_b - intersection
if union <= 0.0:
# A valid box can be so small that its area underflows to 0.0; the ratio is then
# undefined, and 0.0 would wrongly call two identical boxes disjoint.
raise ValueError("box areas are too small to compare: the union underflows to zero")
return intersection / union


def expand_box(box: Any, fraction: float = 0.0) -> dict[str, float]:
Expand Down Expand Up @@ -342,15 +371,26 @@ class ImageTile:


def generate_tiles(
image: Image.Image, *, tile_size: tuple[int, int] = (512, 512), overlap: int = 0
image: Image.Image,
*,
tile_size: tuple[int, int] = (512, 512),
overlap: int = 0,
max_tiles: int = DEFAULT_MAX_TILES,
) -> list[ImageTile]:
"""Cover an image in row-major order with tiles of at most ``(width, height)``.

``overlap`` is the minimum overlap in pixels. Final windows are anchored to the
right/bottom boundary, so they may overlap more. Images smaller than a tile yield
their full extent. Tile sizes are geometry choices; validate saved tiles with
:func:`image_asset` before sending them to the API.

The number of tiles grows with the inverse square of the stride (tile size minus
``overlap``), so the tile count is computed first and a configuration above
``max_tiles`` (default 256) raises ``ValueError`` before any tile is cropped. Each tile
is usually one API call, so the cap bounds cost as well as memory. Raise ``max_tiles``
deliberately.
"""
max_tiles = _positive_int(max_tiles, "max_tiles")
try:
tile_width, tile_height = tile_size
except (TypeError, ValueError) as exc:
Expand All @@ -375,9 +415,16 @@ def starts(length: int, window: int) -> list[int]:
positions.append(length - window)
return positions

rows, columns = starts(height, tile_height), starts(width, tile_width)
if len(rows) * len(columns) > max_tiles:
raise ValueError(
f"tile_size={tile_width, tile_height} with overlap={overlap} would make "
f"{len(rows) * len(columns):,} tiles, above max_tiles={max_tiles:,}; "
"use a larger tile, less overlap or a larger max_tiles"
)
tiles = []
for top in starts(height, tile_height):
for left in starts(width, tile_width):
for top in rows:
for left in columns:
bounds = (left, top, min(left + tile_width, width), min(top + tile_height, height))
tiles.append(ImageTile(bounds, image.crop(bounds)))
return tiles
Expand All @@ -400,7 +447,11 @@ def draw_boxes(image: Image.Image, boxes: Mapping[str, Any]) -> Image.Image:


def box_confidence(answer: Any) -> float | None:
"""Read confidence conservatively: absent or null stays ``None``, never zero or one."""
"""Read confidence conservatively: absent or null stays ``None``, never a number.

A reported ``0.0`` or ``1.0`` is returned as reported; only absence is never coerced
into a confidence. A non-numeric or out-of-range value raises ``ValueError``.
"""
value = (
answer.get("confidence")
if isinstance(answer, Mapping)
Expand Down Expand Up @@ -431,7 +482,9 @@ def localization_metrics(gold: Iterable[Any], predicted: Iterable[Any]) -> Local
Predictions must already have passed the recipe's presence/confidence policy.
A review outcome is not an absent prediction: report reviews separately through
``evaluation.evaluate_outcomes`` and use this function on the resolved subset.
Invalid rectangles raise, including predictions on absent gold examples.
Invalid rectangles raise, including predictions on absent gold examples. A
``ValueError`` from :func:`box_iou` (both areas underflow to zero) means a degenerate
gold label, since the union is at least as large as the gold box's own area.
"""
actual, guesses = list(gold), list(predicted)
if not actual or len(actual) != len(guesses):
Expand Down
Loading
Loading