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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ eventify = eventify.cli:main
@@ -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.