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.
- tailcam/__init__.py +3 -0
- tailcam/__main__.py +4 -0
- tailcam/activelearning/__init__.py +20 -0
- tailcam/activelearning/annotations.py +230 -0
- tailcam/activelearning/backends.py +270 -0
- tailcam/activelearning/florence.py +246 -0
- tailcam/activelearning/labelstudio.py +284 -0
- tailcam/activelearning/qwen.py +298 -0
- tailcam/activelearning/service.py +656 -0
- tailcam/ai/__init__.py +8 -0
- tailcam/ai/analyzer.py +185 -0
- tailcam/ai/detector.py +367 -0
- tailcam/ai/pull.py +121 -0
- tailcam/ai/remote.py +151 -0
- tailcam/camera/__init__.py +1 -0
- tailcam/camera/enumerate.py +158 -0
- tailcam/camera/frame.py +131 -0
- tailcam/camera/manager.py +261 -0
- tailcam/camera/properties.py +45 -0
- tailcam/camera/source.py +376 -0
- tailcam/camera/transforms.py +101 -0
- tailcam/camera/worker.py +201 -0
- tailcam/cli.py +868 -0
- tailcam/cluster/__init__.py +2 -0
- tailcam/cluster/fleet.py +57 -0
- tailcam/cluster/remote_feed.py +227 -0
- tailcam/cluster/service.py +238 -0
- tailcam/config.py +538 -0
- tailcam/desktop/__init__.py +40 -0
- tailcam/desktop/app.py +182 -0
- tailcam/desktop/assets/app-icon-512.png +0 -0
- tailcam/desktop/assets/server_down.html +31 -0
- tailcam/desktop/assets/tray-icon-template.png +0 -0
- tailcam/desktop/assets/tray-icon.png +0 -0
- tailcam/desktop/linux_desktop.py +149 -0
- tailcam/desktop/macos_bundle.py +144 -0
- tailcam/desktop/menu.py +92 -0
- tailcam/desktop/nodes.py +51 -0
- tailcam/desktop/server.py +96 -0
- tailcam/desktop/state.py +56 -0
- tailcam/desktop/tray.py +81 -0
- tailcam/desktop/updates.py +29 -0
- tailcam/desktop/window.py +59 -0
- tailcam/desktop/windows_shortcut.py +136 -0
- tailcam/hostinfo.py +125 -0
- tailcam/integrations/__init__.py +1 -0
- tailcam/integrations/base.py +100 -0
- tailcam/integrations/homeassistant.py +323 -0
- tailcam/integrations/homekit.py +329 -0
- tailcam/logging_setup.py +26 -0
- tailcam/management/__init__.py +7 -0
- tailcam/management/audit.py +49 -0
- tailcam/management/capabilities.py +43 -0
- tailcam/management/health.py +190 -0
- tailcam/mcp/__init__.py +56 -0
- tailcam/mcp/client.py +378 -0
- tailcam/mcp/errors.py +63 -0
- tailcam/mcp/prompts.py +109 -0
- tailcam/mcp/protocol.py +106 -0
- tailcam/mcp/resources.py +98 -0
- tailcam/mcp/server.py +289 -0
- tailcam/mcp/toolctx.py +80 -0
- tailcam/mcp/tools.py +1151 -0
- tailcam/mcp/transport_http.py +235 -0
- tailcam/mcp/transport_stdio.py +95 -0
- tailcam/media/__init__.py +1 -0
- tailcam/media/capture_router.py +279 -0
- tailcam/media/gallery.py +95 -0
- tailcam/media/recorder.py +247 -0
- tailcam/media/snapshot.py +66 -0
- tailcam/media/video_sink.py +248 -0
- tailcam/migrate.py +126 -0
- tailcam/motion/__init__.py +1 -0
- tailcam/motion/detector.py +63 -0
- tailcam/motion/events.py +39 -0
- tailcam/motion/worker.py +193 -0
- tailcam/notify/__init__.py +0 -0
- tailcam/notify/service.py +262 -0
- tailcam/paths.py +152 -0
- tailcam/persistence/__init__.py +1 -0
- tailcam/persistence/models.py +209 -0
- tailcam/persistence/store.py +1230 -0
- tailcam/plugins/__init__.py +0 -0
- tailcam/plugins/builtin/__init__.py +0 -0
- tailcam/plugins/builtin/ai_providers.py +34 -0
- tailcam/plugins/builtin/channels.py +72 -0
- tailcam/plugins/hookspecs.py +111 -0
- tailcam/plugins/market.py +259 -0
- tailcam/plugins/registry.py +140 -0
- tailcam/plugins/sdk.py +98 -0
- tailcam/proc.py +25 -0
- tailcam/security/__init__.py +5 -0
- tailcam/security/principal.py +167 -0
- tailcam/service/__init__.py +1 -0
- tailcam/service/installer.py +364 -0
- tailcam/streaming/__init__.py +1 -0
- tailcam/streaming/backend.py +24 -0
- tailcam/streaming/encoder.py +14 -0
- tailcam/streaming/mjpeg.py +118 -0
- tailcam/tailscale/__init__.py +1 -0
- tailcam/tailscale/client.py +200 -0
- tailcam/timelapse/__init__.py +7 -0
- tailcam/timelapse/analyzer.py +169 -0
- tailcam/timelapse/ffmpeg.py +199 -0
- tailcam/timelapse/presets.py +64 -0
- tailcam/timelapse/rife.py +76 -0
- tailcam/timelapse/service.py +570 -0
- tailcam/timelapse/worker.py +122 -0
- tailcam/training/__init__.py +8 -0
- tailcam/training/engine.py +58 -0
- tailcam/training/inference.py +351 -0
- tailcam/training/runner.py +209 -0
- tailcam/training/service.py +508 -0
- tailcam/update.py +110 -0
- tailcam/web/__init__.py +1 -0
- tailcam/web/app.py +102 -0
- tailcam/web/context.py +493 -0
- tailcam/web/deps.py +11 -0
- tailcam/web/routes_active.py +201 -0
- tailcam/web/routes_api.py +1884 -0
- tailcam/web/routes_fleet_v1.py +172 -0
- tailcam/web/routes_node_v1.py +189 -0
- tailcam/web/routes_pages.py +46 -0
- tailcam/web/routes_proxy.py +88 -0
- tailcam/web/routes_remote.py +162 -0
- tailcam/web/routes_stream.py +168 -0
- tailcam/web/schemas.py +960 -0
- tailcam/web/security.py +126 -0
- tailcam/web/spa/apple-touch-icon-light.png +0 -0
- tailcam/web/spa/apple-touch-icon.png +0 -0
- tailcam/web/spa/assets/index-CEMCQF_y.css +1 -0
- tailcam/web/spa/assets/index-cqHUOMzL.js +2343 -0
- tailcam/web/spa/favicon-16.png +0 -0
- tailcam/web/spa/favicon-32.png +0 -0
- tailcam/web/spa/favicon.ico +0 -0
- tailcam/web/spa/favicon.svg +9 -0
- tailcam/web/spa/icon-192-maskable.png +0 -0
- tailcam/web/spa/icon-192.png +0 -0
- tailcam/web/spa/icon-512-light.png +0 -0
- tailcam/web/spa/icon-512-maskable.png +0 -0
- tailcam/web/spa/icon-512.png +0 -0
- tailcam/web/spa/index.html +26 -0
- tailcam/web/spa/manifest.webmanifest +1 -0
- tailcam/web/spa/registerSW.js +1 -0
- tailcam/web/spa/sw.js +1 -0
- tailcam/web/spa/workbox-abeb32eb.js +1 -0
- tailcam/web/static/css/app.css +95 -0
- tailcam/web/static/js/app.js +232 -0
- tailcam/web/templates/base.html +25 -0
- tailcam/web/templates/camera.html +78 -0
- tailcam/web/templates/events.html +23 -0
- tailcam/web/templates/gallery.html +25 -0
- tailcam/web/templates/index.html +32 -0
- tailcam-1.8.3.dist-info/METADATA +648 -0
- tailcam-1.8.3.dist-info/RECORD +158 -0
- tailcam-1.8.3.dist-info/WHEEL +4 -0
- tailcam-1.8.3.dist-info/entry_points.txt +2 -0
- tailcam-1.8.3.dist-info/licenses/LICENSE +21 -0
tailcam/__init__.py
ADDED
tailcam/__main__.py
ADDED
|
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
|
91
|
+
.replace('"', """)
|
|
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 []
|