khoroos 0.1.0__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.
- khoroos/__init__.py +110 -0
- khoroos/annotations/__init__.py +6 -0
- khoroos/annotations/codecs.py +164 -0
- khoroos/annotations/detections.py +94 -0
- khoroos/cli.py +306 -0
- khoroos/config.py +337 -0
- khoroos/environment.py +75 -0
- khoroos/interfaces.py +166 -0
- khoroos/models/__init__.py +1 -0
- khoroos/models/action.py +116 -0
- khoroos/models/card.py +71 -0
- khoroos/models/detector.py +168 -0
- khoroos/models/metadata.py +30 -0
- khoroos/models/registry.py +138 -0
- khoroos/models/selection.py +82 -0
- khoroos/pipeline/__init__.py +1 -0
- khoroos/pipeline/analyze.py +438 -0
- khoroos/pipeline/components.py +92 -0
- khoroos/pipeline/jobs.py +311 -0
- khoroos/pipeline/runner.py +144 -0
- khoroos/pipeline/types.py +236 -0
- khoroos/py.typed +0 -0
- khoroos/statistics/__init__.py +1 -0
- khoroos/statistics/export.py +122 -0
- khoroos/statistics/metrics.py +300 -0
- khoroos/tracking/__init__.py +1 -0
- khoroos/tracking/kalman.py +84 -0
- khoroos/tracking/tracker.py +176 -0
- khoroos/tracking/tracklets.py +266 -0
- khoroos/video/__init__.py +1 -0
- khoroos/video/reader.py +148 -0
- khoroos/video/writer.py +251 -0
- khoroos/web/__init__.py +1 -0
- khoroos/web/app.py +51 -0
- khoroos/web/routes.py +312 -0
- khoroos/web/static/Khoroos-transparent.png +0 -0
- khoroos/web/static/app.js +1566 -0
- khoroos/web/static/index.html +311 -0
- khoroos/web/static/khoroos.png +0 -0
- khoroos/web/static/styles.css +1135 -0
- khoroos-0.1.0.dist-info/METADATA +186 -0
- khoroos-0.1.0.dist-info/RECORD +45 -0
- khoroos-0.1.0.dist-info/WHEEL +4 -0
- khoroos-0.1.0.dist-info/entry_points.txt +3 -0
- khoroos-0.1.0.dist-info/licenses/LICENSE +73 -0
khoroos/__init__.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Khoroos — a toolkit for poultry behavior analysis.
|
|
2
|
+
|
|
3
|
+
Typical use::
|
|
4
|
+
|
|
5
|
+
from khoroos import analyze_video
|
|
6
|
+
|
|
7
|
+
result = analyze_video("farm.mp4", preset="balanced")
|
|
8
|
+
print(result.metrics["time_budget"])
|
|
9
|
+
|
|
10
|
+
Or from the command line::
|
|
11
|
+
|
|
12
|
+
khoroos ui # web interface
|
|
13
|
+
khoroos analyze farm.mp4 -o out # batch analysis
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from khoroos.config import (
|
|
17
|
+
ACTION_CLASSES,
|
|
18
|
+
BEHAVIOUR_GROUPS,
|
|
19
|
+
PRESETS,
|
|
20
|
+
UNCERTAIN_LABEL,
|
|
21
|
+
AnalysisParams,
|
|
22
|
+
Settings,
|
|
23
|
+
get_settings,
|
|
24
|
+
params_for_preset,
|
|
25
|
+
)
|
|
26
|
+
from khoroos.pipeline.types import (
|
|
27
|
+
ActionPrediction,
|
|
28
|
+
AnalysisResult,
|
|
29
|
+
ProgressEvent,
|
|
30
|
+
Track,
|
|
31
|
+
Tracklet,
|
|
32
|
+
VideoInfo,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _installed_version() -> str:
|
|
37
|
+
"""Read the version from package metadata, so pyproject.toml stays its one source."""
|
|
38
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
return version("khoroos")
|
|
42
|
+
except PackageNotFoundError: # running from a source tree that was never installed
|
|
43
|
+
return "0.0.0+unknown"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__version__ = _installed_version()
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"ACTION_CLASSES",
|
|
50
|
+
"BEHAVIOUR_GROUPS",
|
|
51
|
+
"PRESETS",
|
|
52
|
+
"UNCERTAIN_LABEL",
|
|
53
|
+
"ActionPrediction",
|
|
54
|
+
"Detector",
|
|
55
|
+
"VideoClassifier",
|
|
56
|
+
"Tracker",
|
|
57
|
+
"VideoReader",
|
|
58
|
+
"PipelineComponents",
|
|
59
|
+
"SelectedClasses",
|
|
60
|
+
"AnalysisParams",
|
|
61
|
+
"AnalysisResult",
|
|
62
|
+
"AnalysisRunner",
|
|
63
|
+
"ProgressEvent",
|
|
64
|
+
"RunArtifacts",
|
|
65
|
+
"Settings",
|
|
66
|
+
"Track",
|
|
67
|
+
"Tracklet",
|
|
68
|
+
"VideoAnalyzer",
|
|
69
|
+
"VideoInfo",
|
|
70
|
+
"__version__",
|
|
71
|
+
"analyze_video",
|
|
72
|
+
"describe_environment",
|
|
73
|
+
"get_settings",
|
|
74
|
+
"params_for_preset",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
#: Names served lazily, and the module each comes from. Everything here transitively
|
|
78
|
+
#: imports torch.
|
|
79
|
+
_LAZY = {
|
|
80
|
+
"Detector": "khoroos.interfaces",
|
|
81
|
+
"VideoClassifier": "khoroos.interfaces",
|
|
82
|
+
"Tracker": "khoroos.interfaces",
|
|
83
|
+
"VideoReader": "khoroos.interfaces",
|
|
84
|
+
"PipelineComponents": "khoroos.pipeline.components",
|
|
85
|
+
"SelectedClasses": "khoroos.models.selection",
|
|
86
|
+
"AnalysisRunner": "khoroos.pipeline.runner",
|
|
87
|
+
"RunArtifacts": "khoroos.pipeline.runner",
|
|
88
|
+
"VideoAnalyzer": "khoroos.pipeline.analyze",
|
|
89
|
+
"analyze_video": "khoroos.pipeline.analyze",
|
|
90
|
+
"describe_environment": "khoroos.environment",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def __getattr__(name: str):
|
|
95
|
+
"""Defer the heavy pipeline import until something actually needs it.
|
|
96
|
+
|
|
97
|
+
Importing :mod:`khoroos` must stay cheap — the CLI touches it just to print a
|
|
98
|
+
version, and pulling in torch and transformers for that costs seconds.
|
|
99
|
+
"""
|
|
100
|
+
module_name = _LAZY.get(name)
|
|
101
|
+
if module_name is None:
|
|
102
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
103
|
+
|
|
104
|
+
from importlib import import_module
|
|
105
|
+
|
|
106
|
+
return getattr(import_module(module_name), name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def __dir__() -> list[str]:
|
|
110
|
+
return sorted(__all__)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Interchangeable per-image bounding-box annotation codecs."""
|
|
2
|
+
|
|
3
|
+
from khoroos.annotations.codecs import AnnotationCodec, CocoCodec, YoloCodec, annotation_codec
|
|
4
|
+
from khoroos.annotations.detections import Detections
|
|
5
|
+
|
|
6
|
+
__all__ = ["AnnotationCodec", "CocoCodec", "Detections", "YoloCodec", "annotation_codec"]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Per-image YOLO text and COCO JSON bounding-box interchange.
|
|
2
|
+
|
|
3
|
+
These codecs handle detection annotations, not segmentation or keypoints. COCO category
|
|
4
|
+
IDs are preserved explicitly; YOLO IDs are supplied by a caller-defined ordered mapping.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from khoroos.annotations.detections import Detections
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _size(width: int, height: int):
|
|
17
|
+
if width <= 0 or height <= 0:
|
|
18
|
+
raise ValueError("Image width and height must be positive")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _bounds(detections: Detections, width: int, height: int):
|
|
22
|
+
_size(width, height)
|
|
23
|
+
boxes = detections.boxes
|
|
24
|
+
if (
|
|
25
|
+
np.any(boxes < -1e-4)
|
|
26
|
+
or np.any(boxes[:, [0, 2]] > width + 1e-4)
|
|
27
|
+
or np.any(boxes[:, [1, 3]] > height + 1e-4)
|
|
28
|
+
):
|
|
29
|
+
raise ValueError("Annotation boxes must lie within image bounds")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AnnotationCodec(ABC):
|
|
33
|
+
"""Implement encode/decode to add another per-image annotation protocol."""
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def encode(self, detections: Detections, *, width: int, height: int) -> Any:
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def decode(self, payload: Any, *, width: int, height: int) -> Detections:
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class YoloCodec(AnnotationCodec):
|
|
45
|
+
"""Standard five-column YOLO labels: class cx cy w h, normalized to image size.
|
|
46
|
+
|
|
47
|
+
category_ids maps YOLO's contiguous zero-based IDs to canonical category IDs. Standard
|
|
48
|
+
YOLO labels have no scores: encoding drops scores and decoding assigns 1.0.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, category_ids: Sequence[int] | None = None):
|
|
52
|
+
self.category_ids = None if category_ids is None else list(category_ids)
|
|
53
|
+
if self.category_ids is not None and (
|
|
54
|
+
not self.category_ids
|
|
55
|
+
or len(set(self.category_ids)) != len(self.category_ids)
|
|
56
|
+
or any(not isinstance(i, int) or i < 0 for i in self.category_ids)
|
|
57
|
+
):
|
|
58
|
+
raise ValueError("category_ids must be unique nonnegative integers")
|
|
59
|
+
|
|
60
|
+
def encode(self, detections, *, width, height) -> str:
|
|
61
|
+
_bounds(detections, width, height)
|
|
62
|
+
lines = []
|
|
63
|
+
for box, category in zip(detections.boxes, detections.class_ids, strict=True):
|
|
64
|
+
category = int(category)
|
|
65
|
+
if self.category_ids is not None:
|
|
66
|
+
category = self.category_ids.index(category)
|
|
67
|
+
x1, y1, x2, y2 = box
|
|
68
|
+
values = [
|
|
69
|
+
(x1 + x2) / (2 * width),
|
|
70
|
+
(y1 + y2) / (2 * height),
|
|
71
|
+
(x2 - x1) / width,
|
|
72
|
+
(y2 - y1) / height,
|
|
73
|
+
]
|
|
74
|
+
lines.append(f"{category} " + " ".join(f"{v:.9g}" for v in values))
|
|
75
|
+
return "\n".join(lines) + ("\n" if lines else "")
|
|
76
|
+
|
|
77
|
+
def decode(self, payload: str, *, width, height) -> Detections:
|
|
78
|
+
_size(width, height)
|
|
79
|
+
boxes, ids = [], []
|
|
80
|
+
for line in payload.splitlines():
|
|
81
|
+
if not line.strip():
|
|
82
|
+
continue
|
|
83
|
+
fields = line.split()
|
|
84
|
+
if len(fields) != 5:
|
|
85
|
+
raise ValueError("YOLO rows must contain class cx cy width height")
|
|
86
|
+
category = int(fields[0])
|
|
87
|
+
if category < 0:
|
|
88
|
+
raise ValueError("YOLO class IDs must be nonnegative")
|
|
89
|
+
if self.category_ids is not None:
|
|
90
|
+
if category >= len(self.category_ids):
|
|
91
|
+
raise ValueError(f"Unknown YOLO class ID {category}")
|
|
92
|
+
category = self.category_ids[category]
|
|
93
|
+
cx, cy, w, h = map(float, fields[1:])
|
|
94
|
+
if not all(np.isfinite(v) and 0 <= v <= 1 for v in (cx, cy, w, h)):
|
|
95
|
+
raise ValueError("YOLO coordinates must be finite and normalized to [0, 1]")
|
|
96
|
+
boxes.append(
|
|
97
|
+
[
|
|
98
|
+
(cx - w / 2) * width,
|
|
99
|
+
(cy - h / 2) * height,
|
|
100
|
+
(cx + w / 2) * width,
|
|
101
|
+
(cy + h / 2) * height,
|
|
102
|
+
]
|
|
103
|
+
)
|
|
104
|
+
ids.append(category)
|
|
105
|
+
result = Detections(np.asarray(boxes).reshape(-1, 4), np.ones(len(boxes)), np.array(ids))
|
|
106
|
+
_bounds(result, width, height)
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class CocoCodec(AnnotationCodec):
|
|
111
|
+
"""COCO per-image annotation records with pixel [x, y, width, height] boxes.
|
|
112
|
+
|
|
113
|
+
Use image_id to select records from a dataset's annotations list. IDs generated during
|
|
114
|
+
encoding start at annotation_id; callers assembling datasets must allocate unique IDs.
|
|
115
|
+
Scores are preserved when present, defaulting to 1.0 for ground-truth records.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, image_id: int = 0, annotation_id: int = 1):
|
|
119
|
+
self.image_id = image_id
|
|
120
|
+
self.annotation_id = annotation_id
|
|
121
|
+
|
|
122
|
+
def encode(self, detections, *, width, height) -> list[dict]:
|
|
123
|
+
_bounds(detections, width, height)
|
|
124
|
+
records = []
|
|
125
|
+
for i, (box, score, category) in enumerate(
|
|
126
|
+
zip(detections.boxes, detections.scores, detections.class_ids, strict=True)
|
|
127
|
+
):
|
|
128
|
+
x1, y1, x2, y2 = map(float, box)
|
|
129
|
+
records.append(
|
|
130
|
+
{
|
|
131
|
+
"id": self.annotation_id + i,
|
|
132
|
+
"image_id": self.image_id,
|
|
133
|
+
"category_id": int(category),
|
|
134
|
+
"bbox": [x1, y1, x2 - x1, y2 - y1],
|
|
135
|
+
"area": (x2 - x1) * (y2 - y1),
|
|
136
|
+
"iscrowd": 0,
|
|
137
|
+
"score": float(score),
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
return records
|
|
141
|
+
|
|
142
|
+
def decode(self, payload: list[dict], *, width, height) -> Detections:
|
|
143
|
+
_size(width, height)
|
|
144
|
+
boxes, scores, ids = [], [], []
|
|
145
|
+
for row in payload:
|
|
146
|
+
if row["image_id"] != self.image_id:
|
|
147
|
+
continue
|
|
148
|
+
x, y, w, h = row["bbox"]
|
|
149
|
+
boxes.append([x, y, x + w, y + h])
|
|
150
|
+
scores.append(row.get("score", 1.0))
|
|
151
|
+
ids.append(row["category_id"])
|
|
152
|
+
result = Detections(np.asarray(boxes).reshape(-1, 4), np.array(scores), np.array(ids))
|
|
153
|
+
_bounds(result, width, height)
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def annotation_codec(name: str, **options) -> AnnotationCodec:
|
|
158
|
+
"""Select a built-in codec by name; custom codecs can be passed directly."""
|
|
159
|
+
codecs = {"yolo": YoloCodec, "coco": CocoCodec}
|
|
160
|
+
try:
|
|
161
|
+
codec = codecs[name.lower()]
|
|
162
|
+
except KeyError:
|
|
163
|
+
raise ValueError(f"Unknown annotation protocol {name!r}; choose yolo or coco") from None
|
|
164
|
+
return codec(**options)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Canonical detections and explicit normalization of legacy detector outputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
InvalidBoxes = Literal["error", "drop"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _arrays(boxes, scores, class_ids):
|
|
14
|
+
boxes = np.asarray(boxes, dtype=np.float32)
|
|
15
|
+
scores = np.asarray(scores, dtype=np.float32)
|
|
16
|
+
if boxes.ndim != 2 or boxes.shape[1] != 4 or scores.shape != (len(boxes),):
|
|
17
|
+
raise ValueError("Expected boxes (N, 4) and scores (N,)")
|
|
18
|
+
if not np.isfinite(scores).all() or np.any((scores < 0) | (scores > 1)):
|
|
19
|
+
raise ValueError("Scores must be finite and in [0, 1]")
|
|
20
|
+
ids = np.zeros(len(boxes), dtype=np.int64) if class_ids is None else np.asarray(class_ids)
|
|
21
|
+
if (
|
|
22
|
+
ids.shape != (len(boxes),)
|
|
23
|
+
or not np.isfinite(ids).all()
|
|
24
|
+
or np.any(ids < 0)
|
|
25
|
+
or np.any(ids != np.floor(ids))
|
|
26
|
+
):
|
|
27
|
+
raise ValueError("class_ids must be nonnegative integers with shape (N,)")
|
|
28
|
+
return boxes, scores, ids.astype(np.int64)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _valid_boxes(boxes):
|
|
32
|
+
return np.isfinite(boxes).all(axis=1) & (boxes[:, 2:] > boxes[:, :2]).all(axis=1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _readonly(array):
|
|
36
|
+
# A bytes-backed copy cannot be changed through input aliases or setflags(write=True).
|
|
37
|
+
return np.frombuffer(array.tobytes(), dtype=array.dtype).reshape(array.shape)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, eq=False)
|
|
41
|
+
class Detections:
|
|
42
|
+
"""Immutable pixel xyxy boxes, scores, and nonnegative integer category IDs.
|
|
43
|
+
|
|
44
|
+
Unpacking yields boxes and scores for the single-population tracking interface.
|
|
45
|
+
dropped_count records boxes explicitly discarded during normalization; it is never
|
|
46
|
+
inferred from confidence filtering or NMS. Array shapes, scores and category IDs are
|
|
47
|
+
always strict. Geometry can be discarded only via from_raw(invalid_boxes="drop").
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
boxes: np.ndarray
|
|
51
|
+
scores: np.ndarray
|
|
52
|
+
class_ids: np.ndarray | None = None
|
|
53
|
+
dropped_count: int = 0
|
|
54
|
+
|
|
55
|
+
def __post_init__(self):
|
|
56
|
+
boxes, scores, ids = _arrays(self.boxes, self.scores, self.class_ids)
|
|
57
|
+
if not _valid_boxes(boxes).all():
|
|
58
|
+
raise ValueError("Boxes must be finite with positive width and height")
|
|
59
|
+
if not isinstance(self.dropped_count, int) or self.dropped_count < 0:
|
|
60
|
+
raise ValueError("dropped_count must be a nonnegative integer")
|
|
61
|
+
for name, array in (("boxes", boxes), ("scores", scores), ("class_ids", ids)):
|
|
62
|
+
object.__setattr__(self, name, _readonly(array))
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_raw(
|
|
66
|
+
cls, boxes, scores, class_ids=None, *, invalid_boxes: InvalidBoxes = "error"
|
|
67
|
+
) -> Detections:
|
|
68
|
+
"""Normalize raw arrays, optionally dropping invalid geometry with a recorded count."""
|
|
69
|
+
if invalid_boxes not in ("error", "drop"):
|
|
70
|
+
raise ValueError("invalid_boxes must be 'error' or 'drop'")
|
|
71
|
+
if invalid_boxes == "error":
|
|
72
|
+
return cls(boxes, scores, class_ids)
|
|
73
|
+
boxes, scores, ids = _arrays(boxes, scores, class_ids)
|
|
74
|
+
valid = _valid_boxes(boxes)
|
|
75
|
+
return cls(boxes[valid], scores[valid], ids[valid], dropped_count=int((~valid).sum()))
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def coerce(cls, value, *, invalid_boxes: InvalidBoxes = "error") -> Detections:
|
|
79
|
+
"""Accept canonical detections or a legacy (boxes, scores) pair at the boundary.
|
|
80
|
+
|
|
81
|
+
Canonical objects are returned unchanged, preserving category IDs and drop counts.
|
|
82
|
+
"""
|
|
83
|
+
if isinstance(value, cls):
|
|
84
|
+
return value
|
|
85
|
+
if not isinstance(value, (tuple, list)) or len(value) != 2:
|
|
86
|
+
raise ValueError("Detector output must be Detections or a (boxes, scores) pair")
|
|
87
|
+
return cls.from_raw(*value, invalid_boxes=invalid_boxes)
|
|
88
|
+
|
|
89
|
+
def __iter__(self):
|
|
90
|
+
yield self.boxes
|
|
91
|
+
yield self.scores
|
|
92
|
+
|
|
93
|
+
def __len__(self):
|
|
94
|
+
return len(self.boxes)
|
khoroos/cli.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from khoroos.config import (
|
|
14
|
+
PRESETS,
|
|
15
|
+
get_settings,
|
|
16
|
+
params_for_preset,
|
|
17
|
+
parse_overrides,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
name="khoroos",
|
|
22
|
+
help="Poultry behavior analysis from farm video.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
add_completion=False,
|
|
25
|
+
)
|
|
26
|
+
models_app = typer.Typer(name="models", help="Manage model checkpoints.", no_args_is_help=True)
|
|
27
|
+
app.add_typer(models_app)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _setup_logging(verbose: bool) -> None:
|
|
31
|
+
logging.basicConfig(
|
|
32
|
+
level=logging.DEBUG if verbose else logging.INFO,
|
|
33
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
34
|
+
stream=sys.stderr,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _runtime_settings(**overrides):
|
|
39
|
+
try:
|
|
40
|
+
return get_settings().with_overrides(**overrides)
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command()
|
|
46
|
+
def analyze(
|
|
47
|
+
video: Annotated[Path, typer.Argument(help="Video file to analyse.")],
|
|
48
|
+
output: Annotated[Path, typer.Option("--output", "-o", help="Output directory.")] = Path(
|
|
49
|
+
"khoroos-output"
|
|
50
|
+
),
|
|
51
|
+
preset: Annotated[
|
|
52
|
+
str, typer.Option("--preset", "-p", help=f"One of: {', '.join(PRESETS)}.")
|
|
53
|
+
] = "balanced",
|
|
54
|
+
max_seconds: Annotated[
|
|
55
|
+
float | None, typer.Option("--max-seconds", help="Analyse only the first N seconds.")
|
|
56
|
+
] = None,
|
|
57
|
+
min_confidence: Annotated[
|
|
58
|
+
float | None, typer.Option("--min-confidence", help="Below this, actions are 'uncertain'.")
|
|
59
|
+
] = None,
|
|
60
|
+
detection_confidence: Annotated[
|
|
61
|
+
float | None,
|
|
62
|
+
typer.Option("--detection-confidence", help="Detector score threshold."),
|
|
63
|
+
] = None,
|
|
64
|
+
detection_batch_size: Annotated[
|
|
65
|
+
int | None,
|
|
66
|
+
typer.Option(
|
|
67
|
+
"--detection-batch-size",
|
|
68
|
+
help="Frames per detector forward pass. Higher is faster but needs more memory.",
|
|
69
|
+
),
|
|
70
|
+
] = None,
|
|
71
|
+
action_batch_size: Annotated[
|
|
72
|
+
int | None,
|
|
73
|
+
typer.Option(
|
|
74
|
+
"--action-batch-size",
|
|
75
|
+
help="Clips per action-model forward pass. Each clip is 64 frames, so this "
|
|
76
|
+
"dominates memory use.",
|
|
77
|
+
),
|
|
78
|
+
] = None,
|
|
79
|
+
overlay: Annotated[
|
|
80
|
+
bool, typer.Option("--overlay/--no-overlay", help="Render an annotated video.")
|
|
81
|
+
] = False,
|
|
82
|
+
set_param: Annotated[
|
|
83
|
+
list[str] | None,
|
|
84
|
+
typer.Option(
|
|
85
|
+
"--set",
|
|
86
|
+
"-s",
|
|
87
|
+
metavar="NAME=VALUE",
|
|
88
|
+
help="Override any analysis parameter, e.g. -s window_seconds=3. Repeatable.",
|
|
89
|
+
),
|
|
90
|
+
] = None,
|
|
91
|
+
device: Annotated[str | None, typer.Option("--device", help="cuda, cpu, mps or auto.")] = None,
|
|
92
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Analyse a video and write results to a directory."""
|
|
95
|
+
_setup_logging(verbose)
|
|
96
|
+
|
|
97
|
+
if not video.is_file():
|
|
98
|
+
typer.secho(f"No such video: {video}", fg=typer.colors.RED, err=True)
|
|
99
|
+
raise typer.Exit(2)
|
|
100
|
+
|
|
101
|
+
settings = _runtime_settings(device=device)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
# The named flags are shorthands for the same fields `--set` reaches, so they go
|
|
105
|
+
# through one map. An explicit `--set` wins over its shorthand.
|
|
106
|
+
overrides: dict[str, object] = {
|
|
107
|
+
"max_duration_seconds": max_seconds,
|
|
108
|
+
"min_confidence": min_confidence,
|
|
109
|
+
"detection_confidence": detection_confidence,
|
|
110
|
+
"detection_batch_size": detection_batch_size,
|
|
111
|
+
"action_batch_size": action_batch_size,
|
|
112
|
+
}
|
|
113
|
+
overrides.update(parse_overrides(set_param or []))
|
|
114
|
+
params = params_for_preset(preset, **overrides)
|
|
115
|
+
except ValueError as exc:
|
|
116
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
117
|
+
raise typer.Exit(2) from None
|
|
118
|
+
|
|
119
|
+
from khoroos.pipeline.runner import AnalysisRunner
|
|
120
|
+
|
|
121
|
+
typer.echo(f"Analysing {video} [{preset}] on {settings.resolved_device()}")
|
|
122
|
+
|
|
123
|
+
with typer.progressbar(length=1000, label="starting") as bar:
|
|
124
|
+
position = 0
|
|
125
|
+
|
|
126
|
+
def on_progress(event) -> None:
|
|
127
|
+
# The bar never moves backwards: stage weights are approximate, and a bar that
|
|
128
|
+
# retreats reads as a bug during a run that already takes minutes.
|
|
129
|
+
nonlocal position
|
|
130
|
+
target = min(int(event.progress * 1000), 1000)
|
|
131
|
+
step = max(target - position, 0)
|
|
132
|
+
position += step
|
|
133
|
+
bar.label = event.stage
|
|
134
|
+
bar.update(step)
|
|
135
|
+
|
|
136
|
+
artifacts = AnalysisRunner(settings=settings).run(
|
|
137
|
+
video,
|
|
138
|
+
output_dir=output,
|
|
139
|
+
params=params,
|
|
140
|
+
render_overlay=overlay,
|
|
141
|
+
on_progress=on_progress,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
_print_summary(artifacts.result)
|
|
145
|
+
typer.echo("\nWrote:")
|
|
146
|
+
for name, path in artifacts.paths.items():
|
|
147
|
+
typer.echo(f" {name:12s} {path}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _print_summary(result) -> None:
|
|
151
|
+
metrics = result.metrics
|
|
152
|
+
budget = metrics.get("time_budget", {})
|
|
153
|
+
population = metrics.get("population", {})
|
|
154
|
+
|
|
155
|
+
typer.echo("")
|
|
156
|
+
typer.secho("Summary", bold=True)
|
|
157
|
+
typer.echo(f" video {result.video.filename} ({result.video.duration_seconds:.1f}s)")
|
|
158
|
+
typer.echo(f" runtime {result.runtime_seconds:.1f}s")
|
|
159
|
+
typer.echo(f" birds tracked {len(result.tracks)}")
|
|
160
|
+
typer.echo(f" mean in frame {population.get('mean', 0)}")
|
|
161
|
+
typer.echo(f" clips classified {len(result.predictions)}")
|
|
162
|
+
typer.echo(f" observed {budget.get('total_bird_seconds', 0):.0f} bird-seconds")
|
|
163
|
+
|
|
164
|
+
by_class = budget.get("by_class", {})
|
|
165
|
+
if by_class:
|
|
166
|
+
typer.echo("\n Time budget:")
|
|
167
|
+
for label, values in list(by_class.items())[:8]:
|
|
168
|
+
bar = "█" * int(values["share"] * 30)
|
|
169
|
+
typer.echo(f" {label:18s} {values['share']:6.1%} {bar}")
|
|
170
|
+
|
|
171
|
+
for warning in result.warnings:
|
|
172
|
+
typer.secho(f" [note] {warning}", fg=typer.colors.MAGENTA)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command()
|
|
176
|
+
def ui(
|
|
177
|
+
host: Annotated[str | None, typer.Option("--host", help="Bind address.")] = None,
|
|
178
|
+
port: Annotated[int | None, typer.Option("--port", help="Port to listen on.")] = None,
|
|
179
|
+
no_browser: Annotated[
|
|
180
|
+
bool, typer.Option("--no-browser", help="Do not open a browser.")
|
|
181
|
+
] = False,
|
|
182
|
+
device: Annotated[str | None, typer.Option("--device", help="cuda, cpu, mps or auto.")] = None,
|
|
183
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Launch the web interface."""
|
|
186
|
+
_setup_logging(verbose)
|
|
187
|
+
|
|
188
|
+
settings = _runtime_settings(device=device, host=host, port=port)
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
import uvicorn
|
|
192
|
+
except ImportError:
|
|
193
|
+
typer.secho(
|
|
194
|
+
"The web interface needs extra dependencies. Install them with:\n"
|
|
195
|
+
" pip install 'khoroos[web]'",
|
|
196
|
+
fg=typer.colors.RED,
|
|
197
|
+
)
|
|
198
|
+
raise typer.Exit(1) from None
|
|
199
|
+
|
|
200
|
+
from khoroos.web.app import create_app
|
|
201
|
+
|
|
202
|
+
if settings.host not in ("127.0.0.1", "localhost", "::1"):
|
|
203
|
+
typer.secho(
|
|
204
|
+
f"Serving on {settings.host} exposes Khoroos to your network. It has no "
|
|
205
|
+
f"authentication — only do this on a trusted network.",
|
|
206
|
+
fg=typer.colors.YELLOW,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
url = f"http://{settings.host}:{settings.port}"
|
|
210
|
+
typer.secho(f"Khoroos UI: {url}", fg=typer.colors.GREEN, bold=True)
|
|
211
|
+
|
|
212
|
+
if not no_browser:
|
|
213
|
+
import threading
|
|
214
|
+
import webbrowser
|
|
215
|
+
|
|
216
|
+
threading.Timer(1.5, lambda: webbrowser.open(url)).start()
|
|
217
|
+
|
|
218
|
+
web_app = create_app(settings)
|
|
219
|
+
try:
|
|
220
|
+
uvicorn.run(web_app, host=settings.host, port=settings.port, log_level="info")
|
|
221
|
+
finally:
|
|
222
|
+
web_app.state.jobs.close()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@models_app.command("status")
|
|
226
|
+
def models_status() -> None:
|
|
227
|
+
"""Show where model checkpoints will be loaded from."""
|
|
228
|
+
from khoroos.models.registry import checkpoint_status
|
|
229
|
+
|
|
230
|
+
_print_checkpoints(checkpoint_status(get_settings()))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@models_app.command("download")
|
|
234
|
+
def models_download() -> None:
|
|
235
|
+
"""Download model checkpoints so later runs work offline."""
|
|
236
|
+
from khoroos.models.registry import CheckpointNotFoundError, resolve_checkpoint
|
|
237
|
+
|
|
238
|
+
failed = False
|
|
239
|
+
for kind in ("detector", "action"):
|
|
240
|
+
typer.echo(f"Resolving {kind}...")
|
|
241
|
+
try:
|
|
242
|
+
path = resolve_checkpoint(kind, allow_download=True)
|
|
243
|
+
except CheckpointNotFoundError as exc:
|
|
244
|
+
failed = True
|
|
245
|
+
typer.secho(f" {kind}: {exc}", fg=typer.colors.RED, err=True)
|
|
246
|
+
else:
|
|
247
|
+
typer.secho(f" {kind}: {path}", fg=typer.colors.GREEN)
|
|
248
|
+
if failed:
|
|
249
|
+
raise typer.Exit(1)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@app.command()
|
|
253
|
+
def info(
|
|
254
|
+
as_json: Annotated[bool, typer.Option("--json", help="Emit machine-readable JSON.")] = False,
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Show presets, behaviours and checkpoint status.
|
|
257
|
+
|
|
258
|
+
The same description the web UI builds its controls from.
|
|
259
|
+
"""
|
|
260
|
+
from khoroos.environment import describe_environment
|
|
261
|
+
|
|
262
|
+
env = describe_environment(get_settings())
|
|
263
|
+
|
|
264
|
+
if as_json:
|
|
265
|
+
typer.echo(json.dumps(env, indent=2))
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
typer.secho(f"Khoroos {env['version']}", bold=True)
|
|
269
|
+
typer.echo(f" device {env['device']}")
|
|
270
|
+
typer.echo(f" cache {env['cache_dir']}")
|
|
271
|
+
|
|
272
|
+
typer.echo("\n Presets:")
|
|
273
|
+
for name, values in env["presets"].items():
|
|
274
|
+
default = " (default)" if name == env["default_preset"] else ""
|
|
275
|
+
summary = " ".join(f"{k}={v}" for k, v in values.items())
|
|
276
|
+
typer.echo(f" {name:10s}{default:10s} {summary}")
|
|
277
|
+
|
|
278
|
+
typer.echo("\n Behaviours:")
|
|
279
|
+
for group, members in env["behaviour_groups"].items():
|
|
280
|
+
typer.echo(f" {group:12s} {', '.join(members)}")
|
|
281
|
+
|
|
282
|
+
typer.echo("\n Models:")
|
|
283
|
+
_print_checkpoints(env["models"])
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _print_checkpoints(models: dict) -> None:
|
|
287
|
+
for kind, details in models.items():
|
|
288
|
+
if details["available"]:
|
|
289
|
+
typer.secho(f" {kind:9s} ✓ {details['path']}", fg=typer.colors.GREEN)
|
|
290
|
+
else:
|
|
291
|
+
typer.secho(
|
|
292
|
+
f" {kind:9s} ✗ not found locally (would download {details['hub_repo']})",
|
|
293
|
+
fg=typer.colors.YELLOW,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@app.command()
|
|
298
|
+
def version() -> None:
|
|
299
|
+
"""Print the installed version."""
|
|
300
|
+
from khoroos import __version__
|
|
301
|
+
|
|
302
|
+
typer.echo(__version__)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
app()
|