Metadata-Version: 2.4
Name: cnrocr
Version: 0.5.10
Summary: Container number detection and recognition (ISO 6346)
Author: Vislab
License-Expression: LicenseRef-Proprietary
Project-URL: Homepage, https://github.com/theodore-labs/cnrocr
Project-URL: Issues, https://github.com/theodore-labs/cnrocr/issues
Keywords: ocr,container,iso6346,onnx,d-fine,crnn,logistics
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Image Recognition
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.23
Requires-Dist: pillow>=9.0
Requires-Dist: onnxruntime>=1.16
Requires-Dist: cryptography>=42
Provides-Extra: gpu
Requires-Dist: onnxruntime-gpu>=1.16; extra == "gpu"
Provides-Extra: server
Requires-Dist: fastapi>=0.110; extra == "server"
Requires-Dist: uvicorn[standard]>=0.27; extra == "server"
Requires-Dist: python-multipart>=0.0.9; extra == "server"
Requires-Dist: pyyaml>=6.0; extra == "server"
Provides-Extra: camera
Requires-Dist: opencv-python-headless>=4.8; extra == "camera"
Provides-Extra: full
Requires-Dist: cnrocr[server]; extra == "full"
Requires-Dist: cnrocr[camera]; extra == "full"
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"
Requires-Dist: ruff>=0.4; extra == "dev"
Requires-Dist: httpx>=0.27; extra == "dev"
Dynamic: license-file

# cnrocr

