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.
- andesprit_labelkit-0.4.2/PKG-INFO +139 -0
- andesprit_labelkit-0.4.2/docs/PYPI.md +128 -0
- andesprit_labelkit-0.4.2/pyproject.toml +30 -0
- andesprit_labelkit-0.4.2/pyproject.toml.orig +30 -0
- andesprit_labelkit-0.4.2/src/labelkit/__init__.py +0 -0
- andesprit_labelkit-0.4.2/src/labelkit/core/__init__.py +0 -0
- andesprit_labelkit-0.4.2/src/labelkit/core/export.py +32 -0
- andesprit_labelkit-0.4.2/src/labelkit/core/labelkit.py +107 -0
- andesprit_labelkit-0.4.2/src/labelkit/core/task.py +93 -0
- andesprit_labelkit-0.4.2/src/labelkit/core/workflow.py +61 -0
- andesprit_labelkit-0.4.2/src/labelkit/main.py +167 -0
- andesprit_labelkit-0.4.2/src/labelkit/modules/__init__.py +0 -0
- andesprit_labelkit-0.4.2/src/labelkit/modules/batch.py +86 -0
- andesprit_labelkit-0.4.2/src/labelkit/modules/coco.py +101 -0
- andesprit_labelkit-0.4.2/src/labelkit/modules/images.py +64 -0
- andesprit_labelkit-0.4.2/src/labelkit/modules/views.py +213 -0
- andesprit_labelkit-0.4.2/src/labelkit/schemas/__init__.py +0 -0
- andesprit_labelkit-0.4.2/src/labelkit/schemas/annotations.py +115 -0
- andesprit_labelkit-0.4.2/src/labelkit/schemas/task.py +20 -0
- andesprit_labelkit-0.4.2/src/labelkit/schemas/workflow.py +89 -0
|
@@ -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]}")
|