apud 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
apud/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """apud: IIIF-grounded, citable vision for historical documents."""
2
+
3
+ __version__ = "0.0.1"
apud/annotations.py ADDED
@@ -0,0 +1,79 @@
1
+ """W3C Web Annotations anchored to IIIF canvases, plus IIIF Content State links for citation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from typing import Any
8
+ from urllib.parse import quote, unquote
9
+
10
+ from apud.iiif.image import Region
11
+
12
+ _FRAGMENT_SPEC = "http://www.w3.org/TR/media-frags/"
13
+
14
+
15
+ def _target(canvas_id: str, manifest_id: str, region: Region) -> dict[str, Any]:
16
+ return {
17
+ "type": "SpecificResource",
18
+ "source": {
19
+ "id": canvas_id,
20
+ "type": "Canvas",
21
+ "partOf": [{"id": manifest_id, "type": "Manifest"}],
22
+ },
23
+ "selector": {
24
+ "type": "FragmentSelector",
25
+ "conformsTo": _FRAGMENT_SPEC,
26
+ "value": f"xywh={region.xywh}",
27
+ },
28
+ }
29
+
30
+
31
+ def transcription_annotation(
32
+ annotation_id: str,
33
+ canvas_id: str,
34
+ manifest_id: str,
35
+ region: Region,
36
+ text: str,
37
+ *,
38
+ language: str | None = None,
39
+ generator: str | None = None,
40
+ ) -> dict[str, Any]:
41
+ """A transcription of ``region`` (canvas coordinates) as a Presentation 3 annotation."""
42
+ body: dict[str, Any] = {"type": "TextualBody", "value": text, "format": "text/plain"}
43
+ if language:
44
+ body["language"] = language
45
+ annotation: dict[str, Any] = {
46
+ "id": annotation_id,
47
+ "type": "Annotation",
48
+ "motivation": "supplementing",
49
+ "body": body,
50
+ "target": _target(canvas_id, manifest_id, region),
51
+ }
52
+ if generator:
53
+ annotation["generator"] = generator
54
+ return annotation
55
+
56
+
57
+ def annotation_page(page_id: str, items: list[dict[str, Any]]) -> dict[str, Any]:
58
+ return {
59
+ "@context": "http://iiif.io/api/presentation/3/context.json",
60
+ "id": page_id,
61
+ "type": "AnnotationPage",
62
+ "items": items,
63
+ }
64
+
65
+
66
+ def encode_content_state(canvas_id: str, manifest_id: str, region: Region) -> str:
67
+ """Encode a IIIF Content State pointing at a canvas region, for ``?iiif-content=`` links."""
68
+ state = {
69
+ "id": f"{canvas_id}#xywh={region.xywh}",
70
+ "type": "Canvas",
71
+ "partOf": [{"id": manifest_id, "type": "Manifest"}],
72
+ }
73
+ uri_encoded = quote(json.dumps(state, separators=(",", ":")), safe="-_.!~*'()")
74
+ return base64.urlsafe_b64encode(uri_encoded.encode()).decode().rstrip("=")
75
+
76
+
77
+ def decode_content_state(encoded: str) -> dict[str, Any]:
78
+ padded = encoded + "=" * (-len(encoded) % 4)
79
+ return json.loads(unquote(base64.urlsafe_b64decode(padded).decode()))
apud/client.py ADDED
@@ -0,0 +1,53 @@
1
+ """Thin HTTP client for IIIF endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from apud import __version__
10
+ from apud.iiif.image import ImageService, Region, RegionRequest, parse_info
11
+ from apud.iiif.manifest import Manifest, parse_manifest
12
+
13
+ USER_AGENT = f"apud/{__version__} (+https://github.com/cadeos/apud)"
14
+
15
+
16
+ class IIIFClient:
17
+ def __init__(self, timeout: float = 30.0, client: httpx.Client | None = None) -> None:
18
+ self._http = client or httpx.Client(
19
+ timeout=timeout, follow_redirects=True, headers={"User-Agent": USER_AGENT}
20
+ )
21
+
22
+ def __enter__(self) -> IIIFClient:
23
+ return self
24
+
25
+ def __exit__(self, *exc: object) -> None:
26
+ self.close()
27
+
28
+ def close(self) -> None:
29
+ self._http.close()
30
+
31
+ def _json(self, url: str) -> dict[str, Any]:
32
+ response = self._http.get(url)
33
+ response.raise_for_status()
34
+ return response.json()
35
+
36
+ def manifest(self, url: str) -> Manifest:
37
+ return parse_manifest(self._json(url))
38
+
39
+ def image_service(self, service_id: str) -> ImageService:
40
+ url = (
41
+ service_id
42
+ if service_id.endswith("info.json")
43
+ else f"{service_id.rstrip('/')}/info.json"
44
+ )
45
+ return parse_info(self._json(url))
46
+
47
+ def region(
48
+ self, service: ImageService, region: Region, max_side: int
49
+ ) -> tuple[RegionRequest, bytes]:
50
+ request = service.region_request(region, max_side)
51
+ response = self._http.get(request.url)
52
+ response.raise_for_status()
53
+ return request, response.content
apud/iiif/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ from apud.iiif.image import ImageService, Region, RegionRequest, UnsupportedRequest, parse_info
2
+ from apud.iiif.manifest import (
3
+ Canvas,
4
+ Manifest,
5
+ canvas_to_image,
6
+ image_to_canvas,
7
+ parse_manifest,
8
+ )
9
+
10
+ __all__ = [
11
+ "Canvas",
12
+ "ImageService",
13
+ "Manifest",
14
+ "Region",
15
+ "RegionRequest",
16
+ "UnsupportedRequest",
17
+ "canvas_to_image",
18
+ "image_to_canvas",
19
+ "parse_info",
20
+ "parse_manifest",
21
+ ]
apud/iiif/image.py ADDED
@@ -0,0 +1,124 @@
1
+ """IIIF Image API: parse info.json (v2 and v3) and build region requests sized for a model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ _LEVEL_RE = re.compile(r"level([012])")
11
+
12
+
13
+ class UnsupportedRequest(ValueError):
14
+ """The image service cannot serve the request at its declared compliance level."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Region:
19
+ """A rectangle in full-resolution image pixels."""
20
+
21
+ x: int
22
+ y: int
23
+ w: int
24
+ h: int
25
+
26
+ def __post_init__(self) -> None:
27
+ if self.w <= 0 or self.h <= 0:
28
+ raise ValueError(f"region must have positive size, got {self.w}x{self.h}")
29
+ if self.x < 0 or self.y < 0:
30
+ raise ValueError(f"region origin must be non-negative, got {self.x},{self.y}")
31
+
32
+ def clamp(self, width: int, height: int) -> Region:
33
+ """Crop the region to the image bounds, as an IIIF server would."""
34
+ if self.x >= width or self.y >= height:
35
+ raise ValueError("region lies entirely outside the image")
36
+ return Region(self.x, self.y, min(self.w, width - self.x), min(self.h, height - self.y))
37
+
38
+ @property
39
+ def xywh(self) -> str:
40
+ return f"{self.x},{self.y},{self.w},{self.h}"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class RegionRequest:
45
+ """A ready-to-fetch region URL plus the scale needed to map model output back to the image."""
46
+
47
+ url: str
48
+ region: Region
49
+ width: int
50
+ height: int
51
+
52
+ @property
53
+ def scale(self) -> float:
54
+ """Requested pixels per full-resolution pixel (<= 1, servers are never asked to upscale)."""
55
+ return self.width / self.region.w
56
+
57
+ def to_image(self, x: float, y: float) -> tuple[float, float]:
58
+ """Map a point in the returned crop to full-resolution image coordinates."""
59
+ return self.region.x + x / self.scale, self.region.y + y / self.scale
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ImageService:
64
+ id: str
65
+ width: int
66
+ height: int
67
+ version: int
68
+ level: int
69
+ sizes: list[tuple[int, int]] = field(default_factory=list)
70
+ max_width: int | None = None
71
+ max_height: int | None = None
72
+ max_area: int | None = None
73
+
74
+ def region_request(self, region: Region, max_side: int, fmt: str = "jpg") -> RegionRequest:
75
+ """Build a region URL whose longer side is at most ``max_side``, honouring server limits.
76
+
77
+ Uses pixel regions and explicit ``w,h`` sizes, the forms required from Level 1 upward.
78
+ """
79
+ if self.level < 1:
80
+ raise UnsupportedRequest(
81
+ "Level 0 services do not support region requests; stitch tiles or use `sizes`"
82
+ )
83
+ region = region.clamp(self.width, self.height)
84
+ limit = max_side
85
+ if self.max_width is not None:
86
+ limit = min(limit, self.max_width, self.max_height or self.max_width)
87
+ if self.max_area is not None:
88
+ limit = min(limit, math.isqrt(self.max_area))
89
+ factor = min(1.0, limit / max(region.w, region.h))
90
+ w = max(1, math.floor(region.w * factor))
91
+ h = max(1, math.floor(region.h * factor))
92
+ url = f"{self.id.rstrip('/')}/{region.xywh}/{w},{h}/0/default.{fmt}"
93
+ return RegionRequest(url=url, region=region, width=w, height=h)
94
+
95
+
96
+ def parse_info(info: dict[str, Any]) -> ImageService:
97
+ """Parse an Image API v2 or v3 info.json document."""
98
+ context = info.get("@context", "")
99
+ contexts = context if isinstance(context, list) else [context]
100
+ version = 3 if any("image/3" in str(c) for c in contexts) else 2
101
+ service_id = info.get("id") or info.get("@id")
102
+ if not service_id:
103
+ raise ValueError("info.json has no id")
104
+ sizes = [(s["width"], s["height"]) for s in info.get("sizes", [])]
105
+ return ImageService(
106
+ id=service_id,
107
+ width=int(info["width"]),
108
+ height=int(info["height"]),
109
+ version=version,
110
+ level=_parse_level(info.get("profile")),
111
+ sizes=sizes,
112
+ max_width=info.get("maxWidth"),
113
+ max_height=info.get("maxHeight"),
114
+ max_area=info.get("maxArea"),
115
+ )
116
+
117
+
118
+ def _parse_level(profile: Any) -> int:
119
+ # v3: "level2"; v2: ["http://iiif.io/api/image/2/level2.json", {...}]
120
+ candidates = profile if isinstance(profile, list) else [profile]
121
+ for candidate in candidates:
122
+ if isinstance(candidate, str) and (match := _LEVEL_RE.search(candidate)):
123
+ return int(match.group(1))
124
+ return 0
apud/iiif/manifest.py ADDED
@@ -0,0 +1,112 @@
1
+ """IIIF Presentation API: lenient extraction of canvases from v2 and v3 manifests.
2
+
3
+ Real-world manifests are often inconsistent (mixed v2/v3 keys, canvas sizes that differ from
4
+ the image), so this module reads only what it needs and does not validate the whole document.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from apud.iiif.image import Region
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Canvas:
17
+ id: str
18
+ width: int
19
+ height: int
20
+ label: str | None
21
+ image_service: str | None
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Manifest:
26
+ id: str
27
+ label: str | None
28
+ canvases: list[Canvas]
29
+
30
+
31
+ def parse_manifest(data: dict[str, Any]) -> Manifest:
32
+ manifest_id = data.get("id") or data.get("@id")
33
+ if not manifest_id:
34
+ raise ValueError("manifest has no id")
35
+ if "items" in data:
36
+ raw_canvases = data["items"]
37
+ to_canvas = _canvas_v3
38
+ else:
39
+ sequences = data.get("sequences") or [{}]
40
+ raw_canvases = sequences[0].get("canvases", [])
41
+ to_canvas = _canvas_v2
42
+ return Manifest(
43
+ id=manifest_id,
44
+ label=_label(data.get("label")),
45
+ canvases=[to_canvas(c) for c in raw_canvases],
46
+ )
47
+
48
+
49
+ def image_to_canvas(region: Region, image_width: int, image_height: int, canvas: Canvas) -> Region:
50
+ """Rescale a region from image pixels to canvas coordinates (they can legitimately differ)."""
51
+ sx = canvas.width / image_width
52
+ sy = canvas.height / image_height
53
+ return Region(
54
+ round(region.x * sx), round(region.y * sy), round(region.w * sx), round(region.h * sy)
55
+ )
56
+
57
+
58
+ def canvas_to_image(region: Region, image_width: int, image_height: int, canvas: Canvas) -> Region:
59
+ """Rescale a region from canvas coordinates to image pixels."""
60
+ sx = image_width / canvas.width
61
+ sy = image_height / canvas.height
62
+ return Region(
63
+ round(region.x * sx), round(region.y * sy), round(region.w * sx), round(region.h * sy)
64
+ )
65
+
66
+
67
+ def _canvas_v3(c: dict[str, Any]) -> Canvas:
68
+ service = None
69
+ for page in c.get("items", []):
70
+ for anno in page.get("items", []):
71
+ service = _service_id(anno.get("body", {}))
72
+ if service:
73
+ break
74
+ if service:
75
+ break
76
+ return Canvas(c["id"], int(c["width"]), int(c["height"]), _label(c.get("label")), service)
77
+
78
+
79
+ def _canvas_v2(c: dict[str, Any]) -> Canvas:
80
+ service = None
81
+ for image in c.get("images", []):
82
+ service = _service_id(image.get("resource", {}))
83
+ if service:
84
+ break
85
+ return Canvas(c["@id"], int(c["width"]), int(c["height"]), _label(c.get("label")), service)
86
+
87
+
88
+ def _service_id(resource: dict[str, Any] | list[Any]) -> str | None:
89
+ if isinstance(resource, list):
90
+ resource = resource[0] if resource else {}
91
+ services = resource.get("service", [])
92
+ for service in services if isinstance(services, list) else [services]:
93
+ if isinstance(service, dict) and (sid := service.get("id") or service.get("@id")):
94
+ return sid
95
+ return None
96
+
97
+
98
+ def _label(label: Any) -> str | None:
99
+ # v3 language map {"en": ["..."]}, v2 plain string or list of {"@value": ...}
100
+ if label is None:
101
+ return None
102
+ if isinstance(label, str):
103
+ return label
104
+ if isinstance(label, dict):
105
+ for values in label.values():
106
+ if values:
107
+ return str(values[0])
108
+ return None
109
+ if isinstance(label, list) and label:
110
+ first = label[0]
111
+ return first.get("@value") if isinstance(first, dict) else str(first)
112
+ return None
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.5
2
+ Name: apud
3
+ Version: 0.0.1
4
+ Summary: IIIF-grounded, citable vision for historical documents: region crops for VLMs, W3C annotations out
5
+ Author: cadeos
6
+ License-Expression: Apache-2.0
7
+ Keywords: digital-humanities,htr,iiif,manuscripts,vlm,web-annotation
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
11
+ Classifier: Topic :: Text Processing :: Linguistic
12
+ Requires-Python: >=3.11
13
+ Requires-Dist: httpx>=0.27
14
+ Description-Content-Type: text/markdown
15
+
16
+ # apud
17
+
18
+ Core library of [apud](https://github.com/cadeos/apud): IIIF Image/Presentation API parsing,
19
+ model-sized region requests, W3C Web Annotations and IIIF Content State citations.
@@ -0,0 +1,9 @@
1
+ apud/__init__.py,sha256=ofYJOMOEezs1comrzhek5Kui_T8JPUwK2vmmUV35LL8,91
2
+ apud/annotations.py,sha256=UY2Yja5AZJyt4R59_Wb3P4ukw-f8adfVWWTJfuof2mM,2471
3
+ apud/client.py,sha256=4w6LpIaFZlWHMCVAM7ih7dS0N0t7Lo32n6wopqlHR40,1600
4
+ apud/iiif/__init__.py,sha256=JJtv3Pbdq8Fnk4qC4NEsgsvMDL8lXeRMUs1_YxyxjOE,431
5
+ apud/iiif/image.py,sha256=nR2SrQhfzUJuQH8YnbaMyg2QdBSfUOM7LCSSfPgD3vw,4429
6
+ apud/iiif/manifest.py,sha256=YEmUemFpRq37AKRxDMe-LCrtyj9wEsVX7Pn_z68Cszg,3575
7
+ apud-0.0.1.dist-info/METADATA,sha256=zwvS-uSHOt42TCFf8Q2CYGwPZqZDDgWf3_dNwZYlSgY,761
8
+ apud-0.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ apud-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any