tailcam 1.8.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. tailcam/__init__.py +3 -0
  2. tailcam/__main__.py +4 -0
  3. tailcam/activelearning/__init__.py +20 -0
  4. tailcam/activelearning/annotations.py +230 -0
  5. tailcam/activelearning/backends.py +270 -0
  6. tailcam/activelearning/florence.py +246 -0
  7. tailcam/activelearning/labelstudio.py +284 -0
  8. tailcam/activelearning/qwen.py +298 -0
  9. tailcam/activelearning/service.py +656 -0
  10. tailcam/ai/__init__.py +8 -0
  11. tailcam/ai/analyzer.py +185 -0
  12. tailcam/ai/detector.py +367 -0
  13. tailcam/ai/pull.py +121 -0
  14. tailcam/ai/remote.py +151 -0
  15. tailcam/camera/__init__.py +1 -0
  16. tailcam/camera/enumerate.py +158 -0
  17. tailcam/camera/frame.py +131 -0
  18. tailcam/camera/manager.py +261 -0
  19. tailcam/camera/properties.py +45 -0
  20. tailcam/camera/source.py +376 -0
  21. tailcam/camera/transforms.py +101 -0
  22. tailcam/camera/worker.py +201 -0
  23. tailcam/cli.py +868 -0
  24. tailcam/cluster/__init__.py +2 -0
  25. tailcam/cluster/fleet.py +57 -0
  26. tailcam/cluster/remote_feed.py +227 -0
  27. tailcam/cluster/service.py +238 -0
  28. tailcam/config.py +538 -0
  29. tailcam/desktop/__init__.py +40 -0
  30. tailcam/desktop/app.py +182 -0
  31. tailcam/desktop/assets/app-icon-512.png +0 -0
  32. tailcam/desktop/assets/server_down.html +31 -0
  33. tailcam/desktop/assets/tray-icon-template.png +0 -0
  34. tailcam/desktop/assets/tray-icon.png +0 -0
  35. tailcam/desktop/linux_desktop.py +149 -0
  36. tailcam/desktop/macos_bundle.py +144 -0
  37. tailcam/desktop/menu.py +92 -0
  38. tailcam/desktop/nodes.py +51 -0
  39. tailcam/desktop/server.py +96 -0
  40. tailcam/desktop/state.py +56 -0
  41. tailcam/desktop/tray.py +81 -0
  42. tailcam/desktop/updates.py +29 -0
  43. tailcam/desktop/window.py +59 -0
  44. tailcam/desktop/windows_shortcut.py +136 -0
  45. tailcam/hostinfo.py +125 -0
  46. tailcam/integrations/__init__.py +1 -0
  47. tailcam/integrations/base.py +100 -0
  48. tailcam/integrations/homeassistant.py +323 -0
  49. tailcam/integrations/homekit.py +329 -0
  50. tailcam/logging_setup.py +26 -0
  51. tailcam/management/__init__.py +7 -0
  52. tailcam/management/audit.py +49 -0
  53. tailcam/management/capabilities.py +43 -0
  54. tailcam/management/health.py +190 -0
  55. tailcam/mcp/__init__.py +56 -0
  56. tailcam/mcp/client.py +378 -0
  57. tailcam/mcp/errors.py +63 -0
  58. tailcam/mcp/prompts.py +109 -0
  59. tailcam/mcp/protocol.py +106 -0
  60. tailcam/mcp/resources.py +98 -0
  61. tailcam/mcp/server.py +289 -0
  62. tailcam/mcp/toolctx.py +80 -0
  63. tailcam/mcp/tools.py +1151 -0
  64. tailcam/mcp/transport_http.py +235 -0
  65. tailcam/mcp/transport_stdio.py +95 -0
  66. tailcam/media/__init__.py +1 -0
  67. tailcam/media/capture_router.py +279 -0
  68. tailcam/media/gallery.py +95 -0
  69. tailcam/media/recorder.py +247 -0
  70. tailcam/media/snapshot.py +66 -0
  71. tailcam/media/video_sink.py +248 -0
  72. tailcam/migrate.py +126 -0
  73. tailcam/motion/__init__.py +1 -0
  74. tailcam/motion/detector.py +63 -0
  75. tailcam/motion/events.py +39 -0
  76. tailcam/motion/worker.py +193 -0
  77. tailcam/notify/__init__.py +0 -0
  78. tailcam/notify/service.py +262 -0
  79. tailcam/paths.py +152 -0
  80. tailcam/persistence/__init__.py +1 -0
  81. tailcam/persistence/models.py +209 -0
  82. tailcam/persistence/store.py +1230 -0
  83. tailcam/plugins/__init__.py +0 -0
  84. tailcam/plugins/builtin/__init__.py +0 -0
  85. tailcam/plugins/builtin/ai_providers.py +34 -0
  86. tailcam/plugins/builtin/channels.py +72 -0
  87. tailcam/plugins/hookspecs.py +111 -0
  88. tailcam/plugins/market.py +259 -0
  89. tailcam/plugins/registry.py +140 -0
  90. tailcam/plugins/sdk.py +98 -0
  91. tailcam/proc.py +25 -0
  92. tailcam/security/__init__.py +5 -0
  93. tailcam/security/principal.py +167 -0
  94. tailcam/service/__init__.py +1 -0
  95. tailcam/service/installer.py +364 -0
  96. tailcam/streaming/__init__.py +1 -0
  97. tailcam/streaming/backend.py +24 -0
  98. tailcam/streaming/encoder.py +14 -0
  99. tailcam/streaming/mjpeg.py +118 -0
  100. tailcam/tailscale/__init__.py +1 -0
  101. tailcam/tailscale/client.py +200 -0
  102. tailcam/timelapse/__init__.py +7 -0
  103. tailcam/timelapse/analyzer.py +169 -0
  104. tailcam/timelapse/ffmpeg.py +199 -0
  105. tailcam/timelapse/presets.py +64 -0
  106. tailcam/timelapse/rife.py +76 -0
  107. tailcam/timelapse/service.py +570 -0
  108. tailcam/timelapse/worker.py +122 -0
  109. tailcam/training/__init__.py +8 -0
  110. tailcam/training/engine.py +58 -0
  111. tailcam/training/inference.py +351 -0
  112. tailcam/training/runner.py +209 -0
  113. tailcam/training/service.py +508 -0
  114. tailcam/update.py +110 -0
  115. tailcam/web/__init__.py +1 -0
  116. tailcam/web/app.py +102 -0
  117. tailcam/web/context.py +493 -0
  118. tailcam/web/deps.py +11 -0
  119. tailcam/web/routes_active.py +201 -0
  120. tailcam/web/routes_api.py +1884 -0
  121. tailcam/web/routes_fleet_v1.py +172 -0
  122. tailcam/web/routes_node_v1.py +189 -0
  123. tailcam/web/routes_pages.py +46 -0
  124. tailcam/web/routes_proxy.py +88 -0
  125. tailcam/web/routes_remote.py +162 -0
  126. tailcam/web/routes_stream.py +168 -0
  127. tailcam/web/schemas.py +960 -0
  128. tailcam/web/security.py +126 -0
  129. tailcam/web/spa/apple-touch-icon-light.png +0 -0
  130. tailcam/web/spa/apple-touch-icon.png +0 -0
  131. tailcam/web/spa/assets/index-CEMCQF_y.css +1 -0
  132. tailcam/web/spa/assets/index-cqHUOMzL.js +2343 -0
  133. tailcam/web/spa/favicon-16.png +0 -0
  134. tailcam/web/spa/favicon-32.png +0 -0
  135. tailcam/web/spa/favicon.ico +0 -0
  136. tailcam/web/spa/favicon.svg +9 -0
  137. tailcam/web/spa/icon-192-maskable.png +0 -0
  138. tailcam/web/spa/icon-192.png +0 -0
  139. tailcam/web/spa/icon-512-light.png +0 -0
  140. tailcam/web/spa/icon-512-maskable.png +0 -0
  141. tailcam/web/spa/icon-512.png +0 -0
  142. tailcam/web/spa/index.html +26 -0
  143. tailcam/web/spa/manifest.webmanifest +1 -0
  144. tailcam/web/spa/registerSW.js +1 -0
  145. tailcam/web/spa/sw.js +1 -0
  146. tailcam/web/spa/workbox-abeb32eb.js +1 -0
  147. tailcam/web/static/css/app.css +95 -0
  148. tailcam/web/static/js/app.js +232 -0
  149. tailcam/web/templates/base.html +25 -0
  150. tailcam/web/templates/camera.html +78 -0
  151. tailcam/web/templates/events.html +23 -0
  152. tailcam/web/templates/gallery.html +25 -0
  153. tailcam/web/templates/index.html +32 -0
  154. tailcam-1.8.3.dist-info/METADATA +648 -0
  155. tailcam-1.8.3.dist-info/RECORD +158 -0
  156. tailcam-1.8.3.dist-info/WHEEL +4 -0
  157. tailcam-1.8.3.dist-info/entry_points.txt +2 -0
  158. tailcam-1.8.3.dist-info/licenses/LICENSE +21 -0
