Recipe — Python HTTP inference server
For LYNX SDK 1.0. Complete, end-to-end. Install:
install/python.md. API:api/python.md. Conventions:api/conventions.md.
Goal: run lynx-basic behind an HTTP endpoint so a client that can't run the SDK on-device — a React Native / JS app, a Go/Rust/Java service, plain curl — can POST an image and get JSON detections back. Model: lynx-basic (keyless, 80 COCO classes — see models/catalog.md).
There is no built-in serve() in the SDK — the API surface is lynx.open / model.predict (see api/python.md). You wrap those in a tiny web server yourself. Below is a complete one using only the standard library (plus Pillow to decode the upload), then a FastAPI variant.
Key idea
- Open the model once, at process start — the first
opendownloads + verifies + caches the model, so you never want that on the request path. Reuse the oneModelacross every request. model.predictwants an image path or a C-contiguous(H, W, 3)uint8 RGB numpy array. The upload arrives as bytes, so decode it (Pillow) to that array before callingpredict.- A
Modelis a single native handle — serialize calls to it with a lock. Threaded servers otherwise callpredictconcurrently on the same handle.
METHODThe whole thing
The whole thing (standard library)1"""A minimal LYNX inference server. Run: python server.py2POST an image: curl -F image=@photo.jpg http://localhost:8080/detect3"""4import io5import json6import threading7from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer8 9import numpy as np10from PIL import Image # pip install pillow11 12import lynx13from lynx.errors import LynxError14 15# ── load the model ONCE, at startup (keyless: no set_license needed) ──────────16# First open downloads + verifies + caches lynx-basic; every request reuses this.17MODEL = lynx.open("lynx-basic")18# One native handle → serialize predict() across worker threads.19MODEL_LOCK = threading.Lock()20 21MIN_CONFIDENCE = 0.422 23 24def run_detection(image_bytes: bytes) -> list:25 """Decode uploaded bytes → (H, W, 3) uint8 RGB → predict → list of dicts."""26 rgb = np.asarray(Image.open(io.BytesIO(image_bytes)).convert("RGB"))27 with MODEL_LOCK: # one handle, one predict at a time28 frame = MODEL.predict(rgb, conf=MIN_CONFIDENCE)29 results = []30 for d in frame.detections:31 x1, y1, x2, y2 = (float(v) for v in d.box) # [x1,y1,x2,y2] px32 results.append({33 "box": [x1, y1, x2, y2],34 "class": MODEL.classes(d.class_id).name, # e.g. "PERSON"35 "confidence": float(d.confidence),36 })37 return results38 39 40class Handler(BaseHTTPRequestHandler):41 def _send_json(self, code, payload):42 body = json.dumps(payload).encode("utf-8")43 self.send_response(code)44 self.send_header("Content-Type", "application/json")45 self.send_header("Content-Length", str(len(body)))46 self.end_headers()47 self.wfile.write(body)48 49 def do_GET(self):50 if self.path == "/health":51 self._send_json(200, {"status": "ok"})52 elif self.path == "/info":53 self._send_json(200, {54 "model": "lynx-basic",55 "version": MODEL.version,56 "classes": [c.name for c in MODEL.classes],57 })58 else:59 self._send_json(404, {"error": "not found"})60 61 def do_POST(self):62 if self.path != "/detect":63 return self._send_json(404, {"error": "not found"})64 length = int(self.headers.get("Content-Length", 0))65 if length <= 0:66 return self._send_json(400, {"error": "empty body"})67 raw = self.rfile.read(length)68 image_bytes = _extract_image(raw, self.headers.get("Content-Type", ""))69 try:70 detections = run_detection(image_bytes)71 except LynxError as e:72 return self._send_json(500, {"error": f"lynx [{e.code}]: {e.message}"})73 except Exception as e: # bad/undecodable upload74 return self._send_json(400, {"error": str(e)})75 self._send_json(200, detections)76 77 def log_message(self, *a): # quiet the default logging78 pass79 80 81def _extract_image(raw: bytes, content_type: str) -> bytes:82 """Accept either a raw image body (Content-Type: image/*) or a single83 multipart/form-data 'image' field. Pillow sniffs the format, so we only84 need to peel off the multipart envelope when present."""85 if "multipart/form-data" not in content_type or "boundary=" not in content_type:86 return raw # raw image bytes87 boundary = ("--" + content_type.split("boundary=", 1)[1]).encode()88 for part in raw.split(boundary):89 head, _, body = part.partition(b"\r\n\r\n")90 if b"Content-Disposition" in head and b"filename" in head:91 return body.rsplit(b"\r\n", 1)[0] # strip trailing CRLF92 raise ValueError("no image part in multipart body")93 94 95if __name__ == "__main__":96 print("lynx-basic loaded:", MODEL.version, "| listening on :8080")97 ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()Call it:
1# multipart (what an HTML form / RN FormData sends)2curl -F image=@photo.jpg http://localhost:8080/detect3 4# or raw bytes5curl --data-binary @photo.jpg -H "Content-Type: image/jpeg" \6 http://localhost:8080/detectResponse:
1[2 {"box": [34.0, 51.0, 220.0, 470.0], "class": "PERSON", "confidence": 0.94},3 {"box": [255.0, 120.0, 610.0, 430.0], "class": "BICYCLE", "confidence": 0.81}4]FastAPI variant
If you'd rather have automatic multipart handling, OpenAPI docs, and an ASGI server, the same three moves (open once, lock, decode-to-array) drop into FastAPI. pip install fastapi uvicorn pillow, then:
1# app.py — run: uvicorn app:app --host 0.0.0.0 --port 8080 --workers 12import io3import threading4 5import numpy as np6from PIL import Image7from fastapi import FastAPI, File, UploadFile8 9import lynx10 11MODEL = lynx.open("lynx-basic") # once, at import (keyless)12MODEL_LOCK = threading.Lock()13app = FastAPI()14 15 16@app.get("/health")17def health():18 return {"status": "ok"}19 20 21@app.get("/info")22def info():23 return {"model": "lynx-basic", "version": MODEL.version,24 "classes": [c.name for c in MODEL.classes]}25 26 27@app.post("/detect")28async def detect(image: UploadFile = File(...)):29 rgb = np.asarray(Image.open(io.BytesIO(await image.read())).convert("RGB"))30 with MODEL_LOCK:31 frame = MODEL.predict(rgb, conf=0.4)32 return [33 {"box": [float(v) for v in d.box],34 "class": MODEL.classes(d.class_id).name,35 "confidence": float(d.confidence)}36 for d in frame.detections37 ]Run it single-process so there's exactly one loaded Model:
1uvicorn app:app --host 0.0.0.0 --port 8080 --workers 1Notes
- One model instance. Keep
--workers 1(FastAPI) / one process (stdlib). Each extra worker process re-runslynx.open— another full model in memory (and, cold, another download). To scale out, run N single-model processes behind a load balancer rather than N workers in one process. - Serialize
predict. AModelis one native handle; theMODEL_LOCKmakes threaded/async servers call it one request at a time. If you need real parallelism, put a pool of separateModelinstances (each its ownlynx.open) behind the lock-free path, or shard across processes. On GPU, setlynx.set_workers(gpus=1)once beforeopen(seeapi/python.md). - Keyless.
lynx-basicneeds no key — the SDK mints a per-device trial on first load. Nolynx.set_license(...)call. boxis[x1, y1, x2, y2]in the uploaded image's pixels (top-left origin). Class name comes frommodel.classes(d.class_id).name— there's noDetection.class_name.conf=accepts a bare float, aConfMode, orNonefor the model's calibrated default (seerecipes/python-detection.md).- Not a production service. This is the "make it callable over HTTP" escape hatch — no auth, no rate limiting, no batching. Put it behind a real gateway (TLS, auth, timeouts) before it faces anything but your own network.