Javier Montalvo commited on
Commit
20905e1
·
1 Parent(s): 498b1c6

updated ui, and video generation

Browse files
DEMO.md CHANGED
@@ -51,11 +51,15 @@ Recommended starting detector settings:
51
 
52
  - Classes: leave defaults, or add obvious objects from the video.
53
  - Confidence: `0.15` for normal objects, `0.05` if detections are missing.
54
- - Frame stride: `5`.
55
  - Max frames: `120`.
56
- - Model: `yoloe-26s-seg.pt` for speed, `yoloe-26l-seg.pt` for a stronger demo.
 
57
 
58
  Rules automatically add their referenced labels to the detector class list.
 
 
 
59
 
60
  ## Prompts To Try
61
 
 
51
 
52
  - Classes: leave defaults, or add obvious objects from the video.
53
  - Confidence: `0.15` for normal objects, `0.05` if detections are missing.
54
+ - Sample interval: `1.0s`.
55
  - Max frames: `120`.
56
+ - Model size: Small for speed, Large/XLarge for a stronger demo.
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
 
README.md CHANGED
@@ -39,9 +39,9 @@ poetry install
39
  poetry run python server.py
40
  ```
41
 
42
- The detector defaults to `yoloe-26s-seg.pt`, which Ultralytics downloads on
43
- first use if it is not already cached. Model weights are not checked into the
44
- repo.
45
 
46
  For small or background objects, use a larger model such as `yoloe-26l-seg.pt`,
47
  set device to `cuda:0`, lower confidence to `0.05`-`0.15`, and raise image size
@@ -90,7 +90,8 @@ Initial video conditions include presence, count, near, and far. Near/far use th
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. Initial actions are simulated events and optional webhooks.
 
94
 
95
  ```yaml
96
  rules:
@@ -132,7 +133,7 @@ Example `.local/config.yaml`:
132
  webhook_url: "http://127.0.0.1:8123/api/webhook/example"
133
  default_detector_model: "yoloe-26x-seg.pt"
134
  default_device: "cuda:0"
135
- default_image_size: 1280
136
  default_max_detections: 300
137
  llm_provider: "anthropic"
138
  replicate_model: "openai/gpt-5.2"
 
39
  poetry run python server.py
40
  ```
41
 
42
+ The detector defaults to the Small YOLOE model (`yoloe-26s-seg.pt`) at 640px,
43
+ which Ultralytics downloads on first use if it is not already cached. Model
44
+ weights are not checked into the repo.
45
 
46
  For small or background objects, use a larger model such as `yoloe-26l-seg.pt`,
47
  set device to `cuda:0`, lower confidence to `0.05`-`0.15`, and raise image size
 
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:
 
133
  webhook_url: "http://127.0.0.1:8123/api/webhook/example"
134
  default_detector_model: "yoloe-26x-seg.pt"
135
  default_device: "cuda:0"
136
+ default_image_size: 640
137
  default_max_detections: 300
138
  llm_provider: "anthropic"
139
  replicate_model: "openai/gpt-5.2"
frontend/dist/assets/{index-BNhsKA1b.js → index-CLWJR9Mi.js} RENAMED
The diff for this file is too large to render. See raw diff
 
frontend/dist/index.html CHANGED
@@ -16,7 +16,7 @@
16
  href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap"
17
  rel="stylesheet"
18
  />
19
- <script type="module" crossorigin src="/assets/index-BNhsKA1b.js"></script>
20
  <link rel="stylesheet" crossorigin href="/assets/index-DpGaJEG3.css">
21
  </head>
22
  <body>
 
16
  href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap"
17
  rel="stylesheet"
18
  />
19
+ <script type="module" crossorigin src="/assets/index-CLWJR9Mi.js"></script>
20
  <link rel="stylesheet" crossorigin href="/assets/index-DpGaJEG3.css">
21
  </head>
22
  <body>
frontend/src/lib/api.ts CHANGED
@@ -39,7 +39,7 @@ export async function detectAndAutomate(
39
  classes: params.classes,
40
  rules_text: rulesText,
41
  confidence: params.confidence,
42
- frame_stride: params.frameStride,
43
  max_frames: params.maxFrames,
44
  model_name: params.modelName,
45
  image_size: params.imageSize,
 
39
  classes: params.classes,
40
  rules_text: rulesText,
41
  confidence: params.confidence,
42
+ sample_interval_sec: params.sampleIntervalSec,
43
  max_frames: params.maxFrames,
44
  model_name: params.modelName,
45
  image_size: params.imageSize,
frontend/src/lib/dashboard.tsx CHANGED
@@ -20,6 +20,14 @@ import type {
20
  } from "./types"
21
 
22
  const DEFAULT_CLASSES = "person, cat, dog, car, chair, cup"
 
 
 
 
 
 
 
 
23
 
24
  const DEFAULT_RULES = `rules:
