eventify-dvs 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eventify/__init__.py +15 -0
- eventify/_fast.py +39 -0
- eventify/cli.py +279 -0
- eventify/dvs.py +232 -0
- eventify_dvs-0.1.0.dist-info/METADATA +150 -0
- eventify_dvs-0.1.0.dist-info/RECORD +9 -0
- eventify_dvs-0.1.0.dist-info/WHEEL +4 -0
- eventify_dvs-0.1.0.dist-info/entry_points.txt +2 -0
- eventify_dvs-0.1.0.dist-info/licenses/LICENSE +21 -0
eventify/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from eventify.dvs import (
|
|
2
|
+
EVENT_DTYPE,
|
|
3
|
+
frame_to_event_tuples,
|
|
4
|
+
interpolate_frames,
|
|
5
|
+
video_to_event_stream,
|
|
6
|
+
write_hdf5,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"EVENT_DTYPE",
|
|
11
|
+
"frame_to_event_tuples",
|
|
12
|
+
"interpolate_frames",
|
|
13
|
+
"video_to_event_stream",
|
|
14
|
+
"write_hdf5",
|
|
15
|
+
]
|
eventify/_fast.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Fast primitives for the low-latency webcam preview."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_log_lut(eps: float = 1.0) -> np.ndarray:
|
|
11
|
+
"""Precompute ``log(x + eps)`` for x = 0..255 as a float32 array."""
|
|
12
|
+
x = np.arange(256, dtype=np.float32)
|
|
13
|
+
return np.log(x + np.float32(eps))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def frame_to_crossing_counts(
|
|
17
|
+
prev_frame: np.ndarray,
|
|
18
|
+
curr_frame: np.ndarray,
|
|
19
|
+
c_thresh: float = 0.05,
|
|
20
|
+
log_lut: Optional[np.ndarray] = None,
|
|
21
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
22
|
+
"""Return ``(on_counts, off_counts)`` 2D int arrays. Inputs must be uint8 grayscale."""
|
|
23
|
+
if prev_frame.dtype != np.uint8 or curr_frame.dtype != np.uint8:
|
|
24
|
+
raise TypeError(
|
|
25
|
+
"frame_to_crossing_counts requires uint8 inputs; "
|
|
26
|
+
f"got {prev_frame.dtype} and {curr_frame.dtype}"
|
|
27
|
+
)
|
|
28
|
+
if prev_frame.shape != curr_frame.shape:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
f"Frame shape mismatch: {prev_frame.shape} vs {curr_frame.shape}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if log_lut is None:
|
|
34
|
+
log_lut = build_log_lut(eps=1.0)
|
|
35
|
+
|
|
36
|
+
delta = log_lut[curr_frame] - log_lut[prev_frame]
|
|
37
|
+
crossings = np.floor_divide(np.abs(delta), c_thresh).astype(np.int16)
|
|
38
|
+
positive = delta > 0
|
|
39
|
+
return np.where(positive, crossings, 0), np.where(positive, 0, crossings)
|
eventify/cli.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Command-line entry point for eventify."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import queue
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import Optional, Sequence
|
|
11
|
+
|
|
12
|
+
import cv2
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from eventify._fast import build_log_lut, frame_to_crossing_counts
|
|
16
|
+
from eventify.dvs import EVENT_DTYPE, video_to_event_stream, write_hdf5
|
|
17
|
+
|
|
18
|
+
# DVS palette (BGR): ON = deep blue, OFF = amber.
|
|
19
|
+
_ON_BGR = np.array([180, 70, 0], dtype=np.float32)
|
|
20
|
+
_OFF_BGR = np.array([0, 170, 220], dtype=np.float32)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_sensor_size(spec: str) -> tuple[int, int]:
|
|
24
|
+
try:
|
|
25
|
+
w_str, h_str = spec.split(",")
|
|
26
|
+
return int(w_str), int(h_str)
|
|
27
|
+
except ValueError as exc:
|
|
28
|
+
raise argparse.ArgumentTypeError(
|
|
29
|
+
f"--sensor-size must be 'W,H' (got {spec!r})"
|
|
30
|
+
) from exc
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _capture_settings_from_args(args: argparse.Namespace) -> Optional[dict]:
|
|
34
|
+
props = {}
|
|
35
|
+
if getattr(args, "width", None):
|
|
36
|
+
props[cv2.CAP_PROP_FRAME_WIDTH] = args.width
|
|
37
|
+
if getattr(args, "height", None):
|
|
38
|
+
props[cv2.CAP_PROP_FRAME_HEIGHT] = args.height
|
|
39
|
+
if getattr(args, "fps", None):
|
|
40
|
+
props[cv2.CAP_PROP_FPS] = args.fps
|
|
41
|
+
return props or None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _events_to_frame(chunk: np.ndarray, h: int, w: int) -> np.ndarray:
|
|
45
|
+
"""Render a structured event array into a BGR uint8 frame."""
|
|
46
|
+
on = np.zeros((h, w), dtype=np.float32)
|
|
47
|
+
off = np.zeros((h, w), dtype=np.float32)
|
|
48
|
+
if len(chunk):
|
|
49
|
+
mask_on = chunk["p"] == 1
|
|
50
|
+
np.add.at(on, (chunk["y"][mask_on], chunk["x"][mask_on]), 1)
|
|
51
|
+
np.add.at(off, (chunk["y"][~mask_on], chunk["x"][~mask_on]), 1)
|
|
52
|
+
|
|
53
|
+
peak = max(on.max(), off.max(), 1.0)
|
|
54
|
+
intensity_on = np.clip(on / peak, 0.0, 1.0)[..., None]
|
|
55
|
+
intensity_off = np.clip(off / peak, 0.0, 1.0)[..., None]
|
|
56
|
+
img = intensity_on * _ON_BGR + intensity_off * _OFF_BGR
|
|
57
|
+
return np.clip(img, 0, 255).astype(np.uint8)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _convert(args: argparse.Namespace) -> int:
|
|
61
|
+
cap = cv2.VideoCapture(args.input)
|
|
62
|
+
if not cap.isOpened():
|
|
63
|
+
print(f"error: could not open input video: {args.input}", file=sys.stderr)
|
|
64
|
+
return 1
|
|
65
|
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
|
66
|
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
67
|
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
68
|
+
cap.release()
|
|
69
|
+
|
|
70
|
+
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
|
71
|
+
writer = cv2.VideoWriter(args.output, fourcc, fps, (width, height), isColor=True)
|
|
72
|
+
if not writer.isOpened():
|
|
73
|
+
print(f"error: could not open output for writing: {args.output}", file=sys.stderr)
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
count = 0
|
|
77
|
+
try:
|
|
78
|
+
for chunk in video_to_event_stream(args.input, c_thresh=args.threshold):
|
|
79
|
+
writer.write(_events_to_frame(chunk, height, width))
|
|
80
|
+
count += 1
|
|
81
|
+
finally:
|
|
82
|
+
writer.release()
|
|
83
|
+
|
|
84
|
+
print(f"wrote {count} event frames to {args.output}")
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _ThreadedCapture:
|
|
89
|
+
"""Drops stale frames so the display loop always gets the latest."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, source, capture_settings: Optional[dict] = None):
|
|
92
|
+
self._cap = cv2.VideoCapture(source)
|
|
93
|
+
if not self._cap.isOpened():
|
|
94
|
+
self._cap.release()
|
|
95
|
+
raise IOError(f"Could not open video source: {source!r}")
|
|
96
|
+
if capture_settings:
|
|
97
|
+
for prop, value in capture_settings.items():
|
|
98
|
+
self._cap.set(prop, value)
|
|
99
|
+
self._q: "queue.Queue" = queue.Queue(maxsize=1)
|
|
100
|
+
self._stop = threading.Event()
|
|
101
|
+
self._thread = threading.Thread(target=self._reader, daemon=True)
|
|
102
|
+
self._thread.start()
|
|
103
|
+
|
|
104
|
+
def _reader(self) -> None:
|
|
105
|
+
while not self._stop.is_set():
|
|
106
|
+
ok, frame = self._cap.read()
|
|
107
|
+
if not ok or frame is None:
|
|
108
|
+
self._stop.set()
|
|
109
|
+
break
|
|
110
|
+
try:
|
|
111
|
+
self._q.get_nowait()
|
|
112
|
+
except queue.Empty:
|
|
113
|
+
pass
|
|
114
|
+
try:
|
|
115
|
+
self._q.put_nowait(frame)
|
|
116
|
+
except queue.Full:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
def read(self, timeout: float = 1.0) -> Optional[np.ndarray]:
|
|
120
|
+
try:
|
|
121
|
+
return self._q.get(timeout=timeout)
|
|
122
|
+
except queue.Empty:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
def release(self) -> None:
|
|
126
|
+
self._stop.set()
|
|
127
|
+
self._thread.join(timeout=1.0)
|
|
128
|
+
self._cap.release()
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def actual_size(self) -> tuple[int, int]:
|
|
132
|
+
return (
|
|
133
|
+
int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
|
|
134
|
+
int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _accum_to_bgr(accum: np.ndarray, max_events: float) -> np.ndarray:
|
|
139
|
+
intensity = np.clip(np.abs(accum) / max_events, 0.0, 1.0)[..., None]
|
|
140
|
+
positive = accum >= 0
|
|
141
|
+
target = np.empty(accum.shape + (3,), dtype=np.float32)
|
|
142
|
+
target[..., 0] = np.where(positive, 180.0, 0.0)
|
|
143
|
+
target[..., 1] = np.where(positive, 70.0, 170.0)
|
|
144
|
+
target[..., 2] = np.where(positive, 0.0, 220.0)
|
|
145
|
+
return np.clip(intensity * target, 0, 255).astype(np.uint8)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _webcam(args: argparse.Namespace) -> int:
|
|
149
|
+
window = "eventify — press q to quit"
|
|
150
|
+
cv2.namedWindow(window, cv2.WINDOW_NORMAL)
|
|
151
|
+
|
|
152
|
+
log_lut = build_log_lut(eps=1.0)
|
|
153
|
+
half_life_sec = max(args.accum_ms, 1) / 1000.0
|
|
154
|
+
|
|
155
|
+
accum: Optional[np.ndarray] = None
|
|
156
|
+
prev_gray: Optional[np.ndarray] = None
|
|
157
|
+
last_tick = time.monotonic()
|
|
158
|
+
frames_shown = 0
|
|
159
|
+
events_seen = 0
|
|
160
|
+
start = time.monotonic()
|
|
161
|
+
|
|
162
|
+
cap = _ThreadedCapture(args.device, capture_settings=_capture_settings_from_args(args))
|
|
163
|
+
print(f"capture opened at {cap.actual_size[0]}x{cap.actual_size[1]}", file=sys.stderr)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
while True:
|
|
167
|
+
frame = cap.read(timeout=1.0)
|
|
168
|
+
if frame is None:
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
172
|
+
|
|
173
|
+
if prev_gray is None:
|
|
174
|
+
prev_gray = gray
|
|
175
|
+
accum = np.zeros(gray.shape, dtype=np.float32)
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
on, off = frame_to_crossing_counts(prev_gray, gray, c_thresh=args.threshold, log_lut=log_lut)
|
|
179
|
+
|
|
180
|
+
now = time.monotonic()
|
|
181
|
+
dt = now - last_tick
|
|
182
|
+
last_tick = now
|
|
183
|
+
decay = 0.5 ** (dt / half_life_sec)
|
|
184
|
+
|
|
185
|
+
accum *= decay
|
|
186
|
+
accum += on.astype(np.float32)
|
|
187
|
+
accum -= off.astype(np.float32)
|
|
188
|
+
|
|
189
|
+
events_seen += int(on.sum()) + int(off.sum())
|
|
190
|
+
cv2.imshow(window, _accum_to_bgr(accum, max_events=args.max_events))
|
|
191
|
+
frames_shown += 1
|
|
192
|
+
prev_gray = gray
|
|
193
|
+
|
|
194
|
+
if cv2.waitKey(1) & 0xFF == ord("q"):
|
|
195
|
+
break
|
|
196
|
+
finally:
|
|
197
|
+
cap.release()
|
|
198
|
+
cv2.destroyAllWindows()
|
|
199
|
+
|
|
200
|
+
elapsed = max(time.monotonic() - start, 1e-6)
|
|
201
|
+
print(
|
|
202
|
+
f"showed {frames_shown} frames in {elapsed:.1f}s "
|
|
203
|
+
f"({frames_shown / elapsed:.1f} fps display, "
|
|
204
|
+
f"{events_seen / elapsed:,.0f} ev/s)"
|
|
205
|
+
)
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _export(args: argparse.Namespace) -> int:
|
|
210
|
+
chunks = []
|
|
211
|
+
total = 0
|
|
212
|
+
for chunk in video_to_event_stream(
|
|
213
|
+
args.input,
|
|
214
|
+
c_thresh=args.threshold,
|
|
215
|
+
sensor_size=args.sensor_size,
|
|
216
|
+
interp=args.interp,
|
|
217
|
+
):
|
|
218
|
+
chunks.append(chunk)
|
|
219
|
+
total += len(chunk)
|
|
220
|
+
|
|
221
|
+
if args.sensor_size is not None:
|
|
222
|
+
w, h = args.sensor_size
|
|
223
|
+
resolved_shape = (h, w)
|
|
224
|
+
else:
|
|
225
|
+
cap = cv2.VideoCapture(args.input)
|
|
226
|
+
resolved_shape = (
|
|
227
|
+
int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
|
|
228
|
+
int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
|
|
229
|
+
)
|
|
230
|
+
cap.release()
|
|
231
|
+
|
|
232
|
+
events = np.concatenate(chunks) if chunks else np.zeros(0, dtype=EVENT_DTYPE)
|
|
233
|
+
write_hdf5(args.output, events, sensor_shape=resolved_shape)
|
|
234
|
+
print(f"wrote {total} events to {args.output} (sensor_shape={resolved_shape})")
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
239
|
+
parser = argparse.ArgumentParser(
|
|
240
|
+
prog="eventify",
|
|
241
|
+
description="Convert video or webcam feeds into simulated event-camera data.",
|
|
242
|
+
)
|
|
243
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
244
|
+
|
|
245
|
+
convert = sub.add_parser("convert", help="Convert a video file to an event-rendered video.")
|
|
246
|
+
convert.add_argument("input", help="Path to the input video file.")
|
|
247
|
+
convert.add_argument("output", help="Path to write the event-rendered video (e.g. out.mp4).")
|
|
248
|
+
convert.add_argument("--threshold", type=float, default=0.05, help="Log-intensity event threshold (default: 0.05).")
|
|
249
|
+
convert.set_defaults(func=_convert)
|
|
250
|
+
|
|
251
|
+
webcam = sub.add_parser("webcam", help="Show live event stream from the webcam.")
|
|
252
|
+
webcam.add_argument("--device", type=int, default=0, help="Webcam device index (default: 0).")
|
|
253
|
+
webcam.add_argument("--threshold", type=float, default=0.05, help="Log-intensity event threshold (default: 0.05).")
|
|
254
|
+
webcam.add_argument("--width", type=int, default=1280, help="Requested capture width (default: 1280).")
|
|
255
|
+
webcam.add_argument("--height", type=int, default=720, help="Requested capture height (default: 720).")
|
|
256
|
+
webcam.add_argument("--fps", type=float, default=60.0, help="Requested capture FPS (default: 60).")
|
|
257
|
+
webcam.add_argument("--accum-ms", type=float, default=80.0, help="Event accumulation half-life in ms (default: 80).")
|
|
258
|
+
webcam.add_argument("--max-events", type=float, default=8.0, help="Saturation ceiling for accumulated events per pixel (default: 8).")
|
|
259
|
+
webcam.set_defaults(func=_webcam)
|
|
260
|
+
|
|
261
|
+
export = sub.add_parser("export", help="Export a video's events to a DVS-Gesture-compatible HDF5 file.")
|
|
262
|
+
export.add_argument("input", help="Path to the input video file.")
|
|
263
|
+
export.add_argument("output", help="Path to write the events HDF5 file (e.g. out.h5).")
|
|
264
|
+
export.add_argument("--threshold", type=float, default=0.05, help="Log-intensity event threshold (default: 0.05).")
|
|
265
|
+
export.add_argument("--sensor-size", type=_parse_sensor_size, default=None, metavar="W,H", help="Override sensor resolution as 'W,H'.")
|
|
266
|
+
export.add_argument("--interp", type=int, default=0, help="Number of interpolated sub-frames between real frames (default: 0).")
|
|
267
|
+
export.set_defaults(func=_export)
|
|
268
|
+
|
|
269
|
+
return parser
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
273
|
+
parser = build_parser()
|
|
274
|
+
args = parser.parse_args(argv)
|
|
275
|
+
return args.func(args)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == "__main__":
|
|
279
|
+
sys.exit(main())
|
eventify/dvs.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""DVS-style event simulation: binary-polarity (x, y, t_us, p) tuples."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Generator, Optional, Tuple, Union
|
|
8
|
+
|
|
9
|
+
import cv2
|
|
10
|
+
import h5py
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
# NumPy structured dtype for a single DVS event.
|
|
14
|
+
# int16 coords cover any realistic sensor; int64 for µs timestamps to
|
|
15
|
+
# hold long captures without overflow. Polarity ∈ {0, 1}.
|
|
16
|
+
EVENT_DTYPE = np.dtype([("x", "<i2"), ("y", "<i2"), ("t", "<i8"), ("p", "<i1")])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _to_gray_float(frame: np.ndarray) -> np.ndarray:
|
|
20
|
+
if frame.ndim == 3:
|
|
21
|
+
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
22
|
+
return frame.astype(np.float32, copy=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _maybe_resize(
|
|
26
|
+
frame: np.ndarray, sensor_size: Optional[Tuple[int, int]]
|
|
27
|
+
) -> np.ndarray:
|
|
28
|
+
if sensor_size is None:
|
|
29
|
+
return frame
|
|
30
|
+
w, h = sensor_size
|
|
31
|
+
return cv2.resize(frame, (w, h), interpolation=cv2.INTER_AREA)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def interpolate_frames(
|
|
35
|
+
prev_frame: np.ndarray,
|
|
36
|
+
curr_frame: np.ndarray,
|
|
37
|
+
n_intermediate: int,
|
|
38
|
+
) -> list:
|
|
39
|
+
"""Linearly interpolate ``n_intermediate`` frames between two endpoints.
|
|
40
|
+
|
|
41
|
+
Returns a list of length ``n_intermediate + 2`` starting with ``prev_frame``
|
|
42
|
+
and ending with ``curr_frame``. A naive stand-in for optical-flow-based
|
|
43
|
+
interpolation (v2e's SuperSloMo) that keeps the library dependency-free.
|
|
44
|
+
"""
|
|
45
|
+
if prev_frame.shape != curr_frame.shape:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Frame shape mismatch: {prev_frame.shape} vs {curr_frame.shape}"
|
|
48
|
+
)
|
|
49
|
+
if n_intermediate < 0:
|
|
50
|
+
raise ValueError(f"n_intermediate must be >= 0, got {n_intermediate}")
|
|
51
|
+
|
|
52
|
+
prev_f = prev_frame.astype(np.float32, copy=False)
|
|
53
|
+
curr_f = curr_frame.astype(np.float32, copy=False)
|
|
54
|
+
|
|
55
|
+
total = n_intermediate + 2
|
|
56
|
+
alphas = np.linspace(0.0, 1.0, total)
|
|
57
|
+
return [(1.0 - a) * prev_f + a * curr_f for a in alphas]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def frame_to_event_tuples(
|
|
61
|
+
prev_frame: np.ndarray,
|
|
62
|
+
curr_frame: np.ndarray,
|
|
63
|
+
prev_t_us: int,
|
|
64
|
+
curr_t_us: int,
|
|
65
|
+
c_thresh: float = 0.05,
|
|
66
|
+
eps: float = 1.0,
|
|
67
|
+
sensor_size: Optional[Tuple[int, int]] = None,
|
|
68
|
+
) -> np.ndarray:
|
|
69
|
+
"""Emit binary-polarity DVS event tuples with multi-crossing.
|
|
70
|
+
|
|
71
|
+
A pixel whose log-intensity change spans ``K`` threshold widths emits
|
|
72
|
+
``K`` events (matching how a real DVS sensor fires once per crossing).
|
|
73
|
+
Each pixel's events are staggered uniformly across
|
|
74
|
+
``[prev_t_us, curr_t_us]`` so their timestamps are strictly monotonic.
|
|
75
|
+
"""
|
|
76
|
+
if prev_frame.shape != curr_frame.shape:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"Frame shape mismatch: {prev_frame.shape} vs {curr_frame.shape}"
|
|
79
|
+
)
|
|
80
|
+
if curr_t_us < prev_t_us:
|
|
81
|
+
raise ValueError(f"curr_t_us ({curr_t_us}) must be >= prev_t_us ({prev_t_us})")
|
|
82
|
+
|
|
83
|
+
prev = _maybe_resize(_to_gray_float(prev_frame), sensor_size)
|
|
84
|
+
curr = _maybe_resize(_to_gray_float(curr_frame), sensor_size)
|
|
85
|
+
|
|
86
|
+
delta = np.log(curr + eps) - np.log(prev + eps)
|
|
87
|
+
|
|
88
|
+
# How many full c_thresh crossings did each pixel span?
|
|
89
|
+
crossings = np.floor(np.abs(delta) / c_thresh).astype(np.int32)
|
|
90
|
+
total_events = int(crossings.sum())
|
|
91
|
+
if total_events == 0:
|
|
92
|
+
return np.zeros(0, dtype=EVENT_DTYPE)
|
|
93
|
+
|
|
94
|
+
# Flat arrays of (x, y, polarity) counts, one entry per firing pixel.
|
|
95
|
+
ys, xs = np.nonzero(crossings)
|
|
96
|
+
counts = crossings[ys, xs]
|
|
97
|
+
polarities = (delta[ys, xs] > 0).astype(np.int8) # 1 = ON, 0 = OFF
|
|
98
|
+
|
|
99
|
+
# Expand: each pixel contributes `counts[i]` copies of its coords/polarity.
|
|
100
|
+
events = np.zeros(total_events, dtype=EVENT_DTYPE)
|
|
101
|
+
events["x"] = np.repeat(xs.astype(np.int16), counts)
|
|
102
|
+
events["y"] = np.repeat(ys.astype(np.int16), counts)
|
|
103
|
+
events["p"] = np.repeat(polarities, counts)
|
|
104
|
+
|
|
105
|
+
# Timestamps: for each pixel with K events, stagger them uniformly across
|
|
106
|
+
# the frame interval. Position k of K → alpha = (k+1)/(K+1) so no event
|
|
107
|
+
# lands exactly on the boundary, and consecutive events at the same pixel
|
|
108
|
+
# are strictly ordered.
|
|
109
|
+
#
|
|
110
|
+
# Vectorized construction of per-pixel indices [1..K1, 1..K2, ...]:
|
|
111
|
+
# start_indices marks where each pixel's block begins in the flat array;
|
|
112
|
+
# a running cumsum minus the offset per block yields 1..K per pixel.
|
|
113
|
+
interval = curr_t_us - prev_t_us
|
|
114
|
+
starts = np.concatenate([[0], np.cumsum(counts[:-1])])
|
|
115
|
+
within_pixel_idx = (np.arange(total_events) - np.repeat(starts, counts) + 1).astype(
|
|
116
|
+
np.float64
|
|
117
|
+
)
|
|
118
|
+
within_pixel_denom = np.repeat(counts + 1, counts).astype(np.float64)
|
|
119
|
+
alphas = within_pixel_idx / within_pixel_denom
|
|
120
|
+
events["t"] = (prev_t_us + alphas * interval).astype(np.int64)
|
|
121
|
+
|
|
122
|
+
return events
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def video_to_event_stream(
|
|
126
|
+
source: Union[str, int],
|
|
127
|
+
c_thresh: float = 0.05,
|
|
128
|
+
sensor_size: Optional[Tuple[int, int]] = None,
|
|
129
|
+
interp: int = 0,
|
|
130
|
+
capture_settings: Optional[dict] = None,
|
|
131
|
+
) -> Generator[np.ndarray, None, None]:
|
|
132
|
+
"""Yield per-frame-pair structured event arrays from a video or webcam.
|
|
133
|
+
|
|
134
|
+
``source`` is a file path or an integer webcam device index. When
|
|
135
|
+
``interp > 0``, that many sub-frames are linearly interpolated between
|
|
136
|
+
each real frame pair and event generation runs on every sub-interval,
|
|
137
|
+
yielding one chunk per sub-interval. ``capture_settings`` is an optional
|
|
138
|
+
dict of OpenCV ``CAP_PROP_*`` overrides (e.g. width/height/fps) applied
|
|
139
|
+
right after the capture opens.
|
|
140
|
+
"""
|
|
141
|
+
cap = cv2.VideoCapture(source)
|
|
142
|
+
if not cap.isOpened():
|
|
143
|
+
cap.release()
|
|
144
|
+
raise IOError(f"Could not open video source: {source!r}")
|
|
145
|
+
|
|
146
|
+
if capture_settings:
|
|
147
|
+
for prop, value in capture_settings.items():
|
|
148
|
+
cap.set(prop, value)
|
|
149
|
+
|
|
150
|
+
fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
|
|
151
|
+
frame_period_us = (
|
|
152
|
+
int(1_000_000 / fps) if fps > 0 else 33_333
|
|
153
|
+
) # ~30 FPS webcam fallback
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
ok, prev = cap.read()
|
|
157
|
+
if not ok or prev is None:
|
|
158
|
+
return
|
|
159
|
+
prev_t_us = 0
|
|
160
|
+
|
|
161
|
+
prev_processed = _maybe_resize(_to_gray_float(prev), sensor_size)
|
|
162
|
+
|
|
163
|
+
frame_idx = 1
|
|
164
|
+
while True:
|
|
165
|
+
ok, curr = cap.read()
|
|
166
|
+
if not ok or curr is None:
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
curr_processed = _maybe_resize(_to_gray_float(curr), sensor_size)
|
|
170
|
+
|
|
171
|
+
pos_ms = cap.get(cv2.CAP_PROP_POS_MSEC)
|
|
172
|
+
if pos_ms and pos_ms > 0:
|
|
173
|
+
curr_t_us = int(pos_ms * 1000)
|
|
174
|
+
else:
|
|
175
|
+
curr_t_us = frame_idx * frame_period_us
|
|
176
|
+
|
|
177
|
+
if interp <= 0:
|
|
178
|
+
yield frame_to_event_tuples(
|
|
179
|
+
prev_processed,
|
|
180
|
+
curr_processed,
|
|
181
|
+
prev_t_us=prev_t_us,
|
|
182
|
+
curr_t_us=curr_t_us,
|
|
183
|
+
c_thresh=c_thresh,
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
sub_frames = interpolate_frames(
|
|
187
|
+
prev_processed, curr_processed, n_intermediate=interp
|
|
188
|
+
)
|
|
189
|
+
n_sub = len(sub_frames) - 1 # number of sub-intervals
|
|
190
|
+
span = curr_t_us - prev_t_us
|
|
191
|
+
for i in range(n_sub):
|
|
192
|
+
sub_prev_t = prev_t_us + i * span // n_sub
|
|
193
|
+
sub_curr_t = prev_t_us + (i + 1) * span // n_sub
|
|
194
|
+
yield frame_to_event_tuples(
|
|
195
|
+
sub_frames[i],
|
|
196
|
+
sub_frames[i + 1],
|
|
197
|
+
prev_t_us=sub_prev_t,
|
|
198
|
+
curr_t_us=sub_curr_t,
|
|
199
|
+
c_thresh=c_thresh,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
prev_processed = curr_processed
|
|
203
|
+
prev_t_us = curr_t_us
|
|
204
|
+
frame_idx += 1
|
|
205
|
+
finally:
|
|
206
|
+
cap.release()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def write_hdf5(
|
|
210
|
+
path: Union[str, os.PathLike],
|
|
211
|
+
events: np.ndarray,
|
|
212
|
+
sensor_shape: Tuple[int, int],
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Write events to an HDF5 file using the DVS-Gesture reprocessed layout.
|
|
215
|
+
|
|
216
|
+
Layout::
|
|
217
|
+
|
|
218
|
+
/events (group)
|
|
219
|
+
.attrs["sensor_shape"] (2,) int – (height, width)
|
|
220
|
+
/xs int16 x coords
|
|
221
|
+
/ys int16 y coords
|
|
222
|
+
/ts int64 timestamps (µs)
|
|
223
|
+
/ps int8 polarities ∈ {0, 1}
|
|
224
|
+
"""
|
|
225
|
+
path = Path(path)
|
|
226
|
+
with h5py.File(path, "w") as f:
|
|
227
|
+
grp = f.create_group("events")
|
|
228
|
+
grp.attrs["sensor_shape"] = np.array(sensor_shape, dtype=np.int64)
|
|
229
|
+
grp.create_dataset("xs", data=events["x"].astype(np.int16), dtype="<i2")
|
|
230
|
+
grp.create_dataset("ys", data=events["y"].astype(np.int16), dtype="<i2")
|
|
231
|
+
grp.create_dataset("ts", data=events["t"].astype(np.int64), dtype="<i8")
|
|
232
|
+
grp.create_dataset("ps", data=events["p"].astype(np.int8), dtype="<i1")
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eventify-dvs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert video files or webcam feeds into simulated event-camera (DVS) data.
|
|
5
|
+
Author: Arpan Pandey
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: h5py>=3.8
|
|
10
|
+
Requires-Dist: numpy>=1.24
|
|
11
|
+
Requires-Dist: opencv-python>=4.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# eventify-dvs
|
|
15
|
+
|
|
16
|
+
Convert video files or webcam feeds into simulated event-camera (DVS) data using log-intensity differencing. A clean-room, dependency-light reimplementation of the core idea behind v2e/ESIM — no CUDA, no PyTorch, no pretrained models. Just NumPy, OpenCV, and h5py.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install eventify-dvs
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The CLI entry point is `eventify`. With `uv`:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv add eventify-dvs
|
|
28
|
+
uv run eventify --help
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## CLI
|
|
32
|
+
|
|
33
|
+
Three subcommands under the `eventify` entry point.
|
|
34
|
+
|
|
35
|
+
### `eventify webcam` — live event preview
|
|
36
|
+
|
|
37
|
+
Opens the default camera and shows the event stream in an OpenCV window. Press `q` to quit. On macOS, grant camera permission to your terminal app first (System Settings → Privacy & Security → Camera).
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Defaults — 1280x720 @ 60 FPS
|
|
41
|
+
eventify webcam
|
|
42
|
+
|
|
43
|
+
# Snappy, high-sensitivity preview
|
|
44
|
+
eventify webcam --threshold 0.03 --accum-ms 40
|
|
45
|
+
|
|
46
|
+
# Different camera
|
|
47
|
+
eventify webcam --device 1
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Flag | Default | Purpose |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `--device` | `0` | Webcam device index |
|
|
53
|
+
| `--threshold` | `0.05` | Log-intensity event threshold |
|
|
54
|
+
| `--width` | `1280` | Requested capture width |
|
|
55
|
+
| `--height` | `720` | Requested capture height |
|
|
56
|
+
| `--fps` | `60` | Requested capture FPS |
|
|
57
|
+
| `--accum-ms` | `80` | Event accumulator half-life in ms |
|
|
58
|
+
| `--max-events` | `8` | Saturation ceiling for accumulated events per pixel |
|
|
59
|
+
|
|
60
|
+
### `eventify convert` — video file → event-visualized video
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
eventify convert input.mp4 events.mp4
|
|
64
|
+
eventify convert input.mp4 events.mp4 --threshold 0.03
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
| Flag | Default | Purpose |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `input` | — | Path to source video |
|
|
70
|
+
| `output` | — | Path to write the rendered MP4 |
|
|
71
|
+
| `--threshold` | `0.05` | Log-intensity event threshold |
|
|
72
|
+
|
|
73
|
+
### `eventify export` — video → DVS-Gesture HDF5
|
|
74
|
+
|
|
75
|
+
Emits binary-polarity DVS events to an HDF5 file compatible with the DVS128 Gesture dataset layout (Tonic / SpikingJelly loaders).
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
eventify export input.mp4 events.h5
|
|
79
|
+
eventify export input.mp4 events.h5 --sensor-size 128,128 --interp 4
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| Flag | Default | Purpose |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `input` | — | Path to source video |
|
|
85
|
+
| `output` | — | Path to write the HDF5 events file |
|
|
86
|
+
| `--threshold` | `0.05` | Log-intensity event threshold |
|
|
87
|
+
| `--sensor-size` | source resolution | Override as `W,H` |
|
|
88
|
+
| `--interp` | `0` | Interpolated sub-frames between real frames |
|
|
89
|
+
|
|
90
|
+
## Library
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import numpy as np
|
|
94
|
+
from eventify import (
|
|
95
|
+
frame_to_event_tuples,
|
|
96
|
+
video_to_event_stream,
|
|
97
|
+
interpolate_frames,
|
|
98
|
+
write_hdf5,
|
|
99
|
+
EVENT_DTYPE,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Per-frame-pair event tuples
|
|
103
|
+
events = frame_to_event_tuples(prev, curr, prev_t_us=0, curr_t_us=1000)
|
|
104
|
+
# events["x"], events["y"], events["t"], events["p"] — p ∈ {0, 1}
|
|
105
|
+
|
|
106
|
+
# Full stream from a video file
|
|
107
|
+
chunks = list(video_to_event_stream("video.mp4", sensor_size=(128, 128), interp=4))
|
|
108
|
+
all_events = np.concatenate(chunks)
|
|
109
|
+
write_hdf5("out.h5", all_events, sensor_shape=(128, 128))
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## API reference
|
|
113
|
+
|
|
114
|
+
- **`frame_to_event_tuples(prev, curr, prev_t_us, curr_t_us, c_thresh=0.05, eps=1.0, sensor_size=None)`** —
|
|
115
|
+
returns a NumPy structured array with dtype `EVENT_DTYPE` and fields
|
|
116
|
+
`(x: i2, y: i2, t: i8, p: i1)`. Polarity is binary (0 = OFF, 1 = ON).
|
|
117
|
+
A pixel whose log-delta spans `K` thresholds emits `K` events, uniformly
|
|
118
|
+
staggered across the interval.
|
|
119
|
+
|
|
120
|
+
- **`video_to_event_stream(source, c_thresh=0.05, sensor_size=None, interp=0, capture_settings=None)`** —
|
|
121
|
+
generator yielding one structured event array per (sub-)frame-pair.
|
|
122
|
+
Timestamps are monotonic microseconds.
|
|
123
|
+
|
|
124
|
+
- **`interpolate_frames(prev, curr, n_intermediate)`** —
|
|
125
|
+
linearly interpolates `n_intermediate` frames between two endpoints,
|
|
126
|
+
returning a list of `n_intermediate + 2` frames.
|
|
127
|
+
|
|
128
|
+
- **`write_hdf5(path, events, sensor_shape)`** — writes events in the
|
|
129
|
+
DVS-Gesture reprocessed layout:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
/events
|
|
133
|
+
.attrs["sensor_shape"] (height, width)
|
|
134
|
+
/xs i2
|
|
135
|
+
/ys i2
|
|
136
|
+
/ts i8 microseconds
|
|
137
|
+
/ps i1 ∈ {0, 1}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
- **`EVENT_DTYPE`** — NumPy structured dtype `[("x", "<i2"), ("y", "<i2"), ("t", "<i8"), ("p", "<i1")]`.
|
|
141
|
+
|
|
142
|
+
## Tests
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
uv run pytest
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
eventify/__init__.py,sha256=o6LvIf2BELvXgk_EklUXxO3RMcT18v5zrlo8vWzPyek,276
|
|
2
|
+
eventify/_fast.py,sha256=gYKQ5gmwnwAWuoQaRjnh6ecMIytOdWNG8UEVWWYu8eo,1327
|
|
3
|
+
eventify/cli.py,sha256=4bWZf8rVbRw0Ry7PM68jaATUom6vXmEm-Y-rnl7rEUE,10193
|
|
4
|
+
eventify/dvs.py,sha256=wyfMdL6cgJ4fe9wr1rI8uFO1Styn5Ejzv9axYL2MBHc,8519
|
|
5
|
+
eventify_dvs-0.1.0.dist-info/METADATA,sha256=Xp_YJ_yJMS02FyujFTm9lS1vm9qbPwSGrMe-FbY5HOE,4519
|
|
6
|
+
eventify_dvs-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
eventify_dvs-0.1.0.dist-info/entry_points.txt,sha256=OB4pJxjEKJk6ePwVLV5f8tXEVK0nOnXy-Cz2Xh03q60,47
|
|
8
|
+
eventify_dvs-0.1.0.dist-info/licenses/LICENSE,sha256=_APnY1FSXTPgIMinskdb35YwV0MPhJ8ph5VjZkKCzuE,1069
|
|
9
|
+
eventify_dvs-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arpan Pandey
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|