Javier Montalvo commited on
Commit
8536e24
·
1 Parent(s): 20905e1

tracking and motion updates

Browse files
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 are frame-local for now. Presence, count, near, far, enter, exit, change,
61
- and cooldown are supported; object identity, movement, speed, direction, and
62
- trajectories are not tracked yet.
 
 
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 far. Near/far use the
90
- minimum horizontal/vertical gap between detection boxes in normalized frame
91
- percent. Gates include enabled state and cooldown. Triggers can fire while a
92
- condition is true, when it becomes true, when it becomes false, or on either
93
- change. Rules are frame-local: Tiny Trigger does not yet track object identities,
94
- movement, speed, direction, or trajectories across frames.
 
 
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(label: str, box: tuple[float, float, float, float], frame: int = 0, time: float = 0.0) -> 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(self, rule: AutomationRule, detections: list[Detection], timestamp_sec: float) -> bool:
 
 
 
 
 
 
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
- Do not generate motion, movement, speed, direction, trajectory, tracking, same-object, or identity rules. Tiny Trigger does not yet track identities across frames.
26
- For requests like "car moving", "person walks", "object moved", or "same car", return a simple presence/near/far approximation only if the request can still be useful without motion; otherwise return JSON with no 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,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
- latest_detections = detector.detect(
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
- label = f"{detection.label} {detection.confidence:.2f}"
 
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)