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.
Files changed (45) hide show
  1. khoroos/__init__.py +110 -0
  2. khoroos/annotations/__init__.py +6 -0
  3. khoroos/annotations/codecs.py +164 -0
  4. khoroos/annotations/detections.py +94 -0
  5. khoroos/cli.py +306 -0
  6. khoroos/config.py +337 -0
  7. khoroos/environment.py +75 -0
  8. khoroos/interfaces.py +166 -0
  9. khoroos/models/__init__.py +1 -0
  10. khoroos/models/action.py +116 -0
  11. khoroos/models/card.py +71 -0
  12. khoroos/models/detector.py +168 -0
  13. khoroos/models/metadata.py +30 -0
  14. khoroos/models/registry.py +138 -0
  15. khoroos/models/selection.py +82 -0
  16. khoroos/pipeline/__init__.py +1 -0
  17. khoroos/pipeline/analyze.py +438 -0
  18. khoroos/pipeline/components.py +92 -0
  19. khoroos/pipeline/jobs.py +311 -0
  20. khoroos/pipeline/runner.py +144 -0
  21. khoroos/pipeline/types.py +236 -0
  22. khoroos/py.typed +0 -0
  23. khoroos/statistics/__init__.py +1 -0
  24. khoroos/statistics/export.py +122 -0
  25. khoroos/statistics/metrics.py +300 -0
  26. khoroos/tracking/__init__.py +1 -0
  27. khoroos/tracking/kalman.py +84 -0
  28. khoroos/tracking/tracker.py +176 -0
  29. khoroos/tracking/tracklets.py +266 -0
  30. khoroos/video/__init__.py +1 -0
  31. khoroos/video/reader.py +148 -0
  32. khoroos/video/writer.py +251 -0
  33. khoroos/web/__init__.py +1 -0
  34. khoroos/web/app.py +51 -0
  35. khoroos/web/routes.py +312 -0
  36. khoroos/web/static/Khoroos-transparent.png +0 -0
  37. khoroos/web/static/app.js +1566 -0
  38. khoroos/web/static/index.html +311 -0
  39. khoroos/web/static/khoroos.png +0 -0
  40. khoroos/web/static/styles.css +1135 -0
  41. khoroos-0.1.0.dist-info/METADATA +186 -0
  42. khoroos-0.1.0.dist-info/RECORD +45 -0
  43. khoroos-0.1.0.dist-info/WHEEL +4 -0
  44. khoroos-0.1.0.dist-info/entry_points.txt +3 -0
  45. 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()