25
  - name: example-person-enters-lights
@@ -38,10 +46,10 @@ const DEFAULT_RULES = `rules:
38
  const DEFAULT_PARAMS: DetectParams = {
39
  classes: DEFAULT_CLASSES,
40
  confidence: 0.25,
41
- frameStride: 5,
42
  maxFrames: 120,
43
  modelName: "yoloe-26s-seg.pt",
44
- imageSize: 0,
45
  device: "auto",
46
  maxDetections: 0,
47
  enableWebhooks: false,
@@ -121,9 +129,15 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
121
  setParams((p) => ({
122
  ...p,
123
  classes: cfg.default_classes ?? p.classes,
124
- modelName: cfg.default_detector_model ?? p.modelName,
 
 
 
125
  device: cfg.default_device ?? p.device,
126
- imageSize: cfg.default_image_size ?? p.imageSize,
 
 
 
127
  maxDetections: cfg.default_max_detections ?? p.maxDetections,
128
  webhookUrl: cfg.webhook_url ?? p.webhookUrl,
129
  }))
 
20
  } from "./types"
21
 
22
  const DEFAULT_CLASSES = "person, cat, dog, car, chair, cup"
23
+ const DETECTOR_MODEL_VALUES = new Set([
24
+ "yoloe-26n-seg.pt",
25
+ "yoloe-26s-seg.pt",
26
+ "yoloe-26m-seg.pt",
27
+ "yoloe-26l-seg.pt",
28
+ "yoloe-26x-seg.pt",
29
+ ])
30
+ const IMAGE_SIZE_VALUES = new Set([320, 640, 960, 1280])
31
 
32
  const DEFAULT_RULES = `rules:
33
  - name: example-person-enters-lights
 
