labeltify 0.2.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.
- labeltify-0.2.0/PKG-INFO +74 -0
- labeltify-0.2.0/README.md +55 -0
- labeltify-0.2.0/labeltify/__init__.py +12 -0
- labeltify-0.2.0/labeltify/client.py +188 -0
- labeltify-0.2.0/labeltify.egg-info/PKG-INFO +74 -0
- labeltify-0.2.0/labeltify.egg-info/SOURCES.txt +10 -0
- labeltify-0.2.0/labeltify.egg-info/dependency_links.txt +1 -0
- labeltify-0.2.0/labeltify.egg-info/requires.txt +3 -0
- labeltify-0.2.0/labeltify.egg-info/top_level.txt +1 -0
- labeltify-0.2.0/pyproject.toml +33 -0
- labeltify-0.2.0/setup.cfg +4 -0
- labeltify-0.2.0/tests/test_client.py +127 -0
labeltify-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: labeltify
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Upload frames and optional annotations into LabelTify with a pipeline token.
|
|
5
|
+
Author: CleverTify
|
|
6
|
+
Project-URL: Homepage, https://labeltify.com
|
|
7
|
+
Project-URL: Source, https://github.com/CleverTify/LabelTify/tree/main/clients/python
|
|
8
|
+
Keywords: labeltify,annotation,computer-vision,deepstream,active-learning,dataset
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
19
|
+
|
|
20
|
+
# labeltify
|
|
21
|
+
|
|
22
|
+
Upload frames, and optionally their annotations, into [LabelTify](https://labeltify.com) from DeepStream, OpenCV, Ultralytics, or any Python pipeline. The only thing it needs is a pipeline token.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install labeltify
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
1. On the dataset **Upload** page, create a pipeline token.
|
|
29
|
+
2. Upload:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from labeltify import LabelTifyClient
|
|
33
|
+
|
|
34
|
+
client = LabelTifyClient("labeltify_…") # or set LABELTIFY_TOKEN and call LabelTifyClient()
|
|
35
|
+
|
|
36
|
+
img = client.upload_image(
|
|
37
|
+
"DATASET_ID",
|
|
38
|
+
jpeg_bytes,
|
|
39
|
+
filename="cam01.jpg",
|
|
40
|
+
camera="cam-01",
|
|
41
|
+
pipeline="ds-prod",
|
|
42
|
+
trigger="low_confidence",
|
|
43
|
+
)
|
|
44
|
+
client.upload_file("DATASET_ID", "frames/cam02.jpg", camera="cam-02")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The token knows its org, so you don't pass one. Timeouts, `429`, and `5xx` responses are retried with exponential backoff (`max_retries=4` by default). Anything else raises `LabelTifyError` with the API's message and `.status`.
|
|
48
|
+
|
|
49
|
+
Annotations are optional. Pass `labels` (COCO, LabelTify, or YOLO JSON bytes) and `work_state` (`done`, `in_progress`, `unsure`, or `untouched`). Images with no labels stay untouched. If labels are sent and `work_state` is omitted, those images are marked Done.
|
|
50
|
+
|
|
51
|
+
Send model proposals to the Loop review queue:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
client.propose(
|
|
55
|
+
"DATASET_ID",
|
|
56
|
+
img["id"],
|
|
57
|
+
[{"type": "bbox", "id": "p1", "classId": "car", "box": {"x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2}, "source": "model", "confidence": 0.4}],
|
|
58
|
+
model_ref="deepstream@1",
|
|
59
|
+
uncertainty=0.8,
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Boxes are normalized `[0,1]`. Camera, pipeline, and trigger are stored on the image so Loop can filter by them.
|
|
64
|
+
|
|
65
|
+
Local API: `LabelTifyClient("labeltify_…", base_url="http://localhost:8787")`.
|
|
66
|
+
|
|
67
|
+
## Develop
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
pip install -e "clients/python[test]"
|
|
71
|
+
pytest clients/python/tests
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Bump `version` in `pyproject.toml` to publish. Each new version that lands on `main` is published to PyPI by `.github/workflows/publish-python.yml`.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# labeltify
|
|
2
|
+
|
|
3
|
+
Upload frames, and optionally their annotations, into [LabelTify](https://labeltify.com) from DeepStream, OpenCV, Ultralytics, or any Python pipeline. The only thing it needs is a pipeline token.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install labeltify
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
1. On the dataset **Upload** page, create a pipeline token.
|
|
10
|
+
2. Upload:
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from labeltify import LabelTifyClient
|
|
14
|
+
|
|
15
|
+
client = LabelTifyClient("labeltify_…") # or set LABELTIFY_TOKEN and call LabelTifyClient()
|
|
16
|
+
|
|
17
|
+
img = client.upload_image(
|
|
18
|
+
"DATASET_ID",
|
|
19
|
+
jpeg_bytes,
|
|
20
|
+
filename="cam01.jpg",
|
|
21
|
+
camera="cam-01",
|
|
22
|
+
pipeline="ds-prod",
|
|
23
|
+
trigger="low_confidence",
|
|
24
|
+
)
|
|
25
|
+
client.upload_file("DATASET_ID", "frames/cam02.jpg", camera="cam-02")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The token knows its org, so you don't pass one. Timeouts, `429`, and `5xx` responses are retried with exponential backoff (`max_retries=4` by default). Anything else raises `LabelTifyError` with the API's message and `.status`.
|
|
29
|
+
|
|
30
|
+
Annotations are optional. Pass `labels` (COCO, LabelTify, or YOLO JSON bytes) and `work_state` (`done`, `in_progress`, `unsure`, or `untouched`). Images with no labels stay untouched. If labels are sent and `work_state` is omitted, those images are marked Done.
|
|
31
|
+
|
|
32
|
+
Send model proposals to the Loop review queue:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
client.propose(
|
|
36
|
+
"DATASET_ID",
|
|
37
|
+
img["id"],
|
|
38
|
+
[{"type": "bbox", "id": "p1", "classId": "car", "box": {"x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2}, "source": "model", "confidence": 0.4}],
|
|
39
|
+
model_ref="deepstream@1",
|
|
40
|
+
uncertainty=0.8,
|
|
41
|
+
)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Boxes are normalized `[0,1]`. Camera, pipeline, and trigger are stored on the image so Loop can filter by them.
|
|
45
|
+
|
|
46
|
+
Local API: `LabelTifyClient("labeltify_…", base_url="http://localhost:8787")`.
|
|
47
|
+
|
|
48
|
+
## Develop
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
pip install -e "clients/python[test]"
|
|
52
|
+
pytest clients/python/tests
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Bump `version` in `pyproject.toml` to publish. Each new version that lands on `main` is published to PyPI by `.github/workflows/publish-python.yml`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""LabelTify client for DeepStream, OpenCV, and any pipeline that uploads frames."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .client import DEFAULT_BASE_URL, LabelTifyClient, LabelTifyError
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("labeltify")
|
|
9
|
+
except PackageNotFoundError: # running from a checkout
|
|
10
|
+
__version__ = "0.0.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["DEFAULT_BASE_URL", "LabelTifyClient", "LabelTifyError", "__version__"]
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Upload frames and optional annotations into LabelTify.
|
|
2
|
+
|
|
3
|
+
Auth is a pipeline token from the dataset Upload page. The token already knows its org.
|
|
4
|
+
|
|
5
|
+
from labeltify import LabelTifyClient
|
|
6
|
+
client = LabelTifyClient("labeltify_…")
|
|
7
|
+
image = client.upload_image(dataset_id, jpeg_bytes, filename="cam01.jpg", camera="cam-01", pipeline="ds-prod", trigger="low_confidence")
|
|
8
|
+
client.upload_file(dataset_id, "frames/cam01.jpg")
|
|
9
|
+
client.propose(dataset_id, image["id"], boxes, model_ref="deepstream@1")
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import mimetypes
|
|
16
|
+
import os
|
|
17
|
+
import random
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
import uuid
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.labeltify.com"
|
|
26
|
+
RETRY_STATUS = {408, 425, 429, 500, 502, 503, 504}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LabelTifyError(RuntimeError):
|
|
30
|
+
"""The API answered with an error, or kept failing after retries."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str, status: int | None = None, body: str | None = None) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.status = status
|
|
35
|
+
self.body = body
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LabelTifyClient:
|
|
39
|
+
"""Pipeline-token client. Only the token is required.
|
|
40
|
+
|
|
41
|
+
``token`` falls back to the ``LABELTIFY_TOKEN`` environment variable. ``org`` is looked up from the
|
|
42
|
+
token on first use. Transient failures (timeouts, 429, 5xx) are retried with exponential backoff.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
token: str | None = None,
|
|
48
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
49
|
+
org: str | None = None,
|
|
50
|
+
*,
|
|
51
|
+
timeout: float = 60.0,
|
|
52
|
+
max_retries: int = 4,
|
|
53
|
+
backoff: float = 0.5,
|
|
54
|
+
) -> None:
|
|
55
|
+
# Older callers passed (base_url, token, org) positionally.
|
|
56
|
+
if token and token.startswith(("http://", "https://")) and base_url and not base_url.startswith(("http://", "https://")):
|
|
57
|
+
token, base_url = base_url, token
|
|
58
|
+
token = token or os.environ.get("LABELTIFY_TOKEN")
|
|
59
|
+
if not token:
|
|
60
|
+
raise ValueError("Pass a pipeline token or set LABELTIFY_TOKEN.")
|
|
61
|
+
self.token = token
|
|
62
|
+
self.base_url = base_url.rstrip("/")
|
|
63
|
+
self._org = org
|
|
64
|
+
self.timeout = timeout
|
|
65
|
+
self.max_retries = max_retries
|
|
66
|
+
self.backoff = backoff
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def org(self) -> str:
|
|
70
|
+
"""The org slug the token belongs to."""
|
|
71
|
+
if self._org is None:
|
|
72
|
+
me = self._request("GET", "/api/me")
|
|
73
|
+
orgs = me.get("orgs") if isinstance(me, dict) else None
|
|
74
|
+
if not orgs:
|
|
75
|
+
raise LabelTifyError("This token is not linked to an org.")
|
|
76
|
+
self._org = orgs[0]["slug"]
|
|
77
|
+
return self._org
|
|
78
|
+
|
|
79
|
+
def _request(self, method: str, path: str, body: bytes | None = None, content_type: str | None = None) -> Any:
|
|
80
|
+
headers = {"authorization": f"Bearer {self.token}", "user-agent": "labeltify-python"}
|
|
81
|
+
if content_type:
|
|
82
|
+
headers["content-type"] = content_type
|
|
83
|
+
attempt = 0
|
|
84
|
+
while True:
|
|
85
|
+
req = urllib.request.Request(f"{self.base_url}{path}", data=body, method=method, headers=headers)
|
|
86
|
+
try:
|
|
87
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
88
|
+
raw = res.read()
|
|
89
|
+
return json.loads(raw.decode("utf-8")) if raw else None
|
|
90
|
+
except urllib.error.HTTPError as e:
|
|
91
|
+
text = e.read().decode("utf-8", "replace")
|
|
92
|
+
if e.code in RETRY_STATUS and attempt < self.max_retries:
|
|
93
|
+
self._sleep(attempt, e.headers.get("retry-after"))
|
|
94
|
+
attempt += 1
|
|
95
|
+
continue
|
|
96
|
+
raise LabelTifyError(f"LabelTify API {e.code}: {_message(text)}", e.code, text) from e
|
|
97
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
|
98
|
+
if attempt < self.max_retries:
|
|
99
|
+
self._sleep(attempt, None)
|
|
100
|
+
attempt += 1
|
|
101
|
+
continue
|
|
102
|
+
raise LabelTifyError(f"Could not reach {self.base_url}: {e}") from e
|
|
103
|
+
|
|
104
|
+
def _sleep(self, attempt: int, retry_after: str | None) -> None:
|
|
105
|
+
try:
|
|
106
|
+
wait = float(retry_after) if retry_after else self.backoff * (2**attempt)
|
|
107
|
+
except ValueError:
|
|
108
|
+
wait = self.backoff * (2**attempt)
|
|
109
|
+
time.sleep(min(wait, 30.0) + random.uniform(0, self.backoff / 2))
|
|
110
|
+
|
|
111
|
+
def upload_image(
|
|
112
|
+
self,
|
|
113
|
+
dataset_id: str,
|
|
114
|
+
data: bytes,
|
|
115
|
+
filename: str,
|
|
116
|
+
*,
|
|
117
|
+
camera: str | None = None,
|
|
118
|
+
pipeline: str | None = None,
|
|
119
|
+
trigger: str | None = None,
|
|
120
|
+
content_type: str = "image/jpeg",
|
|
121
|
+
labels: bytes | None = None,
|
|
122
|
+
labels_filename: str = "labels.json",
|
|
123
|
+
work_state: str | None = None,
|
|
124
|
+
) -> dict[str, Any]:
|
|
125
|
+
"""Upload one image. ``labels`` is optional COCO, LabelTify, or YOLO JSON for it."""
|
|
126
|
+
boundary = f"----labeltify{uuid.uuid4().hex}"
|
|
127
|
+
parts: list[bytes] = []
|
|
128
|
+
|
|
129
|
+
def field(name: str, value: str) -> None:
|
|
130
|
+
parts.append(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n".encode())
|
|
131
|
+
|
|
132
|
+
def file(name: str, fname: str, ctype: str, payload: bytes) -> None:
|
|
133
|
+
head = (
|
|
134
|
+
f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"{fname}\"\r\n"
|
|
135
|
+
f"Content-Type: {ctype}\r\n\r\n"
|
|
136
|
+
).encode()
|
|
137
|
+
parts.append(head + payload + b"\r\n")
|
|
138
|
+
|
|
139
|
+
if camera:
|
|
140
|
+
field("camera", camera)
|
|
141
|
+
if pipeline:
|
|
142
|
+
field("pipeline", pipeline)
|
|
143
|
+
if trigger:
|
|
144
|
+
field("trigger", trigger)
|
|
145
|
+
if work_state:
|
|
146
|
+
field("workState", work_state)
|
|
147
|
+
file("file", filename, content_type, data)
|
|
148
|
+
if labels is not None:
|
|
149
|
+
file("file", labels_filename, "application/json", labels)
|
|
150
|
+
parts.append(f"--{boundary}--\r\n".encode())
|
|
151
|
+
result = self._request(
|
|
152
|
+
"POST",
|
|
153
|
+
f"/api/orgs/{self.org}/datasets/{dataset_id}/images",
|
|
154
|
+
b"".join(parts),
|
|
155
|
+
f"multipart/form-data; boundary={boundary}",
|
|
156
|
+
)
|
|
157
|
+
items = result.get("items") if isinstance(result, dict) else None
|
|
158
|
+
if not items:
|
|
159
|
+
raise LabelTifyError("upload returned no items")
|
|
160
|
+
return items[0]
|
|
161
|
+
|
|
162
|
+
def upload_file(self, dataset_id: str, path: str | os.PathLike[str], **kwargs: Any) -> dict[str, Any]:
|
|
163
|
+
"""Upload an image file from disk. Keyword arguments are passed to ``upload_image``."""
|
|
164
|
+
p = Path(path)
|
|
165
|
+
kwargs.setdefault("content_type", mimetypes.guess_type(p.name)[0] or "image/jpeg")
|
|
166
|
+
return self.upload_image(dataset_id, p.read_bytes(), p.name, **kwargs)
|
|
167
|
+
|
|
168
|
+
def propose(
|
|
169
|
+
self,
|
|
170
|
+
dataset_id: str,
|
|
171
|
+
image_id: str,
|
|
172
|
+
proposals: list[dict[str, Any]],
|
|
173
|
+
*,
|
|
174
|
+
model_ref: str,
|
|
175
|
+
uncertainty: float = 0.5,
|
|
176
|
+
) -> dict[str, Any]:
|
|
177
|
+
"""Send model proposals for an image to the Loop review queue."""
|
|
178
|
+
payload = json.dumps(
|
|
179
|
+
{"imageId": image_id, "modelRef": model_ref, "proposals": proposals, "uncertainty": uncertainty}
|
|
180
|
+
).encode()
|
|
181
|
+
return self._request("POST", f"/api/orgs/{self.org}/datasets/{dataset_id}/al/tasks", payload, "application/json")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _message(text: str) -> str:
|
|
185
|
+
try:
|
|
186
|
+
return json.loads(text)["error"]["message"]
|
|
187
|
+
except (ValueError, KeyError, TypeError):
|
|
188
|
+
return text[:500]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: labeltify
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Upload frames and optional annotations into LabelTify with a pipeline token.
|
|
5
|
+
Author: CleverTify
|
|
6
|
+
Project-URL: Homepage, https://labeltify.com
|
|
7
|
+
Project-URL: Source, https://github.com/CleverTify/LabelTify/tree/main/clients/python
|
|
8
|
+
Keywords: labeltify,annotation,computer-vision,deepstream,active-learning,dataset
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
19
|
+
|
|
20
|
+
# labeltify
|
|
21
|
+
|
|
22
|
+
Upload frames, and optionally their annotations, into [LabelTify](https://labeltify.com) from DeepStream, OpenCV, Ultralytics, or any Python pipeline. The only thing it needs is a pipeline token.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install labeltify
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
1. On the dataset **Upload** page, create a pipeline token.
|
|
29
|
+
2. Upload:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from labeltify import LabelTifyClient
|
|
33
|
+
|
|
34
|
+
client = LabelTifyClient("labeltify_…") # or set LABELTIFY_TOKEN and call LabelTifyClient()
|
|
35
|
+
|
|
36
|
+
img = client.upload_image(
|
|
37
|
+
"DATASET_ID",
|
|
38
|
+
jpeg_bytes,
|
|
39
|
+
filename="cam01.jpg",
|
|
40
|
+
camera="cam-01",
|
|
41
|
+
pipeline="ds-prod",
|
|
42
|
+
trigger="low_confidence",
|
|
43
|
+
)
|
|
44
|
+
client.upload_file("DATASET_ID", "frames/cam02.jpg", camera="cam-02")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The token knows its org, so you don't pass one. Timeouts, `429`, and `5xx` responses are retried with exponential backoff (`max_retries=4` by default). Anything else raises `LabelTifyError` with the API's message and `.status`.
|
|
48
|
+
|
|
49
|
+
Annotations are optional. Pass `labels` (COCO, LabelTify, or YOLO JSON bytes) and `work_state` (`done`, `in_progress`, `unsure`, or `untouched`). Images with no labels stay untouched. If labels are sent and `work_state` is omitted, those images are marked Done.
|
|
50
|
+
|
|
51
|
+
Send model proposals to the Loop review queue:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
client.propose(
|
|
55
|
+
"DATASET_ID",
|
|
56
|
+
img["id"],
|
|
57
|
+
[{"type": "bbox", "id": "p1", "classId": "car", "box": {"x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2}, "source": "model", "confidence": 0.4}],
|
|
58
|
+
model_ref="deepstream@1",
|
|
59
|
+
uncertainty=0.8,
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Boxes are normalized `[0,1]`. Camera, pipeline, and trigger are stored on the image so Loop can filter by them.
|
|
64
|
+
|
|
65
|
+
Local API: `LabelTifyClient("labeltify_…", base_url="http://localhost:8787")`.
|
|
66
|
+
|
|
67
|
+
## Develop
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
pip install -e "clients/python[test]"
|
|
71
|
+
pytest clients/python/tests
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Bump `version` in `pyproject.toml` to publish. Each new version that lands on `main` is published to PyPI by `.github/workflows/publish-python.yml`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
labeltify/__init__.py
|
|
4
|
+
labeltify/client.py
|
|
5
|
+
labeltify.egg-info/PKG-INFO
|
|
6
|
+
labeltify.egg-info/SOURCES.txt
|
|
7
|
+
labeltify.egg-info/dependency_links.txt
|
|
8
|
+
labeltify.egg-info/requires.txt
|
|
9
|
+
labeltify.egg-info/top_level.txt
|
|
10
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
labeltify
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "labeltify"
|
|
7
|
+
# Bump this to publish. .github/workflows/publish-python.yml releases each new version pushed to main.
|
|
8
|
+
version = "0.2.0"
|
|
9
|
+
description = "Upload frames and optional annotations into LabelTify with a pipeline token."
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "CleverTify" }]
|
|
13
|
+
keywords = ["labeltify", "annotation", "computer-vision", "deepstream", "active-learning", "dataset"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Image Recognition",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
test = ["pytest>=8"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://labeltify.com"
|
|
29
|
+
Source = "https://github.com/CleverTify/LabelTify/tree/main/clients/python"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["."]
|
|
33
|
+
include = ["labeltify*"]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from labeltify import LabelTifyClient, LabelTifyError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Api:
|
|
11
|
+
"""A tiny stand-in for the LabelTify API that records requests and can fail on demand."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.requests = []
|
|
15
|
+
self.fail_next = [] # status codes to return before succeeding
|
|
16
|
+
|
|
17
|
+
def handle(self, handler: BaseHTTPRequestHandler):
|
|
18
|
+
length = int(handler.headers.get("content-length") or 0)
|
|
19
|
+
body = handler.rfile.read(length) if length else b""
|
|
20
|
+
self.requests.append((handler.command, handler.path, handler.headers, body))
|
|
21
|
+
if handler.headers.get("authorization") != "Bearer labeltify_test":
|
|
22
|
+
return self.reply(handler, 401, {"error": {"message": "bad token"}})
|
|
23
|
+
if self.fail_next:
|
|
24
|
+
return self.reply(handler, self.fail_next.pop(0), {"error": {"message": "busy"}})
|
|
25
|
+
if handler.path == "/api/me":
|
|
26
|
+
return self.reply(handler, 200, {"orgs": [{"slug": "acme"}]})
|
|
27
|
+
if handler.path.endswith("/images"):
|
|
28
|
+
return self.reply(handler, 201, {"items": [{"id": "img-1"}]})
|
|
29
|
+
if handler.path.endswith("/al/tasks"):
|
|
30
|
+
return self.reply(handler, 201, {"id": "task-1", "echo": json.loads(body)})
|
|
31
|
+
return self.reply(handler, 404, {"error": {"message": "not found"}})
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def reply(handler, status, payload):
|
|
35
|
+
raw = json.dumps(payload).encode()
|
|
36
|
+
handler.send_response(status)
|
|
37
|
+
handler.send_header("content-type", "application/json")
|
|
38
|
+
handler.send_header("content-length", str(len(raw)))
|
|
39
|
+
if status == 429:
|
|
40
|
+
handler.send_header("retry-after", "0")
|
|
41
|
+
handler.end_headers()
|
|
42
|
+
handler.wfile.write(raw)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture
|
|
46
|
+
def api():
|
|
47
|
+
state = Api()
|
|
48
|
+
|
|
49
|
+
class Handler(BaseHTTPRequestHandler):
|
|
50
|
+
def do_GET(self):
|
|
51
|
+
state.handle(self)
|
|
52
|
+
|
|
53
|
+
def do_POST(self):
|
|
54
|
+
state.handle(self)
|
|
55
|
+
|
|
56
|
+
def log_message(self, *args):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
60
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
61
|
+
thread.start()
|
|
62
|
+
state.url = f"http://127.0.0.1:{server.server_address[1]}"
|
|
63
|
+
yield state
|
|
64
|
+
server.shutdown()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_token_is_all_it_needs(api):
|
|
68
|
+
client = LabelTifyClient("labeltify_test", base_url=api.url, backoff=0)
|
|
69
|
+
image = client.upload_image("ds-1", b"\xff\xd8jpeg", "cam01.jpg", camera="cam-01", pipeline="ds", trigger="motion")
|
|
70
|
+
assert image == {"id": "img-1"}
|
|
71
|
+
assert client.org == "acme"
|
|
72
|
+
method, path, headers, body = api.requests[-1]
|
|
73
|
+
assert (method, path) == ("POST", "/api/orgs/acme/datasets/ds-1/images")
|
|
74
|
+
assert headers["content-type"].startswith("multipart/form-data; boundary=")
|
|
75
|
+
for part in (b'name="camera"\r\n\r\ncam-01', b'name="pipeline"\r\n\r\nds', b'name="trigger"\r\n\r\nmotion', b'filename="cam01.jpg"'):
|
|
76
|
+
assert part in body
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_token_from_environment(api, monkeypatch):
|
|
80
|
+
monkeypatch.setenv("LABELTIFY_TOKEN", "labeltify_test")
|
|
81
|
+
client = LabelTifyClient(base_url=api.url)
|
|
82
|
+
assert client.org == "acme"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_missing_token_is_a_clear_error(monkeypatch):
|
|
86
|
+
monkeypatch.delenv("LABELTIFY_TOKEN", raising=False)
|
|
87
|
+
with pytest.raises(ValueError, match="LABELTIFY_TOKEN"):
|
|
88
|
+
LabelTifyClient()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_old_positional_order_still_works(api):
|
|
92
|
+
client = LabelTifyClient(api.url, "labeltify_test", "acme")
|
|
93
|
+
assert client.base_url == api.url
|
|
94
|
+
assert client.upload_image("ds-1", b"x", "a.jpg")["id"] == "img-1"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_retries_busy_responses(api):
|
|
98
|
+
api.fail_next = [503, 429]
|
|
99
|
+
client = LabelTifyClient("labeltify_test", base_url=api.url, org="acme", backoff=0)
|
|
100
|
+
assert client.upload_image("ds-1", b"x", "a.jpg")["id"] == "img-1"
|
|
101
|
+
assert len(api.requests) == 3
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_gives_up_with_the_api_message(api):
|
|
105
|
+
client = LabelTifyClient("labeltify_wrong", base_url=api.url, org="acme", backoff=0)
|
|
106
|
+
with pytest.raises(LabelTifyError, match="401: bad token") as err:
|
|
107
|
+
client.upload_image("ds-1", b"x", "a.jpg")
|
|
108
|
+
assert err.value.status == 401
|
|
109
|
+
assert len(api.requests) == 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_labels_and_upload_file(api, tmp_path):
|
|
113
|
+
frame = tmp_path / "frame.png"
|
|
114
|
+
frame.write_bytes(b"\x89PNG")
|
|
115
|
+
client = LabelTifyClient("labeltify_test", base_url=api.url, org="acme", backoff=0)
|
|
116
|
+
client.upload_file("ds-1", frame, labels=b'{"images": []}', work_state="done")
|
|
117
|
+
body = api.requests[-1][3]
|
|
118
|
+
assert b"Content-Type: image/png" in body
|
|
119
|
+
assert b'filename="labels.json"' in body
|
|
120
|
+
assert b'name="workState"\r\n\r\ndone' in body
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_propose(api):
|
|
124
|
+
client = LabelTifyClient("labeltify_test", base_url=api.url, org="acme")
|
|
125
|
+
task = client.propose("ds-1", "img-1", [{"classId": "car"}], model_ref="deepstream@1")
|
|
126
|
+
assert task["echo"]["modelRef"] == "deepstream@1"
|
|
127
|
+
assert api.requests[-1][1] == "/api/orgs/acme/datasets/ds-1/al/tasks"
|