annolabel 0.5.0__tar.gz

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.
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.3
2
+ Name: annolabel
3
+ Version: 0.5.0
4
+ Summary: Local image annotation primitives for vision-capable agents
5
+ Requires-Dist: pillow>=12.3.0
6
+ Requires-Dist: pydantic>=2.13.5
7
+ Requires-Python: >=3.11
8
+ Project-URL: Repository, https://github.com/Andesprit/annolabel
9
+ Project-URL: Issues, https://github.com/Andesprit/annolabel/issues
10
+ Description-Content-Type: text/markdown
11
+
12
+ # AnnoLabel by Andesprit
13
+
14
+ A local image-labeling CLI for vision-capable agents. The agent inspects your image and chooses scene labels, bounding boxes, and segmentation polygons. AnnoLabel validates and saves annotations, renders review images, and exports **COCO datasets**.
15
+
16
+ The PyPI distribution, executable, and Python module are all `annolabel`.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ uv tool install annolabel
22
+ annolabel --version
23
+ annolabel --help
24
+ ```
25
+
26
+ Or install into an existing Python environment:
27
+
28
+ ```sh
29
+ python -m pip install annolabel
30
+ ```
31
+
32
+ Requires Python 3.11 or later. Runtime dependencies are Pillow and Pydantic. AnnoLabel itself does not call LLMs, download models, or need API keys. Your chosen agent needs an image-viewing tool and shell access, and uses its own model-provider authentication.
33
+
34
+ Previously published as `andesprit-labelkit`. When upgrading, replace `labelkit` commands and Python imports with `annolabel`; the core facade is now `AnnoLabel` in `annolabel.core.annolabel`. Existing annotation sidecars and task handles remain compatible. Keep active task directories in place.
35
+
36
+ ## Label an image
37
+
38
+ ```sh
39
+ annolabel task /data/photo.jpg --output /data/work/photo-task
40
+ ```
41
+
42
+ Open the returned `view` using your agent's image tool. It has the source image's exact EXIF-oriented pixel dimensions with no padding, grid or resizing. The top-left corner is `(0,0)`; x increases right and y increases down.
43
+
44
+ Ask the agent to write a complete JSON snapshot such as this, replacing every label and coordinate with its own visual interpretation:
45
+
46
+ ```json
47
+ {
48
+ "classifications": [{"label": "outdoor"}],
49
+ "objects": [
50
+ {
51
+ "key": "object-1",
52
+ "label": "example object",
53
+ "box": [50, 30, 250, 180],
54
+ "polygon": [[50, 100], [120, 30], [250, 100], [200, 180], [50, 180]],
55
+ "note": "Describe uncertain identity or boundary placement here."
56
+ }
57
+ ]
58
+ }
59
+ ```
60
+
61
+ These are schema examples, not annotations for your image. Both `classifications` and `objects` arrays are required. Coordinates must stay within the original oriented image bounds. Polygons need at least three distinct vertices, no self-intersections, and no repeated closing vertex. The box must contain the polygon; omit `box` to derive it automatically from the polygon.
62
+
63
+ Submit all annotations together:
64
+
65
+ ```sh
66
+ annolabel submit /data/work/photo-task/task.json --file /data/annotations.json
67
+ ```
68
+
69
+ Open the returned review `view`. Check missing objects, class meaning, clipped contours, excess background, and coordinate shifts. If necessary, submit one corrected complete snapshot using the **new task handle**:
70
+
71
+ ```sh
72
+ annolabel submit /data/work/photo-task/pass1/task.json --file /data/corrected.json
73
+ ```
74
+
75
+ Retain stable object keys and every unchanged object/class. Each submission replaces the whole image snapshot; omitted objects and classes are removed. By default a task allows two successful submissions. Use `--max-passes 1` when creating a task to allow one.
76
+
77
+ If output is lost, recover the latest handle and view:
78
+
79
+ ```sh
80
+ annolabel task-status /data/work/photo-task/task.json
81
+ ```
82
+
83
+ Invalid submissions preserve the existing annotations. Stale external edits are rejected. Keep a single writer per source image; revision checks are not a multi-process lock.
84
+
85
+ ## Choose the task
86
+
87
+ - Whole-image classification: `annolabel label IMAGE --label CLASS`.
88
+ - Boxes only: create a task with `--geometry boxes` and submit objects with `box` and no polygon.
89
+ - Segmentation: the default requires a polygon per object, with a derived or explicit bounding box.
90
+ - Restrict object labels: pass `--categories categories.json`, a JSON array such as `["car", "person"]`. Scene labels are independent.
91
+ - Save research instructions: pass `--instructions instructions.txt` when creating a task. Also supply those instructions directly to the agent; the compact response does not repeat them.
92
+
93
+ ## Export for training
94
+
95
+ ```sh
96
+ annolabel export /data/photo.jpg --output /data/dataset
97
+ # Or export every annotated source discovered recursively:
98
+ annolabel export /data/images --output /data/dataset-all
99
+ ```
100
+
101
+ COCO is the default and currently the only built-in training format. Export produces:
102
+
103
+ ```text
104
+ dataset/
105
+ annotations/instances_default.json
106
+ images/default/000001.png
107
+ categories.json
108
+ classifications.csv
109
+ provenance.json
110
+ README.txt
111
+ ```
112
+
113
+ Use `images/default/` as your COCO loader's image root. Each object has one record combining its box and optional polygon. COCO boxes use `[x,y,width,height]`; the CLI accepts `[x1,y1,x2,y2]`. Scene classifications and original IDs/notes are preserved separately. Provenance includes original source paths.
114
+
115
+ Reuse an ordered category vocabulary through `export --categories categories.json` across splits. AnnoLabel does not invent a train/validation split. Folder export skips images without annotation sidecars.
116
+
117
+ ## Give your agent this workflow
118
+
119
+ ```text
120
+ Use AnnoLabel to annotate IMAGE_PATH. Research task: YOUR_LABELS_AND_BOUNDARY_POLICY.
121
+ Create a task in NEW_OUTPUT_DIRECTORY. Open its returned view and use original
122
+ oriented pixels. Submit all scene labels and objects together. Review the returned
123
+ view once and, if needed, submit one corrected full snapshot with the new task
124
+ handle. Retain unchanged objects. Use your own vision; no detectors, segmentation
125
+ models, crop scripts or implementation inspection. Export COCO and report paths
126
+ and remaining uncertainty. Use task-status if a command response is lost.
127
+ ```
128
+
129
+ Works with Codex, Claude Code, Gemini/Antigravity, or another image-capable agent with shell access. Folder traversal and batching are handled by the calling agent, with one task per image.
130
+
131
+ ## More tools and limitations
132
+
133
+ Run `annolabel COMMAND --help` for any of: `task`, `submit`, `task-status`, `info`, `label`, `box`, `polygon`, `link`, `remove`, `render`, `mask`, `prepare`, `apply`, `review`, and `export`.
134
+
135
+ `prepare` / `apply` / `review` support detailed packets and padded crop reviews when more inspection is needed. They use a full revision-aware snapshot instead of the short `submit` contract. Detailed ruler values are original coordinates, not PNG margin positions.
136
+
137
+ Annotations are stored beside the source as `IMAGE.labels.json`. Output directories must be new. Source images remain unchanged. Task handles contain absolute paths and should stay in place during active work; use COCO exports for portable datasets.
138
+
139
+ Supports single-frame raster images and simple single-component polygons. No polygon holes, multipart instances, keypoints, video, inference engine, or GUI. Geometry validation does not establish visual accuracy: the agent must review the pixels and report uncertainty.
140
+
141
+ Public source repository: [Andesprit/annolabel](https://github.com/Andesprit/annolabel).
@@ -0,0 +1,130 @@
1
+ # AnnoLabel by Andesprit
2
+
3
+ A local image-labeling CLI for vision-capable agents. The agent inspects your image and chooses scene labels, bounding boxes, and segmentation polygons. AnnoLabel validates and saves annotations, renders review images, and exports **COCO datasets**.
4
+
5
+ The PyPI distribution, executable, and Python module are all `annolabel`.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ uv tool install annolabel
11
+ annolabel --version
12
+ annolabel --help
13
+ ```
14
+
15
+ Or install into an existing Python environment:
16
+
17
+ ```sh
18
+ python -m pip install annolabel
19
+ ```
20
+
21
+ Requires Python 3.11 or later. Runtime dependencies are Pillow and Pydantic. AnnoLabel itself does not call LLMs, download models, or need API keys. Your chosen agent needs an image-viewing tool and shell access, and uses its own model-provider authentication.
22
+
23
+ Previously published as `andesprit-labelkit`. When upgrading, replace `labelkit` commands and Python imports with `annolabel`; the core facade is now `AnnoLabel` in `annolabel.core.annolabel`. Existing annotation sidecars and task handles remain compatible. Keep active task directories in place.
24
+
25
+ ## Label an image
26
+
27
+ ```sh
28
+ annolabel task /data/photo.jpg --output /data/work/photo-task
29
+ ```
30
+
31
+ Open the returned `view` using your agent's image tool. It has the source image's exact EXIF-oriented pixel dimensions with no padding, grid or resizing. The top-left corner is `(0,0)`; x increases right and y increases down.
32
+
33
+ Ask the agent to write a complete JSON snapshot such as this, replacing every label and coordinate with its own visual interpretation:
34
+
35
+ ```json
36
+ {
37
+ "classifications": [{"label": "outdoor"}],
38
+ "objects": [
39
+ {
40
+ "key": "object-1",
41
+ "label": "example object",
42
+ "box": [50, 30, 250, 180],
43
+ "polygon": [[50, 100], [120, 30], [250, 100], [200, 180], [50, 180]],
44
+ "note": "Describe uncertain identity or boundary placement here."
45
+ }
46
+ ]
47
+ }
48
+ ```
49
+
50
+ These are schema examples, not annotations for your image. Both `classifications` and `objects` arrays are required. Coordinates must stay within the original oriented image bounds. Polygons need at least three distinct vertices, no self-intersections, and no repeated closing vertex. The box must contain the polygon; omit `box` to derive it automatically from the polygon.
51
+
52
+ Submit all annotations together:
53
+
54
+ ```sh
55
+ annolabel submit /data/work/photo-task/task.json --file /data/annotations.json
56
+ ```
57
+
58
+ Open the returned review `view`. Check missing objects, class meaning, clipped contours, excess background, and coordinate shifts. If necessary, submit one corrected complete snapshot using the **new task handle**:
59
+
60
+ ```sh
61
+ annolabel submit /data/work/photo-task/pass1/task.json --file /data/corrected.json
62
+ ```
63
+
64
+ Retain stable object keys and every unchanged object/class. Each submission replaces the whole image snapshot; omitted objects and classes are removed. By default a task allows two successful submissions. Use `--max-passes 1` when creating a task to allow one.
65
+
66
+ If output is lost, recover the latest handle and view:
67
+
68
+ ```sh
69
+ annolabel task-status /data/work/photo-task/task.json
70
+ ```
71
+
72
+ Invalid submissions preserve the existing annotations. Stale external edits are rejected. Keep a single writer per source image; revision checks are not a multi-process lock.
73
+
74
+ ## Choose the task
75
+
76
+ - Whole-image classification: `annolabel label IMAGE --label CLASS`.
77
+ - Boxes only: create a task with `--geometry boxes` and submit objects with `box` and no polygon.
78
+ - Segmentation: the default requires a polygon per object, with a derived or explicit bounding box.
79
+ - Restrict object labels: pass `--categories categories.json`, a JSON array such as `["car", "person"]`. Scene labels are independent.
80
+ - Save research instructions: pass `--instructions instructions.txt` when creating a task. Also supply those instructions directly to the agent; the compact response does not repeat them.
81
+
82
+ ## Export for training
83
+
84
+ ```sh
85
+ annolabel export /data/photo.jpg --output /data/dataset
86
+ # Or export every annotated source discovered recursively:
87
+ annolabel export /data/images --output /data/dataset-all
88
+ ```
89
+
90
+ COCO is the default and currently the only built-in training format. Export produces:
91
+
92
+ ```text
93
+ dataset/
94
+ annotations/instances_default.json
95
+ images/default/000001.png
96
+ categories.json
97
+ classifications.csv
98
+ provenance.json
99
+ README.txt
100
+ ```
101
+
102
+ Use `images/default/` as your COCO loader's image root. Each object has one record combining its box and optional polygon. COCO boxes use `[x,y,width,height]`; the CLI accepts `[x1,y1,x2,y2]`. Scene classifications and original IDs/notes are preserved separately. Provenance includes original source paths.
103
+
104
+ Reuse an ordered category vocabulary through `export --categories categories.json` across splits. AnnoLabel does not invent a train/validation split. Folder export skips images without annotation sidecars.
105
+
106
+ ## Give your agent this workflow
107
+
108
+ ```text
109
+ Use AnnoLabel to annotate IMAGE_PATH. Research task: YOUR_LABELS_AND_BOUNDARY_POLICY.
110
+ Create a task in NEW_OUTPUT_DIRECTORY. Open its returned view and use original
111
+ oriented pixels. Submit all scene labels and objects together. Review the returned
112
+ view once and, if needed, submit one corrected full snapshot with the new task
113
+ handle. Retain unchanged objects. Use your own vision; no detectors, segmentation
114
+ models, crop scripts or implementation inspection. Export COCO and report paths
115
+ and remaining uncertainty. Use task-status if a command response is lost.
116
+ ```
117
+
118
+ Works with Codex, Claude Code, Gemini/Antigravity, or another image-capable agent with shell access. Folder traversal and batching are handled by the calling agent, with one task per image.
119
+
120
+ ## More tools and limitations
121
+
122
+ Run `annolabel COMMAND --help` for any of: `task`, `submit`, `task-status`, `info`, `label`, `box`, `polygon`, `link`, `remove`, `render`, `mask`, `prepare`, `apply`, `review`, and `export`.
123
+
124
+ `prepare` / `apply` / `review` support detailed packets and padded crop reviews when more inspection is needed. They use a full revision-aware snapshot instead of the short `submit` contract. Detailed ruler values are original coordinates, not PNG margin positions.
125
+
126
+ Annotations are stored beside the source as `IMAGE.labels.json`. Output directories must be new. Source images remain unchanged. Task handles contain absolute paths and should stay in place during active work; use COCO exports for portable datasets.
127
+
128
+ Supports single-frame raster images and simple single-component polygons. No polygon holes, multipart instances, keypoints, video, inference engine, or GUI. Geometry validation does not establish visual accuracy: the agent must review the pixels and report uncertainty.
129
+
130
+ Public source repository: [Andesprit/annolabel](https://github.com/Andesprit/annolabel).
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "annolabel"
3
+ version = "0.5.0"
4
+ description = "Local image annotation primitives for vision-capable agents"
5
+ readme = "docs/PYPI.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "pillow>=12.3.0",
9
+ "pydantic>=2.13.5",
10
+ ]
11
+
12
+ [project.urls]
13
+ Repository = "https://github.com/Andesprit/annolabel"
14
+ Issues = "https://github.com/Andesprit/annolabel/issues"
15
+
16
+ [project.scripts]
17
+ annolabel = "annolabel.main:main"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.11.29,<0.12.0"]
21
+ build-backend = "uv_build"
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "pycocotools>=2.0.11",
26
+ "pytest>=9.1.1",
27
+ ]
28
+
29
+ [tool.uv.build-backend]
30
+ module-name = "annolabel"
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "annolabel"
3
+ version = "0.5.0"
4
+ description = "Local image annotation primitives for vision-capable agents"
5
+ readme = "docs/PYPI.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "pillow>=12.3.0",
9
+ "pydantic>=2.13.5",
10
+ ]
11
+
12
+ [project.urls]
13
+ Repository = "https://github.com/Andesprit/annolabel"
14
+ Issues = "https://github.com/Andesprit/annolabel/issues"
15
+
16
+ [project.scripts]
17
+ annolabel = "annolabel.main:main"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.11.29,<0.12.0"]
21
+ build-backend = "uv_build"
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "pycocotools>=2.0.11",
26
+ "pytest>=9.1.1",
27
+ ]
28
+
29
+ [tool.uv.build-backend]
30
+ module-name = "annolabel"
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
File without changes
File without changes
@@ -0,0 +1,114 @@
1
+ """Local annotation operations exposed to the CLI."""
2
+ import os
3
+ import tempfile
4
+ from pathlib import Path
5
+ from uuid import uuid4
6
+ from annolabel.modules.images import polygon_mask, render
7
+ from annolabel.schemas.annotations import Annotation, Document, Point
8
+ from annolabel.services.images.base import ImageServiceBase
9
+ from annolabel.services.images.local import LocalImageService
10
+
11
+
12
+ class AnnoLabel:
13
+ """Open an image and its optional sidecar without modifying either.
14
+
15
+ :param image_path: Source image filename.
16
+ :param image_service: Image reader; defaults to the local Pillow service.
17
+ """
18
+ def __init__(self, image_path: str, *, image_service: ImageServiceBase | None = None) -> None:
19
+ self.image_service = image_service if image_service is not None else LocalImageService()
20
+ self.path = Path(image_path).expanduser().resolve(strict=True)
21
+ self.sidecar = self.path.with_name(self.path.name + ".labels.json")
22
+ self.image, info = self.image_service.load(self.path)
23
+ if self.sidecar.exists():
24
+ self.document = Document.model_validate_json(self.sidecar.read_text())
25
+ if self.document.image != info:
26
+ raise ValueError("source image differs from its annotation sidecar; restore the original image or move the old sidecar before starting again")
27
+ else:
28
+ self.document = Document(image=info)
29
+
30
+ def info(self) -> dict:
31
+ """Return source dimensions, annotation data, and sidecar location."""
32
+ return {"image_path": str(self.path), "sidecar": str(self.sidecar),
33
+ **self.document.model_dump(mode="json")}
34
+
35
+ def _save(self, annotations: list[Annotation]) -> None:
36
+ document = Document(image=self.document.image, annotations=annotations)
37
+ # Same-directory replace prevents partially written annotation documents.
38
+ fd, temporary = tempfile.mkstemp(prefix=".annolabel-", dir=self.sidecar.parent)
39
+ try:
40
+ with os.fdopen(fd, "w") as handle:
41
+ handle.write(document.model_dump_json(indent=2) + "\n")
42
+ os.replace(temporary, self.sidecar)
43
+ finally:
44
+ Path(temporary).unlink(missing_ok=True)
45
+ self.document = document
46
+
47
+ def annotate(self, kind: str, label: str, points: list[Point],
48
+ annotation_id: str | None = None, note: str | None = None,
49
+ object_id: str | None = None) -> dict:
50
+ """Add an annotation, or replace an existing ID when supplied."""
51
+ existing = self.document.annotations
52
+ if annotation_id is not None and not any(a.id == annotation_id for a in existing):
53
+ raise ValueError(f"annotation ID not found: {annotation_id}")
54
+ previous = next((a for a in existing if a.id == annotation_id), None)
55
+ if object_id is not None and not any(a.object_id == object_id for a in existing):
56
+ raise ValueError(f"object ID not found: {object_id}")
57
+ if previous and object_id is None and kind != "label":
58
+ object_id = previous.object_id
59
+ annotation = Annotation(id=annotation_id or uuid4().hex[:8], kind=kind,
60
+ label=label, points=points, note=note, object_id=object_id)
61
+ if annotation_id:
62
+ updated = [annotation if a.id == annotation_id else
63
+ a.model_copy(update={"label": label}) if
64
+ annotation.object_id is not None and a.object_id == annotation.object_id else a
65
+ for a in existing]
66
+ else:
67
+ updated = [*existing, annotation]
68
+ self._save(updated)
69
+ return {"sidecar": str(self.sidecar), "annotation": annotation.model_dump(mode="json")}
70
+
71
+ def link(self, annotation_ids: list[str]) -> dict:
72
+ """Join existing shapes into the first selected shape’s object."""
73
+ if len(set(annotation_ids)) < 2:
74
+ raise ValueError("link requires at least two different annotation IDs")
75
+ by_id = {a.id: a for a in self.document.annotations}
76
+ if any(i not in by_id for i in annotation_ids):
77
+ raise ValueError("one or more annotation IDs were not found")
78
+ selected = [by_id[i] for i in annotation_ids]
79
+ if any(a.kind == "label" for a in selected):
80
+ raise ValueError("whole-image labels cannot be linked to objects")
81
+ groups = {a.object_id for a in selected}
82
+ object_id = selected[0].object_id
83
+ updated = [a.model_copy(update={"object_id": object_id}) if a.object_id in groups else a
84
+ for a in self.document.annotations]
85
+ self._save(updated)
86
+ return {"sidecar": str(self.sidecar), "object_id": object_id,
87
+ "annotation_ids": [a.id for a in updated if a.object_id == object_id]}
88
+
89
+ def remove(self, annotation_id: str) -> dict:
90
+ """Remove exactly one existing annotation by ID."""
91
+ updated = [a for a in self.document.annotations if a.id != annotation_id]
92
+ if len(updated) == len(self.document.annotations):
93
+ raise ValueError(f"annotation ID not found: {annotation_id}")
94
+ self._save(updated)
95
+ return {"sidecar": str(self.sidecar), "removed": annotation_id}
96
+
97
+ def export_image(self, output: str, *, grid: int = 0,
98
+ annotation_id: str | None = None, force: bool = False) -> dict:
99
+ """Write a preview or polygon mask as PNG, protecting source files."""
100
+ destination = Path(output).expanduser().absolute()
101
+ for protected in [self.path, self.sidecar]:
102
+ if destination.resolve() == protected.resolve() or (
103
+ destination.exists() and protected.exists() and destination.samefile(protected)
104
+ ):
105
+ raise ValueError("output must not overwrite the source image or annotation sidecar")
106
+ if destination.suffix.lower() != ".png":
107
+ raise ValueError("output must end with .png")
108
+ result = (polygon_mask(self.document, annotation_id) if annotation_id
109
+ else render(self.image, self.document, grid))
110
+ destination.parent.mkdir(parents=True, exist_ok=True)
111
+ with destination.open("wb" if force else "xb") as handle:
112
+ result.save(handle, format="PNG")
113
+ return {"output": str(destination), "width": result.width, "height": result.height,
114
+ "kind": "mask" if annotation_id else "preview"}
@@ -0,0 +1,43 @@
1
+ """Resolve source files and invoke the default dataset exporter."""
2
+ import json
3
+ from pathlib import Path
4
+ from annolabel.core.annolabel import AnnoLabel
5
+ from annolabel.modules.coco import export_coco
6
+ from annolabel.services.images.base import ImageServiceBase
7
+ from annolabel.services.images.local import LocalImageService
8
+
9
+
10
+ def export_dataset(source: str, output: str, categories_file: str | None = None, *,
11
+ image_service: ImageServiceBase | None = None) -> dict:
12
+ """Export one image or recursively discovered annotation sidecars.
13
+
14
+ :param source: Image or directory containing sidecars.
15
+ :param output: New dataset directory.
16
+ :param categories_file: Optional ordered category vocabulary.
17
+ :param image_service: Image reader; defaults to the local Pillow service.
18
+ :returns: COCO export receipt and dataset paths.
19
+ """
20
+ service = image_service if image_service is not None else LocalImageService()
21
+ path = Path(source).expanduser().resolve(strict=True)
22
+ if path.is_dir():
23
+ suffix = ".labels.json"
24
+ sources = [p.with_name(p.name[:-len(suffix)]) for p in sorted(path.rglob("*" + suffix))]
25
+ if not sources:
26
+ raise ValueError("no annotation sidecars found in input directory")
27
+ else:
28
+ sources = [path]
29
+ items = []
30
+ seen = set()
31
+ for image_path in sources:
32
+ resolved = image_path.resolve(strict=True)
33
+ if resolved in seen:
34
+ continue
35
+ seen.add(resolved)
36
+ kit = AnnoLabel(str(resolved), image_service=service)
37
+ items.append((kit.path, kit.document))
38
+ categories = None
39
+ if categories_file:
40
+ categories = json.loads(Path(categories_file).expanduser().read_text())
41
+ if not isinstance(categories, list):
42
+ raise ValueError("categories file must contain a JSON array of label names")
43
+ return export_coco(items, output, categories, image_service=service)
@@ -0,0 +1,93 @@
1
+ """Short annotation tasks with immutable checkpoints and compact receipts."""
2
+ import hashlib
3
+ import json
4
+ import shutil
5
+ from pathlib import Path
6
+ from annolabel.core.workflow import Workflow
7
+ from annolabel.modules.batch import batch_document, revision
8
+ from annolabel.schemas.task import Submission, TaskHandle
9
+ from annolabel.schemas.workflow import Batch
10
+
11
+
12
+ class TaskError(ValueError):
13
+ """Actionable task failure without implementation details."""
14
+ def __init__(self, code: str, message: str, recovery: str) -> None:
15
+ super().__init__(message)
16
+ self.code, self.recovery = code, recovery
17
+
18
+
19
+ def start_task(image: str, output: str, *, instructions: str | None = None,
20
+ categories: str | None = None, geometry: str = "both", max_passes: int = 2) -> dict:
21
+ """Create a coordinate view and immutable initial task handle."""
22
+ workflow = Workflow(image)
23
+ output_path = Path(output).expanduser().absolute()
24
+ handle = TaskHandle(image=str(workflow.path), snapshot=str(output_path/"annotations.json"),
25
+ packet=str(output_path/"packet.json"), max_passes=max_passes)
26
+ workflow.prepare(str(output_path), instructions=instructions, categories=categories,
27
+ geometry=geometry, short=True)
28
+ try:
29
+ (output_path/"task.json").write_text(handle.model_dump_json(indent=2)+"\n")
30
+ except Exception:
31
+ shutil.rmtree(output_path)
32
+ raise
33
+ return {"status": "ready", "task": str(output_path/"task.json"), "view": str(output_path/"view.png"),
34
+ "width": workflow.image.width, "height": workflow.image.height, "passes_left": max_passes}
35
+
36
+
37
+ class Task:
38
+ """Read or submit a task checkpoint; serialize writers for each source image."""
39
+ def __init__(self, path: str) -> None:
40
+ self.path = Path(path).expanduser().absolute()
41
+ self.handle = TaskHandle.model_validate_json(self.path.read_text())
42
+ self.workflow = Workflow(self.handle.image)
43
+ self.snapshot = Batch.model_validate_json(Path(self.handle.snapshot).read_text())
44
+ self.next_dir = self.path.parent/f"pass{self.handle.pass_count+1}"
45
+
46
+ def _check_current(self, expected_revision: str) -> None:
47
+ if (self.snapshot.image_sha256 != self.workflow.document.image.sha256
48
+ or expected_revision != revision(self.workflow.document)):
49
+ raise TaskError("TASK_CONFLICT", "Source image or annotations changed since this checkpoint.",
50
+ "Stop and reconcile the changed source; do not overwrite it or reconstruct hashes.")
51
+
52
+ def status(self) -> dict:
53
+ """Recover the latest committed receipt without resubmitting geometry."""
54
+ if self.next_dir.exists():
55
+ receipt = json.loads((self.next_dir/"receipt.json").read_text())
56
+ next_task = Task(receipt["result"]["task"])
57
+ return next_task.status()
58
+ self._check_current(self.snapshot.base_revision)
59
+ receipt_path = self.path.parent/"receipt.json"
60
+ if receipt_path.exists():
61
+ return json.loads(receipt_path.read_text())["result"]
62
+ return {"status": "ready", "task": str(self.path), "view": str(self.path.parent/"view.png"),
63
+ "objects": len(self.snapshot.objects),
64
+ "passes_left": self.handle.max_passes-self.handle.pass_count}
65
+
66
+ def submit(self, payload: str) -> dict:
67
+ """Validate and save all annotations; an identical retry replays its receipt."""
68
+ submission = Submission.model_validate_json(payload)
69
+ digest = hashlib.sha256(submission.model_dump_json().encode()).hexdigest()
70
+ if self.next_dir.exists():
71
+ receipt = json.loads((self.next_dir/"receipt.json").read_text())
72
+ if receipt["submission_sha256"] != digest:
73
+ raise TaskError("CHECKPOINT_USED", "This checkpoint already saved a different submission.",
74
+ f"Run annolabel task-status {self.path} and use the returned task for a correction.")
75
+ self._check_current(receipt["saved_revision"])
76
+ return receipt["result"]
77
+ if self.handle.pass_count >= self.handle.max_passes:
78
+ raise TaskError("PASS_LIMIT", "This task has used its allowed annotation passes.",
79
+ "Stop labeling and report any remaining uncertainty. Export is still available.")
80
+ self._check_current(self.snapshot.base_revision)
81
+ batch = Batch(image_sha256=self.snapshot.image_sha256, base_revision=self.snapshot.base_revision,
82
+ **submission.model_dump())
83
+ packet = self.workflow._packet(self.handle.packet)
84
+ document = batch_document(batch, self.workflow.document, packet)
85
+ next_handle = TaskHandle(image=self.handle.image, snapshot=str(self.next_dir/"annotations.json"),
86
+ packet=str(self.next_dir/"packet.json"), pass_count=self.handle.pass_count+1,
87
+ max_passes=self.handle.max_passes)
88
+ result = {"status": "saved", "task": str(self.next_dir/"task.json"), "view": str(self.next_dir/"view.png"),
89
+ "objects": len(submission.objects), "passes_left": next_handle.max_passes-next_handle.pass_count}
90
+ receipt = {"submission_sha256": digest, "saved_revision": revision(document), "result": result}
91
+ self.workflow.apply(batch.model_dump_json(), output=str(self.next_dir), packet_path=self.handle.packet,
92
+ short=True, extra_files={"task.json": next_handle.model_dump(), "receipt.json": receipt})
93
+ return result
@@ -0,0 +1,61 @@
1
+ """Prepare, atomically apply, and review agent annotation snapshots."""
2
+ import json
3
+ import shutil
4
+ from pathlib import Path
5
+ from uuid import uuid4
6
+ from pydantic import TypeAdapter
7
+ from annolabel.core.annolabel import AnnoLabel
8
+ from annolabel.modules.batch import batch_document, check_packet, revision
9
+ from annolabel.modules.views import write_bundle
10
+ from annolabel.schemas.workflow import Batch, Packet, Region, Rules
11
+
12
+
13
+ class Workflow(AnnoLabel):
14
+ """Agent workflow facade over the existing annotation sidecars."""
15
+
16
+ def _packet(self, path: str | None) -> Packet | None:
17
+ packet = Packet.model_validate_json(Path(path).expanduser().read_text()) if path else None
18
+ if packet:
19
+ check_packet(packet, self.document)
20
+ return packet
21
+
22
+ def prepare(self, output: str, *, instructions: str | None = None,
23
+ categories: str | None = None, geometry: str = "both", short: bool = False) -> dict:
24
+ """Create original/grid views, current snapshot, schema, and task context."""
25
+ rules = Rules(geometry=geometry,
26
+ **({"instructions": Path(instructions).expanduser().read_text()} if instructions else {}),
27
+ categories=json.loads(Path(categories).expanduser().read_text()) if categories else None)
28
+ return write_bundle(self.image, self.document, output, rules=rules, prepare=True, short=short)
29
+
30
+ def review(self, output: str, *, packet_path: str | None = None, regions: str | None = None,
31
+ per_page: int = 2, padding: float = .25) -> dict:
32
+ """Create paired original/annotated views for objects or supplied regions."""
33
+ packet = self._packet(packet_path)
34
+ selected = TypeAdapter(list[Region]).validate_json(Path(regions).expanduser().read_text()) if regions else None
35
+ return write_bundle(self.image, self.document, output, rules=packet.rules if packet else Rules(),
36
+ regions=selected, per_page=per_page, padding=padding)
37
+
38
+ def apply(self, payload: str, *, output: str, packet_path: str | None = None,
39
+ short: bool = False, extra_files: dict[str, dict] | None = None) -> dict:
40
+ """Replace a full snapshot and render its review; failures preserve labels."""
41
+ batch = Batch.model_validate_json(payload)
42
+ packet = self._packet(packet_path)
43
+ document = batch_document(batch, self.document, packet)
44
+ bundle = write_bundle(self.image, document, output, rules=packet.rules if packet else Rules(),
45
+ short=short, extra_files=extra_files)
46
+ try:
47
+ # Optimistic stale edit detection, not a lock: keep one writer per image.
48
+ if revision(AnnoLabel(str(self.path), image_service=self.image_service).document) != revision(self.document):
49
+ raise ValueError("source annotations changed during apply; prepare again")
50
+ self._save(document.annotations)
51
+ except Exception:
52
+ shutil.rmtree(bundle["output"])
53
+ raise
54
+ return {**bundle, "sidecar": str(self.sidecar), "operation": "replace_snapshot",
55
+ "objects": [{"key": a.object_id, "annotation_id": a.id, "kind": a.kind}
56
+ for a in self.document.annotations if a.object_id is not None]}
57
+
58
+ def default_review_output(self, file: str) -> str:
59
+ """Place generated reviews beside the batch file, outside its source inputs."""
60
+ parent = Path(file).expanduser().absolute().parent if file != "-" else Path.cwd()
61
+ return str(parent / "annolabel-reviews" / f"{self.path.stem}-{uuid4().hex[:8]}")