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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ labeltify