[![PyPI](https://img.shields.io/pypi/v/cnrocr.svg)](https://pypi.org/project/cnrocr/)
[![Python](https://img.shields.io/pypi/pyversions/cnrocr.svg)](https://pypi.org/project/cnrocr/)
[![Downloads](https://img.shields.io/pypi/dm/cnrocr.svg)](https://pypi.org/project/cnrocr/)
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macOS%20%7C%20Windows-lightgrey.svg)](https://pypi.org/project/cnrocr/#files)
[![License](https://img.shields.io/badge/license-Proprietary-red.svg)](#license)

Container number detection and recognition — **ISO 6346** end-to-end, ONNX only.

A region detector locates the number, ISO type code, owner code and serial on
the container; an OCR recognizer reads each crop with a spec-constrained beam
search. Fragments split across panels are merged back into a single number and
validated against the ISO 6346 check digit.

**No PyTorch required.** The runtime is `onnxruntime` + `numpy` + `pillow`.

---

## Install

```bash
pip install cnrocr             # CPU
pip install "cnrocr[gpu]"      # NVIDIA CUDA — see the note below
pip install "cnrocr[server]"   # local REST API + dashboard
```

Model weights (~113 MB) are **not** bundled in the wheel. They are downloaded on
first use and cached locally:

```bash
cnrocr models download       # optional — happens automatically otherwise
```

### About the GPU extra

`onnxruntime-gpu` does not carry the CUDA runtime, so `cnrocr[gpu]` on its own
is often not enough. If the runtime is missing or a different major version,
onnxruntime prints an error, **keeps going on the CPU**, and the only symptom
is that inference is slow. Two things to know:

- Remove the CPU build first. `onnxruntime` and `onnxruntime-gpu` unpack into
  the same directory and cannot both own it:
  `pip uninstall -y onnxruntime && pip install "cnrocr[gpu]"`
- Check what you actually got. `cnrocr check` prints the providers in use, and
  `--device cuda` now warns when it lands on the CPU anyway.

---

## Python API

```python
from cnrocr import ContainerOCR

ocr = ContainerOCR()                      # weights resolved from cache
result = ocr.read("gate_cam.jpg")

for c in result.containers:
    print(c.number, c.iso_type, c.confidence, c.needs_review)
# TGHU8913889 22G1 0.9997 False
```

### Multiple images

```python
results = ocr.read_many(["a.jpg", "b.jpg", "c.jpg"], batch_size=8)
```

`read_many` treats the images as **unrelated** — N images in, N results out.

### Multi-view fusion

When several cameras photograph the **same** container, fuse them into one
answer instead of voting on strings:

```python
mv = ocr.read_multiview(["cam1.jpg", "cam2.jpg", "cam3.jpg"])
print(mv.number, mv.agreement, mv.mode)
```

Beam-search candidates from every view are summed in log space, so a character
that one view is unsure about can be settled by the others. If the views appear
to be looking at *different* containers, they are not fused — `mv.consensus`
becomes `False` rather than producing a confident wrong answer.

### Review triage

A check digit alone is not enough. The constrained decoder only emits
spec-conforming candidates, so when the true string is absent from the beam it
will confidently output a *plausible* wrong number that still passes the check
digit. `needs_review` combines the check digit, the spec flag and a confidence
floor:

```python
if c.needs_review:
    print(c.review_reason)     # "low confidence (0.612 < 0.7)"
```

### Owner code registry (optional)

Real-world owner codes are registered with the BIC. Supplying the list filters
out invented codes that would otherwise pass both the format check and the check
digit:

```python
from cnrocr import OwnerCodeRegistry
ocr = ContainerOCR(registry=OwnerCodeRegistry.from_file("bic_codes.txt"))
```

The list is not shipped with this package.

---

## Command line

```bash
cnrocr read gate_cam.jpg
cnrocr read *.jpg --json --device cuda
cnrocr multiview cam1.jpg cam2.jpg cam3.jpg
cnrocr models status
cnrocr check                    # diagnose install, providers, cache
```

`--fail-on-review` makes the process exit with code 2 when any result needs
human review, which is convenient in batch pipelines.

---

## Local server

A REST API and a browser dashboard, both served from your own machine. Images
never leave it.

```bash
pip install "cnrocr[server]"
cnrocr server start                  # http://127.0.0.1:8000
cnrocr server start --daemon         # background; `server stop` to end it
```

Open the address for the dashboard, or `/docs` for the interactive API. The
models are loaded once at startup and shared by every request.

```bash
curl -X POST -F "file=@gate_cam.jpg" http://127.0.0.1:8000/api/read
```

```json
{
  "detection_id": 41,
  "device": "cuda",
  "containers": [{"number": "TGHU8913889", "iso_type": "22G1",
                  "confidence": 0.9997, "needs_review": false}],
  "elapsed_ms": 38.4
}
```

| Endpoint | Purpose |
|---|---|
| `POST /api/read` | One image (multipart). `/api/read/base64`, `/api/read/binary` take other shapes |
| `POST /api/read/batch` | Several images in one call |
| `POST /api/multiview` | Several views of the **same** container, fused into one answer |
| `GET /api/review` | The queue of results a human should look at |
| `POST /api/review/{id}` | Record the human's verdict; corrections are check-digit validated |
| `GET /api/history` | Past detections, with filters and CSV/JSON export |
| `GET /api/health` | Liveness, version, and which device is actually in use |
| `GET /api/stats` | Counts, review rate, remaining quota |

### Review queue

Results below `--review-confidence` (default `0.7`) are flagged and collected
for a human instead of being silently trusted. In practice that is a small
fraction of traffic — on our validation set every misread scored below 0.7
while correct reads sat near 1.0, so the threshold catches the errors and
sends only a few percent of good reads along with them.

The dashboard shows each flagged result next to its photo, with the number in
an editable field. Confirming stores the corrected value, which is the only
record of which misreadings repeat.

### Access from a phone

The dashboard is built for a phone first: the camera button photographs a
container and uploads it directly, which is enough to work the queue at the
gate.

```bash
cnrocr server start --host 0.0.0.0 --token "$(python -c 'import secrets;print(secrets.token_urlsafe(24))')"
```

Binding to `0.0.0.0` exposes the server to everyone who can reach the host, so
**set a token when you do**. The server says so at startup if you forget; it
does not refuse to start, because a closed network is a legitimate setup.

### Configuration

Flags, a YAML file, or the environment — later wins, and flags win over all.

```bash
cnrocr server start --config server.yaml --port 9000 --device cuda
```

```yaml
server:
  host: 127.0.0.1
  port: 8000
  api_token: ""            # required in practice once host is not loopback
models:
  device: auto             # auto | cpu | cuda
  workers: 4
  review_confidence: 0.7
storage:
  save_images: true        # the review screen needs the photo
  max_history: 5000
```

Every setting also reads from `CNROCR_SERVER_<NAME>`. An unknown key in the
YAML is an error rather than a silent no-op — a typo in `api_token` must not
quietly leave authentication off.

### Telling something else

The API is pull-only, which is no use to a barrier or a terminal operating
system. Point the server at a URL and every result is POSTed there as it
happens:

```bash
cnrocr server start --webhook https://gate.internal/cnrocr \
                    --webhook-token "$(openssl rand -base64 24)"
```

`--webhook-token` is sent to the receiver as a bearer token so it can tell the
posts came from here. It is not `--token`, which guards this server.

Results go out for anything that passes **through the server** — the dashboard,
`cnrocr server read`, or your own `POST /api/read`. Plain `cnrocr read` runs in
its own process and never reaches the server, so it sends nothing.

```json
{"event": "read", "server": "cnrocr", "ts": "...", "data": { ... }}
```

`event` is `read` for a recognition and `review` for a human verdict, so a
receiver can supersede what it was told when the read first came in. Delivery
never blocks or fails a request: a detection that was stored succeeded whether
or not anyone could be told. Failures are retried a couple of times, then
counted in `/api/stats` — a webhook that stopped working is otherwise
invisible. If deliveries must not be lost, poll `/api/history` and treat the
webhook as a latency improvement rather than a transport.

```yaml
webhook:
  webhook_url: "https://gate.internal/cnrocr"
  webhook_token: ""          # sent to the receiver as a Bearer token
  webhook_events: [read, review]
  webhook_retries: 2
```

### Starting at boot

A gate PC reboots. `cnrocr server install` prints a systemd unit, a launchd
plist or a `schtasks` command for this machine:

```bash
cnrocr server install                    # print it
cnrocr server install --write /tmp       # write it to a file
```

It generates the unit and the one command that installs it; it does not
install anything itself, because that needs administrator rights and you
should read both before running either. It also names what will otherwise
break after the next reboot — a licence key that only exists in your shell, a
token that a scheduled task cannot carry.

### Storage

Photographs are kept for the review screen and capped separately from the
history, because a row costs a few hundred bytes and the picture beside it
costs a few hundred kilobytes:

```yaml
storage:
  save_images: true
  max_history: 5000        # rows
  max_images: 2000         # photographs; they age out first
```

Deleting or trimming a detection deletes its photographs, and any left behind
by an earlier version are swept at startup.

```bash
cnrocr license --set <key>  # register a licence (or paste it in the dashboard)
cnrocr server status        # is it up, on what device, since when
cnrocr server list          # every instance this machine knows about
cnrocr server logs -f       # follow
cnrocr server read img.jpg  # send a file to a running instance — not the same
                            # as `cnrocr read`, which never touches the server
cnrocr server install       # a unit file that starts it at boot
cnrocr server stop
```

### What this is not

It reads container numbers. It does not watch cameras, and it does not decide
whether to open anything. There is no RTSP input, no booking lookup and no
barrier control — a gate needs the truck's plate and a booking reference as
well as the container number, and those decisions belong to a terminal
operating system. Use `--webhook` to hand results to whatever makes them.

---

## Licensing

Licences are priced by **daily volume**. A licence raises the daily limit to
the tier you are on; the counter is per image, not per call, and resets at
00:00 UTC. Tiers start at 100 images a day and run to unlimited — email
**vislab2026@gmail.com** for current pricing, or see the
[project page](https://github.com/theodore-labs/cnrocr#licensing).

```bash
cnrocr license      # which tier, and today's usage
```

> **Multi-view counts per view.** `read_multiview` with three photographs of
> one container spends three images, not one. Three hundred containers
> photographed from three angles is 900 images a day, not 300 — worth checking
> against the tier before choosing it.

The library, the CLI and the server all draw on the same daily budget.
When it runs out the process exits with code 3, and the server answers **429**
rather than 500 — the request was fine and so is the server. Nothing is
processed on a call that would exceed the allowance, so a refused call costs
none of it.

## Evaluation limit

Without a licence, cnrocr processes **30 images per day**.

### Evaluating it properly

Thirty images is enough to see whether it reads your photographs. It is not
enough to wire up the API, try the batch and base64 shapes, and put any load
through it — that is an afternoon's work and rather more than thirty images.

Email **vislab2026@gmail.com** for a **free 14-day evaluation key with no daily
limit**. Say who you are and what you are building; there is nothing to
negotiate and no card involved. When it expires the key simply stops applying
and you are back to 30 images per day — nothing breaks, nothing to uninstall.

The same address issues full licences. A key looks like this:

```bash
# Windows
setx CNROCR_LICENSE "eyJlbWFpbCI6..."

# macOS / Linux
export CNROCR_LICENSE='eyJlbWFpbCI6...'
```

The key may also be saved to a file named `license` in the cache directory
(`cnrocr models path` shows where). Verify with `cnrocr check`, which also
warns for thirty days before a licence expires — a renewal should not be
discovered by a server that stopped working.

---

## Weights and caching

| Platform | Location |
|---|---|
| Cache (Windows) | `%LOCALAPPDATA%\cnrocr\Cache\models\<set>` |
| Cache (macOS) | `~/Library/Caches/cnrocr/models/<set>` |
| Cache (Linux) | `~/.cache/cnrocr/models/<set>` |

Every file is verified against a SHA-256 recorded in the wheel. Released assets
are immutable: a new model set ships under a new tag and a new library version,
so upgrading never invalidates an existing install.

The weights are encrypted and are decrypted into memory when a session is
built. The cache holds ciphertext only; no plaintext model is written to disk.

Environment overrides:

| Variable | Effect |
|---|---|
| `CNROCR_MODEL_DIR` | Use this directory as-is; never download |
| `CNROCR_CACHE_DIR` | Relocate the cache root |
| `CNROCR_WEIGHTS_BASE_URL` | Fetch weights from somewhere else (`file://` works) |

---

## License

Proprietary. Evaluation and non-commercial research use only — see `LICENSE`.
Contact the copyright holder for commercial licensing.
