8000 feat(objects): find objects with anytopdf, without ONNX Runtime in Lens by adeelahmad · Pull Request #228 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ The backend (`fastapi_backend`) and the frontend (`nextjs-frontend`) are version

## Unreleased

- **anytopdf can find objects.** Settings › Video › Object detector › anytopdf runs Lens's YOLOX model with
anytopdf's objects plugin (sandboxed, no network), so a lean server or a Raspberry Pi finds objects without ONNX
Runtime. Lens fetches anytopdf and the model on first use. YOLOX on ONNX Runtime stays the default
([Objects](docs/configuration.md#objects)).
- **Places and dates from anytopdf.** Turn on Settings › Analysis › Find places and dates with anytopdf (off by
default) and analysis also asks anytopdf, offline, for the places a text names, the dates it gives (with their ISO
value) and where a photo was taken (from its GPS fix, with `exiftool` installed). They become places and dates in the
Expand Down
2 changes: 1 addition & 1 deletion docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ archive that only uses cloud models never downloads them, which keeps a small ma
| Whisper model | `transcribe.engine` is `whisper` | the faster-whisper model named in `transcribe.whisper.model` |
| Voice IDs | speakers are separated by clustering (the default) | SpeechBrain and its ECAPA voiceprint model |
| Face models | `video.face_engine` is `opencv` (the default) | OpenCV, YuNet and SFace |
| Object model | `video.object_engine` is `yolox` (the default) | ONNX Runtime and YOLOX-s |
| Object model | `video.object_engine` is `yolox` (the default) or `anytopdf` | YOLOX-s, and ONNX Runtime for `yolox` |
| Chat model, embedding model | the LLM provider or embeddings server is Ollama | pulled on that server (`/api/pull`) |
| Outlook .msg emails | listed in `components.also` | extract-msg (GPL-3.0, so only when asked for) |

Expand Down
5 changes: 4 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,13 +303,16 @@ video's sampled frames and on a document's or an image's pages ([API](api.md#obj
a job needs them ([Components](components.md)), and also finds a model put in `/opt/lens/models`.
* `ultralytics`: Ultralytics YOLO (`pip install ultralytics`). It's AGPL-3.0: a server that lets others use it must
offer them its source, so it's in no image and no extra.
* `anytopdf`: the same YOLOX model, run by [anytopdf](#anytopdf)'s objects plugin, sandboxed with no network. It
needs no ONNX Runtime in Lens, for a lean server or a Raspberry Pi; Lens fetches anytopdf and YOLOX-s the first
time a job needs them. anytopdf runs here even when documents go to a conversion node.
* `off`.

Without one the step is skipped, and its job says why. Settings → Video, OCR, faces and objects:

| Setting | Default | |
|---|---|---|
| `video.object_engine` | `yolox` | `yolox`, `ultralytics` or `off` |
| `video.object_engine` | `yolox` | `yolox`, `ultralytics`, `anytopdf` or `off` |
| `video.object_min_score` | 0.4 | how sure the detector must be to keep what it found, 0.05–0.95 |

Set at startup only:
Expand Down
10000 31 changes: 18 additions & 13 deletions fastapi_backend/app/domain/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,39 +389,44 @@ def fetch(self, cfg, m, say):

class Objects(Component):
def needed(self, cfg, m):
return cfg["video"].get("object_engine") == "yolox"
return cfg["video"].get("object_engine") in ("yolox", "anytopdf")

def present(self, cfg):
from . import objects

return importable("onnxruntime") and bool(objects.yolox_model(cfg))
== "anytopdf" or importable("onnxruntime") # anytopdf runs the model itself
return onnx and bool(objects.yolox_model(cfg))

def fetch(self, cfg, m, say):
if not importable("onnxruntime"):
if cfg["video"].get("object_engine") != "anytopdf" and not importable("onnxruntime"):
pip_install(cfg, "objects", say)
fetch_file(cfg, "yolox_s.onnx", say)


class Anytopdf(Component):
"""The anytopdf program (anytopdf.py), fetched when Settings → Documents asks for it to run here, or Settings →
Analysis asks it for places and dates (enrich.py)."""
Analysis asks it for places and dates (enrich.py), or Settings → Video for objects (objects.py)."""

def needed(self, cfg, m):
def _serves(self, cfg):
from . import anytopdf

here = anytopdf.mode(cfg) == "anytopdf" and not anytopdf.node(cfg)
return (here or bool((cfg.get("analysis") or {}).get("anytopdf"))) and bool(anytopdf.archive())
converts = anytopdf.mode(cfg) == "anytopdf" and not anytopdf.node(cfg)
detects = cfg["video"].get("object_engine") == "anytopdf" # its objects plugin runs here, node or not
enriches = bool((cfg.get("analysis") or {}).get("anytopdf"))
return ({"transcribe"} if converts else set()) | ({"objects"} if detects else set()) | ({"analyze"} if enriches else set())

def present(self, cfg):
def needed(self, cfg, m):
from . import anytopdf

return bool(anytopdf.binary(cfg))
return bool(self._serves(cfg)) and bool(anytopdf.archive())

def serves(self, cfg, m):
return self._serves(cfg)

def present(self, cfg):
from . import anytopdf

here = anytopdf.mode(cfg) == "anytopdf" and not anytopdf.node(cfg)
return ({"transcribe"} if here else set()) | ({"analyze"} if (cfg.get("analysis") or {}).get("anytopdf") else set())
return bool(anytopdf.binary(cfg))

def fetch(self, cfg, m, say):
from . import anytopdf
Expand Down Expand Up @@ -556,8 +561,8 @@ def detail(self, cfg):
Anytopdf(
"anytopdf",
"anytopdf",
"makes documents and photographed pages into searchable PDFs (documents.converter), finds places and dates",
steps={"transcribe", "analyze"},
"makes documents and photographed pages into searchable PDFs (documents.converter), finds places and dates, and objects (video.object_engine)",
steps={"transcribe", "analyze", "objects"},
size_mb=50,
license="MIT OR Apache-2.0",
),
Expand Down
32 changes: 31 additions & 1 deletion fastapi_backend/app/domain/objects.py
8000
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
one in MODELS or the data folder's models (components.py fetches yolox_s there on first use); `pip install "lens[objects]"` brings ONNX Runtime.
- ultralytics: Ultralytics YOLO (video.ultralytics_model, such as yolov8n.pt). It's AGPL-3.0: a server that lets
others use it must offer them its source, so it's in no image or extra; `pip install ultralytics` to use it.
- anytopdf: the same YOLOX model, run by anytopdf's objects plugin (anytopdf.analyze, sandboxed with no network), so a
lean server or a Raspberry Pi needs no ONNX Runtime in Python. components.py fetches anytopdf and the model.
Without one the objects step is skipped, saying why.

A resource's objects are rows of object_track, one per kind: the spans it's in (time, or pages for a document, as
Expand Down Expand Up @@ -167,11 +169,39 @@ def detect(self, path):
return sorted(out, key=lambda o: -o["score"])


class AnytopdfObjects:
"""YOLOX run by anytopdf's objects plugin: the program here (documents.anytopdf, or the one fetched) and a YOLOX
.onnx model, without ONNX Runtime in Lens."""

name = "anytopdf"

def __init__(self, cfg):
from . import anytopdf

if not anytopdf.binary(cfg):
raise RuntimeError("anytopdf isn't installed here (it's fetched the first time a job needs it)")
model = yolox_model(cfg)
if not model or not pathlib.Path(model).is_file():
raise RuntimeError("no YOLOX model: set video.yolox_model to a YOLOX .onnx file (the lens:full image has one)")
self.cfg, self.models = cfg, {"ANYTOPDF_OBJECTS_MODEL": model}
self.min_score = float(cfg["video"].get("object_min_score") or 0.4)

def detect(self, path):
from . import anytopdf

out = []
for o in anytopdf.analyze(self.cfg, path, self.models):
if o["kind"] == "object" and o["score"] >= self.min_score:
label = str(o["label"]).replace("_", " ").casefold()
out.append({"label": label, "score": o["score"], "box": [round(float(v), 4) for v in o["box"]]})
return sorted(out, key=lambda o: -o["score"])


def _fractions(x1, y1, x2, y2, W, H):
return [round(x1 / W, 4), round(y1 / H, 4), round((x2 - x1) / W, 4), round((y2 - y1) / H, 4)]


ENGINES = {"yolox": YoloxOnnx, "ultralytics": UltralyticsYolo}
ENGINES = {"yolox": YoloxOnnx, "ultralytics": UltralyticsYolo, "anytopdf": AnytopdfObjects}


def engine(cfg):
Expand Down
2 changes: 1 addition & 1 deletion fastapi_backend/app/domain/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
("reports", "audio"): {"link", "embed", "none"},
("video", "ocr_engine"): {"auto", "tesseract", "apple-vision", "rapidocr", "doctr", "none"},
("video", "face_engine"): {"opencv", "insightface", "none"},
("video", "object_engine"): {"yolox", "ultralytics", "off"},
("video", "object_engine"): {"yolox", "ultralytics", "anytopdf", "off"},
("decisions", "engine"): {"auto", "jev", "laya", "llm", "off"},
("voice", "input"): {"auto", "server", "browser"},
("voice", "stt"): {"same", "sensevoice", "whisper", "mlx-whisper", "openai", "elevenlabs", "assemblyai", "deepgram"},
Expand Down
33 changes: 32 additions & 1 deletion fastapi_backend/tests/api/test_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

import pytest

from app.domain import ingest, jobs, objects, store
from app.domain import components, ingest, jobs, objects, store
from tests import fake_anytopdf
from tests.helpers import drain, login, make_user, quiet, text_pdf
from tests.video_helpers import make_video, needs_ffmpeg

Expand Down Expand Up @@ -146,7 +147,37 @@ def test_object_settings_are_checked(client, people):
assert (r.status_code, r.json()["detail"]) == (400, "video.object_min_score is a number from 0.05 to 0.95")
r = client.put("/api/v1/settings/video", json={"object_engine": "magic"}, headers=ha)
assert r.status_code == 400 and "ultralytics" in r.json()["detail"]
assert client.put("/api/v1/settings/video", json={"object_engine": "anytopdf"}, headers=ha).status_code == 200
assert client.put("/api/v1/settings/video", json={"object_engine": "off", "object_min_score": 0.5}, headers=ha).status_code == 200
assert client.put("/api/v1/settings/video", json={"yolox_model": "/tmp/x.onnx"}, headers=ha).status_code == 400 # startup only
assert client.put("/api/v1/settings/video", json={"object_min_score": 0.5}, headers=people["he"]).status_code == 403
assert "yolox_model" in client.get("/api/v1/settings", headers=ha).json()["bootstrap"]


def test_anytopdf_finds_objects_with_lens_yolox_model(cfg, tmp_path, monkeypatch):
cfg["video"].update(object_engine="anytopdf", yolox_model=None)
monkeypatch.setattr(objects, "MODELS", tmp_path / "no-models")
cfg["documents"]["anytopdf"] = str(tmp_path / "gone")
assert objects.engine(cfg) == (None, "anytopdf isn't installed here (it's fetched the first time a job needs it)")
log = tmp_path / "runs.jsonl"
cfg["documents"]["anytopdf"] = fake_anytopdf.make(tmp_path / "anytopdf", log)
found, why = objects.engine(cfg)
assert found is None and why.startswith("no YOLOX model")

model = tmp_path / "yolox_s.onnx"
model.write_bytes(b"a model")
cfg["video"]["yolox_model"] = str(model)
found, why = objects.engine(cfg)
assert (found.name, why) == ("anytopdf", None)
pic = tmp_path / "bus.jpg"
pic.write_bytes(b"a picture")
assert found.detect(pic) == [{"label": "bus", "score": 0.93, "box": [0.02, 0.2, 0.9, 0.5]}] # its face is left out
assert fake_anytopdf.runs(log)[-1]["anytopdf_env"] == ["ANYTOPDF_DATA_DIR", "ANYTOPDF_OBJECTS_MODEL"]
cfg["video"]["object_min_score"] = 0.95
assert objects.engine(cfg)[0].detect(pic) == [] # not sure enough

# what it needs is fetched on first use: anytopdf and the model, not ONNX Runtime
assert components.BY_ID["anytopdf"].serves(cfg, None) == {"objects"}
assert components.BY_ID["objects"].needed(cfg, None) and components.BY_ID["objects"].present(cfg)
cfg["video"]["object_engine"] = "yolox"
assert components.BY_ID["anytopdf"].serves(cfg, None) == set()
1 change: 1 addition & 0 deletions nextjs-frontend/components/settings/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1496,6 +1496,7 @@ export const FIELDS: FieldSpec[] = [
options: [
{ value: "yolox", label: "YOLOX on ONNX Runtime (Apache-2.0)" },
{ value: "ultralytics", label: "Ultralytics YOLO (AGPL-3.0)" },
{ value: "anytopdf", label: "YOLOX run by anytopdf (no ONNX Runtime)" },
{ value: "off", label: "Off" },
],
hint: "Finds people, vehicles, animals and everyday things on frames and pages",
Expand Down
Loading
0