tailcam/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """TailCam — view any webcam from anywhere over Tailscale."""
2
+
3
+ __version__ = "1.8.3"
tailcam/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from tailcam.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,20 @@
1
+ """Human-in-the-loop active learning.
2
+
3
+ A labeling model watches frames from your cameras (or an existing dataset),
4
+ keeps confident detections as machine labels, and sends only the uncertain
5
+ frames to Label Studio for human review. Reviewed annotations sync back into
6
+ the training dataset, which then fine-tunes the model of your choice — YOLO
7
+ (the existing Ultralytics pipeline), Florence-2, or Qwen2.5-VL via Unsloth.
8
+
9
+ Module map:
10
+
11
+ - :mod:`annotations` — the canonical TailCam annotation format and converters
12
+ to/from Label Studio and per-model training formats.
13
+ - :mod:`backends` — the labeling-model abstraction (built-in YOLO, trained/BYO
14
+ models, Ollama, Florence-2, Qwen2.5-VL) with availability reporting.
15
+ - :mod:`florence` / :mod:`qwen` — the VLM backends (lazy heavy imports,
16
+ graceful degradation when torch/CUDA are missing).
17
+ - :mod:`labelstudio` — Label Studio integration via the official Python SDK.
18
+ - :mod:`service` — the ActiveLearningService loop: capture → infer → route →
19
+ review → sync → fine-tune.
20
+ """
@@ -0,0 +1,230 @@
1
+ """Canonical annotation format + converters (Label Studio ⇄ TailCam ⇄ models).
2
+
3
+ TailCam's internal region format is the one the annotation editor and YOLO
4
+ export already use: normalized center/size boxes ``(cx, cy, w, h)`` in 0..1.
5
+ Everything converts through :class:`FrameAnnotation` / :class:`AnnotatedFrame`
6
+ so adding a new annotation source or training format only means one converter.
7
+
8
+ Label Studio rectangles are percent-based top-left/size (``x, y, width,
9
+ height`` in 0..100), so the conversions here are pure arithmetic — no image
10
+ decoding required.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import time
17
+ from dataclasses import asdict, dataclass, field
18
+
19
+ # Annotation provenance values (who produced the label).
20
+ SOURCE_MACHINE = "machine"
21
+ SOURCE_HUMAN = "human"
22
+ SOURCE_REVIEWED = "reviewed-machine"
23
+
24
+
25
+ @dataclass
26
+ class FrameAnnotation:
27
+ """One labeled region. Coordinates are normalized 0..1 center/size — the
28
+ same layout as SampleAnnotationRecord, so store writes are direct."""
29
+
30
+ label: str
31
+ cx: float
32
+ cy: float
33
+ w: float
34
+ h: float
35
+ confidence: float | None = None
36
+ source: str = SOURCE_MACHINE # machine | human | reviewed-machine
37
+
38
+
39
+ @dataclass
40
+ class AnnotatedFrame:
41
+ """A frame plus everything the pipeline knows about it — the canonical
42
+ interchange record between capture, Label Studio, and training export."""
43
+
44
+ image_path: str
45
+ annotations: list[FrameAnnotation] = field(default_factory=list)
46
+ camera_id: str = "" # camera source ("" for dataset-file frames)
47
+ source_video: str = "" # originating video/recording path, if any
48
+ timestamp: float = 0.0
49
+ frame_number: int = 0
50
+ labeling_model: str = "" # backend id that pre-labeled the frame
51
+ dataset_version: int = 1
52
+
53
+ def to_dict(self) -> dict:
54
+ return asdict(self)
55
+
56
+
57
+ def clamp01(value: object) -> float:
58
+ try:
59
+ return min(1.0, max(0.0, float(value))) # type: ignore[arg-type]
60
+ except (TypeError, ValueError):
61
+ return 0.0
62
+
63
+
64
+ # -- Label Studio ------------------------------------------------------------
65
+
66
+ # Value keys of a Label Studio rectangle result (percent of image size).
67
+ _LS_FROM_NAME = "label"
68
+ _LS_TO_NAME = "image"
69
+
70
+
71
+ def label_config_xml(labels: list[str]) -> str:
72
+ """The default object-detection labeling config: multiple rectangles per
73
+ image, one class per rectangle. Extend by editing the project in Label
74
+ Studio (classification/keypoints/segmentation tags can be added alongside)."""
75
+ label_tags = "\n".join(
76
+ f' <Label value="{_xml_escape(lb)}"/>' for lb in labels if lb.strip()
77
+ )
78
+ return (
79
+ "<View>\n"
80
+ f' <Image name="{_LS_TO_NAME}" value="$image" zoom="true"/>\n'
81
+ f' <RectangleLabels name="{_LS_FROM_NAME}" toName="{_LS_TO_NAME}">\n'
82
+ f"{label_tags}\n"
83
+ " </RectangleLabels>\n"
84
+ "</View>"
85
+ )
86
+
87
+
88
+ def _xml_escape(text: str) -> str:
89
+ return (
90
+ text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
91
+ .replace('"', "&quot;")
92
+ )
93
+
94
+
95
+ def to_label_studio_predictions(
96
+ annotations: list[FrameAnnotation], model_id: str
97
+ ) -> dict:
98
+ """Build the ``predictions`` entry for a Label Studio task so the reviewer
99
+ starts from the model's boxes instead of a blank image."""
100
+ results = []
101
+ for i, a in enumerate(annotations):
102
+ x = clamp01(a.cx - a.w / 2) * 100.0
103
+ y = clamp01(a.cy - a.h / 2) * 100.0
104
+ results.append(
105
+ {
106
+ "id": f"pred-{i}",
107
+ "type": "rectanglelabels",
108
+ "from_name": _LS_FROM_NAME,
109
+ "to_name": _LS_TO_NAME,
110
+ "original_width": 100,
111
+ "original_height": 100,
112
+ "value": {
113
+ "x": x,
114
+ "y": y,
115
+ "width": min(a.w * 100.0, 100.0 - x),
116
+ "height": min(a.h * 100.0, 100.0 - y),
117
+ "rotation": 0,
118
+ "rectanglelabels": [a.label],
119
+ },
120
+ "score": a.confidence,
121
+ }
122
+ )
123
+ scores = [a.confidence for a in annotations if a.confidence is not None]
124
+ return {
125
+ "model_version": model_id,
126
+ "score": min(scores) if scores else None,
127
+ "result": results,
128
+ }
129
+
130
+
131
+ def to_label_studio_task(frame: AnnotatedFrame, image_data: str, model_id: str) -> dict:
132
+ """One importable Label Studio task. ``image_data`` is what LS displays —
133
+ a data URI (uploaded inline, works with zero storage setup) or a served URL."""
134
+ task: dict = {
135
+ "data": {
136
+ "image": image_data,
137
+ "meta": {
138
+ "tailcam_image_path": frame.image_path,
139
+ "camera_id": frame.camera_id,
140
+ "source_video": frame.source_video,
141
+ "timestamp": frame.timestamp,
142
+ "frame_number": frame.frame_number,
143
+ "labeling_model": frame.labeling_model,
144
+ },
145
+ }
146
+ }
147
+ if frame.annotations:
148
+ task["predictions"] = [to_label_studio_predictions(frame.annotations, model_id)]
149
+ return task
150
+
151
+
152
+ def from_label_studio_result(result: list[dict]) -> list[FrameAnnotation]:
153
+ """Convert one completed Label Studio annotation's ``result`` list into
154
+ canonical boxes. Non-rectangle regions (relations, classifications added by
155
+ a customized config) are skipped — they don't map to boxes."""
156
+ boxes: list[FrameAnnotation] = []
157
+ for item in result or []:
158
+ if item.get("type") != "rectanglelabels":
159
+ continue
160
+ value = item.get("value") or {}
161
+ labels = value.get("rectanglelabels") or []
162
+ if not labels:
163
+ continue
164
+ w = clamp01(float(value.get("width", 0)) / 100.0)
165
+ h = clamp01(float(value.get("height", 0)) / 100.0)
166
+ if w <= 0 or h <= 0:
167
+ continue
168
+ x = clamp01(float(value.get("x", 0)) / 100.0)
169
+ y = clamp01(float(value.get("y", 0)) / 100.0)
170
+ boxes.append(
171
+ FrameAnnotation(
172
+ label=str(labels[0]),
173
+ cx=clamp01(x + w / 2),
174
+ cy=clamp01(y + h / 2),
175
+ w=w,
176
+ h=h,
177
+ confidence=None,
178
+ source=SOURCE_HUMAN,
179
+ )
180
+ )
181
+ return boxes
182
+
183
+
184
+ # -- model training formats ---------------------------------------------------
185
+
186
+
187
+ def to_yolo_lines(annotations: list[FrameAnnotation], class_idx: dict[str, int]) -> list[str]:
188
+ """YOLO detection label lines: ``<class> cx cy w h`` (normalized)."""
189
+ return [
190
+ f"{class_idx[a.label]} {a.cx:.6f} {a.cy:.6f} {a.w:.6f} {a.h:.6f}"
191
+ for a in annotations
192
+ if a.label in class_idx
193
+ ]
194
+
195
+
196
+ def to_florence_od_string(
197
+ annotations: list[FrameAnnotation], width: int = 1000, height: int = 1000
198
+ ) -> str:
199
+ """Florence-2 OD target string: ``label<loc_x1><loc_y1><loc_x2><loc_y2>…``
200
+ with coordinates binned to 0..999 (the model's location vocabulary)."""
201
+ parts: list[str] = []
202
+ for a in annotations:
203
+ x1 = int(clamp01(a.cx - a.w / 2) * (width - 1))
204
+ y1 = int(clamp01(a.cy - a.h / 2) * (height - 1))
205
+ x2 = int(clamp01(a.cx + a.w / 2) * (width - 1))
206
+ y2 = int(clamp01(a.cy + a.h / 2) * (height - 1))
207
+ parts.append(f"{a.label}<loc_{x1}><loc_{y1}><loc_{x2}><loc_{y2}>")
208
+ return "".join(parts)
209
+
210
+
211
+ def to_qwen_json(annotations: list[FrameAnnotation], width: int, height: int) -> str:
212
+ """Qwen2.5-VL grounding target: a JSON list of absolute-pixel boxes, the
213
+ format its detection prompts are trained to emit."""
214
+ objects = [
215
+ {
216
+ "bbox_2d": [
217
+ int(clamp01(a.cx - a.w / 2) * width),
218
+ int(clamp01(a.cy - a.h / 2) * height),
219
+ int(clamp01(a.cx + a.w / 2) * width),
220
+ int(clamp01(a.cy + a.h / 2) * height),
221
+ ],
222
+ "label": a.label,
223
+ }
224
+ for a in annotations
225
+ ]
226
+ return json.dumps(objects)
227
+
228
+
229
+ def now_ts() -> float:
230
+ return time.time()
@@ -0,0 +1,270 @@
1
+ """Labeling-model abstraction for the active learning pipeline.
2
+
3
+ Every model that can watch frames — the built-in YOLO detector, a trained/BYO
4
+ model from the registry, Ollama, Florence-2, Qwen2.5-VL — is wrapped in a
5
+ :class:`LabelingBackend` so the pipeline itself never cares which one runs.
6
+ Backends report their own availability (missing package, missing GPU, wrong
7
+ OS) as human-readable text instead of raising, so the UI can explain what's
8
+ supported on this machine.
9
+
10
+ Backend ids are stable strings persisted in config:
11
+
12
+ - ``builtin`` — the plug-and-play object detector.
13
+ - ``model:<id>`` — a trained/BYO *detection* model from the registry.
14
+ - ``ollama`` — the Ollama vision analyzer (whole-frame label, no boxes).
15
+ - ``florence2`` — Florence-2 open-vocabulary detection (see :mod:`florence`).
16
+ - ``qwen2.5-vl`` — Qwen2.5-VL detection via transformers (see :mod:`qwen`).
17
+
18
+ New models plug in by adding a backend class here (or its own module) and one
19
+ entry in :func:`list_labeling_backends` — the pipeline and UI pick it up.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import sys
26
+ from dataclasses import dataclass
27
+ from typing import Any, Protocol
28
+
29
+ import numpy as np
30
+
31
+ from tailcam.ai.analyzer import Detection, OllamaAnalyzer
32
+ from tailcam.ai.detector import BuiltinDetector
33
+ from tailcam.logging_setup import get_logger
34
+ from tailcam.persistence.store import Store
35
+
36
+ log = get_logger(__name__)
37
+
38
+
39
+ @dataclass
40
+ class BackendInfo:
41
+ """What the UI needs to render a model choice: can it run here, and if
42
+ not, what would make it work."""
43
+
44
+ id: str
45
+ name: str
46
+ kind: str # "detector" | "vlm" | "classifier"
47
+ available: bool
48
+ detail: str = "" # availability note ("ready", or what's missing)
49
+ boxes: bool = True # produces bounding boxes (vs whole-frame labels)
50
+
51
+
52
+ @dataclass
53
+ class FinetuneInfo:
54
+ """One fine-tunable model target and whether this machine can train it."""
55
+
56
+ id: str
57
+ name: str
58
+ available: bool
59
+ detail: str = ""
60
+
61
+
62
+ class LabelingBackend(Protocol):
63
+ """What the active learning loop needs from any labeling model."""
64
+
65
+ def info(self) -> BackendInfo: ...
66
+
67
+ def predict(self, image: np.ndarray) -> list[Detection] | None:
68
+ """Detections for one frame, or None when inference failed."""
69
+ ...
70
+
71
+
72
+ # -- existing TailCam models ---------------------------------------------------
73
+
74
+
75
+ class BuiltinBackend:
76
+ """The zero-setup object detector (80 COCO classes)."""
77
+
78
+ def __init__(self, detector: BuiltinDetector) -> None:
79
+ self._detector = detector
80
+
81
+ def info(self) -> BackendInfo:
82
+ s = self._detector.status()
83
+ detail = "ready" if s.status == "ready" else (s.detail or s.error or s.status)
84
+ return BackendInfo(
85
+ id="builtin",
86
+ name=f"Built-in detector ({s.model or 'YOLO'})",
87
+ kind="detector",
88
+ available=s.status != "error",
89
+ detail=detail,
90
+ )
91
+
92
+ def predict(self, image: np.ndarray) -> list[Detection] | None:
93
+ self._detector.ensure_ready()
94
+ return self._detector.detect(image)
95
+
96
+
97
+ class RegistryModelBackend:
98
+ """A trained / bring-your-own detection model from the model registry."""
99
+
100
+ def __init__(self, store: Store, model_id: int, min_conf: float = 0.05) -> None:
101
+ self._store = store
102
+ self.model_id = model_id
103
+ self._min_conf = min_conf
104
+ # Lazily-built LocalDetector (imported on first use; torch is optional).
105
+ self._detector: Any = None
106
+
107
+ def info(self) -> BackendInfo:
108
+ record = self._store.get_model(self.model_id)
109
+ bid = f"model:{self.model_id}"
110
+ if record is None:
111
+ return BackendInfo(
112
+ id=bid, name=f"model #{self.model_id}", kind="detector",
113
+ available=False, detail="model no longer exists",
114
+ )
115
+ if record.task != "detection":
116
+ return BackendInfo(
117
+ id=bid, name=record.name, kind="detector", available=False,
118
+ detail="classification model — pick a detection model for boxes",
119
+ )
120
+ if not record.path:
121
+ return BackendInfo(
122
+ id=bid, name=record.name, kind="detector", available=False,
123
+ detail="no weights yet — train it first",
124
+ )
125
+ return BackendInfo(
126
+ id=bid, name=record.name, kind="detector", available=True, detail="ready"
127
+ )
128
+
129
+ def predict(self, image: np.ndarray) -> list[Detection] | None:
130
+ if self._detector is None:
131
+ from tailcam.training.inference import LocalDetector
132
+
133
+ record = self._store.get_model(self.model_id)
134
+ if record is None or not record.path:
135
+ return None
136
+ # A deliberately low floor so *uncertain* boxes still surface —
137
+ # the active-learning threshold does the actual routing.
138
+ self._detector = LocalDetector(record.path, conf=self._min_conf)
139
+ return self._detector.detect(image)
140
+
141
+
142
+ class OllamaBackend:
143
+ """The Ollama vision analyzer. Classification-only: the frame's label is
144
+ reported as one full-frame region so it flows through the same routing."""
145
+
146
+ def __init__(self, analyzer: OllamaAnalyzer) -> None:
147
+ self._analyzer = analyzer
148
+
149
+ def info(self) -> BackendInfo:
150
+ enabled = self._analyzer.enabled
151
+ return BackendInfo(
152
+ id="ollama",
153
+ name=f"Ollama ({self._analyzer.config.model})",
154
+ kind="classifier",
155
+ available=enabled,
156
+ detail="ready" if enabled else "enable AI analysis and start Ollama first",
157
+ boxes=False,
158
+ )
159
+
160
+ def predict(self, image: np.ndarray) -> list[Detection] | None:
161
+ result = self._analyzer.analyze(image)
162
+ if result is None:
163
+ return None
164
+ if result.label == "nothing":
165
+ return []
166
+ return [
167
+ Detection(
168
+ label=result.label, confidence=result.confidence,
169
+ cx=0.5, cy=0.5, w=1.0, h=1.0,
170
+ )
171
+ ]
172
+
173
+
174
+ # -- registry -------------------------------------------------------------------
175
+
176
+
177
+ def build_labeling_backend(
178
+ backend_id: str,
179
+ store: Store,
180
+ detector: BuiltinDetector,
181
+ analyzer: OllamaAnalyzer,
182
+ ) -> LabelingBackend | None:
183
+ """Instantiate the backend a config string names, or None if unknown."""
184
+ if backend_id == "builtin":
185
+ return BuiltinBackend(detector)
186
+ if backend_id == "ollama":
187
+ return OllamaBackend(analyzer)
188
+ if backend_id == "florence2":
189
+ from tailcam.activelearning.florence import Florence2Backend
190
+
191
+ return Florence2Backend()
192
+ if backend_id == "qwen2.5-vl":
193
+ from tailcam.activelearning.qwen import QwenVLBackend
194
+
195
+ return QwenVLBackend()
196
+ if backend_id.startswith("model:"):
197
+ try:
198
+ model_id = int(backend_id.split(":", 1)[1])
199
+ except ValueError:
200
+ return None
201
+ return RegistryModelBackend(store, model_id)
202
+ return None
203
+
204
+
205
+ def list_labeling_backends(
206
+ store: Store, detector: BuiltinDetector, analyzer: OllamaAnalyzer
207
+ ) -> list[BackendInfo]:
208
+ """Everything the labeling-model selector can offer, with availability."""
209
+ from tailcam.activelearning.florence import Florence2Backend
210
+ from tailcam.activelearning.qwen import QwenVLBackend
211
+
212
+ infos = [BuiltinBackend(detector).info()]
213
+ for record in store.list_models():
214
+ if record.task == "detection" and record.id is not None:
215
+ infos.append(RegistryModelBackend(store, record.id).info())
216
+ infos.append(OllamaBackend(analyzer).info())
217
+ infos.append(Florence2Backend().info())
218
+ infos.append(QwenVLBackend().info())
219
+ return infos
220
+
221
+
222
+ def list_finetune_backends(store: Store) -> list[FinetuneInfo]:
223
+ """Fine-tune targets with per-machine availability (GPU, packages, OS)."""
224
+ from tailcam.activelearning.florence import florence_finetune_support
225
+ from tailcam.activelearning.qwen import qwen_finetune_support
226
+ from tailcam.training.engine import engine_available, torch_device
227
+
228
+ yolo_ok = engine_available()
229
+ device = torch_device()
230
+ infos = [
231
+ FinetuneInfo(
232
+ id="yolo",
233
+ name="TailCam YOLO (Ultralytics)",
234
+ available=yolo_ok,
235
+ detail=(
236
+ f"ready · device: {device}" if yolo_ok
237
+ else "install the training engine: pip install 'tailcam[training]'"
238
+ ),
239
+ )
240
+ ]
241
+ fl_ok, fl_detail = florence_finetune_support()
242
+ infos.append(FinetuneInfo(id="florence2", name="Florence-2", available=fl_ok,
243
+ detail=fl_detail))
244
+ qw_ok, qw_detail = qwen_finetune_support()
245
+ infos.append(FinetuneInfo(id="qwen2.5-vl", name="Qwen2.5-VL (Unsloth)",
246
+ available=qw_ok, detail=qw_detail))
247
+ return infos
248
+
249
+
250
+ def platform_summary() -> dict:
251
+ """OS + accelerator facts for the UI's capability notes."""
252
+ from tailcam.training.engine import torch_device
253
+
254
+ device = torch_device()
255
+ return {
256
+ "os": {"darwin": "macos", "win32": "windows"}.get(sys.platform, "linux"),
257
+ "device": device,
258
+ "cuda": device == "cuda",
259
+ "mps": device == "mps",
260
+ }
261
+
262
+
263
+ def model_classes(store: Store, model_id: int) -> list[str]:
264
+ record = store.get_model(model_id)
265
+ if record is None:
266
+ return []
267
+ try:
268
+ return list(json.loads(record.classes_json) or [])
269
+ except (ValueError, TypeError):
270
+ return []