Metadata-Version: 2.4
Name: labeltify
Version: 0.6.0
Summary: Upload frames and optional annotations into LabelTify with a pipeline token.
Author: CleverTify
Project-URL: Homepage, https://labeltify.com
Project-URL: Source, https://github.com/CleverTify/LabelTify/tree/main/clients/python
Keywords: labeltify,annotation,computer-vision,deepstream,active-learning,dataset
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Scientific/Engineering :: Image Recognition
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: Pillow>=10
Requires-Dist: numpy>=1.26
Provides-Extra: test
Requires-Dist: pytest>=8; extra == "test"
Provides-Extra: images

# labeltify

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.

```sh
pip install labeltify
```

1. On the dataset **Upload** page, create a pipeline token.
2. Upload:

```python
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). Scoring uses every CPU core, up to 8 processes, and falls back to threads. Pillow and numpy are installed with the package.

### Upload a folder with its annotations

```python
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_at` is required. `exif` reads DateTimeOriginal and OffsetTimeOriginal (a missing offset names the file and stops). `mtime` uses the file's mtime as UTC. `exif_or_mtime` uses 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_for` returns a full `ImageMeta` for 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_state` must be `done` or `in_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=True` also skips near-duplicate frames, like the Upload page. Pillow is already installed.

#### Migrating thousands of images

```bash
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; `1` sends one after another) requests run in parallel over kept connections, so there is no new TLS handshake per request. Each request holds at most `max_request_bytes` (default 24 MiB) and `max_request_images` (default 50). Files are read from disk only when their request is sent, so memory is about `workers` × 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=True` or `skip_existing=False` turns this off, and an older server without the lookup is handled as before.
- **Progress.** `progress=` is called after each request with `requests_done`, `requests_total`, `images_stored` and `skipped_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.

```python
# 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",
)
```

```python
# 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:

```python
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:

```bash
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:

```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.part` and are replaced atomically.
- **Parallel.** `--workers` (default 16, at most 64) downloads at once. Network errors, `429`, and `5xx` are retried with exponential backoff (`--retries`, default 5), honouring `Retry-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-verify` skips 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

```sh
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`.
