"""Image API conformance probing.
Given a loaded :class:`~iiisight.model.ImageService`, this plans a battery of
Image API requests appropriate to the service's claimed compliance level, and —
for requests whose output size is predictable — verifies that the server returns
an image of the **expected pixel dimensions**, not merely an HTTP 200.
Dimensions are read straight from the returned image's header (JPEG / PNG / GIF)
with :func:`image_size`, so no image-decoding dependency is required. When a
response is in a format we can't measure, the check verifies reachability only and
says so. The client drives the requests (:meth:`iiisight.AsyncClient.check_compliance`);
the planning and evaluation here are pure.
"""
from dataclasses import dataclass
from typing import NamedTuple
from .model import ImageService
_LEVELS = {"level0": 0, "level1": 1, "level2": 2}
#: Allowed per-axis pixel tolerance — servers round downscaled dimensions slightly.
_TOLERANCE = 2
[docs]
@dataclass(frozen=True)
class Check:
"""The result of a single conformance request."""
name: str
level: str
url: str
ok: bool
detail: str
[docs]
class CheckSpec(NamedTuple):
"""A planned request: what to fetch and the expected output dimensions."""
name: str
level: str
url: str
expected: tuple[int, int] | None # None => verify reachability only
[docs]
def plan_checks(service: ImageService) -> list[CheckSpec]:
"""Plan the conformance requests for a loaded image service."""
if service.width is None or service.height is None:
return []
width, height = service.width, service.height
level = _LEVELS.get(service.compliance_level or "", None)
full_w, full_h = _max_full_size(service)
specs = [
CheckSpec("full image", "level0", service.url(region="full", size="max"), (full_w, full_h)),
]
if service.sizes:
size = service.sizes[0]
specs.append(
CheckSpec(
"listed size",
"level0",
service.url(region="full", size=f"{size.width},"),
(size.width, size.height),
)
)
if level is None or level >= 1:
half_w = max(1, width // 2)
specs.append(
CheckSpec(
"sizeByW",
"level1",
service.url(region="full", size=f"{half_w},"),
(half_w, round(height * half_w / width)),
)
)
region_w, region_h = max(1, width // 2), max(1, height // 2)
specs.append(
CheckSpec(
"regionByPx",
"level1",
service.url(region=f"0,0,{region_w},{region_h}", size="max"),
(region_w, region_h),
)
)
if level is None or level >= 2:
specs.append(
CheckSpec(
"regionByPct",
"level2",
service.url(region="pct:0,0,50,50", size="max"),
(round(width * 0.5), round(height * 0.5)),
)
)
specs.append(
CheckSpec(
"rotationBy90",
"level2",
service.url(region="full", size="max", rotation="90"),
(full_h, full_w), # a 90° rotation swaps the axes
)
)
return specs
[docs]
def evaluate(spec: CheckSpec, status: int, content_type: str, data: bytes) -> Check:
"""Turn an HTTP response to a planned request into a :class:`Check`."""
if status != 200:
return Check(spec.name, spec.level, spec.url, False, f"HTTP {status}")
if not _is_image(content_type, data):
return Check(spec.name, spec.level, spec.url, False, f"not an image ({content_type})")
if spec.expected is None:
return Check(spec.name, spec.level, spec.url, True, f"HTTP 200, {content_type}")
dims = image_size(data)
if dims is None:
return Check(spec.name, spec.level, spec.url, True, "HTTP 200; dimensions not verified")
ex_w, ex_h = spec.expected
ok = abs(dims[0] - ex_w) <= _TOLERANCE and abs(dims[1] - ex_h) <= _TOLERANCE
detail = f"got {dims[0]}x{dims[1]}, expected ~{ex_w}x{ex_h}"
return Check(spec.name, spec.level, spec.url, ok, detail)
[docs]
def image_size(data: bytes) -> tuple[int, int] | None:
"""Read (width, height) from an image's header, without decoding pixels.
Supports PNG, JPEG, and GIF — the formats IIIF Image API servers return in
practice. Returns ``None`` for anything else.
"""
if data[:8] == b"\x89PNG\r\n\x1a\n" and data[12:16] == b"IHDR":
return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
if data[:2] == b"\xff\xd8":
return _jpeg_size(data)
if data[:6] in (b"GIF87a", b"GIF89a"):
return int.from_bytes(data[6:8], "little"), int.from_bytes(data[8:10], "little")
return None
def _jpeg_size(data: bytes) -> tuple[int, int] | None:
i, n = 2, len(data)
while i < n - 8: # need indices up to i+8 to read the SOF width/height
if data[i] != 0xFF:
i += 1
continue
marker = data[i + 1]
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
return int.from_bytes(data[i + 7 : i + 9], "big"), int.from_bytes(
data[i + 5 : i + 7], "big"
)
i += 2 + int.from_bytes(data[i + 2 : i + 4], "big")
return None
def _is_image(content_type: str, data: bytes) -> bool:
return content_type.lower().startswith("image/") or image_size(data) is not None
def _max_full_size(service: ImageService) -> tuple[int, int]:
"""The dimensions ``full/max`` should return, honoring any max constraints."""
width, height = service.width, service.height
scale = 1.0
if service.max_width:
scale = min(scale, service.max_width / width)
if service.max_height:
scale = min(scale, service.max_height / height)
if service.max_area:
scale = min(scale, (service.max_area / (width * height)) ** 0.5)
return round(width * scale), round(height * scale)