labeltify
Upload frames, and optionally their annotations, into LabelTify from DeepStream, OpenCV, Ultralytics, or any Python pipeline. The only thing it needs is a pipeline token.
pip install labeltify
- On the dataset Upload page, create a pipeline token.
- Upload:
from labeltify import LabelTifyClient
client = LabelTifyClient("labeltify_…") # or set LABELTIFY_TOKEN and call LabelTifyClient()
img = client.upload_image(
"DATASET_ID",
jpeg_bytes,
filename="cam01.jpg",
captured_at="2026-06-01T12:00:00Z",
camera="cam-01",
pipeline="ds-prod",
trigger="low_confidence",
model_name="park-net",
model_version="1.2",
)
client.upload_file("DATASET_ID", "frames/cam02.jpg", captured_at="2026-06-01T12:00:05Z", camera="cam-02")
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.
captured_at is required. Pass an aware datetime or an ISO 8601 string with Z or an offset. A naive datetime is rejected before any request.
original_detection is what the device's model saw (COCO, LabelTify, or YOLO JSON bytes). LabelTify stores it and does not show it, and the frame stays untouched. final_annotation is a ready annotation file, the same import as before. With it, work_state must be done or in_progress. Images with no final annotation stay untouched.
model_name, model_version, gps ((lat, lon) or {"lat", "lon"}), and additional_data (a JSON object, at most 2 KiB) are optional and separate. model_version requires model_name.
Frames whose corrupt reason is smear, grey, green, blank or truncated, and whose score is at least 0.5, are not uploaded. They come back in report["files"] with status skipped_corrupt, the score, and the reason. A blocks, tear or ghost hit is uploaded with the score so LabelTify can show it as Suspected corrupt. Pass skip_corrupt=False to upload every frame (they are still scored). Pillow and numpy are installed with the package.
Corrupt scoring speed
By default each frame is scored one after another in the calling thread. That takes about 25–40 ms per 1080p frame, and it is safe inside a running DeepStream, GStreamer or CUDA program. The client never starts a process.
For big batches or folders, pass score_workers to score several frames at once in threads:
score_workers=N(at most 8);score_workers=0or"auto"for every core, at most 8;--score-workers Non the command line.
On 8 threads this is 3–4x faster: roughly 100–130 frames/s instead of 25–40. In upload_directory the number is the total, shared by the requests in flight.
client.upload_directory("DATASET_ID", "frames/", captured_at="exif_or_mtime", score_workers="auto")
Upload a folder with its annotations
report = client.upload_directory(
"DATASET_ID",
"datasets/parking",
captured_at="exif_or_mtime",
work_state="done",
model_name="park-net",
model_version="1.2",
)
print(len(report["items"]), "images stored in", report["requests"], "requests;", report["imported"])
This sends the folder the way dropping it on the dataset's Upload page would: images, plus COCO (polygons and boxes), YOLO (labels/*.txt with data.yaml or classes.txt), Pascal VOC, LabelMe, CSV, metadata.jsonl, a folder per class, or a LabelTify export. The server reads the annotations; the client only splits the folder into requests (at most 100 images and 80 MB each). A COCO file is cut down to each request's images, so polygons stay polygons.
- The dataset must already exist. Create it on the site. Parking polygons need type Instance segmentation. Traffic boxes need Object detection.
captured_atis required.exifreads DateTimeOriginal and OffsetTimeOriginal (a missing offset names the file and stops).mtimeuses the file's mtime as UTC.exif_or_mtimeuses EXIF when the timestamp is there and mtime otherwise. You can also pass one aware datetime, a mapping of filename to datetime, or a function of the path.meta_forreturns a fullImageMetafor one file.work_state="done"marks every image that received a final annotation. Images with no annotation stay untouched. With any annotation file in the folder,work_statemust bedoneorin_progress.- A zip in the folder is expanded here. Each member keeps its path as the upload filename, and each image is scored and gets its own metadata.
- Smear, grey, green, blank and truncated frames are left out unless you pass
skip_corrupt=False. Other corrupt hits are uploaded and scored. Scoring runs on all cores. - Create the pipeline token on that dataset's Upload page.
- One credit covers 10,000 uploaded images. A free dataset holds 100 photos.
- Frame embeddings follow the dataset setting. Turn them off on the dataset before a large upload when you do not want that charge.
- Use the portable pixel COCO files. The raw Studio shards store boxes as fractions from 0 to 1, and the server divides by width and height again.
- Two images with the same file name in different folders stop the upload before anything is sent; rename one.
- Images over 20 MiB, empty files, and files that are neither images nor annotations are listed in
report["files"]and not sent. A zip is expanded when it holds at most 200 files and 20 MB. - If a request fails after its retries, the error says how many images were already stored; run the folder again and only what is missing is uploaded (see below).
compute_dhash=Truealso skips near-duplicate frames, like the Upload page. Pillow is already installed.
Migrating thousands of images
export LABELTIFY_TOKEN=labeltify_…
python -m labeltify upload ./photos --dataset DATASET_ID --captured-at exif_or_mtime --workers 6 --work-state done
or client.upload_directory(dataset_id, folder, workers=6, progress=print).
- Several requests at once.
workers(default 4, at most 8;1sends one after another) requests run in parallel over kept connections, so there is no new TLS handshake per request. Each request holds at mostmax_request_bytes(default 24 MiB) andmax_request_images(default 50). Files are read from disk only when their request is sent, so memory is aboutworkers× 24 MiB, not the size of the folder. - Resume for free. With
skip_existing=True(default) every image is hashed (SHA-256), the dataset is asked which hashes it already holds (client.images_exist(dataset_id, hashes)), and only the rest is sent. Running the same folder again after an interruption takes seconds, not a second upload. Label files of images that are already there are still sent.force=Trueorskip_existing=Falseturns this off, and an older server without the lookup is handled as before. - Progress.
progress=is called after each request withrequests_done,requests_total,images_storedandskipped_existing; the command line prints it. - Frame embeddings follow the dataset setting and are made in the background after the upload: thousands of images take a while to get their vectors. The dataset's Images tab shows how many are done.
- If a request fails, the requests not started yet are dropped, those already stored stay stored, and the error says how many; run the command again to continue.
upload_bundle(dataset_id, files, images_meta=…, work_state=…) sends one such request from bytes you already have (files is a list of {"filename", "data", "content_type"}, and images_meta is one ImageMeta per image).
Upgrade from 0.4
labels is gone. Capture time is required. Corrupt frames are skipped unless you opt in.
# 0.4
client.upload_image(dataset_id, data, "cam.jpg", labels=open("ann.json", "rb").read(), work_state="done")
# 0.5
client.upload_image(
dataset_id,
data,
filename="cam.jpg",
captured_at="2026-06-01T12:00:00Z",
final_annotation=open("ann.json", "rb").read(),
work_state="done",
)
# 0.4
client.upload_directory(dataset_id, "frames", work_state="done")
# 0.5
client.upload_directory(dataset_id, "frames", captured_at="exif_or_mtime", work_state="done")
A deployment behind an access proxy can take extra headers on every request: LabelTifyClient(token, base_url=…, headers={"CF-Access-Client-Id": …, "CF-Access-Client-Secret": …}).
Send model proposals to the Loop review queue:
client.propose(
"DATASET_ID",
img["id"],
[{"type": "bbox", "id": "p1", "classId": "car", "box": {"x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2}, "source": "model", "confidence": 0.4}],
model_ref="deepstream@1",
uncertainty=0.8,
)
Boxes are normalized [0,1]. Camera, pipeline, and trigger are stored on the image so Loop can filter by them.
Local API: LabelTifyClient("labeltify_…", base_url="http://localhost:8787").
Download a dataset export
The export tool on the site (or the MCP export tool) hands out a file-list URL. One command pulls every image it lists, plus the annotation bundle:
export LABELTIFY_EXPORT_KEY="<key from the export>"
python -m labeltify download "https://labeltify.com/api/ds/EXPORT_ID/files" --out ./ofan --workers 16
or labeltify download … with the installed console script. From Python:
from labeltify import download_export
summary = download_export("https://labeltify.com/api/ds/…/files", "./ofan", key="<key>", progress=print)
# {"images": 77769, "downloaded": 77000, "skipped": 769, "failed": 0, "bytes": …, "annotations": True, "failures": []}
The export key can be passed via --key / LABELTIFY_EXPORT_KEY, as an explicit key= argument to download_export, or left in the URL as ?key=… (still supported for browsers and old scripts). For scripts and CI, prefer the header or explicit argument so the key does not land in request URLs and logs.
- Resume for free. Files already on disk with the right size (and SHA-256, when the list carries one) are skipped, so re-running after an interruption only fetches what is missing. Partial files land in
name.partand are replaced atomically. - Parallel.
--workers(default 16, at most 64) downloads at once. Network errors,429, and5xxare retried with exponential backoff (--retries, default 5), honouringRetry-After. An expired image link (403/404) is reported as failed, not retried forever. - Annotations are extracted next to the images, unless
--no-annotations. Zip entries that would land outside the output folder are refused. An images-only export has no annotations, so that step is skipped on its own. --no-verifyskips the size and hash checks. An expired file-list link (410) says so clearly. When every file made it, the exit code is 0 and the last stdout line is the JSON summary; otherwise it is 1.
Develop
pip install -e "clients/python[test]"
pytest clients/python/tests
Bump version in pyproject.toml to publish. Each new version that lands on main is published to PyPI by .github/workflows/publish-python.yml.
Metadata
Release files for labeltify 0.6.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| labeltify-0.6.1.tar.gz | 59.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| labeltify-0.6.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 98.2 kB
Release files / labeltify-0.6.1.tar.gz
| Download URL | labeltify-0.6.1.tar.gz |
|---|---|
| Size | 59.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2935092847b3c90a2b6f287b3a8f58bba91611bf095814eb863e89bf49f36fb7
|
|
BLAKE2b-256 checksum How to use checksums |
29aa44b7a03d7d943bd0470830a0db1570c23b2986804b55b98159220f1c37bf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.12.15
|
Release files / labeltify-0.6.1-py3-none-any.whl
| Download URL | labeltify-0.6.1-py3-none-any.whl |
|---|---|
| Size | 39.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4d24f23afeded9f3cde9ab6cf80af75da30f4bd8ff3c500a35b14f7c5b201ff2
|
|
BLAKE2b-256 checksum How to use checksums |
cdc291332f292695057e709bda92532a8a2c1ea703c909f73eae97a059e495ac
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.12.15
|