46
  const DEFAULT_PARAMS: DetectParams = {
47
  classes: DEFAULT_CLASSES,
48
  confidence: 0.25,
49
+ sampleIntervalSec: 1.0,
50
  maxFrames: 120,
51
  modelName: "yoloe-26s-seg.pt",
52
+ imageSize: 640,
53
  device: "auto",
54
  maxDetections: 0,
55
  enableWebhooks: false,
 
129
  setParams((p) => ({
130
  ...p,
131
  classes: cfg.default_classes ?? p.classes,
132
+ modelName:
133
+ cfg.default_detector_model && DETECTOR_MODEL_VALUES.has(cfg.default_detector_model)
134
+ ? cfg.default_detector_model
135
+ : p.modelName,
136
  device: cfg.default_device ?? p.device,
137
+ imageSize:
138
+ cfg.default_image_size && IMAGE_SIZE_VALUES.has(cfg.default_image_size)
139
+ ? cfg.default_image_size
140
+ : p.imageSize,
141
  maxDetections: cfg.default_max_detections ?? p.maxDetections,
142
  webhookUrl: cfg.webhook_url ?? p.webhookUrl,
143
  }))
frontend/src/lib/types.ts CHANGED
@@ -26,6 +26,8 @@ export interface RunStats {
26
  processed_frames: number
27
  source_fps: number
28
  output_fps: number
 
 
29
  }
30
 
31
  export interface RunResult {
@@ -94,7 +96,7 @@ export interface LocalConfig {
94
  export interface DetectParams {
95
  classes: string
96
  confidence: number
97
- frameStride: number
98
  maxFrames: number
99
  modelName: string
100
  imageSize: number
 
26
  processed_frames: number
27
  source_fps: number
28
  output_fps: number
29
+ frame_stride: number
30
+ sample_interval_sec: number | null
31
  }
32
 
33
  export interface RunResult {
 
96
  export interface DetectParams {
97
  classes: string
98
  confidence: number
99
+ sampleIntervalSec: number
100
  maxFrames: number
101
  modelName: string
102
  imageSize: number
frontend/src/modules/detector/DetectorPanel.tsx CHANGED
@@ -9,6 +9,16 @@ import { Slider } from "@/components/ui/slider"
9
  import { Separator } from "@/components/ui/separator"
10
  import { cn } from "@/lib/utils"
11
 
 
 
 
 
 
 
 
 
 
 
12
  function SliderRow({
13
  label,
14
  value,
@@ -110,12 +120,18 @@ export function DetectorPanel() {
110
 
111
  <div className="space-y-2">
112
  <Label htmlFor="model">Detector model</Label>
113
- <Input
114
  id="model"
115
  value={params.modelName}
116
  onChange={(e) => setParam("modelName", e.target.value)}
117
- className="font-mono text-xs"
118
- />
 
 
 
 
 
 
119
  </div>
120
 
121
  <div className="space-y-4">
@@ -129,13 +145,13 @@ export function DetectorPanel() {
129
  onChange={(v) => setParam("confidence", Number(v.toFixed(2)))}
130
  />
131
  <SliderRow
132
- label="Frame stride"
133
- value={params.frameStride}
134
- display={`${params.frameStride}`}
135
- min={1}
136
- max={30}
137
- step={1}
138
- onChange={(v) => setParam("frameStride", v)}
139
  />
140
  <SliderRow
141
  label="Max frames"
@@ -171,14 +187,18 @@ export function DetectorPanel() {
171
  </div>
172
  <div className="space-y-1.5">
173
  <Label htmlFor="imgsz">Image size</Label>
174
- <Input
175
  id="imgsz"
176
- type="number"
177
- value={params.imageSize || ""}
178
- onChange={(e) => setParam("imageSize", Number(e.target.value) || 0)}
179
- className="font-mono text-xs"
180
- placeholder="auto"
181
- />
 
 
 
 
182
  </div>
183
  </div>
184
  <div className="space-y-1.5">
 
9
  import { Separator } from "@/components/ui/separator"
10
  import { cn } from "@/lib/utils"
11
 
12
+ const DETECTOR_MODELS = [
13
+ { label: "Nano", value: "yoloe-26n-seg.pt" },
14
+ { label: "Small", value: "yoloe-26s-seg.pt" },
15
+ { label: "Medium", value: "yoloe-26m-seg.pt" },
16
+ { label: "Large", value: "yoloe-26l-seg.pt" },
17
+ { label: "XLarge", value: "yoloe-26x-seg.pt" },
18
+ ]
19
+
20
+ const IMAGE_SIZES = [320, 640, 960, 1280]
21
+
22
  function SliderRow({
23
  label,
24
  value,
 
120
 
121
  <div className="space-y-2">
122
  <Label htmlFor="model">Detector model</Label>
123
+ <select
124
  id="model"
125
  value={params.modelName}
126
  onChange={(e) => setParam("modelName", e.target.value)}
127
+ className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
128
+ >
129
+ {DETECTOR_MODELS.map((model) => (
130
+ <option key={model.value} value={model.value}>
131
+ {model.label}
132
+ </option>
133
+ ))}
134
+ </select>
135
  </div>
136
 
137
  <div className="space-y-4">
 
145
  onChange={(v) => setParam("confidence", Number(v.toFixed(2)))}
146
  />
147
  <SliderRow
148
+ label="Sample interval"
149
+ value={params.sampleIntervalSec}
150
+ display={`${params.sampleIntervalSec.toFixed(1)}s`}
151
+ min={0.1}
152
+ max={5}
153
+ step={0.1}
154
+ onChange={(v) => setParam("sampleIntervalSec", Number(v.toFixed(1)))}
155
  />
156
  <SliderRow
157
  label="Max frames"
 
187
  </div>
188
  <div className="space-y-1.5">
189
  <Label htmlFor="imgsz">Image size</Label>
190
+ <select
191
  id="imgsz"
192
+ value={params.imageSize}
193
+ onChange={(e) => setParam("imageSize", Number(e.target.value))}
194
+ className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
195
+ >
196
+ {IMAGE_SIZES.map((size) => (
197
+ <option key={size} value={size}>
198
+ {size}
199
+ </option>
200
+ ))}
201
+ </select>
202
  </div>
203
  </div>
204
  <div className="space-y-1.5">
server.py CHANGED
@@ -103,6 +103,7 @@ def detect_and_automate(
103
  rules_text: str,
104
  confidence: float = 0.25,
105
  frame_stride: int = 5,
 
106
  max_frames: int = 120,
107
  model_name: str = DEFAULT_MODEL,
108
  image_size: int = 0,
@@ -124,6 +125,7 @@ def detect_and_automate(
124
  class_prompt=detection_classes,
125
  confidence=confidence,
126
  frame_stride=frame_stride,
 
127
  max_frames=max_frames,
128
  model_name=model_name or DEFAULT_MODEL,
129
  image_size=image_size or None,
@@ -149,7 +151,7 @@ def detect_and_automate(
149
  source_video_path=video_path,
150
  detections=result.detections,
151
  events=dispatched,
152
- frame_stride=frame_stride,
153
  max_frames=max_frames,
154
  output_dir=str(RENDERS),
155
  )
@@ -167,6 +169,8 @@ def detect_and_automate(
167
  "processed_frames": result.processed_frames,
168
  "source_fps": round(result.source_fps, 2),
169
  "output_fps": round(result.output_fps, 2),
 
 
170
  },
171
  "detections": [_detection_dict(d) for d in result.detections],
172
  "events": [_event_dict(e) for e in dispatched],
 
103
  rules_text: str,
104
  confidence: float = 0.25,
105
  frame_stride: int = 5,
106
+ sample_interval_sec: float | None = None,
107
  max_frames: int = 120,
108
  model_name: str = DEFAULT_MODEL,
109
  image_size: int = 0,
 
125
  class_prompt=detection_classes,
126
  confidence=confidence,
127
  frame_stride=frame_stride,
128
+ sample_interval_sec=sample_interval_sec,
129
  max_frames=max_frames,
130
  model_name=model_name or DEFAULT_MODEL,
131
  image_size=image_size or None,
 
151
  source_video_path=video_path,
152
  detections=result.detections,
153
  events=dispatched,
154
+ frame_stride=result.frame_stride,
155
  max_frames=max_frames,
156
  output_dir=str(RENDERS),
157
  )
 
169
  "processed_frames": result.processed_frames,
170
  "source_fps": round(result.source_fps, 2),
171
  "output_fps": round(result.output_fps, 2),
172
+ "frame_stride": result.frame_stride,
173
+ "sample_interval_sec": result.sample_interval_sec,
174
  },
175
  "detections": [_detection_dict(d) for d in result.detections],
176
  "events": [_event_dict(e) for e in dispatched],
tests/test_video.py CHANGED
@@ -4,7 +4,7 @@ from pathlib import Path
4
  from typing import Any
5
 
6
  from tiny_trigger.models import ActionEvent, Detection
7
- from tiny_trigger.video import process_video, render_automation_video
8
 
9
 
10
  class FakeDetector:
@@ -35,6 +35,7 @@ class FakeDetector:
35
 
36
 
37
  def test_process_video_with_fake_detector(tmp_path: Path) -> None:
 
38
  video_path = _make_video(tmp_path)
39
 
40
  result = process_video(
@@ -51,6 +52,52 @@ def test_process_video_with_fake_detector(tmp_path: Path) -> None:
51
  assert Path(result.output_video_path).exists()
52
  assert result.processed_frames == 2
53
  assert [item.frame_index for item in result.detections] == [0, 2]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
 
56
  def test_render_automation_video_with_fired_event(tmp_path: Path) -> None:
@@ -89,7 +136,32 @@ def test_render_automation_video_with_fired_event(tmp_path: Path) -> None:
89
  assert output_path.endswith("-automated.mp4")
90
 
91
 
92
- def test_process_video_repeats_low_fps_sampled_frames_for_playback(tmp_path: Path) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  cv2 = __import__("cv2")
94
  video_path = _make_video(tmp_path, fps=5.0, frames=15)
95
 
@@ -106,8 +178,8 @@ def test_process_video_repeats_low_fps_sampled_frames_for_playback(tmp_path: Pat
106
 
107
  capture = cv2.VideoCapture(result.output_video_path)
108
  try:
109
- assert capture.get(cv2.CAP_PROP_FPS) == 8.0
110
- assert capture.get(cv2.CAP_PROP_FRAME_COUNT) == 24
111
  finally:
112
  capture.release()
113
 
@@ -130,6 +202,34 @@ def test_process_video_writes_faststart_mp4(tmp_path: Path) -> None:
130
  assert data.find(b"moov") < data.find(b"mdat")
131
 
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  def _make_video(tmp_path: Path, *, fps: float = 10.0, frames: int = 4) -> Path:
134
  cv2 = __import__("cv2")
135
  video_path = tmp_path / "input.mp4"
 
4
  from typing import Any
5
 
6
  from tiny_trigger.models import ActionEvent, Detection
7
+ from tiny_trigger.video import _create_browser_mp4_writer, process_video, render_automation_video
8
 
9
 
10
  class FakeDetector:
 
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)
40
 
41
  result = process_video(
 
52
  assert Path(result.output_video_path).exists()
53
  assert result.processed_frames == 2
54
  assert [item.frame_index for item in result.detections] == [0, 2]
55
+ assert result.frame_stride == 2
56
+ assert result.sample_interval_sec is None
57
+ capture = cv2.VideoCapture(result.output_video_path)
58
+ try:
59
+ assert capture.get(cv2.CAP_PROP_FRAME_COUNT) == 4
60
+ assert capture.get(cv2.CAP_PROP_FPS) == 10.0
61
+ finally:
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
+
68
+ result = process_video(
69
+ video_path=str(video_path),
70
+ class_prompt="cat",
71
+ frame_stride=2,
72
+ sample_interval_sec=1.0,
73
+ max_frames=3,
74
+ image_size=960,
75
+ max_detections=20,
76
+ detector=FakeDetector(),
77
+ output_dir=str(tmp_path),
78
+ )
79
+
80
+ assert result.frame_stride == 30
81
+ assert result.sample_interval_sec == 1.0
82
+ assert [item.frame_index for item in result.detections] == [0, 30, 60]
83
+
84
+
85
+ def test_process_video_samples_half_second_intervals(tmp_path: Path) -> None:
86
+ video_path = _make_video(tmp_path, fps=10.0, frames=16)
87
+
88
+ result = process_video(
89
+ video_path=str(video_path),
90
+ class_prompt="cat",
91
+ sample_interval_sec=0.5,
92
+ max_frames=3,
93
+ image_size=960,
94
+ max_detections=20,
95
+ detector=FakeDetector(),
96
+ output_dir=str(tmp_path),
97
+ )
98
+
99
+ assert result.frame_stride == 5
100
+ assert [item.frame_index for item in result.detections] == [0, 5, 10]
101
 
102
 
103
  def test_render_automation_video_with_fired_event(tmp_path: Path) -> None:
 
136
  assert output_path.endswith("-automated.mp4")
137
 
138
 
139
+ def test_render_automation_video_uses_computed_stride(tmp_path: Path) -> None:
140
+ video_path = _make_video(tmp_path, fps=30.0, frames=95)
141
+ result = process_video(
142
+ video_path=str(video_path),
143
+ class_prompt="cat",
144
+ sample_interval_sec=1.0,
145
+ max_frames=3,
146
+ image_size=960,
147
+ max_detections=20,
148
+ detector=FakeDetector(),
149
+ output_dir=str(tmp_path),
150
+ )
151
+
152
+ output_path = render_automation_video(
153
+ source_video_path=str(video_path),
154
+ detections=result.detections,
155
+ events=[],
156
+ frame_stride=result.frame_stride,
157
+ max_frames=3,
158
+ output_dir=str(tmp_path),
159
+ )
160
+
161
+ assert Path(output_path).exists()
162
+
163
+
164
+ def test_process_video_writes_full_motion_clip_with_sampled_overlays(tmp_path: Path) -> None:
165
  cv2 = __import__("cv2")
166
  video_path = _make_video(tmp_path, fps=5.0, frames=15)
167
 
 
178
 
179
  capture = cv2.VideoCapture(result.output_video_path)
180
  try:
181
+ assert capture.get(cv2.CAP_PROP_FPS) == 5.0
182
+ assert capture.get(cv2.CAP_PROP_FRAME_COUNT) == 15
183
  finally:
184
  capture.release()
185
 
 
202
  assert data.find(b"moov") < data.find(b"mdat")
203
 
204
 
205
+ def test_browser_mp4_writer_uses_mp4v_only(monkeypatch, tmp_path: Path) -> None:
206
+ calls: list[str] = []
207
+
208
+ class Writer:
209
+ def isOpened(self) -> bool:
210
+ return True
211
+
212
+ def release(self) -> None:
213
+ return None
214
+
215
+ class CV2:
216
+ @staticmethod
217
+ def VideoWriter_fourcc(*codec):
218
+ calls.append("".join(codec))
219
+ return 1234
220
+
221
+ @staticmethod
222
+ def VideoWriter(path, fourcc, fps, frame_size):
223
+ return Writer()
224
+
225
+ monkeypatch.setitem(__import__("sys").modules, "cv2", CV2)
226
+
227
+ writer = _create_browser_mp4_writer(tmp_path / "out.mp4", 8.0, (32, 32))
228
+
229
+ assert writer is not None
230
+ assert calls == ["mp4v"]
231
+
232
+
233
  def _make_video(tmp_path: Path, *, fps: float = 10.0, frames: int = 4) -> Path:
234
  cv2 = __import__("cv2")
235
  video_path = tmp_path / "input.mp4"
tiny_trigger/llm.py CHANGED
@@ -22,6 +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
  If the user mentions elapsed time since an action or limiting repeat fires, encode it as gate.cooldown.
26
  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.
27
  """
 
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
  """
tiny_trigger/models.py CHANGED
@@ -27,6 +27,8 @@ class VideoProcessResult(BaseModel):
27
  processed_frames: int
28
  source_fps: float
29
  output_fps: float
 
 
30
 
31
 
32
  class ActionEvent(BaseModel):
 
27
  processed_frames: int
28
  source_fps: float
29
  output_fps: float
30
+ frame_stride: int
31
+ sample_interval_sec: float | None = None
32
 
33
 
34
  class ActionEvent(BaseModel):
tiny_trigger/video.py CHANGED
@@ -13,9 +13,7 @@ from .models import ActionEvent, Detection, FrameSample, VideoProcessResult
13
 
14
 
15
  ProgressCallback = Callable[[int, int | None], None]
16
- MIN_PREVIEW_FPS = 8.0
17
- MAX_PREVIEW_FPS = 30.0
18
- MP4_CODEC_CANDIDATES = ("avc1", "H264", "h264", "mp4v")
19
 
20
 
21
  def process_video(
@@ -24,6 +22,7 @@ def process_video(
24
  class_prompt: str | list[str],
25
  confidence: float = 0.25,
26
  frame_stride: int = 5,
 
27
  max_frames: int = 120,
28
  model_name: str = "yoloe-26s-seg.pt",
29
  image_size: int | None = None,
@@ -44,6 +43,8 @@ def process_video(
44
  raise ValueError("At least one class prompt is required.")
45
  if frame_stride < 1:
46
  raise ValueError("frame_stride must be at least 1.")
 
 
47
  if max_frames < 1:
48
  raise ValueError("max_frames must be at least 1.")
49
  if image_size is not None and image_size < 32:
@@ -57,9 +58,14 @@ def process_video(
57
  raise ValueError(f"Could not open video: {video_path}")
58
 
59
  source_fps = float(capture.get(cv2.CAP_PROP_FPS) or 30.0)
 
 
 
 
 
60
  width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
61
  height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
62
- output_fps, repeat_count = _preview_timing(source_fps, frame_stride)
63
  output_size = _browser_frame_size(width, height)
64
  output_path = _output_path(video_path, output_dir)
65
  writer = _create_browser_mp4_writer(output_path, output_fps, output_size)
@@ -71,33 +77,33 @@ def process_video(
71
  frames: list[FrameSample] = []
72
  processed_frames = 0
73
  frame_index = -1
 
74
  try:
75
  while True:
76
  ok, frame = capture.read()
77
  if not ok:
78
  break
79
  frame_index += 1
80
- if frame_index % frame_stride != 0:
81
- continue
82
- if processed_frames >= max_frames:
83
- break
84
-
85
- timestamp_sec = frame_index / source_fps
86
- frames.append(FrameSample(frame_index=frame_index, timestamp_sec=timestamp_sec))
87
- frame_detections = detector.detect(
88
- frame,
89
- frame_index=frame_index,
90
- timestamp_sec=timestamp_sec,
91
- confidence=confidence,
92
- image_size=image_size,
93
- max_detections=max_detections,
94
- )
95
- detections.extend(frame_detections)
96
- _draw_detections(frame, frame_detections)
97
- _write_preview_frame(writer, _fit_frame_to_output(frame, output_size), repeat_count)
98
- processed_frames += 1
99
- if progress:
100
- progress(processed_frames, max_frames)
101
  finally:
102
  writer.release()
103
  capture.release()
@@ -111,6 +117,8 @@ def process_video(
111
  processed_frames=processed_frames,
112
  source_fps=source_fps,
113
  output_fps=output_fps,
 
 
114
  )
115
 
116
 
@@ -120,6 +128,7 @@ def render_automation_video(
120
  detections: list[Detection],
121
  events: list[ActionEvent],
122
  frame_stride: int,
 
123
  max_frames: int,
124
  output_dir: str | None = None,
125
  ) -> str:
@@ -131,6 +140,8 @@ def render_automation_video(
131
 
132
  if frame_stride < 1:
133
  raise ValueError("frame_stride must be at least 1.")
 
 
134
  if max_frames < 1:
135
  raise ValueError("max_frames must be at least 1.")
136
 
@@ -139,9 +150,14 @@ def render_automation_video(
139
  raise ValueError(f"Could not open video: {source_video_path}")
140
 
141
  source_fps = float(capture.get(cv2.CAP_PROP_FPS) or 30.0)
 
 
 
 
 
142
  width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
143
  height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
144
- output_fps, repeat_count = _preview_timing(source_fps, frame_stride)
145
  output_size = _browser_frame_size(width, height)
146
  output_path = _output_path(source_video_path, output_dir, suffix="automated")
147
  writer = _create_browser_mp4_writer(output_path, output_fps, output_size)
@@ -153,23 +169,25 @@ def render_automation_video(
153
  events_by_frame = _group_events_by_frame(events)
154
  processed_frames = 0
155
  frame_index = -1
 
 
156
  try:
157
  while True:
158
  ok, frame = capture.read()
159
  if not ok:
160
  break
161
  frame_index += 1
162
- if frame_index % frame_stride != 0:
163
- continue
164
- if processed_frames >= max_frames:
165
- break
166
-
167
- _draw_detections(frame, detections_by_frame.get(frame_index, []))
168
- frame_events = events_by_frame.get(frame_index, [])
169
- if frame_events:
170
- _draw_action_events(frame, frame_events)
171
- _write_preview_frame(writer, _fit_frame_to_output(frame, output_size), repeat_count)
172
- processed_frames += 1
173
  finally:
174
  writer.release()
175
  capture.release()
@@ -184,15 +202,15 @@ def _output_path(video_path: str, output_dir: str | None, *, suffix: str = "anno
184
  return base_dir / f"{Path(video_path).stem}-{uuid4().hex[:8]}-{suffix}.mp4"
185
 
186
 
187
- def _preview_timing(source_fps: float, frame_stride: int) -> tuple[float, int]:
188
- sampled_fps = source_fps / frame_stride if frame_stride else source_fps
189
- output_fps = min(MAX_PREVIEW_FPS, max(MIN_PREVIEW_FPS, sampled_fps or MIN_PREVIEW_FPS))
190
- repeat_count = max(1, round(output_fps / sampled_fps)) if sampled_fps > 0 else 1
191
- return output_fps, repeat_count
192
-
193
-
194
- def _preview_fps(source_fps: float, frame_stride: int) -> float:
195
- return _preview_timing(source_fps, frame_stride)[0]
196
 
197
 
198
  def _browser_frame_size(width: int, height: int) -> tuple[int, int]:
@@ -206,11 +224,10 @@ def _browser_frame_size(width: int, height: int) -> tuple[int, int]:
206
  def _create_browser_mp4_writer(output_path: Path, fps: float, frame_size: tuple[int, int]):
207
  import cv2
208
 
209
- for codec in MP4_CODEC_CANDIDATES:
210
- writer = cv2.VideoWriter(str(output_path), cv2.VideoWriter_fourcc(*codec), fps, frame_size)
211
- if writer.isOpened():
212
- return writer
213
- writer.release()
214
  return None
215
 
216
 
@@ -229,8 +246,12 @@ def _finalize_browser_mp4(output_path: Path) -> None:
229
  "error",
230
  "-i",
231
  str(output_path),
232
- "-c",
233
- "copy",
 
 
 
 
234
  "-movflags",
235
  "+faststart",
236
  str(faststart_path),
@@ -263,9 +284,8 @@ def _fit_frame_to_output(frame, output_size: tuple[int, int]):
263
  return frame[:output_height, :output_width]
264
 
265
 
266
- def _write_preview_frame(writer, frame, repeat_count: int) -> None:
267
- for _ in range(repeat_count):
268
- writer.write(frame)
269
 
270
 
271
  def _draw_detections(frame, detections: list[Detection]) -> None:
 
13
 
14
 
15
  ProgressCallback = Callable[[int, int | None], None]
16
+ MP4_CODEC = "mp4v"
 
 
17
 
18
 
19
  def process_video(
 
22
  class_prompt: str | list[str],
23
  confidence: float = 0.25,
24
  frame_stride: int = 5,
25
+ sample_interval_sec: float | None = None,
26
  max_frames: int = 120,
27
  model_name: str = "yoloe-26s-seg.pt",
28
  image_size: int | None = None,
 
43
  raise ValueError("At least one class prompt is required.")
44
  if frame_stride < 1:
45
  raise ValueError("frame_stride must be at least 1.")
46
+ if sample_interval_sec is not None and sample_interval_sec <= 0:
47
+ raise ValueError("sample_interval_sec must be greater than 0.")
48
  if max_frames < 1:
49
  raise ValueError("max_frames must be at least 1.")
50
  if image_size is not None and image_size < 32:
 
58
  raise ValueError(f"Could not open video: {video_path}")
59
 
60
  source_fps = float(capture.get(cv2.CAP_PROP_FPS) or 30.0)
61
+ effective_frame_stride = _sampling_frame_stride(
62
+ source_fps=source_fps,
63
+ frame_stride=frame_stride,
64
+ sample_interval_sec=sample_interval_sec,
65
+ )
66
  width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
67
  height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
68
+ output_fps = source_fps
69
  output_size = _browser_frame_size(width, height)
70
  output_path = _output_path(video_path, output_dir)
71
  writer = _create_browser_mp4_writer(output_path, output_fps, output_size)
 
77
  frames: list[FrameSample] = []
78
  processed_frames = 0
79
  frame_index = -1
80
+ latest_detections: list[Detection] = []
81
  try:
82
  while True:
83
  ok, frame = capture.read()
84
  if not ok:
85
  break
86
  frame_index += 1
87
+ if frame_index % effective_frame_stride == 0:
88
+ if processed_frames >= max_frames:
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,
96
+ confidence=confidence,
97
+ image_size=image_size,
98
+ max_detections=max_detections,
99
+ )
100
+ detections.extend(latest_detections)
101
+ processed_frames += 1
102
+ if progress:
103
+ progress(processed_frames, max_frames)
104
+
105
+ _draw_detections(frame, latest_detections)
106
+ _write_frame(writer, _fit_frame_to_output(frame, output_size))
 
107
  finally:
108
  writer.release()
109
  capture.release()
 
117
  processed_frames=processed_frames,
118
  source_fps=source_fps,
119
  output_fps=output_fps,
120
+ frame_stride=effective_frame_stride,
121
+ sample_interval_sec=sample_interval_sec,
122
  )
123
 
124
 
 
128
  detections: list[Detection],
129
  events: list[ActionEvent],
130
  frame_stride: int,
131
+ sample_interval_sec: float | None = None,
132
  max_frames: int,
133
  output_dir: str | None = None,
134
  ) -> str:
 
140
 
141
  if frame_stride < 1:
142
  raise ValueError("frame_stride must be at least 1.")
143
+ if sample_interval_sec is not None and sample_interval_sec <= 0:
144
+ raise ValueError("sample_interval_sec must be greater than 0.")
145
  if max_frames < 1:
146
  raise ValueError("max_frames must be at least 1.")
147
 
 
150
  raise ValueError(f"Could not open video: {source_video_path}")
151
 
152
  source_fps = float(capture.get(cv2.CAP_PROP_FPS) or 30.0)
153
+ effective_frame_stride = _sampling_frame_stride(
154
+ source_fps=source_fps,
155
+ frame_stride=frame_stride,
156
+ sample_interval_sec=sample_interval_sec,
157
+ )
158
  width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
159
  height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
160
+ output_fps = source_fps
161
  output_size = _browser_frame_size(width, height)
162
  output_path = _output_path(source_video_path, output_dir, suffix="automated")
163
  writer = _create_browser_mp4_writer(output_path, output_fps, output_size)
 
169
  events_by_frame = _group_events_by_frame(events)
170
  processed_frames = 0
171
  frame_index = -1
172
+ latest_detections: list[Detection] = []
173
+ latest_events: list[ActionEvent] = []
174
  try:
175
  while True:
176
  ok, frame = capture.read()
177
  if not ok:
178
  break
179
  frame_index += 1
180
+ if frame_index % effective_frame_stride == 0:
181
+ if processed_frames >= max_frames:
182
+ break
183
+ latest_detections = detections_by_frame.get(frame_index, [])
184
+ latest_events = events_by_frame.get(frame_index, [])
185
+ processed_frames += 1
186
+
187
+ _draw_detections(frame, latest_detections)
188
+ if latest_events:
189
+ _draw_action_events(frame, latest_events)
190
+ _write_frame(writer, _fit_frame_to_output(frame, output_size))
191
  finally:
192
  writer.release()
193
  capture.release()
 
202
  return base_dir / f"{Path(video_path).stem}-{uuid4().hex[:8]}-{suffix}.mp4"
203
 
204
 
205
+ def _sampling_frame_stride(
206
+ *,
207
+ source_fps: float,
208
+ frame_stride: int,
209
+ sample_interval_sec: float | None,
210
+ ) -> int:
211
+ if sample_interval_sec is None:
212
+ return frame_stride
213
+ return max(1, round(source_fps * sample_interval_sec))
214
 
215
 
216
  def _browser_frame_size(width: int, height: int) -> tuple[int, int]:
 
224
  def _create_browser_mp4_writer(output_path: Path, fps: float, frame_size: tuple[int, int]):
225
  import cv2
226
 
227
+ writer = cv2.VideoWriter(str(output_path), cv2.VideoWriter_fourcc(*MP4_CODEC), fps, frame_size)
228
+ if writer.isOpened():
229
+ return writer
230
+ writer.release()
 
231
  return None
232
 
233
 
 
246
  "error",
247
  "-i",
248
  str(output_path),
249
+ "-c:v",
250
+ "libx264",
251
+ "-pix_fmt",
252
+ "yuv420p",
253
+ "-preset",
254
+ "veryfast",
255
  "-movflags",
256
  "+faststart",
257
  str(faststart_path),
 
284
  return frame[:output_height, :output_width]
285
 
286
 
287
+ def _write_frame(writer, frame) -> None:
288
+ writer.write(frame)
 
289
 
290
 
291
  def _draw_detections(frame, detections: list[Detection]) -> None: