Spaces:
Sleeping
Sleeping
Javier Montalvo commited on
Commit ·
8536e24
1
Parent(s): 20905e1
tracking and motion updates
Browse files- DEMO.md +11 -3
- README.md +8 -6
- frontend/src/lib/types.ts +1 -0
- server.py +1 -0
- tests/test_automation.py +123 -1
- tests/test_detector.py +30 -1
- tests/test_tracking.py +26 -0
- tests/test_video.py +90 -0
- tiny_trigger/automation.py +86 -5
- tiny_trigger/detector.py +42 -1
- tiny_trigger/llm.py +21 -3
- tiny_trigger/models.py +1 -0
- tiny_trigger/tracking.py +85 -0
- tiny_trigger/video.py +8 -3
DEMO.md
CHANGED
|
@@ -57,9 +57,11 @@ Recommended starting detector settings:
|
|
| 57 |
- Resolution: `640` by default, `960` or `1280` if detections are missing.
|
| 58 |
|
| 59 |
Rules automatically add their referenced labels to the detector class list.
|
| 60 |
-
Rules
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
| 63 |
|
| 64 |
## Prompts To Try
|
| 65 |
|
|
@@ -87,6 +89,12 @@ Simple presence:
|
|
| 87 |
When a laptop is visible, notify me.
|
| 88 |
```
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
## Expected Flow
|
| 91 |
|
| 92 |
1. Upload the video in Detector.
|
|
|
|
| 57 |
- Resolution: `640` by default, `960` or `1280` if detections are missing.
|
| 58 |
|
| 59 |
Rules automatically add their referenced labels to the detector class list.
|
| 60 |
+
Rules can use presence, count, near, far, moving, enter, exit, change, and
|
| 61 |
+
cooldown. Moving uses lightweight same-label tracking across sampled frames;
|
| 62 |
+
it requires at least three tracked observations and tolerates one missed sampled
|
| 63 |
+
frame by default. Speed, direction, long-gap re-identification, and trajectory
|
| 64 |
+
paths are not supported yet.
|
| 65 |
|
| 66 |
## Prompts To Try
|
| 67 |
|
|
|
|
| 89 |
When a laptop is visible, notify me.
|
| 90 |
```
|
| 91 |
|
| 92 |
+
Simple motion:
|
| 93 |
+
|
| 94 |
+
```text
|
| 95 |
+
If a car is moving, notify me.
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
## Expected Flow
|
| 99 |
|
| 100 |
1. Upload the video in Detector.
|
README.md
CHANGED
|
@@ -86,12 +86,14 @@ rules:
|
|
| 86 |
name: turn on pc
|
| 87 |
```
|
| 88 |
|
| 89 |
-
Initial video conditions include presence, count, near, and
|
| 90 |
-
minimum horizontal/vertical gap between detection boxes in normalized
|
| 91 |
-
percent.
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
| 95 |
|
| 96 |
```yaml
|
| 97 |
rules:
|
|
|
|
| 86 |
name: turn on pc
|
| 87 |
```
|
| 88 |
|
| 89 |
+
Initial video conditions include presence, count, near, far, and moving. Near/far
|
| 90 |
+
use the minimum horizontal/vertical gap between detection boxes in normalized
|
| 91 |
+
frame percent. Moving uses lightweight same-label centroid tracking across
|
| 92 |
+
sampled frames and tolerates one missed sampled frame by default to avoid repeat
|
| 93 |
+
alerts from detector flicker. Gates include enabled state and cooldown. Triggers
|
| 94 |
+
can fire while a condition is true, when it becomes true, when it becomes false,
|
| 95 |
+
or on either change. Tiny Trigger does not yet support speed, direction, long-gap
|
| 96 |
+
re-identification, or trajectory path rules.
|
| 97 |
|
| 98 |
```yaml
|
| 99 |
rules:
|
frontend/src/lib/types.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface Detection {
|
|
| 5 |
confidence: number
|
| 6 |
bbox_xyxy: [number, number, number, number]
|
| 7 |
bbox_xyxy_norm: [number, number, number, number]
|
|
|
|
| 8 |
}
|
| 9 |
|
| 10 |
export interface ActionEvent {
|
|
|
|
| 5 |
confidence: number
|
| 6 |
bbox_xyxy: [number, number, number, number]
|
| 7 |
bbox_xyxy_norm: [number, number, number, number]
|
| 8 |
+
track_id: number | null
|
| 9 |
}
|
| 10 |
|
| 11 |
export interface ActionEvent {
|
server.py
CHANGED
|
@@ -61,6 +61,7 @@ def _detection_dict(d: Any) -> dict[str, Any]:
|
|
| 61 |
"confidence": round(d.confidence, 4),
|
| 62 |
"bbox_xyxy": [round(v, 1) for v in d.bbox_xyxy],
|
| 63 |
"bbox_xyxy_norm": [round(v, 4) for v in d.bbox_xyxy_norm],
|
|
|
|
| 64 |
}
|
| 65 |
|
| 66 |
|
|
|
|
| 61 |
"confidence": round(d.confidence, 4),
|
| 62 |
"bbox_xyxy": [round(v, 1) for v in d.bbox_xyxy],
|
| 63 |
"bbox_xyxy_norm": [round(v, 4) for v in d.bbox_xyxy_norm],
|
| 64 |
+
"track_id": d.track_id,
|
| 65 |
}
|
| 66 |
|
| 67 |
|
tests/test_automation.py
CHANGED
|
@@ -9,7 +9,13 @@ from tiny_trigger.automation import RuleEngine, document_labels, evaluate_video_
|
|
| 9 |
from tiny_trigger.models import Detection, FrameSample
|
| 10 |
|
| 11 |
|
| 12 |
-
def detection(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
return Detection(
|
| 14 |
frame_index=frame,
|
| 15 |
timestamp_sec=time,
|
|
@@ -17,6 +23,7 @@ def detection(label: str, box: tuple[float, float, float, float], frame: int = 0
|
|
| 17 |
confidence=0.9,
|
| 18 |
bbox_xyxy=(0.0, 0.0, 10.0, 10.0),
|
| 19 |
bbox_xyxy_norm=box,
|
|
|
|
| 20 |
)
|
| 21 |
|
| 22 |
|
|
@@ -122,6 +129,121 @@ def test_rule_labels_include_condition_labels() -> None:
|
|
| 122 |
assert document_labels(document) == ["person", "monitor", "cat", "door"]
|
| 123 |
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
def test_gate_cooldown_fire_once_per_window() -> None:
|
| 126 |
document = load_automation_text(
|
| 127 |
json.dumps(
|
|
|
|
| 9 |
from tiny_trigger.models import Detection, FrameSample
|
| 10 |
|
| 11 |
|
| 12 |
+
def detection(
|
| 13 |
+
label: str,
|
| 14 |
+
box: tuple[float, float, float, float],
|
| 15 |
+
frame: int = 0,
|
| 16 |
+
time: float = 0.0,
|
| 17 |
+
track_id: int | None = None,
|
| 18 |
+
) -> Detection:
|
| 19 |
return Detection(
|
| 20 |
frame_index=frame,
|
| 21 |
timestamp_sec=time,
|
|
|
|
| 23 |
confidence=0.9,
|
| 24 |
bbox_xyxy=(0.0, 0.0, 10.0, 10.0),
|
| 25 |
bbox_xyxy_norm=box,
|
| 26 |
+
track_id=track_id,
|
| 27 |
)
|
| 28 |
|
| 29 |
|
|
|
|
| 129 |
assert document_labels(document) == ["person", "monitor", "cat", "door"]
|
| 130 |
|
| 131 |
|
| 132 |
+
def test_moving_condition_uses_tracked_centroid_history() -> None:
|
| 133 |
+
document = load_automation_text(
|
| 134 |
+
json.dumps(
|
| 135 |
+
{
|
| 136 |
+
"rules": [
|
| 137 |
+
{
|
| 138 |
+
"name": "car-moving",
|
| 139 |
+
"when": {
|
| 140 |
+
"all": [
|
| 141 |
+
{"moving": {"label": "car", "min_displacement_percent": 3, "window_frames": 3}}
|
| 142 |
+
]
|
| 143 |
+
},
|
| 144 |
+
"then": [{"type": "simulate", "name": "notify"}],
|
| 145 |
+
}
|
| 146 |
+
]
|
| 147 |
+
}
|
| 148 |
+
)
|
| 149 |
+
)
|
| 150 |
+
engine = RuleEngine(document.rules)
|
| 151 |
+
|
| 152 |
+
first = engine.evaluate_frame(
|
| 153 |
+
[detection("car", (0.10, 0.10, 0.20, 0.20), frame=0, track_id=1)],
|
| 154 |
+
frame_index=0,
|
| 155 |
+
timestamp_sec=0.0,
|
| 156 |
+
)
|
| 157 |
+
second = engine.evaluate_frame(
|
| 158 |
+
[detection("car", (0.11, 0.10, 0.21, 0.20), frame=1, time=1.0, track_id=1)],
|
| 159 |
+
frame_index=1,
|
| 160 |
+
timestamp_sec=1.0,
|
| 161 |
+
)
|
| 162 |
+
third = engine.evaluate_frame(
|
| 163 |
+
[detection("car", (0.20, 0.10, 0.30, 0.20), frame=2, time=2.0, track_id=1)],
|
| 164 |
+
frame_index=2,
|
| 165 |
+
timestamp_sec=2.0,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
assert first == []
|
| 169 |
+
assert second == []
|
| 170 |
+
assert [event.action for event in third] == ["notify"]
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_moving_condition_migrates_short_window_to_three() -> None:
|
| 174 |
+
document = load_automation_text(
|
| 175 |
+
json.dumps(
|
| 176 |
+
{
|
| 177 |
+
"rules": [
|
| 178 |
+
{
|
| 179 |
+
"name": "legacy-moving",
|
| 180 |
+
"when": {"all": [{"moving": {"label": "car", "window_frames": 2}}]},
|
| 181 |
+
"then": [{"type": "simulate", "name": "notify"}],
|
| 182 |
+
}
|
| 183 |
+
]
|
| 184 |
+
}
|
| 185 |
+
)
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
moving = document.rules[0].when.all_conditions[0].moving
|
| 189 |
+
assert moving is not None
|
| 190 |
+
assert moving.window_frames == 3
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def test_moving_condition_does_not_reenter_after_one_missed_frame() -> None:
|
| 194 |
+
document = load_automation_text(
|
| 195 |
+
json.dumps(
|
| 196 |
+
{
|
| 197 |
+
"rules": [
|
| 198 |
+
{
|
| 199 |
+
"name": "car-moving",
|
| 200 |
+
"when": {
|
| 201 |
+
"all": [
|
| 202 |
+
{
|
| 203 |
+
"moving": {
|
| 204 |
+
"label": "car",
|
| 205 |
+
"min_displacement_percent": 3,
|
| 206 |
+
"window_frames": 3,
|
| 207 |
+
"max_missing_frames": 1,
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
]
|
| 211 |
+
},
|
| 212 |
+
"then": [{"type": "simulate", "name": "notify"}],
|
| 213 |
+
}
|
| 214 |
+
]
|
| 215 |
+
}
|
| 216 |
+
)
|
| 217 |
+
)
|
| 218 |
+
engine = RuleEngine(document.rules)
|
| 219 |
+
|
| 220 |
+
assert engine.evaluate_frame(
|
| 221 |
+
[detection("car", (0.10, 0.10, 0.20, 0.20), frame=0, track_id=1)],
|
| 222 |
+
frame_index=0,
|
| 223 |
+
timestamp_sec=0.0,
|
| 224 |
+
) == []
|
| 225 |
+
assert engine.evaluate_frame(
|
| 226 |
+
[detection("car", (0.14, 0.10, 0.24, 0.20), frame=1, time=1.0, track_id=1)],
|
| 227 |
+
frame_index=1,
|
| 228 |
+
timestamp_sec=1.0,
|
| 229 |
+
) == []
|
| 230 |
+
first_alert = engine.evaluate_frame(
|
| 231 |
+
[detection("car", (0.18, 0.10, 0.28, 0.20), frame=2, time=2.0, track_id=1)],
|
| 232 |
+
frame_index=2,
|
| 233 |
+
timestamp_sec=2.0,
|
| 234 |
+
)
|
| 235 |
+
missed_frame = engine.evaluate_frame([], frame_index=3, timestamp_sec=3.0)
|
| 236 |
+
same_track_returns = engine.evaluate_frame(
|
| 237 |
+
[detection("car", (0.22, 0.10, 0.32, 0.20), frame=4, time=4.0, track_id=1)],
|
| 238 |
+
frame_index=4,
|
| 239 |
+
timestamp_sec=4.0,
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
assert [event.action for event in first_alert] == ["notify"]
|
| 243 |
+
assert missed_frame == []
|
| 244 |
+
assert same_track_returns == []
|
| 245 |
+
|
| 246 |
+
|
| 247 |
def test_gate_cooldown_fire_once_per_window() -> None:
|
| 248 |
document = load_automation_text(
|
| 249 |
json.dumps(
|
tests/test_detector.py
CHANGED
|
@@ -1,7 +1,36 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from tiny_trigger.detector import parse_class_prompt
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
def test_parse_class_prompt_splits_and_dedupes() -> None:
|
| 7 |
assert parse_class_prompt(" cat, feeder robot\npackage; cat ") == ["cat", "feeder robot", "package"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from tiny_trigger.detector import parse_class_prompt, suppress_duplicate_detections
|
| 4 |
+
from tiny_trigger.models import Detection
|
| 5 |
|
| 6 |
|
| 7 |
def test_parse_class_prompt_splits_and_dedupes() -> None:
|
| 8 |
assert parse_class_prompt(" cat, feeder robot\npackage; cat ") == ["cat", "feeder robot", "package"]
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def detection(label: str, confidence: float, box: tuple[float, float, float, float]) -> Detection:
|
| 12 |
+
return Detection(
|
| 13 |
+
frame_index=0,
|
| 14 |
+
timestamp_sec=0.0,
|
| 15 |
+
label=label,
|
| 16 |
+
confidence=confidence,
|
| 17 |
+
bbox_xyxy=(0.0, 0.0, 10.0, 10.0),
|
| 18 |
+
bbox_xyxy_norm=box,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_suppress_duplicate_detections_keeps_highest_confidence_same_label_box() -> None:
|
| 23 |
+
detections = [
|
| 24 |
+
detection("person", 0.62, (0.10, 0.10, 0.50, 0.50)),
|
| 25 |
+
detection("person", 0.91, (0.11, 0.11, 0.51, 0.51)),
|
| 26 |
+
detection("person", 0.80, (0.60, 0.10, 0.80, 0.30)),
|
| 27 |
+
detection("bag", 0.70, (0.11, 0.11, 0.51, 0.51)),
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
filtered = suppress_duplicate_detections(detections, iou_threshold=0.8)
|
| 31 |
+
|
| 32 |
+
assert len(filtered) == 3
|
| 33 |
+
assert ("person", 0.91) in [(item.label, item.confidence) for item in filtered]
|
| 34 |
+
assert ("person", 0.62) not in [(item.label, item.confidence) for item in filtered]
|
| 35 |
+
assert ("person", 0.80) in [(item.label, item.confidence) for item in filtered]
|
| 36 |
+
assert ("bag", 0.70) in [(item.label, item.confidence) for item in filtered]
|
tests/test_tracking.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from tiny_trigger.models import Detection
|
| 4 |
+
from tiny_trigger.tracking import SimpleTracker
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def detection(label: str, box: tuple[float, float, float, float]) -> Detection:
|
| 8 |
+
return Detection(
|
| 9 |
+
frame_index=0,
|
| 10 |
+
timestamp_sec=0.0,
|
| 11 |
+
label=label,
|
| 12 |
+
confidence=0.9,
|
| 13 |
+
bbox_xyxy=(0.0, 0.0, 10.0, 10.0),
|
| 14 |
+
bbox_xyxy_norm=box,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_simple_tracker_keeps_same_label_track_id() -> None:
|
| 19 |
+
tracker = SimpleTracker(max_match_distance_percent=20)
|
| 20 |
+
|
| 21 |
+
first = tracker.update([detection("car", (0.10, 0.10, 0.20, 0.20))])
|
| 22 |
+
second = tracker.update([detection("car", (0.15, 0.10, 0.25, 0.20))])
|
| 23 |
+
third = tracker.update([detection("person", (0.15, 0.10, 0.25, 0.20))])
|
| 24 |
+
|
| 25 |
+
assert first[0].track_id == second[0].track_id
|
| 26 |
+
assert third[0].track_id != first[0].track_id
|
tests/test_video.py
CHANGED
|
@@ -34,6 +34,65 @@ class FakeDetector:
|
|
| 34 |
]
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def test_process_video_with_fake_detector(tmp_path: Path) -> None:
|
| 38 |
cv2 = __import__("cv2")
|
| 39 |
video_path = _make_video(tmp_path)
|
|
@@ -62,6 +121,37 @@ def test_process_video_with_fake_detector(tmp_path: Path) -> None:
|
|
| 62 |
capture.release()
|
| 63 |
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
def test_process_video_samples_once_per_second(tmp_path: Path) -> None:
|
| 66 |
video_path = _make_video(tmp_path, fps=30.0, frames=95)
|
| 67 |
|
|
|
|
| 34 |
]
|
| 35 |
|
| 36 |
|
| 37 |
+
class MovingDetector:
|
| 38 |
+
class_names = ["cat"]
|
| 39 |
+
|
| 40 |
+
def detect(
|
| 41 |
+
self,
|
| 42 |
+
frame: Any,
|
| 43 |
+
*,
|
| 44 |
+
frame_index: int,
|
| 45 |
+
timestamp_sec: float,
|
| 46 |
+
confidence: float,
|
| 47 |
+
image_size: int | None = None,
|
| 48 |
+
max_detections: int | None = None,
|
| 49 |
+
) -> list[Detection]:
|
| 50 |
+
offset = frame_index * 0.01
|
| 51 |
+
return [
|
| 52 |
+
Detection(
|
| 53 |
+
frame_index=frame_index,
|
| 54 |
+
timestamp_sec=timestamp_sec,
|
| 55 |
+
label="cat",
|
| 56 |
+
confidence=0.99,
|
| 57 |
+
bbox_xyxy=(2.0 + frame_index, 2.0, 12.0 + frame_index, 12.0),
|
| 58 |
+
bbox_xyxy_norm=(0.1 + offset, 0.1, 0.2 + offset, 0.2),
|
| 59 |
+
)
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class DuplicateDetector:
|
| 64 |
+
class_names = ["cat"]
|
| 65 |
+
|
| 66 |
+
def detect(
|
| 67 |
+
self,
|
| 68 |
+
frame: Any,
|
| 69 |
+
*,
|
| 70 |
+
frame_index: int,
|
| 71 |
+
timestamp_sec: float,
|
| 72 |
+
confidence: float,
|
| 73 |
+
image_size: int | None = None,
|
| 74 |
+
max_detections: int | None = None,
|
| 75 |
+
) -> list[Detection]:
|
| 76 |
+
return [
|
| 77 |
+
Detection(
|
| 78 |
+
frame_index=frame_index,
|
| 79 |
+
timestamp_sec=timestamp_sec,
|
| 80 |
+
label="cat",
|
| 81 |
+
confidence=0.62,
|
| 82 |
+
bbox_xyxy=(2.0, 2.0, 16.0, 16.0),
|
| 83 |
+
bbox_xyxy_norm=(0.1, 0.1, 0.5, 0.5),
|
| 84 |
+
),
|
| 85 |
+
Detection(
|
| 86 |
+
frame_index=frame_index,
|
| 87 |
+
timestamp_sec=timestamp_sec,
|
| 88 |
+
label="cat",
|
| 89 |
+
confidence=0.91,
|
| 90 |
+
bbox_xyxy=(3.0, 3.0, 17.0, 17.0),
|
| 91 |
+
bbox_xyxy_norm=(0.11, 0.11, 0.51, 0.51),
|
| 92 |
+
),
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
|
| 96 |
def test_process_video_with_fake_detector(tmp_path: Path) -> None:
|
| 97 |
cv2 = __import__("cv2")
|
| 98 |
video_path = _make_video(tmp_path)
|
|
|
|
| 121 |
capture.release()
|
| 122 |
|
| 123 |
|
| 124 |
+
def test_process_video_assigns_track_ids(tmp_path: Path) -> None:
|
| 125 |
+
video_path = _make_video(tmp_path, fps=10.0, frames=4)
|
| 126 |
+
|
| 127 |
+
result = process_video(
|
| 128 |
+
video_path=str(video_path),
|
| 129 |
+
class_prompt="cat",
|
| 130 |
+
frame_stride=1,
|
| 131 |
+
max_frames=3,
|
| 132 |
+
detector=MovingDetector(),
|
| 133 |
+
output_dir=str(tmp_path),
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
assert [item.track_id for item in result.detections] == [1, 1, 1]
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_process_video_suppresses_duplicate_same_label_boxes(tmp_path: Path) -> None:
|
| 140 |
+
video_path = _make_video(tmp_path, fps=10.0, frames=2)
|
| 141 |
+
|
| 142 |
+
result = process_video(
|
| 143 |
+
video_path=str(video_path),
|
| 144 |
+
class_prompt="cat",
|
| 145 |
+
frame_stride=1,
|
| 146 |
+
max_frames=1,
|
| 147 |
+
detector=DuplicateDetector(),
|
| 148 |
+
output_dir=str(tmp_path),
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
assert len(result.detections) == 1
|
| 152 |
+
assert result.detections[0].confidence == 0.91
|
| 153 |
+
|
| 154 |
+
|
| 155 |
def test_process_video_samples_once_per_second(tmp_path: Path) -> None:
|
| 156 |
video_path = _make_video(tmp_path, fps=30.0, frames=95)
|
| 157 |
|
tiny_trigger/automation.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import json
|
| 4 |
from collections import defaultdict
|
|
|
|
| 5 |
from typing import Any, Literal
|
| 6 |
|
| 7 |
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
@@ -60,6 +61,25 @@ class FarCondition(BaseModel):
|
|
| 60 |
min_gap_percent: float = Field(default=25.0, ge=0.0)
|
| 61 |
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
class CooldownCondition(BaseModel):
|
| 64 |
key: str | None = None
|
| 65 |
seconds: float | None = Field(default=None, ge=0.0)
|
|
@@ -79,11 +99,12 @@ class ConditionBlock(BaseModel):
|
|
| 79 |
count: CountCondition | None = None
|
| 80 |
near: NearCondition | None = None
|
| 81 |
far: FarCondition | None = None
|
|
|
|
| 82 |
cooldown: CooldownCondition | None = None
|
| 83 |
|
| 84 |
@model_validator(mode="after")
|
| 85 |
def exactly_one_condition(self) -> "ConditionBlock":
|
| 86 |
-
selected = [self.present, self.count, self.near, self.far, self.cooldown]
|
| 87 |
if sum(item is not None for item in selected) != 1:
|
| 88 |
raise ValueError("Each condition block must contain exactly one condition.")
|
| 89 |
return self
|
|
@@ -195,6 +216,8 @@ def rule_labels(rule: AutomationRule) -> list[str]:
|
|
| 195 |
labels.extend([condition.near.a, condition.near.b])
|
| 196 |
if condition.far:
|
| 197 |
labels.extend([condition.far.a, condition.far.b])
|
|
|
|
|
|
|
| 198 |
return _dedupe_labels(labels)
|
| 199 |
|
| 200 |
|
|
@@ -217,6 +240,8 @@ class RuleEngine:
|
|
| 217 |
self.rules = rules
|
| 218 |
self.last_fired: dict[str, float] = dict(last_fired or {})
|
| 219 |
self.last_matched: dict[str, bool] = dict(last_matched or {})
|
|
|
|
|
|
|
| 220 |
|
| 221 |
def evaluate_frame(
|
| 222 |
self,
|
|
@@ -226,11 +251,12 @@ class RuleEngine:
|
|
| 226 |
timestamp_sec: float,
|
| 227 |
) -> list[ActionEvent]:
|
| 228 |
events: list[ActionEvent] = []
|
|
|
|
| 229 |
for rule in self.rules:
|
| 230 |
if not self._gate_allows(rule, timestamp_sec):
|
| 231 |
self.last_matched[rule.name] = False
|
| 232 |
continue
|
| 233 |
-
matched = self._rule_matches(rule, detections, timestamp_sec)
|
| 234 |
previous = self.last_matched.get(rule.name, False)
|
| 235 |
edge = _trigger_edge(previous=previous, matched=matched)
|
| 236 |
self.last_matched[rule.name] = matched
|
|
@@ -262,15 +288,21 @@ class RuleEngine:
|
|
| 262 |
)
|
| 263 |
return events
|
| 264 |
|
| 265 |
-
def _rule_matches(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
all_ok = all(
|
| 267 |
-
self._condition_matches(condition, detections, rule.name, timestamp_sec)
|
| 268 |
for condition in rule.when.all_conditions
|
| 269 |
)
|
| 270 |
any_ok = True
|
| 271 |
if rule.when.any_conditions:
|
| 272 |
any_ok = any(
|
| 273 |
-
self._condition_matches(condition, detections, rule.name, timestamp_sec)
|
| 274 |
for condition in rule.when.any_conditions
|
| 275 |
)
|
| 276 |
return all_ok and any_ok
|
|
@@ -286,6 +318,7 @@ class RuleEngine:
|
|
| 286 |
self,
|
| 287 |
condition: ConditionBlock,
|
| 288 |
detections: list[Detection],
|
|
|
|
| 289 |
rule_name: str,
|
| 290 |
timestamp_sec: float,
|
| 291 |
) -> bool:
|
|
@@ -311,11 +344,54 @@ class RuleEngine:
|
|
| 311 |
return False
|
| 312 |
return _min_box_gap_percent(left, right) >= condition.far.min_gap_percent
|
| 313 |
|
|
|
|
|
|
|
|
|
|
| 314 |
if condition.cooldown:
|
| 315 |
return self._cooldown_allows(condition.cooldown, rule_name, timestamp_sec)
|
| 316 |
|
| 317 |
return False
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
def _mark_cooldowns(self, rule: AutomationRule, timestamp_sec: float) -> None:
|
| 320 |
if rule.gate.cooldown:
|
| 321 |
self.last_fired[rule.gate.cooldown.key or rule.name] = timestamp_sec
|
|
@@ -380,6 +456,11 @@ def _box_gap_percent(left: Detection, right: Detection) -> float:
|
|
| 380 |
return max(gap_x, gap_y) * 100.0
|
| 381 |
|
| 382 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
def _trigger_edge(*, previous: bool, matched: bool) -> Literal["enter", "exit", "while", "none"]:
|
| 384 |
if matched and not previous:
|
| 385 |
return "enter"
|
|
|
|
| 2 |
|
| 3 |
import json
|
| 4 |
from collections import defaultdict
|
| 5 |
+
from math import hypot
|
| 6 |
from typing import Any, Literal
|
| 7 |
|
| 8 |
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
|
|
| 61 |
min_gap_percent: float = Field(default=25.0, ge=0.0)
|
| 62 |
|
| 63 |
|
| 64 |
+
class MovingCondition(BaseModel):
|
| 65 |
+
label: str
|
| 66 |
+
min_displacement_percent: float = Field(default=3.0, ge=0.0)
|
| 67 |
+
window_frames: int = Field(default=3, ge=3)
|
| 68 |
+
max_missing_frames: int = Field(default=1, ge=0)
|
| 69 |
+
|
| 70 |
+
@model_validator(mode="before")
|
| 71 |
+
@classmethod
|
| 72 |
+
def migrate_short_window(cls, data: Any) -> Any:
|
| 73 |
+
if isinstance(data, dict) and data.get("window_frames") is not None:
|
| 74 |
+
try:
|
| 75 |
+
window_frames = int(data["window_frames"])
|
| 76 |
+
except (TypeError, ValueError):
|
| 77 |
+
return data
|
| 78 |
+
if window_frames < 3:
|
| 79 |
+
return {**data, "window_frames": 3}
|
| 80 |
+
return data
|
| 81 |
+
|
| 82 |
+
|
| 83 |
class CooldownCondition(BaseModel):
|
| 84 |
key: str | None = None
|
| 85 |
seconds: float | None = Field(default=None, ge=0.0)
|
|
|
|
| 99 |
count: CountCondition | None = None
|
| 100 |
near: NearCondition | None = None
|
| 101 |
far: FarCondition | None = None
|
| 102 |
+
moving: MovingCondition | None = None
|
| 103 |
cooldown: CooldownCondition | None = None
|
| 104 |
|
| 105 |
@model_validator(mode="after")
|
| 106 |
def exactly_one_condition(self) -> "ConditionBlock":
|
| 107 |
+
selected = [self.present, self.count, self.near, self.far, self.moving, self.cooldown]
|
| 108 |
if sum(item is not None for item in selected) != 1:
|
| 109 |
raise ValueError("Each condition block must contain exactly one condition.")
|
| 110 |
return self
|
|
|
|
| 216 |
labels.extend([condition.near.a, condition.near.b])
|
| 217 |
if condition.far:
|
| 218 |
labels.extend([condition.far.a, condition.far.b])
|
| 219 |
+
if condition.moving:
|
| 220 |
+
labels.append(condition.moving.label)
|
| 221 |
return _dedupe_labels(labels)
|
| 222 |
|
| 223 |
|
|
|
|
| 240 |
self.rules = rules
|
| 241 |
self.last_fired: dict[str, float] = dict(last_fired or {})
|
| 242 |
self.last_matched: dict[str, bool] = dict(last_matched or {})
|
| 243 |
+
self.track_history: dict[int, list[tuple[int, tuple[float, float]]]] = {}
|
| 244 |
+
self.moving_track_last_seen: dict[tuple[str, int], int] = {}
|
| 245 |
|
| 246 |
def evaluate_frame(
|
| 247 |
self,
|
|
|
|
| 251 |
timestamp_sec: float,
|
| 252 |
) -> list[ActionEvent]:
|
| 253 |
events: list[ActionEvent] = []
|
| 254 |
+
self._update_track_history(detections)
|
| 255 |
for rule in self.rules:
|
| 256 |
if not self._gate_allows(rule, timestamp_sec):
|
| 257 |
self.last_matched[rule.name] = False
|
| 258 |
continue
|
| 259 |
+
matched = self._rule_matches(rule, detections, frame_index, timestamp_sec)
|
| 260 |
previous = self.last_matched.get(rule.name, False)
|
| 261 |
edge = _trigger_edge(previous=previous, matched=matched)
|
| 262 |
self.last_matched[rule.name] = matched
|
|
|
|
| 288 |
)
|
| 289 |
return events
|
| 290 |
|
| 291 |
+
def _rule_matches(
|
| 292 |
+
self,
|
| 293 |
+
rule: AutomationRule,
|
| 294 |
+
detections: list[Detection],
|
| 295 |
+
frame_index: int,
|
| 296 |
+
timestamp_sec: float,
|
| 297 |
+
) -> bool:
|
| 298 |
all_ok = all(
|
| 299 |
+
self._condition_matches(condition, detections, frame_index, rule.name, timestamp_sec)
|
| 300 |
for condition in rule.when.all_conditions
|
| 301 |
)
|
| 302 |
any_ok = True
|
| 303 |
if rule.when.any_conditions:
|
| 304 |
any_ok = any(
|
| 305 |
+
self._condition_matches(condition, detections, frame_index, rule.name, timestamp_sec)
|
| 306 |
for condition in rule.when.any_conditions
|
| 307 |
)
|
| 308 |
return all_ok and any_ok
|
|
|
|
| 318 |
self,
|
| 319 |
condition: ConditionBlock,
|
| 320 |
detections: list[Detection],
|
| 321 |
+
frame_index: int,
|
| 322 |
rule_name: str,
|
| 323 |
timestamp_sec: float,
|
| 324 |
) -> bool:
|
|
|
|
| 344 |
return False
|
| 345 |
return _min_box_gap_percent(left, right) >= condition.far.min_gap_percent
|
| 346 |
|
| 347 |
+
if condition.moving:
|
| 348 |
+
return self._moving_matches(condition.moving, by_label, frame_index)
|
| 349 |
+
|
| 350 |
if condition.cooldown:
|
| 351 |
return self._cooldown_allows(condition.cooldown, rule_name, timestamp_sec)
|
| 352 |
|
| 353 |
return False
|
| 354 |
|
| 355 |
+
def _moving_matches(
|
| 356 |
+
self,
|
| 357 |
+
condition: MovingCondition,
|
| 358 |
+
by_label: dict[str, list[Detection]],
|
| 359 |
+
frame_index: int,
|
| 360 |
+
) -> bool:
|
| 361 |
+
label = _label(condition.label)
|
| 362 |
+
label_detections = by_label[label]
|
| 363 |
+
for detection in label_detections:
|
| 364 |
+
if detection.track_id is None:
|
| 365 |
+
continue
|
| 366 |
+
history = self.track_history.get(detection.track_id, [])
|
| 367 |
+
if len(history) < condition.window_frames:
|
| 368 |
+
continue
|
| 369 |
+
_first_frame, first_centroid = history[-condition.window_frames]
|
| 370 |
+
_last_frame, last_centroid = history[-1]
|
| 371 |
+
displacement = hypot(
|
| 372 |
+
last_centroid[0] - first_centroid[0],
|
| 373 |
+
last_centroid[1] - first_centroid[1],
|
| 374 |
+
)
|
| 375 |
+
if displacement >= condition.min_displacement_percent:
|
| 376 |
+
self.moving_track_last_seen[(label, detection.track_id)] = detection.frame_index
|
| 377 |
+
return True
|
| 378 |
+
if label_detections:
|
| 379 |
+
return False
|
| 380 |
+
return any(
|
| 381 |
+
last_seen_frame <= frame_index
|
| 382 |
+
and frame_index - last_seen_frame <= condition.max_missing_frames
|
| 383 |
+
for (track_label, _track_id), last_seen_frame in self.moving_track_last_seen.items()
|
| 384 |
+
if track_label == label
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
def _update_track_history(self, detections: list[Detection]) -> None:
|
| 388 |
+
for detection in detections:
|
| 389 |
+
if detection.track_id is None:
|
| 390 |
+
continue
|
| 391 |
+
history = self.track_history.setdefault(detection.track_id, [])
|
| 392 |
+
history.append((detection.frame_index, _centroid_percent(detection)))
|
| 393 |
+
del history[:-10]
|
| 394 |
+
|
| 395 |
def _mark_cooldowns(self, rule: AutomationRule, timestamp_sec: float) -> None:
|
| 396 |
if rule.gate.cooldown:
|
| 397 |
self.last_fired[rule.gate.cooldown.key or rule.name] = timestamp_sec
|
|
|
|
| 456 |
return max(gap_x, gap_y) * 100.0
|
| 457 |
|
| 458 |
|
| 459 |
+
def _centroid_percent(detection: Detection) -> tuple[float, float]:
|
| 460 |
+
x1, y1, x2, y2 = detection.bbox_xyxy_norm
|
| 461 |
+
return ((x1 + x2) * 50.0, (y1 + y2) * 50.0)
|
| 462 |
+
|
| 463 |
+
|
| 464 |
def _trigger_edge(*, previous: bool, matched: bool) -> Literal["enter", "exit", "while", "none"]:
|
| 465 |
if matched and not previous:
|
| 466 |
return "enter"
|
tiny_trigger/detector.py
CHANGED
|
@@ -122,7 +122,25 @@ def detections_from_ultralytics_result(
|
|
| 122 |
bbox_xyxy_norm=_normalize_box(bbox, width, height),
|
| 123 |
)
|
| 124 |
)
|
| 125 |
-
return detections
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
|
| 128 |
def _label_from_names(names: Any, class_id: int, fallback_names: list[str]) -> str:
|
|
@@ -149,3 +167,26 @@ def _normalize_box(bbox: list[float], width: int, height: int) -> tuple[float, f
|
|
| 149 |
|
| 150 |
def _clamp01(value: float) -> float:
|
| 151 |
return max(0.0, min(1.0, value))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
bbox_xyxy_norm=_normalize_box(bbox, width, height),
|
| 123 |
)
|
| 124 |
)
|
| 125 |
+
return suppress_duplicate_detections(detections)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def suppress_duplicate_detections(
|
| 129 |
+
detections: list[Detection],
|
| 130 |
+
*,
|
| 131 |
+
iou_threshold: float = 0.8,
|
| 132 |
+
) -> list[Detection]:
|
| 133 |
+
"""Keep the highest-confidence same-label box for heavily overlapping detections."""
|
| 134 |
+
kept: list[Detection] = []
|
| 135 |
+
for detection in sorted(detections, key=lambda item: item.confidence, reverse=True):
|
| 136 |
+
duplicate = any(
|
| 137 |
+
_same_label(detection, existing)
|
| 138 |
+
and _box_iou(detection.bbox_xyxy_norm, existing.bbox_xyxy_norm) >= iou_threshold
|
| 139 |
+
for existing in kept
|
| 140 |
+
)
|
| 141 |
+
if not duplicate:
|
| 142 |
+
kept.append(detection)
|
| 143 |
+
return sorted(kept, key=lambda item: (item.frame_index, item.label, item.bbox_xyxy_norm))
|
| 144 |
|
| 145 |
|
| 146 |
def _label_from_names(names: Any, class_id: int, fallback_names: list[str]) -> str:
|
|
|
|
| 167 |
|
| 168 |
def _clamp01(value: float) -> float:
|
| 169 |
return max(0.0, min(1.0, value))
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _same_label(left: Detection, right: Detection) -> bool:
|
| 173 |
+
return left.label.strip().lower() == right.label.strip().lower()
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _box_iou(
|
| 177 |
+
left: tuple[float, float, float, float],
|
| 178 |
+
right: tuple[float, float, float, float],
|
| 179 |
+
) -> float:
|
| 180 |
+
ax1, ay1, ax2, ay2 = left
|
| 181 |
+
bx1, by1, bx2, by2 = right
|
| 182 |
+
intersection_width = max(0.0, min(ax2, bx2) - max(ax1, bx1))
|
| 183 |
+
intersection_height = max(0.0, min(ay2, by2) - max(ay1, by1))
|
| 184 |
+
intersection = intersection_width * intersection_height
|
| 185 |
+
if intersection <= 0:
|
| 186 |
+
return 0.0
|
| 187 |
+
left_area = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
|
| 188 |
+
right_area = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
|
| 189 |
+
union = left_area + right_area - intersection
|
| 190 |
+
if union <= 0:
|
| 191 |
+
return 0.0
|
| 192 |
+
return intersection / union
|
tiny_trigger/llm.py
CHANGED
|
@@ -12,7 +12,7 @@ from .automation import AutomationDocument, automation_schema
|
|
| 12 |
SYSTEM_PROMPT = """You compile home automation requests into Tiny Trigger rules.
|
| 13 |
Return JSON only. Never return code, markdown, explanations, or tool calls.
|
| 14 |
The root object MUST include a non-empty "rules" array.
|
| 15 |
-
Use video conditions in when: present, count, near, far.
|
| 16 |
Use state gates in gate: enabled, cooldown.
|
| 17 |
Use trigger.on for edge behavior: while, enter, exit, change.
|
| 18 |
Use only these action types: simulate, webhook.
|
|
@@ -22,8 +22,8 @@ Use trigger.on="while" only when the user explicitly wants repeated actions whil
|
|
| 22 |
When the request says one object is near, next to, beside, at, by, close to, or in front of another object, you MUST emit a near condition.
|
| 23 |
Do not replace a near relation with two present conditions.
|
| 24 |
Use max_gap_percent for near/far box-edge distance. It is the largest horizontal/vertical edge gap between boxes in normalized frame percent; touching or overlapping boxes have gap 0.
|
| 25 |
-
|
| 26 |
-
|
| 27 |
If the user mentions elapsed time since an action or limiting repeat fires, encode it as gate.cooldown.
|
| 28 |
If the user asks for one action when a condition starts and another action when it stops, use trigger.on="change" and then.enter / then.exit.
|
| 29 |
"""
|
|
@@ -462,6 +462,24 @@ JSON:
|
|
| 462 |
]
|
| 463 |
}}
|
| 464 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
User: If person is near monitor turn on lights. When they leave, turn off lights.
|
| 466 |
JSON:
|
| 467 |
{{
|
|
|
|
| 12 |
SYSTEM_PROMPT = """You compile home automation requests into Tiny Trigger rules.
|
| 13 |
Return JSON only. Never return code, markdown, explanations, or tool calls.
|
| 14 |
The root object MUST include a non-empty "rules" array.
|
| 15 |
+
Use video conditions in when: present, count, near, far, moving.
|
| 16 |
Use state gates in gate: enabled, cooldown.
|
| 17 |
Use trigger.on for edge behavior: while, enter, exit, change.
|
| 18 |
Use only these action types: simulate, webhook.
|
|
|
|
| 22 |
When the request says one object is near, next to, beside, at, by, close to, or in front of another object, you MUST emit a near condition.
|
| 23 |
Do not replace a near relation with two present conditions.
|
| 24 |
Use max_gap_percent for near/far box-edge distance. It is the largest horizontal/vertical edge gap between boxes in normalized frame percent; touching or overlapping boxes have gap 0.
|
| 25 |
+
Use moving for simple same-object centroid displacement across sampled frames, such as "car moving" or "person walks". Use min_displacement_percent, default 3, window_frames, minimum/default 3, and max_missing_frames, default 1.
|
| 26 |
+
Do not generate speed, direction, long-gap re-identification, or trajectory path rules.
|
| 27 |
If the user mentions elapsed time since an action or limiting repeat fires, encode it as gate.cooldown.
|
| 28 |
If the user asks for one action when a condition starts and another action when it stops, use trigger.on="change" and then.enter / then.exit.
|
| 29 |
"""
|
|
|
|
| 462 |
]
|
| 463 |
}}
|
| 464 |
|
| 465 |
+
User: If a car is moving, notify me.
|
| 466 |
+
JSON:
|
| 467 |
+
{{
|
| 468 |
+
"rules": [
|
| 469 |
+
{{
|
| 470 |
+
"name": "car-moving",
|
| 471 |
+
"when": {{
|
| 472 |
+
"all": [
|
| 473 |
+
{{"moving": {{"label": "car", "min_displacement_percent": 3, "window_frames": 3, "max_missing_frames": 1}}}}
|
| 474 |
+
]
|
| 475 |
+
}},
|
| 476 |
+
"trigger": {{"on": "enter"}},
|
| 477 |
+
"gate": {{"enabled": true}},
|
| 478 |
+
"then": [{{"type": "simulate", "name": "notify me"}}]
|
| 479 |
+
}}
|
| 480 |
+
]
|
| 481 |
+
}}
|
| 482 |
+
|
| 483 |
User: If person is near monitor turn on lights. When they leave, turn off lights.
|
| 484 |
JSON:
|
| 485 |
{{
|
tiny_trigger/models.py
CHANGED
|
@@ -12,6 +12,7 @@ class Detection(BaseModel):
|
|
| 12 |
confidence: float = Field(ge=0.0, le=1.0)
|
| 13 |
bbox_xyxy: tuple[float, float, float, float]
|
| 14 |
bbox_xyxy_norm: tuple[float, float, float, float]
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
class FrameSample(BaseModel):
|
|
|
|
| 12 |
confidence: float = Field(ge=0.0, le=1.0)
|
| 13 |
bbox_xyxy: tuple[float, float, float, float]
|
| 14 |
bbox_xyxy_norm: tuple[float, float, float, float]
|
| 15 |
+
track_id: int | None = None
|
| 16 |
|
| 17 |
|
| 18 |
class FrameSample(BaseModel):
|
tiny_trigger/tracking.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from math import hypot
|
| 5 |
+
|
| 6 |
+
from .models import Detection
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _label(value: str) -> str:
|
| 10 |
+
return value.strip().lower()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _centroid_percent(detection: Detection) -> tuple[float, float]:
|
| 14 |
+
x1, y1, x2, y2 = detection.bbox_xyxy_norm
|
| 15 |
+
return ((x1 + x2) * 50.0, (y1 + y2) * 50.0)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class _Track:
|
| 20 |
+
id: int
|
| 21 |
+
label: str
|
| 22 |
+
centroid: tuple[float, float]
|
| 23 |
+
missed: int = 0
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class SimpleTracker:
|
| 27 |
+
"""Small label-aware centroid tracker for sampled video detections."""
|
| 28 |
+
|
| 29 |
+
def __init__(self, *, max_match_distance_percent: float = 20.0, max_missed: int = 2) -> None:
|
| 30 |
+
self.max_match_distance_percent = max_match_distance_percent
|
| 31 |
+
self.max_missed = max_missed
|
| 32 |
+
self._next_id = 1
|
| 33 |
+
self._tracks: dict[int, _Track] = {}
|
| 34 |
+
|
| 35 |
+
def update(self, detections: list[Detection]) -> list[Detection]:
|
| 36 |
+
candidates: list[tuple[float, int, int]] = []
|
| 37 |
+
detection_centroids = [_centroid_percent(detection) for detection in detections]
|
| 38 |
+
for detection_index, detection in enumerate(detections):
|
| 39 |
+
label = _label(detection.label)
|
| 40 |
+
centroid = detection_centroids[detection_index]
|
| 41 |
+
for track in self._tracks.values():
|
| 42 |
+
if track.label != label:
|
| 43 |
+
continue
|
| 44 |
+
distance = hypot(centroid[0] - track.centroid[0], centroid[1] - track.centroid[1])
|
| 45 |
+
if distance <= self.max_match_distance_percent:
|
| 46 |
+
candidates.append((distance, detection_index, track.id))
|
| 47 |
+
|
| 48 |
+
assigned_detections: set[int] = set()
|
| 49 |
+
assigned_tracks: set[int] = set()
|
| 50 |
+
active_tracks: set[int] = set()
|
| 51 |
+
detection_track_ids: dict[int, int] = {}
|
| 52 |
+
for _distance, detection_index, track_id in sorted(candidates):
|
| 53 |
+
if detection_index in assigned_detections or track_id in assigned_tracks:
|
| 54 |
+
continue
|
| 55 |
+
assigned_detections.add(detection_index)
|
| 56 |
+
assigned_tracks.add(track_id)
|
| 57 |
+
detection_track_ids[detection_index] = track_id
|
| 58 |
+
|
| 59 |
+
tracked: list[Detection] = []
|
| 60 |
+
for detection_index, detection in enumerate(detections):
|
| 61 |
+
centroid = detection_centroids[detection_index]
|
| 62 |
+
track_id = detection_track_ids.get(detection_index)
|
| 63 |
+
if track_id is None:
|
| 64 |
+
track_id = self._next_id
|
| 65 |
+
self._next_id += 1
|
| 66 |
+
self._tracks[track_id] = _Track(
|
| 67 |
+
id=track_id,
|
| 68 |
+
label=_label(detection.label),
|
| 69 |
+
centroid=centroid,
|
| 70 |
+
)
|
| 71 |
+
else:
|
| 72 |
+
track = self._tracks[track_id]
|
| 73 |
+
track.centroid = centroid
|
| 74 |
+
track.missed = 0
|
| 75 |
+
active_tracks.add(track_id)
|
| 76 |
+
tracked.append(detection.model_copy(update={"track_id": track_id}))
|
| 77 |
+
|
| 78 |
+
for track_id, track in list(self._tracks.items()):
|
| 79 |
+
if track_id in active_tracks:
|
| 80 |
+
continue
|
| 81 |
+
track.missed += 1
|
| 82 |
+
if track.missed > self.max_missed:
|
| 83 |
+
del self._tracks[track_id]
|
| 84 |
+
|
| 85 |
+
return tracked
|
tiny_trigger/video.py
CHANGED
|
@@ -8,8 +8,9 @@ from pathlib import Path
|
|
| 8 |
from typing import Callable
|
| 9 |
from uuid import uuid4
|
| 10 |
|
| 11 |
-
from .detector import Detector, UltralyticsYOLOEDetector, parse_class_prompt
|
| 12 |
from .models import ActionEvent, Detection, FrameSample, VideoProcessResult
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
ProgressCallback = Callable[[int, int | None], None]
|
|
@@ -75,6 +76,7 @@ def process_video(
|
|
| 75 |
|
| 76 |
detections: list[Detection] = []
|
| 77 |
frames: list[FrameSample] = []
|
|
|
|
| 78 |
processed_frames = 0
|
| 79 |
frame_index = -1
|
| 80 |
latest_detections: list[Detection] = []
|
|
@@ -89,7 +91,7 @@ def process_video(
|
|
| 89 |
break
|
| 90 |
timestamp_sec = frame_index / source_fps
|
| 91 |
frames.append(FrameSample(frame_index=frame_index, timestamp_sec=timestamp_sec))
|
| 92 |
-
|
| 93 |
frame,
|
| 94 |
frame_index=frame_index,
|
| 95 |
timestamp_sec=timestamp_sec,
|
|
@@ -97,6 +99,8 @@ def process_video(
|
|
| 97 |
image_size=image_size,
|
| 98 |
max_detections=max_detections,
|
| 99 |
)
|
|
|
|
|
|
|
| 100 |
detections.extend(latest_detections)
|
| 101 |
processed_frames += 1
|
| 102 |
if progress:
|
|
@@ -294,7 +298,8 @@ def _draw_detections(frame, detections: list[Detection]) -> None:
|
|
| 294 |
for detection in detections:
|
| 295 |
x1, y1, x2, y2 = [int(value) for value in detection.bbox_xyxy]
|
| 296 |
color = _color_for_label(detection.label)
|
| 297 |
-
|
|
|
|
| 298 |
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
|
| 299 |
text_y = max(18, y1 - 8)
|
| 300 |
cv2.putText(frame, label, (x1, text_y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2, cv2.LINE_AA)
|
|
|
|
| 8 |
from typing import Callable
|
| 9 |
from uuid import uuid4
|
| 10 |
|
| 11 |
+
from .detector import Detector, UltralyticsYOLOEDetector, parse_class_prompt, suppress_duplicate_detections
|
| 12 |
from .models import ActionEvent, Detection, FrameSample, VideoProcessResult
|
| 13 |
+
from .tracking import SimpleTracker
|
| 14 |
|
| 15 |
|
| 16 |
ProgressCallback = Callable[[int, int | None], None]
|
|
|
|
| 76 |
|
| 77 |
detections: list[Detection] = []
|
| 78 |
frames: list[FrameSample] = []
|
| 79 |
+
tracker = SimpleTracker()
|
| 80 |
processed_frames = 0
|
| 81 |
frame_index = -1
|
| 82 |
latest_detections: list[Detection] = []
|
|
|
|
| 91 |
break
|
| 92 |
timestamp_sec = frame_index / source_fps
|
| 93 |
frames.append(FrameSample(frame_index=frame_index, timestamp_sec=timestamp_sec))
|
| 94 |
+
frame_detections = detector.detect(
|
| 95 |
frame,
|
| 96 |
frame_index=frame_index,
|
| 97 |
timestamp_sec=timestamp_sec,
|
|
|
|
| 99 |
image_size=image_size,
|
| 100 |
max_detections=max_detections,
|
| 101 |
)
|
| 102 |
+
frame_detections = suppress_duplicate_detections(frame_detections)
|
| 103 |
+
latest_detections = tracker.update(frame_detections)
|
| 104 |
detections.extend(latest_detections)
|
| 105 |
processed_frames += 1
|
| 106 |
if progress:
|
|
|
|
| 298 |
for detection in detections:
|
| 299 |
x1, y1, x2, y2 = [int(value) for value in detection.bbox_xyxy]
|
| 300 |
color = _color_for_label(detection.label)
|
| 301 |
+
track = f" #{detection.track_id}" if detection.track_id is not None else ""
|
| 302 |
+
label = f"{detection.label}{track} {detection.confidence:.2f}"
|
| 303 |
cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
|
| 304 |
text_y = max(18, y1 - 8)
|
| 305 |
cv2.putText(frame, label, (x1, text_y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2, cv2.LINE_AA)
|