labeltify 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- labeltify/__init__.py +12 -0
- labeltify/client.py +188 -0
- labeltify-0.2.0.dist-info/METADATA +74 -0
- labeltify-0.2.0.dist-info/RECORD +6 -0
- labeltify-0.2.0.dist-info/WHEEL +5 -0
- labeltify-0.2.0.dist-info/top_level.txt +1 -0
labeltify/__init__.py
ADDED
|
@@ -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__"]
|
labeltify/client.py
ADDED
|
@@ -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,6 @@
|
|
|
1
|
+
labeltify/__init__.py,sha256=XWJvFZbFgUpjYppyUrqwgp4GiHQkJ1nlxcVsOcAncoM,429
|
|
2
|
+
labeltify/client.py,sha256=TmCPlXZo4kO8FZH4QQQhG1NZuJYSFVs3Q9koPJAgV9U,7443
|
|
3
|
+
labeltify-0.2.0.dist-info/METADATA,sha256=ciMJBG4Qnck7tEnlgZ6WYSOfjB75btNlxIbC0QANUYk,2732
|
|
4
|
+
labeltify-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
labeltify-0.2.0.dist-info/top_level.txt,sha256=s8avvtBzwNWUyPjJ_Dvpfi2SES8a0ACVGOwsftLk_es,10
|
|
6
|
+
labeltify-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
labeltify
|