andesprit-labelkit 0.4.2__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,139 @@
1
+ Metadata-Version: 2.3
2
+ Name: andesprit-labelkit
3
+ Version: 0.4.2
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/labelkit
9
+ Project-URL: Issues, https://github.com/Andesprit/labelkit/issues
10
+ Description-Content-Type: text/markdown
11
+
12
+ # LabelKit 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. LabelKit validates and saves annotations, renders review images, and exports **COCO datasets**.
15
+
16
+ The PyPI distribution is `andesprit-labelkit`. The executable and Python module are both `labelkit`. The unrelated PyPI project named `labelkit` is not this tool.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ uv tool install andesprit-labelkit
22
+ labelkit --version
23
+ labelkit --help
24
+ ```
25
+
26
+ Or install into an existing Python environment:
27
+
28
+ ```sh
29
+ python -m pip install andesprit-labelkit
30
+ ```
31
+
32
+ Requires Python 3.11 or later. Runtime dependencies are Pillow and Pydantic. LabelKit 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
+ ## Label an image
35
+
36
+ ```sh
37
+ labelkit task /data/photo.jpg --output /data/work/photo-task
38
+ ```
39
+
40
+ 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.
41
+
42
+ Ask the agent to write a complete JSON snapshot such as this, replacing every label and coordinate with its own visual interpretation:
43
+
44
+ ```json
45
+ {
46
+ "classifications": [{"label": "outdoor"}],
47
+ "objects": [
48
+ {
49
+ "key": "object-1",
50
+ "label": "example object",
51
+ "box": [50, 30, 250, 180],
52
+ "polygon": [[50, 100], [120, 30], [250, 100], [200, 180], [50, 180]],
53
+ "note": "Describe uncertain identity or boundary placement here."
54
+ }
55
+ ]
56
+ }
57
+ ```
58
+
59
+ 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.
60
+
61
+ Submit all annotations together:
62
+
63
+ ```sh
64
+ labelkit submit /data/work/photo-task/task.json --file /data/annotations.json
65
+ ```
66
+
67
+ 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**:
68
+
69
+ ```sh
70
+ labelkit submit /data/work/photo-task/pass1/task.json --file /data/corrected.json
71
+ ```
72
+
73
+ 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.
74
+
75
+ If output is lost, recover the latest handle and view:
76
+
77
+ ```sh
78
+ labelkit task-status /data/work/photo-task/task.json
79
+ ```
80
+
81
+ 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.
82
+
83
+ ## Choose the task
84
+
85
+ - Whole-image classification: `labelkit label IMAGE --label CLASS`.
86
+ - Boxes only: create a task with `--geometry boxes` and submit objects with `box` and no polygon.
87
+ - Segmentation: the default requires a polygon per object, with a derived or explicit bounding box.
88
+ - Restrict object labels: pass `--categories categories.json`, a JSON array such as `["car", "person"]`. Scene labels are independent.
89
+ - 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.
90
+
91
+ ## Export for training
92
+
93
+ ```sh
94
+ labelkit export /data/photo.jpg --output /data/dataset
95
+ # Or export every annotated source discovered recursively:
96
+ labelkit export /data/images --output /data/dataset-all
97
+ ```
98
+
99
+ COCO is the default and currently the only built-in training format. Export produces:
100
+
101
+ ```text
102
+ dataset/
103
+ annotations/instances_default.json
104
+ images/default/000001.png
105
+ categories.json
106
+ classifications.csv
107
+ provenance.json
108
+ README.txt
109
+ ```
110
+
111
+ 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.
112
+
113
+ Reuse an ordered category vocabulary through `export --categories categories.json` across splits. LabelKit does not invent a train/validation split. Folder export skips images without annotation sidecars.
114
+
115
+ ## Give your agent this workflow
116
+
117
+ ```text
118
+ Use LabelKit to annotate IMAGE_PATH. Research task: YOUR_LABELS_AND_BOUNDARY_POLICY.
119
+ Create a task in NEW_OUTPUT_DIRECTORY. Open its returned view and use original
120
+ oriented pixels. Submit all scene labels and objects together. Review the returned
121
+ view once and, if needed, submit one corrected full snapshot with the new task
122
+ handle. Retain unchanged objects. Use your own vision; no detectors, segmentation
123
+ models, crop scripts or implementation inspection. Export COCO and report paths
124
+ and remaining uncertainty. Use task-status if a command response is lost.
125
+ ```
126
+
127
+ 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.
128
+
129
+ ## More tools and limitations
130
+
131
+ Run `labelkit COMMAND --help` for any of: `task`, `submit`, `task-status`, `info`, `label`, `box`, `polygon`, `link`, `remove`, `render`, `mask`, `prepare`, `apply`, `review`, and `export`.
132
+
133
+ `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.
134
+
135
+ 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.
136
+
137
+ 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.
138
+
139
+ Source repository: [Andesprit/labelkit](https://github.com/Andesprit/labelkit) (repository access may be required).
@@ -0,0 +1,128 @@
1
+ # LabelKit 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. LabelKit validates and saves annotations, renders review images, and exports **COCO datasets**.
4
+
5
+ The PyPI distribution is `andesprit-labelkit`. The executable and Python module are both `labelkit`. The unrelated PyPI project named `labelkit` is not this tool.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ uv tool install andesprit-labelkit
11
+ labelkit --version
12
+ labelkit --help
13
+ ```
14
+
15
+ Or install into an existing Python environment:
16
+
17
+ ```sh
18
+ python -m pip install andesprit-labelkit
19
+ ```
20
+
21
+ Requires Python 3.11 or later. Runtime dependencies are Pillow and Pydantic. LabelKit 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
+ ## Label an image
24
+
25
+ ```sh
26
+ labelkit task /data/photo.jpg --output /data/work/photo-task
27
+ ```
28
+
29
+ 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.
30
+
31
+ Ask the agent to write a complete JSON snapshot such as this, replacing every label and coordinate with its own visual interpretation:
32
+
33
+ ```json
34
+ {
35
+ "classifications": [{"label": "outdoor"}],
36
+ "objects": [
37
+ {
38
+ "key": "object-1",
39
+ "label": "example object",
40
+ "box": [50, 30, 250, 180],
41
+ "polygon": [[50, 100], [120, 30], [250, 100], [200, 180], [50, 180]],
42
+ "note": "Describe uncertain identity or boundary placement here."
43
+ }
44
+ ]
45
+ }
46
+ ```
47
+
48
+ 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.
49
+
50
+ Submit all annotations together:
51
+
52
+ ```sh
53
+ labelkit submit /data/work/photo-task/task.json --file /data/annotations.json
54
+ ```
55
+
56
+ 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**:
57
+
58
+ ```sh
59
+ labelkit submit /data/work/photo-task/pass1/task.json --file /data/corrected.json
60
+ ```
61
+
62
+ 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.
63
+
64
+ If output is lost, recover the latest handle and view:
65
+
66
+ ```sh
67
+ labelkit task-status /data/work/photo-task/task.json
68
+ ```
69
+
70
+ 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.
71
+
72
+ ## Choose the task
73
+
74
+ - Whole-image classification: `labelkit label IMAGE --label CLASS`.
75
+ - Boxes only: create a task with `--geometry boxes` and submit objects with `box` and no polygon.
76
+ - Segmentation: the default requires a polygon per object, with a derived or explicit bounding box.
77
+ - Restrict object labels: pass `--categories categories.json`, a JSON array such as `["car", "person"]`. Scene labels are independent.
78
+ - 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.
79
+
80
+ ## Export for training
81
+
82
+ ```sh
83
+ labelkit export /data/photo.jpg --output /data/dataset
84
+ # Or export every annotated source discovered recursively:
85
+ labelkit export /data/images --output /data/dataset-all
86
+ ```
87
+
88
+ COCO is the default and currently the only built-in training format. Export produces:
89
+
90
+ ```text
91
+ dataset/
92
+ annotations/instances_default.json
93
+ images/default/000001.png
94
+ categories.json
95
+ classifications.csv
96
+ provenance.json
97
+ README.txt
98
+ ```
99
+
100
+ 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.
101
+
102
+ Reuse an ordered category vocabulary through `export --categories categories.json` across splits. LabelKit does not invent a train/validation split. Folder export skips images without annotation sidecars.
103
+
104
+ ## Give your agent this workflow
105
+
106
+ ```text
107
+ Use LabelKit to annotate IMAGE_PATH. Research task: YOUR_LABELS_AND_BOUNDARY_POLICY.
108
+ Create a task in NEW_OUTPUT_DIRECTORY. Open its returned view and use original
109
+ oriented pixels. Submit all scene labels and objects together. Review the returned
110
+ view once and, if needed, submit one corrected full snapshot with the new task
111
+ handle. Retain unchanged objects. Use your own vision; no detectors, segmentation
112
+ models, crop scripts or implementation inspection. Export COCO and report paths
113
+ and remaining uncertainty. Use task-status if a command response is lost.
114
+ ```
115
+
116
+ 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.
117
+
118
+ ## More tools and limitations
119
+
120
+ Run `labelkit COMMAND --help` for any of: `task`, `submit`, `task-status`, `info`, `label`, `box`, `polygon`, `link`, `remove`, `render`, `mask`, `prepare`, `apply`, `review`, and `export`.
121
+
122
+ `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.
123
+
124
+ 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.
125
+
126
+ 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.
127
+
128
+ Source repository: [Andesprit/labelkit](https://github.com/Andesprit/labelkit) (repository access may be required).
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "andesprit-labelkit"
3
+ version = "0.4.2"
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/labelkit"
14
+ Issues = "https://github.com/Andesprit/labelkit/issues"
15
+
16
+ [project.scripts]
17
+ labelkit = "labelkit.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 = "labelkit"
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "andesprit-labelkit"
3
+ version = "0.4.2"
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/labelkit"
14
+ Issues = "https://github.com/Andesprit/labelkit/issues"
15
+
16
+ [project.scripts]
17
+ labelkit = "labelkit.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 = "labelkit"
File without changes
File without changes
@@ -0,0 +1,32 @@
1
+ """Resolve source files and invoke the default dataset exporter."""
2
+ import json
3
+ from pathlib import Path
4
+ from labelkit.core.labelkit import LabelKit
5
+ from labelkit.modules.coco import export_coco
6
+
7
+
8
+ def export_dataset(source: str, output: str, categories_file: str | None = None) -> dict:
9
+ """Export one image or recursively discovered annotation sidecars."""
10
+ path = Path(source).expanduser().resolve(strict=True)
11
+ if path.is_dir():
12
+ suffix = ".labels.json"
13
+ sources = [p.with_name(p.name[:-len(suffix)]) for p in sorted(path.rglob("*" + suffix))]
14
+ if not sources:
15
+ raise ValueError("no annotation sidecars found in input directory")
16
+ else:
17
+ sources = [path]
18
+ items = []
19
+ seen = set()
20
+ for image_path in sources:
21
+ resolved = image_path.resolve(strict=True)
22
+ if resolved in seen:
23
+ continue
24
+ seen.add(resolved)
25
+ kit = LabelKit(str(resolved))
26
+ items.append((kit.path, kit.document))
27
+ categories = None
28
+ if categories_file:
29
+ categories = json.loads(Path(categories_file).expanduser().read_text())
30
+ if not isinstance(categories, list):
31
+ raise ValueError("categories file must contain a JSON array of label names")
32
+ return export_coco(items, output, categories)
@@ -0,0 +1,107 @@
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 labelkit.modules.images import load_image, polygon_mask, render
7
+ from labelkit.schemas.annotations import Annotation, Document, Point
8
+
9
+
10
+ class LabelKit:
11
+ """Open an image and its optional sidecar without modifying either."""
12
+ def __init__(self, image_path: str) -> None:
13
+ self.path = Path(image_path).expanduser().resolve(strict=True)
14
+ self.sidecar = self.path.with_name(self.path.name + ".labels.json")
15
+ self.image, info = load_image(self.path)
16
+ if self.sidecar.exists():
17
+ self.document = Document.model_validate_json(self.sidecar.read_text())
18
+ if self.document.image != info:
19
+ raise ValueError("source image differs from its annotation sidecar; restore the original image or move the old sidecar before starting again")
20
+ else:
21
+ self.document = Document(image=info)
22
+
23
+ def info(self) -> dict:
24
+ """Return source dimensions, annotation data, and sidecar location."""
25
+ return {"image_path": str(self.path), "sidecar": str(self.sidecar),
26
+ **self.document.model_dump(mode="json")}
27
+
28
+ def _save(self, annotations: list[Annotation]) -> None:
29
+ document = Document(image=self.document.image, annotations=annotations)
30
+ # Same-directory replace prevents partially written annotation documents.
31
+ fd, temporary = tempfile.mkstemp(prefix=".labelkit-", dir=self.sidecar.parent)
32
+ try:
33
+ with os.fdopen(fd, "w") as handle:
34
+ handle.write(document.model_dump_json(indent=2) + "\n")
35
+ os.replace(temporary, self.sidecar)
36
+ finally:
37
+ Path(temporary).unlink(missing_ok=True)
38
+ self.document = document
39
+
40
+ def annotate(self, kind: str, label: str, points: list[Point],
41
+ annotation_id: str | None = None, note: str | None = None,
42
+ object_id: str | None = None) -> dict:
43
+ """Add an annotation, or replace an existing ID when supplied."""
44
+ existing = self.document.annotations
45
+ if annotation_id is not None and not any(a.id == annotation_id for a in existing):
46
+ raise ValueError(f"annotation ID not found: {annotation_id}")
47
+ previous = next((a for a in existing if a.id == annotation_id), None)
48
+ if object_id is not None and not any(a.object_id == object_id for a in existing):
49
+ raise ValueError(f"object ID not found: {object_id}")
50
+ if previous and object_id is None and kind != "label":
51
+ object_id = previous.object_id
52
+ annotation = Annotation(id=annotation_id or uuid4().hex[:8], kind=kind,
53
+ label=label, points=points, note=note, object_id=object_id)
54
+ if annotation_id:
55
+ updated = [annotation if a.id == annotation_id else
56
+ a.model_copy(update={"label": label}) if
57
+ annotation.object_id is not None and a.object_id == annotation.object_id else a
58
+ for a in existing]
59
+ else:
60
+ updated = [*existing, annotation]
61
+ self._save(updated)
62
+ return {"sidecar": str(self.sidecar), "annotation": annotation.model_dump(mode="json")}
63
+
64
+ def link(self, annotation_ids: list[str]) -> dict:
65
+ """Join existing shapes into the first selected shape’s object."""
66
+ if len(set(annotation_ids)) < 2:
67
+ raise ValueError("link requires at least two different annotation IDs")
68
+ by_id = {a.id: a for a in self.document.annotations}
69
+ if any(i not in by_id for i in annotation_ids):
70
+ raise ValueError("one or more annotation IDs were not found")
71
+ selected = [by_id[i] for i in annotation_ids]
72
+ if any(a.kind == "label" for a in selected):
73
+ raise ValueError("whole-image labels cannot be linked to objects")
74
+ groups = {a.object_id for a in selected}
75
+ object_id = selected[0].object_id
76
+ updated = [a.model_copy(update={"object_id": object_id}) if a.object_id in groups else a
77
+ for a in self.document.annotations]
78
+ self._save(updated)
79
+ return {"sidecar": str(self.sidecar), "object_id": object_id,
80
+ "annotation_ids": [a.id for a in updated if a.object_id == object_id]}
81
+
82
+ def remove(self, annotation_id: str) -> dict:
83
+ """Remove exactly one existing annotation by ID."""
84
+ updated = [a for a in self.document.annotations if a.id != annotation_id]
85
+ if len(updated) == len(self.document.annotations):
86
+ raise ValueError(f"annotation ID not found: {annotation_id}")
87
+ self._save(updated)
88
+ return {"sidecar": str(self.sidecar), "removed": annotation_id}
89
+
90
+ def export_image(self, output: str, *, grid: int = 0,
91
+ annotation_id: str | None = None, force: bool = False) -> dict:
92
+ """Write a preview or polygon mask as PNG, protecting source files."""
93
+ destination = Path(output).expanduser().absolute()
94
+ for protected in [self.path, self.sidecar]:
95
+ if destination.resolve() == protected.resolve() or (
96
+ destination.exists() and protected.exists() and destination.samefile(protected)
97
+ ):
98
+ raise ValueError("output must not overwrite the source image or annotation sidecar")
99
+ if destination.suffix.lower() != ".png":
100
+ raise ValueError("output must end with .png")
101
+ result = (polygon_mask(self.document, annotation_id) if annotation_id
102
+ else render(self.image, self.document, grid))
103
+ destination.parent.mkdir(parents=True, exist_ok=True)
104
+ with destination.open("wb" if force else "xb") as handle:
105
+ result.save(handle, format="PNG")
106
+ return {"output": str(destination), "width": result.width, "height": result.height,
107
+ "kind": "mask" if annotation_id else "preview"}
@@ -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 labelkit.core.workflow import Workflow
7
+ from labelkit.modules.batch import batch_document, revision
8
+ from labelkit.schemas.task import Submission, TaskHandle
9
+ from labelkit.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 labelkit 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 labelkit.core.labelkit import LabelKit
8
+ from labelkit.modules.batch import batch_document, check_packet, revision
9
+ from labelkit.modules.views import write_bundle
10
+ from labelkit.schemas.workflow import Batch, Packet, Region, Rules
11
+
12
+
13
+ class Workflow(LabelKit):
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(LabelKit(str(self.path)).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 / "labelkit-reviews" / f"{self.path.stem}-{uuid4().hex[:8]}")