evlab 0.1.0a1__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.
- evlab/__init__.py +6 -0
- evlab/cli.py +204 -0
- evlab/filters.py +109 -0
- evlab/formats.py +315 -0
- evlab/metrics.py +100 -0
- evlab/representations.py +74 -0
- evlab/synth.py +62 -0
- evlab/viz.py +82 -0
- evlab-0.1.0a1.dist-info/METADATA +115 -0
- evlab-0.1.0a1.dist-info/RECORD +13 -0
- evlab-0.1.0a1.dist-info/WHEEL +4 -0
- evlab-0.1.0a1.dist-info/entry_points.txt +2 -0
- evlab-0.1.0a1.dist-info/licenses/LICENSE +21 -0
evlab/__init__.py
ADDED
evlab/cli.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""evlab command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from . import formats, metrics
|
|
11
|
+
from .filters import FILTERS
|
|
12
|
+
from .representations import voxel_grid
|
|
13
|
+
from .synth import GENERATORS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
@click.version_option(package_name="evlab")
|
|
18
|
+
def main():
|
|
19
|
+
"""Inspect, clean, convert, and benchmark event-camera data."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@main.command()
|
|
23
|
+
@click.argument("path", type=click.Path(exists=True, dir_okay=False))
|
|
24
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON.")
|
|
25
|
+
def info(path, as_json):
|
|
26
|
+
"""Summarize an event file (count, resolution, duration, rate)."""
|
|
27
|
+
data = formats.load(path)
|
|
28
|
+
stats = metrics.summary(data)
|
|
29
|
+
if as_json:
|
|
30
|
+
click.echo(json.dumps(stats, indent=2))
|
|
31
|
+
return
|
|
32
|
+
click.echo(f"{path}")
|
|
33
|
+
click.echo(f" events : {stats['num_events']:,}")
|
|
34
|
+
click.echo(f" resolution : {stats['width']} x {stats['height']}")
|
|
35
|
+
click.echo(f" duration : {stats['duration_s']:.3f} s")
|
|
36
|
+
click.echo(f" event rate : {stats['event_rate_hz']:,.0f} ev/s")
|
|
37
|
+
if stats["num_events"]:
|
|
38
|
+
click.echo(f" polarity ON : {stats['polarity_balance']:.1%}")
|
|
39
|
+
click.echo(
|
|
40
|
+
f" active px : {stats['active_pixels']:,}"
|
|
41
|
+
f" ({stats['active_pixel_fraction']:.1%} of sensor)"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@main.command()
|
|
46
|
+
@click.argument("src", type=click.Path(exists=True, dir_okay=False))
|
|
47
|
+
@click.argument("dst", type=click.Path(dir_okay=False))
|
|
48
|
+
def convert(src, dst):
|
|
49
|
+
"""Convert between event file formats (npz, csv, txt, aedat4-in)."""
|
|
50
|
+
data = formats.load(src)
|
|
51
|
+
formats.save(data, dst)
|
|
52
|
+
click.echo(f"wrote {len(data.events):,} events -> {dst}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@main.command()
|
|
56
|
+
@click.argument("src", type=click.Path(exists=True, dir_okay=False))
|
|
57
|
+
@click.argument("dst", type=click.Path(dir_okay=False))
|
|
58
|
+
@click.option(
|
|
59
|
+
"--filter",
|
|
60
|
+
"filter_name",
|
|
61
|
+
type=click.Choice(sorted(FILTERS)),
|
|
62
|
+
default="baf",
|
|
63
|
+
show_default=True,
|
|
64
|
+
help="Denoising filter to apply.",
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"--window",
|
|
68
|
+
type=int,
|
|
69
|
+
default=5000,
|
|
70
|
+
show_default=True,
|
|
71
|
+
help="Time window in microseconds (baf) / refractory period (refractory).",
|
|
72
|
+
)
|
|
73
|
+
def denoise(src, dst, filter_name, window):
|
|
74
|
+
"""Denoise an event stream and write the result."""
|
|
75
|
+
data = formats.load(src)
|
|
76
|
+
if filter_name == "baf":
|
|
77
|
+
out = FILTERS[filter_name](data, time_window_us=window)
|
|
78
|
+
else:
|
|
79
|
+
out = FILTERS[filter_name](data, refractory_us=window)
|
|
80
|
+
formats.save(out, dst)
|
|
81
|
+
kept = metrics.retention(data, out)
|
|
82
|
+
click.echo(f"kept {len(out.events):,}/{len(data.events):,} events ({kept:.1%}) -> {dst}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@main.command()
|
|
86
|
+
@click.argument("src", type=click.Path(exists=True, dir_okay=False))
|
|
87
|
+
@click.argument("dst", type=click.Path(dir_okay=False))
|
|
88
|
+
@click.option("--bins", type=int, default=10, show_default=True, help="Temporal bins.")
|
|
89
|
+
def voxel(src, dst, bins):
|
|
90
|
+
"""Build a (bins, H, W) voxel grid and save it as .npy."""
|
|
91
|
+
data = formats.load(src)
|
|
92
|
+
grid = voxel_grid(data, bins=bins)
|
|
93
|
+
np.save(dst, grid)
|
|
94
|
+
click.echo(f"voxel grid {grid.shape} (sum={grid.sum():+.1f}) -> {dst}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@main.command()
|
|
98
|
+
@click.argument("src", type=click.Path(exists=True, dir_okay=False))
|
|
99
|
+
@click.argument("dst", type=click.Path(dir_okay=False))
|
|
100
|
+
@click.option(
|
|
101
|
+
"--mode",
|
|
102
|
+
type=click.Choice(["accumulate", "time-surface", "gif"]),
|
|
103
|
+
default="accumulate",
|
|
104
|
+
show_default=True,
|
|
105
|
+
)
|
|
106
|
+
@click.option(
|
|
107
|
+
"--window", type=float, default=33.0, show_default=True, help="Frame window in ms (gif mode)."
|
|
108
|
+
)
|
|
109
|
+
@click.option(
|
|
110
|
+
"--tau", type=float, default=30.0, show_default=True, help="Time-surface decay in ms."
|
|
111
|
+
)
|
|
112
|
+
def visualize(src, dst, mode, window, tau):
|
|
113
|
+
"""Render events to a PNG (accumulate/time-surface) or GIF."""
|
|
114
|
+
from . import viz
|
|
115
|
+
|
|
116
|
+
data = formats.load(src)
|
|
117
|
+
if mode == "gif":
|
|
118
|
+
n = viz.render_gif(data, dst, window_ms=window)
|
|
119
|
+
click.echo(f"wrote {n} frames -> {dst}")
|
|
120
|
+
else:
|
|
121
|
+
viz.render_frame(data, mode, dst, tau_us=tau * 1000)
|
|
122
|
+
click.echo(f"wrote {mode} image -> {dst}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@main.command()
|
|
126
|
+
@click.argument("dst", type=click.Path(dir_okay=False))
|
|
127
|
+
@click.option(
|
|
128
|
+
"--pattern", type=click.Choice(sorted(GENERATORS)), default="moving-bar", show_default=True
|
|
129
|
+
)
|
|
130
|
+
@click.option("--resolution", default="240x240", show_default=True, help="WIDTHxHEIGHT.")
|
|
131
|
+
@click.option("--duration", type=float, default=1.0, show_default=True, help="Seconds.")
|
|
132
|
+
@click.option("--signal-rate", type=float, default=20000, show_default=True, help="Signal ev/s.")
|
|
133
|
+
@click.option("--noise-rate", type=float, default=5000, show_default=True, help="Noise ev/s.")
|
|
134
|
+
@click.option("--seed", type=int, default=0, show_default=True)
|
|
135
|
+
def synth(dst, pattern, resolution, duration, signal_rate, noise_rate, seed):
|
|
136
|
+
"""Generate a synthetic stream with ground-truth signal/noise labels."""
|
|
137
|
+
width, _, height = resolution.partition("x")
|
|
138
|
+
data = GENERATORS[pattern](
|
|
139
|
+
width=int(width),
|
|
140
|
+
height=int(height),
|
|
141
|
+
duration_us=int(duration * 1e6),
|
|
142
|
+
signal_rate_hz=signal_rate,
|
|
143
|
+
noise_rate_hz=noise_rate,
|
|
144
|
+
seed=seed,
|
|
145
|
+
)
|
|
146
|
+
formats.save(data, dst)
|
|
147
|
+
n_sig = int(data.meta["signal"].sum())
|
|
148
|
+
click.echo(f"wrote {len(data.events):,} events ({n_sig:,} signal) -> {dst}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@main.command("denoise-bench")
|
|
152
|
+
@click.argument("src", type=click.Path(exists=True, dir_okay=False))
|
|
153
|
+
@click.option(
|
|
154
|
+
"--filter", "filter_name", type=click.Choice(sorted(FILTERS)), default="baf", show_default=True
|
|
155
|
+
)
|
|
156
|
+
@click.option(
|
|
157
|
+
"--window",
|
|
158
|
+
type=int,
|
|
159
|
+
default=5000,
|
|
160
|
+
show_default=True,
|
|
161
|
+
help="Time window (baf) / refractory period, microseconds.",
|
|
162
|
+
)
|
|
163
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON.")
|
|
164
|
+
def denoise_bench(src, filter_name, window, as_json):
|
|
165
|
+
"""Score a denoising filter against a labeled stream (see `evlab synth`)."""
|
|
166
|
+
from .filters import MASKS
|
|
167
|
+
|
|
168
|
+
data = formats.load(src)
|
|
169
|
+
if filter_name == "baf":
|
|
170
|
+
mask = MASKS[filter_name](data, time_window_us=window)
|
|
171
|
+
else:
|
|
172
|
+
mask = MASKS[filter_name](data, refractory_us=window)
|
|
173
|
+
score = metrics.denoise_score(data, mask)
|
|
174
|
+
if as_json:
|
|
175
|
+
click.echo(json.dumps(score, indent=2))
|
|
176
|
+
return
|
|
177
|
+
click.echo(f"{filter_name} (window={window} us) on {src}")
|
|
178
|
+
click.echo(f" precision : {score['precision']:.1%}")
|
|
179
|
+
click.echo(f" recall : {score['recall']:.1%}")
|
|
180
|
+
click.echo(f" f1 : {score['f1']:.3f}")
|
|
181
|
+
click.echo(f" noise removed : {score['noise_removed']:.1%}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@main.command()
|
|
185
|
+
@click.argument("reference", type=click.Path(exists=True, dir_okay=False))
|
|
186
|
+
@click.argument("other", type=click.Path(exists=True, dir_okay=False))
|
|
187
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON.")
|
|
188
|
+
def benchmark(reference, other, as_json):
|
|
189
|
+
"""Compare two event streams (e.g. raw vs. denoised)."""
|
|
190
|
+
ref = formats.load(reference)
|
|
191
|
+
oth = formats.load(other)
|
|
192
|
+
result = metrics.compare(ref, oth)
|
|
193
|
+
if as_json:
|
|
194
|
+
click.echo(json.dumps(result, indent=2))
|
|
195
|
+
return
|
|
196
|
+
click.echo(f"retention : {result['retention']:.1%}")
|
|
197
|
+
click.echo(f"event rate (ref) : {result['event_rate_ref_hz']:,.0f} ev/s")
|
|
198
|
+
click.echo(f"event rate (new) : {result['event_rate_other_hz']:,.0f} ev/s")
|
|
199
|
+
click.echo(f"structure (ref) : {result['structure_ref']:.3f}")
|
|
200
|
+
click.echo(f"structure (new) : {result['structure_other']:.3f}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
main()
|
evlab/filters.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Event stream denoising filters.
|
|
2
|
+
|
|
3
|
+
Each filter has a mask function (returns the boolean keep-mask, used by
|
|
4
|
+
`evlab denoise-bench` to score against ground truth) and a public wrapper
|
|
5
|
+
that applies it. Returned EventData shares no mutable state with the input;
|
|
6
|
+
per-event metadata (like the ``signal`` labels) is subset alongside events.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from .formats import EventData
|
|
14
|
+
|
|
15
|
+
# Sentinel for "never fired": far in the past, but small enough that
|
|
16
|
+
# `t - sentinel` cannot overflow int64 for microsecond timestamps.
|
|
17
|
+
_NEVER = np.int64(np.iinfo(np.int64).min // 4)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _apply_mask(data: EventData, keep: np.ndarray) -> EventData:
|
|
21
|
+
meta = {}
|
|
22
|
+
for key, value in data.meta.items():
|
|
23
|
+
arr = np.asarray(value) if not np.isscalar(value) else None
|
|
24
|
+
if arr is not None and arr.shape[:1] == (len(data.events),):
|
|
25
|
+
meta[key] = arr[keep].copy()
|
|
26
|
+
else:
|
|
27
|
+
meta[key] = value
|
|
28
|
+
return EventData(data.events[keep].copy(), data.width, data.height, meta)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def background_activity_mask(
|
|
32
|
+
data: EventData, time_window_us: int = 5000, neighborhood: int = 1
|
|
33
|
+
) -> np.ndarray:
|
|
34
|
+
"""Keep-mask for the nearest-neighbor / background-activity filter.
|
|
35
|
+
|
|
36
|
+
An event survives if at least one other event occurred within
|
|
37
|
+
``time_window_us`` in its ``(2*neighborhood+1)^2 - 1`` spatial
|
|
38
|
+
neighborhood. This is the classic background-activity filter used by
|
|
39
|
+
DVS pipelines.
|
|
40
|
+
"""
|
|
41
|
+
ev = data.events
|
|
42
|
+
keep = np.zeros(len(ev), dtype=bool)
|
|
43
|
+
if len(ev) == 0:
|
|
44
|
+
return keep
|
|
45
|
+
|
|
46
|
+
# last_seen[y, x] = timestamp of the most recent event at that pixel
|
|
47
|
+
last_seen = np.full(
|
|
48
|
+
(data.height + 2 * neighborhood, data.width + 2 * neighborhood), _NEVER, dtype=np.int64
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
xs = ev["x"].astype(np.int64) + neighborhood
|
|
52
|
+
ys = ev["y"].astype(np.int64) + neighborhood
|
|
53
|
+
ts = ev["t"]
|
|
54
|
+
|
|
55
|
+
n = neighborhood
|
|
56
|
+
for i in range(len(ev)):
|
|
57
|
+
x, y, t = xs[i], ys[i], ts[i]
|
|
58
|
+
window = last_seen[y - n : y + n + 1, x - n : x + n + 1]
|
|
59
|
+
# Exclude the center pixel: a pixel refiring alone is still noise.
|
|
60
|
+
center = window[n, n]
|
|
61
|
+
window[n, n] = _NEVER
|
|
62
|
+
keep[i] = bool((t - window <= time_window_us).any())
|
|
63
|
+
window[n, n] = center
|
|
64
|
+
last_seen[y, x] = t
|
|
65
|
+
return keep
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def background_activity_filter(
|
|
69
|
+
data: EventData, time_window_us: int = 5000, neighborhood: int = 1
|
|
70
|
+
) -> EventData:
|
|
71
|
+
"""Apply the background-activity filter (see `background_activity_mask`)."""
|
|
72
|
+
return _apply_mask(data, background_activity_mask(data, time_window_us, neighborhood))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def refractory_mask(data: EventData, refractory_us: int = 1000) -> np.ndarray:
|
|
76
|
+
"""Keep-mask dropping events within ``refractory_us`` of the previous
|
|
77
|
+
event at the same pixel (hot-pixel / oscillation suppression)."""
|
|
78
|
+
ev = data.events
|
|
79
|
+
keep = np.zeros(len(ev), dtype=bool)
|
|
80
|
+
if len(ev) == 0:
|
|
81
|
+
return keep
|
|
82
|
+
|
|
83
|
+
last_seen = np.full((data.height, data.width), _NEVER, dtype=np.int64)
|
|
84
|
+
xs = ev["x"].astype(np.intp)
|
|
85
|
+
ys = ev["y"].astype(np.intp)
|
|
86
|
+
ts = ev["t"]
|
|
87
|
+
|
|
88
|
+
for i in range(len(ev)):
|
|
89
|
+
x, y, t = xs[i], ys[i], ts[i]
|
|
90
|
+
keep[i] = (t - last_seen[y, x]) > refractory_us
|
|
91
|
+
if keep[i]:
|
|
92
|
+
last_seen[y, x] = t
|
|
93
|
+
return keep
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def refractory_filter(data: EventData, refractory_us: int = 1000) -> EventData:
|
|
97
|
+
"""Apply the refractory filter (see `refractory_mask`)."""
|
|
98
|
+
return _apply_mask(data, refractory_mask(data, refractory_us))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
FILTERS = {
|
|
102
|
+
"baf": background_activity_filter,
|
|
103
|
+
"refractory": refractory_filter,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
MASKS = {
|
|
107
|
+
"baf": background_activity_mask,
|
|
108
|
+
"refractory": refractory_mask,
|
|
109
|
+
}
|
evlab/formats.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Canonical event representation and file format loaders/savers.
|
|
2
|
+
|
|
3
|
+
The canonical in-memory representation is a numpy structured array with
|
|
4
|
+
fields ``x`` (uint16), ``y`` (uint16), ``t`` (int64, microseconds), and
|
|
5
|
+
``p`` (int8, polarity 0/1), sorted by ``t``, plus a metadata dict carrying
|
|
6
|
+
at least ``width`` and ``height``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
EVENT_DTYPE = np.dtype([("x", "u2"), ("y", "u2"), ("t", "i8"), ("p", "i1")])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class EventData:
|
|
21
|
+
"""Events plus sensor metadata."""
|
|
22
|
+
|
|
23
|
+
events: np.ndarray
|
|
24
|
+
width: int
|
|
25
|
+
height: int
|
|
26
|
+
meta: dict = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
def __post_init__(self):
|
|
29
|
+
if self.events.dtype != EVENT_DTYPE:
|
|
30
|
+
raise ValueError(f"events must have dtype {EVENT_DTYPE}, got {self.events.dtype}")
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def duration_us(self) -> int:
|
|
34
|
+
if len(self.events) == 0:
|
|
35
|
+
return 0
|
|
36
|
+
return int(self.events["t"][-1] - self.events["t"][0])
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def event_rate(self) -> float:
|
|
40
|
+
"""Mean events per second over the recording."""
|
|
41
|
+
dur = self.duration_us
|
|
42
|
+
if dur == 0:
|
|
43
|
+
return 0.0
|
|
44
|
+
return len(self.events) / (dur / 1e6)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def from_arrays(x, y, t, p, width: int | None = None, height: int | None = None) -> EventData:
|
|
48
|
+
"""Build EventData from separate coordinate arrays (any numeric dtype)."""
|
|
49
|
+
x = np.asarray(x)
|
|
50
|
+
y = np.asarray(y)
|
|
51
|
+
t = np.asarray(t)
|
|
52
|
+
p = np.asarray(p)
|
|
53
|
+
if not (len(x) == len(y) == len(t) == len(p)):
|
|
54
|
+
raise ValueError("x, y, t, p must have equal lengths")
|
|
55
|
+
|
|
56
|
+
events = np.empty(len(x), dtype=EVENT_DTYPE)
|
|
57
|
+
events["x"] = x
|
|
58
|
+
events["y"] = y
|
|
59
|
+
events["t"] = t
|
|
60
|
+
# Normalize polarity: accept {0,1}, {-1,1}, or booleans.
|
|
61
|
+
p = p.astype(np.int8)
|
|
62
|
+
p[p < 0] = 0
|
|
63
|
+
events["p"] = p
|
|
64
|
+
|
|
65
|
+
order = np.argsort(events["t"], kind="stable")
|
|
66
|
+
events = events[order]
|
|
67
|
+
|
|
68
|
+
if width is None:
|
|
69
|
+
width = int(events["x"].max()) + 1 if len(events) else 0
|
|
70
|
+
if height is None:
|
|
71
|
+
height = int(events["y"].max()) + 1 if len(events) else 0
|
|
72
|
+
return EventData(events, width=width, height=height)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# npz
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
_NPZ_KEY_CANDIDATES = {
|
|
80
|
+
"x": ["x", "xs"],
|
|
81
|
+
"y": ["y", "ys"],
|
|
82
|
+
"t": ["t", "ts", "timestamp", "timestamps", "time"],
|
|
83
|
+
"p": ["p", "ps", "pol", "polarity", "polarities"],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _resolve_npz_keys(available: set[str]) -> dict[str, str]:
|
|
88
|
+
resolved = {}
|
|
89
|
+
for canon, candidates in _NPZ_KEY_CANDIDATES.items():
|
|
90
|
+
for cand in candidates:
|
|
91
|
+
if cand in available:
|
|
92
|
+
resolved[canon] = cand
|
|
93
|
+
break
|
|
94
|
+
else:
|
|
95
|
+
raise KeyError(
|
|
96
|
+
f"could not find a key for '{canon}' in npz file "
|
|
97
|
+
f"(available: {sorted(available)}; accepted: {candidates})"
|
|
98
|
+
)
|
|
99
|
+
return resolved
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_npz(path: str) -> EventData:
|
|
103
|
+
with np.load(path) as data:
|
|
104
|
+
available = set(data.files)
|
|
105
|
+
if "events" in available and data["events"].dtype.names:
|
|
106
|
+
ev = data["events"]
|
|
107
|
+
width = int(data["width"]) if "width" in available else None
|
|
108
|
+
height = int(data["height"]) if "height" in available else None
|
|
109
|
+
out = from_arrays(ev["x"], ev["y"], ev["t"], ev["p"], width, height)
|
|
110
|
+
else:
|
|
111
|
+
keys = _resolve_npz_keys(available)
|
|
112
|
+
width = int(data["width"]) if "width" in available else None
|
|
113
|
+
height = int(data["height"]) if "height" in available else None
|
|
114
|
+
out = from_arrays(
|
|
115
|
+
data[keys["x"]], data[keys["y"]], data[keys["t"]], data[keys["p"]], width, height
|
|
116
|
+
)
|
|
117
|
+
# Ground-truth signal/noise labels (written by `evlab synth`). The
|
|
118
|
+
# events were saved t-sorted and from_arrays' stable sort is the
|
|
119
|
+
# identity on sorted input, so the mask stays aligned.
|
|
120
|
+
if "signal" in available:
|
|
121
|
+
out.meta["signal"] = data["signal"].astype(bool)
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def save_npz(data: EventData, path: str) -> None:
|
|
126
|
+
extra = {}
|
|
127
|
+
if "signal" in data.meta:
|
|
128
|
+
extra["signal"] = np.asarray(data.meta["signal"], dtype=bool)
|
|
129
|
+
np.savez_compressed(
|
|
130
|
+
path, events=data.events, width=np.int64(data.width), height=np.int64(data.height), **extra
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# csv / txt (columns: t x y p, header optional, delimiter auto)
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_csv(path: str, order: str = "txyp") -> EventData:
|
|
140
|
+
if sorted(order) != sorted("txyp"):
|
|
141
|
+
raise ValueError(f"order must be a permutation of 'txyp', got '{order}'")
|
|
142
|
+
arr = np.loadtxt(path, delimiter=None if path.endswith(".txt") else ",", ndmin=2)
|
|
143
|
+
if arr.shape[1] < 4:
|
|
144
|
+
raise ValueError(f"expected at least 4 columns (t x y p), got {arr.shape[1]}")
|
|
145
|
+
cols = {ch: arr[:, i] for i, ch in enumerate(order)}
|
|
146
|
+
return from_arrays(cols["x"], cols["y"], cols["t"], cols["p"])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def save_csv(data: EventData, path: str) -> None:
|
|
150
|
+
ev = data.events
|
|
151
|
+
out = np.column_stack([ev["t"], ev["x"], ev["y"], ev["p"]])
|
|
152
|
+
np.savetxt(path, out, fmt="%d", delimiter="," if path.endswith(".csv") else " ")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# aedat4 (optional dependency)
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def load_aedat4(path: str) -> EventData:
|
|
161
|
+
try:
|
|
162
|
+
import aedat # type: ignore
|
|
163
|
+
except ImportError as e:
|
|
164
|
+
raise ImportError(
|
|
165
|
+
"reading .aedat4 requires the 'aedat' package: pip install evlab[aedat]"
|
|
166
|
+
) from e
|
|
167
|
+
|
|
168
|
+
xs, ys, ts, ps = [], [], [], []
|
|
169
|
+
width = height = None
|
|
170
|
+
decoder = aedat.Decoder(path)
|
|
171
|
+
for packet in decoder:
|
|
172
|
+
if "events" in packet:
|
|
173
|
+
ev = packet["events"]
|
|
174
|
+
xs.append(ev["x"])
|
|
175
|
+
ys.append(ev["y"])
|
|
176
|
+
ts.append(ev["t"])
|
|
177
|
+
ps.append(ev["on"])
|
|
178
|
+
if not xs:
|
|
179
|
+
raise ValueError(f"no event packets found in {path}")
|
|
180
|
+
return from_arrays(
|
|
181
|
+
np.concatenate(xs),
|
|
182
|
+
np.concatenate(ys),
|
|
183
|
+
np.concatenate(ts),
|
|
184
|
+
np.concatenate(ps),
|
|
185
|
+
width,
|
|
186
|
+
height,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Prophesee legacy .dat (2D CD events)
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def load_dat(path: str) -> EventData:
|
|
196
|
+
"""Read a Prophesee legacy ``.dat`` file (2D CD events).
|
|
197
|
+
|
|
198
|
+
Layout: ASCII header lines starting with ``%``, then one byte event
|
|
199
|
+
type + one byte event size, then 8-byte records of little-endian
|
|
200
|
+
``(uint32 t_us, uint32 addr)`` with ``x = addr & 0x3FFF``,
|
|
201
|
+
``y = (addr >> 14) & 0x3FFF``, ``p = (addr >> 28) & 1``. The uint32
|
|
202
|
+
timestamp wraps every ~71 minutes; wraps are unwrapped monotonically.
|
|
203
|
+
"""
|
|
204
|
+
width = height = None
|
|
205
|
+
with open(path, "rb") as f:
|
|
206
|
+
while True:
|
|
207
|
+
pos = f.tell()
|
|
208
|
+
line = f.readline()
|
|
209
|
+
if not line.startswith(b"%"):
|
|
210
|
+
f.seek(pos)
|
|
211
|
+
break
|
|
212
|
+
text = line[1:].strip().decode("ascii", errors="replace")
|
|
213
|
+
if text.lower().startswith("width"):
|
|
214
|
+
width = int(text.split()[-1])
|
|
215
|
+
elif text.lower().startswith("height"):
|
|
216
|
+
height = int(text.split()[-1])
|
|
217
|
+
header = f.read(2)
|
|
218
|
+
if len(header) < 2:
|
|
219
|
+
raise ValueError(f"truncated .dat file: {path}")
|
|
220
|
+
ev_size = header[1]
|
|
221
|
+
if ev_size != 8:
|
|
222
|
+
raise ValueError(f"unsupported .dat event size {ev_size} (expected 8): {path}")
|
|
223
|
+
raw = np.frombuffer(f.read(), dtype=np.dtype([("t", "<u4"), ("addr", "<u4")]))
|
|
224
|
+
|
|
225
|
+
t = raw["t"].astype(np.int64)
|
|
226
|
+
# Unwrap uint32 timestamp overflows.
|
|
227
|
+
wraps = np.cumsum(np.diff(t, prepend=t[:1]) < 0)
|
|
228
|
+
t += wraps * (np.int64(1) << 32)
|
|
229
|
+
|
|
230
|
+
addr = raw["addr"]
|
|
231
|
+
x = addr & 0x3FFF
|
|
232
|
+
y = (addr >> 14) & 0x3FFF
|
|
233
|
+
p = (addr >> 28) & 0x1
|
|
234
|
+
return from_arrays(x, y, t, p, width, height)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# ROS bags (optional dependency; dvs_msgs/prophesee-style EventArray topics)
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def load_rosbag(path: str, topic: str | None = None) -> EventData:
|
|
243
|
+
"""Extract events from a ROS1/ROS2 bag containing EventArray messages.
|
|
244
|
+
|
|
245
|
+
Requires the pure-python ``rosbags`` package (``pip install evlab[ros]``).
|
|
246
|
+
Reads every connection whose message type ends in ``EventArray``
|
|
247
|
+
(dvs_msgs, prophesee_event_msgs, ...), or only ``topic`` if given.
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
from rosbags.highlevel import AnyReader
|
|
251
|
+
except ImportError as e:
|
|
252
|
+
raise ImportError(
|
|
253
|
+
"reading ROS bags requires the 'rosbags' package: pip install evlab[ros]"
|
|
254
|
+
) from e
|
|
255
|
+
from pathlib import Path
|
|
256
|
+
|
|
257
|
+
xs, ys, ts, ps = [], [], [], []
|
|
258
|
+
width = height = None
|
|
259
|
+
with AnyReader([Path(path)]) as reader:
|
|
260
|
+
conns = [
|
|
261
|
+
c
|
|
262
|
+
for c in reader.connections
|
|
263
|
+
if c.msgtype.endswith("EventArray") and (topic is None or c.topic == topic)
|
|
264
|
+
]
|
|
265
|
+
if not conns:
|
|
266
|
+
available = sorted({f"{c.topic} ({c.msgtype})" for c in reader.connections})
|
|
267
|
+
raise ValueError(f"no EventArray topic found in {path}; connections: {available}")
|
|
268
|
+
for conn, _timestamp, raw in reader.messages(connections=conns):
|
|
269
|
+
msg = reader.deserialize(raw, conn.msgtype)
|
|
270
|
+
if getattr(msg, "width", 0):
|
|
271
|
+
width, height = int(msg.width), int(msg.height)
|
|
272
|
+
for ev in msg.events:
|
|
273
|
+
xs.append(ev.x)
|
|
274
|
+
ys.append(ev.y)
|
|
275
|
+
ts.append(int(ev.ts.sec) * 1_000_000 + int(ev.ts.nanosec) // 1000)
|
|
276
|
+
ps.append(bool(ev.polarity))
|
|
277
|
+
if not xs:
|
|
278
|
+
raise ValueError(f"EventArray topic in {path} contained no events")
|
|
279
|
+
return from_arrays(np.array(xs), np.array(ys), np.array(ts), np.array(ps), width, height)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
# dispatch
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
_LOADERS = {
|
|
287
|
+
".npz": load_npz,
|
|
288
|
+
".csv": load_csv,
|
|
289
|
+
".txt": load_csv,
|
|
290
|
+
".aedat4": load_aedat4,
|
|
291
|
+
".dat": load_dat,
|
|
292
|
+
".bag": load_rosbag,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
_SAVERS = {
|
|
296
|
+
".npz": save_npz,
|
|
297
|
+
".csv": save_csv,
|
|
298
|
+
".txt": save_csv,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def load(path: str) -> EventData:
|
|
303
|
+
"""Load events from any supported file format (dispatched on extension)."""
|
|
304
|
+
ext = os.path.splitext(path)[1].lower()
|
|
305
|
+
if ext not in _LOADERS:
|
|
306
|
+
raise ValueError(f"unsupported input format '{ext}' (supported: {sorted(_LOADERS)})")
|
|
307
|
+
return _LOADERS[ext](path)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def save(data: EventData, path: str) -> None:
|
|
311
|
+
"""Save events to any supported output format (dispatched on extension)."""
|
|
312
|
+
ext = os.path.splitext(path)[1].lower()
|
|
313
|
+
if ext not in _SAVERS:
|
|
314
|
+
raise ValueError(f"unsupported output format '{ext}' (supported: {sorted(_SAVERS)})")
|
|
315
|
+
_SAVERS[ext](data, path)
|
evlab/metrics.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Metrics for comparing and characterizing event streams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .formats import EventData
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def summary(data: EventData) -> dict:
|
|
11
|
+
"""Basic stream statistics used by `evlab info`."""
|
|
12
|
+
ev = data.events
|
|
13
|
+
n = len(ev)
|
|
14
|
+
out = {
|
|
15
|
+
"num_events": n,
|
|
16
|
+
"width": data.width,
|
|
17
|
+
"height": data.height,
|
|
18
|
+
"duration_s": data.duration_us / 1e6,
|
|
19
|
+
"event_rate_hz": data.event_rate,
|
|
20
|
+
}
|
|
21
|
+
if n:
|
|
22
|
+
out["t_start_us"] = int(ev["t"][0])
|
|
23
|
+
out["t_end_us"] = int(ev["t"][-1])
|
|
24
|
+
out["polarity_balance"] = float(ev["p"].mean())
|
|
25
|
+
active = len(np.unique(ev["y"].astype(np.int64) * data.width + ev["x"]))
|
|
26
|
+
out["active_pixels"] = active
|
|
27
|
+
out["active_pixel_fraction"] = active / max(data.width * data.height, 1)
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def retention(original: EventData, filtered: EventData) -> float:
|
|
32
|
+
"""Fraction of events surviving a filter."""
|
|
33
|
+
if len(original.events) == 0:
|
|
34
|
+
return 1.0
|
|
35
|
+
return len(filtered.events) / len(original.events)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def event_structural_ratio(data: EventData, patch: int = 8) -> float:
|
|
39
|
+
"""Ratio of event mass in the densest patches vs. total (0..1).
|
|
40
|
+
|
|
41
|
+
A crude but dependency-free proxy for signal structure: real scenes
|
|
42
|
+
concentrate events on edges/objects; uniform sensor noise spreads them
|
|
43
|
+
evenly. Higher = more structured.
|
|
44
|
+
"""
|
|
45
|
+
ev = data.events
|
|
46
|
+
if len(ev) == 0:
|
|
47
|
+
return 0.0
|
|
48
|
+
counts = np.zeros((data.height, data.width), dtype=np.int64)
|
|
49
|
+
np.add.at(counts, (ev["y"].astype(np.intp), ev["x"].astype(np.intp)), 1)
|
|
50
|
+
|
|
51
|
+
ph = max(data.height // patch, 1)
|
|
52
|
+
pw = max(data.width // patch, 1)
|
|
53
|
+
pooled = counts[: ph * patch, : pw * patch].reshape(ph, patch, pw, patch).sum(axis=(1, 3))
|
|
54
|
+
flat = np.sort(pooled.ravel())[::-1]
|
|
55
|
+
top = flat[: max(len(flat) // 10, 1)].sum()
|
|
56
|
+
return float(top / max(flat.sum(), 1))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def denoise_score(data: EventData, keep_mask) -> dict:
|
|
60
|
+
"""Score a denoising keep-mask against ground-truth labels.
|
|
61
|
+
|
|
62
|
+
Requires ``data.meta['signal']`` (boolean per-event array, True =
|
|
63
|
+
signal), as produced by `evlab.synth`. Positive class = signal kept.
|
|
64
|
+
"""
|
|
65
|
+
import numpy as np
|
|
66
|
+
|
|
67
|
+
if "signal" not in data.meta:
|
|
68
|
+
raise ValueError("no ground-truth labels: data.meta['signal'] missing (see `evlab synth`)")
|
|
69
|
+
signal = np.asarray(data.meta["signal"], dtype=bool)
|
|
70
|
+
keep = np.asarray(keep_mask, dtype=bool)
|
|
71
|
+
if signal.shape != keep.shape:
|
|
72
|
+
raise ValueError(f"label/mask shape mismatch: {signal.shape} vs {keep.shape}")
|
|
73
|
+
|
|
74
|
+
tp = int((keep & signal).sum())
|
|
75
|
+
fp = int((keep & ~signal).sum())
|
|
76
|
+
fn = int((~keep & signal).sum())
|
|
77
|
+
n_noise = int((~signal).sum())
|
|
78
|
+
precision = tp / max(tp + fp, 1)
|
|
79
|
+
recall = tp / max(tp + fn, 1)
|
|
80
|
+
f1 = 2 * precision * recall / max(precision + recall, 1e-12)
|
|
81
|
+
return {
|
|
82
|
+
"precision": precision,
|
|
83
|
+
"recall": recall,
|
|
84
|
+
"f1": f1,
|
|
85
|
+
"signal_events": int(signal.sum()),
|
|
86
|
+
"noise_events": n_noise,
|
|
87
|
+
"kept_events": int(keep.sum()),
|
|
88
|
+
"noise_removed": (n_noise - fp) / max(n_noise, 1),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def compare(reference: EventData, other: EventData) -> dict:
|
|
93
|
+
"""Metrics comparing two streams (e.g. clean vs denoised)."""
|
|
94
|
+
return {
|
|
95
|
+
"retention": retention(reference, other),
|
|
96
|
+
"event_rate_ref_hz": reference.event_rate,
|
|
97
|
+
"event_rate_other_hz": other.event_rate,
|
|
98
|
+
"structure_ref": event_structural_ratio(reference),
|
|
99
|
+
"structure_other": event_structural_ratio(other),
|
|
100
|
+
}
|
evlab/representations.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Dense representations of event streams (voxel grids, time surfaces, frames)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .formats import EventData
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def voxel_grid(data: EventData, bins: int = 10) -> np.ndarray:
|
|
11
|
+
"""Accumulate events into a (bins, height, width) voxel grid.
|
|
12
|
+
|
|
13
|
+
Polarity is signed (+1/-1) and events are bilinearly distributed across
|
|
14
|
+
the two temporal bins they straddle, matching the common formulation
|
|
15
|
+
from Zhu et al. (EV-FlowNet) used by most learning pipelines.
|
|
16
|
+
"""
|
|
17
|
+
ev = data.events
|
|
18
|
+
grid = np.zeros((bins, data.height, data.width), dtype=np.float32)
|
|
19
|
+
if len(ev) == 0:
|
|
20
|
+
return grid
|
|
21
|
+
|
|
22
|
+
t = ev["t"].astype(np.float64)
|
|
23
|
+
t0, t1 = t[0], t[-1]
|
|
24
|
+
denom = max(t1 - t0, 1.0)
|
|
25
|
+
# Scaled timestamp in [0, bins - 1]
|
|
26
|
+
tau = (t - t0) / denom * (bins - 1)
|
|
27
|
+
left = np.floor(tau).astype(np.int64)
|
|
28
|
+
right = np.minimum(left + 1, bins - 1)
|
|
29
|
+
w_right = tau - left
|
|
30
|
+
w_left = 1.0 - w_right
|
|
31
|
+
|
|
32
|
+
pol = ev["p"].astype(np.float32) * 2.0 - 1.0
|
|
33
|
+
ys = ev["y"].astype(np.intp)
|
|
34
|
+
xs = ev["x"].astype(np.intp)
|
|
35
|
+
|
|
36
|
+
np.add.at(grid, (left, ys, xs), (pol * w_left).astype(np.float32))
|
|
37
|
+
np.add.at(grid, (right, ys, xs), (pol * w_right).astype(np.float32))
|
|
38
|
+
return grid
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def time_surface(data: EventData, tau_us: float = 30000.0, at_t: int | None = None) -> np.ndarray:
|
|
42
|
+
"""Exponentially-decayed time surface, one channel per polarity.
|
|
43
|
+
|
|
44
|
+
Returns a (2, height, width) float32 array where each pixel holds
|
|
45
|
+
``exp(-(at_t - t_last) / tau_us)`` for the most recent event of that
|
|
46
|
+
polarity, or 0 if the pixel never fired.
|
|
47
|
+
"""
|
|
48
|
+
ev = data.events
|
|
49
|
+
surface = np.zeros((2, data.height, data.width), dtype=np.float32)
|
|
50
|
+
if len(ev) == 0:
|
|
51
|
+
return surface
|
|
52
|
+
|
|
53
|
+
if at_t is None:
|
|
54
|
+
at_t = int(ev["t"][-1])
|
|
55
|
+
|
|
56
|
+
last = np.full((2, data.height, data.width), -np.inf, dtype=np.float64)
|
|
57
|
+
idx = (ev["p"].astype(np.intp), ev["y"].astype(np.intp), ev["x"].astype(np.intp))
|
|
58
|
+
# Later events overwrite earlier ones at the same pixel (events are t-sorted).
|
|
59
|
+
last[idx] = ev["t"].astype(np.float64)
|
|
60
|
+
|
|
61
|
+
fired = np.isfinite(last)
|
|
62
|
+
surface[fired] = np.exp(-(at_t - last[fired]) / tau_us).astype(np.float32)
|
|
63
|
+
return surface
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def accumulate_frame(data: EventData, clip: int = 3) -> np.ndarray:
|
|
67
|
+
"""Signed event-count frame in [-clip, clip], shape (height, width)."""
|
|
68
|
+
ev = data.events
|
|
69
|
+
frame = np.zeros((data.height, data.width), dtype=np.int32)
|
|
70
|
+
if len(ev) == 0:
|
|
71
|
+
return frame
|
|
72
|
+
pol = ev["p"].astype(np.int32) * 2 - 1
|
|
73
|
+
np.add.at(frame, (ev["y"].astype(np.intp), ev["x"].astype(np.intp)), pol)
|
|
74
|
+
return np.clip(frame, -clip, clip)
|
evlab/synth.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Synthetic event streams with ground-truth signal/noise labels.
|
|
2
|
+
|
|
3
|
+
Used by ``evlab synth`` and ``evlab denoise-bench`` to score denoising
|
|
4
|
+
filters with known ground truth: real labeled denoising datasets are rare,
|
|
5
|
+
and a controllable generator makes filter comparisons reproducible.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .formats import EventData, from_arrays
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def moving_bar(
|
|
16
|
+
width: int = 240,
|
|
17
|
+
height: int = 240,
|
|
18
|
+
duration_us: int = 1_000_000,
|
|
19
|
+
signal_rate_hz: float = 20_000.0,
|
|
20
|
+
noise_rate_hz: float = 5_000.0,
|
|
21
|
+
seed: int = 0,
|
|
22
|
+
) -> EventData:
|
|
23
|
+
"""A vertical bar sweeping left to right over uniform background noise.
|
|
24
|
+
|
|
25
|
+
Returns EventData whose ``meta['signal']`` is a boolean array marking
|
|
26
|
+
ground-truth signal events (True) vs. noise events (False), aligned
|
|
27
|
+
with the t-sorted event array.
|
|
28
|
+
"""
|
|
29
|
+
rng = np.random.default_rng(seed)
|
|
30
|
+
|
|
31
|
+
n_sig = int(signal_rate_hz * duration_us / 1e6)
|
|
32
|
+
t_sig = np.sort(rng.integers(0, duration_us, n_sig))
|
|
33
|
+
# Bar leading edge moves across the full width over the recording.
|
|
34
|
+
edge = (t_sig / duration_us * (width - 4)).astype(np.int64)
|
|
35
|
+
x_sig = edge + rng.integers(0, 3, n_sig)
|
|
36
|
+
y_sig = rng.integers(0, height, n_sig)
|
|
37
|
+
# ON events at the leading edge, OFF at the trailing edge.
|
|
38
|
+
p_sig = (rng.random(n_sig) < 0.5).astype(np.int8)
|
|
39
|
+
|
|
40
|
+
n_noise = int(noise_rate_hz * duration_us / 1e6)
|
|
41
|
+
t_noise = rng.integers(0, duration_us, n_noise)
|
|
42
|
+
x_noise = rng.integers(0, width, n_noise)
|
|
43
|
+
y_noise = rng.integers(0, height, n_noise)
|
|
44
|
+
p_noise = rng.integers(0, 2, n_noise).astype(np.int8)
|
|
45
|
+
|
|
46
|
+
x = np.concatenate([x_sig, x_noise])
|
|
47
|
+
y = np.concatenate([y_sig, y_noise])
|
|
48
|
+
t = np.concatenate([t_sig, t_noise])
|
|
49
|
+
p = np.concatenate([p_sig, p_noise])
|
|
50
|
+
is_signal = np.concatenate([np.ones(n_sig, bool), np.zeros(n_noise, bool)])
|
|
51
|
+
|
|
52
|
+
# Sort everything by time with the same permutation from_arrays will
|
|
53
|
+
# apply (stable argsort), so the label mask stays aligned.
|
|
54
|
+
order = np.argsort(t, kind="stable")
|
|
55
|
+
data = from_arrays(x[order], y[order], t[order], p[order], width, height)
|
|
56
|
+
data.meta["signal"] = is_signal[order]
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
GENERATORS = {
|
|
61
|
+
"moving-bar": moving_bar,
|
|
62
|
+
}
|
evlab/viz.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Rendering helpers (require the [viz] extra)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .formats import EventData
|
|
8
|
+
from .representations import accumulate_frame, time_surface
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _require_matplotlib():
|
|
12
|
+
try:
|
|
13
|
+
import matplotlib
|
|
14
|
+
|
|
15
|
+
matplotlib.use("Agg")
|
|
16
|
+
import matplotlib.pyplot as plt # noqa: F401
|
|
17
|
+
|
|
18
|
+
return matplotlib
|
|
19
|
+
except ImportError as e:
|
|
20
|
+
raise ImportError("visualization requires: pip install evlab[viz]") from e
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render_frame(data: EventData, mode: str, out_path: str, **kwargs) -> None:
|
|
24
|
+
"""Render a single image (PNG) of the stream in the given mode."""
|
|
25
|
+
_require_matplotlib()
|
|
26
|
+
import matplotlib.pyplot as plt
|
|
27
|
+
|
|
28
|
+
if mode == "accumulate":
|
|
29
|
+
img = accumulate_frame(data, clip=kwargs.get("clip", 3))
|
|
30
|
+
cmap, vmin, vmax = "RdBu_r", -abs(img).max() or 1, abs(img).max() or 1
|
|
31
|
+
elif mode == "time-surface":
|
|
32
|
+
surf = time_surface(data, tau_us=kwargs.get("tau_us", 30000.0))
|
|
33
|
+
img = surf[1] - surf[0] # ON minus OFF
|
|
34
|
+
cmap, vmin, vmax = "RdBu_r", -1, 1
|
|
35
|
+
else:
|
|
36
|
+
raise ValueError(f"unknown mode '{mode}' (accumulate, time-surface)")
|
|
37
|
+
|
|
38
|
+
fig, ax = plt.subplots(figsize=(6, 6 * data.height / max(data.width, 1)))
|
|
39
|
+
ax.imshow(img, cmap=cmap, vmin=vmin, vmax=vmax, interpolation="nearest")
|
|
40
|
+
ax.set_axis_off()
|
|
41
|
+
fig.savefig(out_path, bbox_inches="tight", dpi=150)
|
|
42
|
+
plt.close(fig)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render_gif(
|
|
46
|
+
data: EventData, out_path: str, window_ms: float = 33.0, clip: int = 3, fps: int = 30
|
|
47
|
+
) -> int:
|
|
48
|
+
"""Render the stream as an animated GIF of accumulated frames.
|
|
49
|
+
|
|
50
|
+
Returns the number of frames written.
|
|
51
|
+
"""
|
|
52
|
+
_require_matplotlib()
|
|
53
|
+
try:
|
|
54
|
+
from PIL import Image
|
|
55
|
+
except ImportError as e:
|
|
56
|
+
raise ImportError("GIF export requires: pip install evlab[viz]") from e
|
|
57
|
+
|
|
58
|
+
ev = data.events
|
|
59
|
+
if len(ev) == 0:
|
|
60
|
+
raise ValueError("cannot render an empty stream")
|
|
61
|
+
|
|
62
|
+
window_us = int(window_ms * 1000)
|
|
63
|
+
t0, t1 = int(ev["t"][0]), int(ev["t"][-1])
|
|
64
|
+
frames = []
|
|
65
|
+
for start in range(t0, t1 + 1, window_us):
|
|
66
|
+
mask = (ev["t"] >= start) & (ev["t"] < start + window_us)
|
|
67
|
+
chunk = EventData(ev[mask].copy(), data.width, data.height)
|
|
68
|
+
img = accumulate_frame(chunk, clip=clip).astype(np.float32)
|
|
69
|
+
# Map [-clip, clip] -> grayscale with ON=white, OFF=black on gray bg
|
|
70
|
+
norm = ((img + clip) / (2 * clip) * 255).astype(np.uint8)
|
|
71
|
+
frames.append(Image.fromarray(norm, mode="L"))
|
|
72
|
+
|
|
73
|
+
if not frames:
|
|
74
|
+
raise ValueError("no frames produced; try a larger --window")
|
|
75
|
+
frames[0].save(
|
|
76
|
+
out_path,
|
|
77
|
+
save_all=True,
|
|
78
|
+
append_images=frames[1:],
|
|
79
|
+
duration=int(1000 / fps),
|
|
80
|
+
loop=0,
|
|
81
|
+
)
|
|
82
|
+
return len(frames)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: evlab
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Inspect, clean, convert, and benchmark event-camera data from the command line
|
|
5
|
+
Project-URL: Homepage, https://github.com/JPL11/evlab
|
|
6
|
+
Project-URL: Issues, https://github.com/JPL11/evlab/issues
|
|
7
|
+
Author-email: Jacky Li <jackydli95@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: dvs,event-based-vision,event-camera,neuromorphic
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: click>=8.1
|
|
17
|
+
Requires-Dist: numpy>=1.24
|
|
18
|
+
Provides-Extra: aedat
|
|
19
|
+
Requires-Dist: aedat>=2.0; extra == 'aedat'
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: matplotlib>=3.7; extra == 'dev'
|
|
22
|
+
Requires-Dist: pillow>=10.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: rosbags>=0.10; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
26
|
+
Provides-Extra: ros
|
|
27
|
+
Requires-Dist: rosbags>=0.10; extra == 'ros'
|
|
28
|
+
Provides-Extra: viz
|
|
29
|
+
Requires-Dist: matplotlib>=3.7; extra == 'viz'
|
|
30
|
+
Requires-Dist: pillow>=10.0; extra == 'viz'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# evlab
|
|
34
|
+
|
|
35
|
+
**Inspect, clean, convert, and benchmark event-camera data from the command line.**
|
|
36
|
+
|
|
37
|
+
Event-based vision has a preprocessing problem: every sensor and dataset ships a
|
|
38
|
+
different container (AEDAT, ROS bags, CSV, NPZ, proprietary `.dat`), every paper
|
|
39
|
+
wants a different representation (voxel grids, time surfaces, frames), and every
|
|
40
|
+
pipeline reimplements the same denoising filters. `evlab` is the small, boring
|
|
41
|
+
tool that handles that mess so you can get to the actual work.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install evlab[viz]
|
|
45
|
+
|
|
46
|
+
evlab info recording.npz # what is this file?
|
|
47
|
+
evlab convert events.aedat4 events.npz # normalize the container
|
|
48
|
+
evlab convert drive.dat events.npz # Prophesee .dat works too
|
|
49
|
+
evlab convert flight.bag events.npz # ...and ROS bags (dvs_msgs)
|
|
50
|
+
evlab denoise events.npz clean.npz --filter baf --window 5000
|
|
51
|
+
evlab voxel clean.npz voxels.npy --bins 10
|
|
52
|
+
evlab visualize clean.npz preview.gif --mode gif
|
|
53
|
+
evlab benchmark events.npz clean.npz # what did the filter do?
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
How good is a denoising filter, really? Generate a labeled stream and score it:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
evlab synth labeled.npz --signal-rate 20000 --noise-rate 8000
|
|
60
|
+
evlab denoise-bench labeled.npz --filter baf --window 3000
|
|
61
|
+
# precision : 99.2%
|
|
62
|
+
# recall : 37.8%
|
|
63
|
+
# f1 : 0.548
|
|
64
|
+
# noise removed : 99.2%
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Status
|
|
68
|
+
|
|
69
|
+
Early alpha. Works and is tested: the canonical representation; loading
|
|
70
|
+
NPZ/CSV/TXT, AEDAT4 (`[aedat]` extra), Prophesee legacy `.dat` (with
|
|
71
|
+
timestamp-wrap handling), and ROS1/ROS2 bags with `EventArray` topics
|
|
72
|
+
(`[ros]` extra); BAF/refractory denoising; voxel grids, time surfaces,
|
|
73
|
+
accumulate frames; synthetic labeled streams and precision/recall filter
|
|
74
|
+
scoring; and the eight CLI commands above. Planned next: Prophesee EVT3
|
|
75
|
+
`.raw`, dataset-aware loaders (via [Tonic]), streaming via [Faery], and
|
|
76
|
+
more filters (STCF, IE/YNoise) under `denoise-bench`.
|
|
77
|
+
|
|
78
|
+
[Tonic]: https://github.com/neuromorphs/tonic
|
|
79
|
+
[Faery]: https://github.com/aestream/faery
|
|
80
|
+
|
|
81
|
+
## Python API
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import evlab
|
|
85
|
+
|
|
86
|
+
data = evlab.load("recording.npz") # EventData: (x, y, t, p) + sensor size
|
|
87
|
+
print(data.event_rate)
|
|
88
|
+
|
|
89
|
+
from evlab.filters import background_activity_filter
|
|
90
|
+
clean = background_activity_filter(data, time_window_us=5000)
|
|
91
|
+
|
|
92
|
+
from evlab.representations import voxel_grid
|
|
93
|
+
grid = voxel_grid(clean, bins=10) # (10, H, W) float32, ready for torch
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Design notes
|
|
97
|
+
|
|
98
|
+
- One canonical in-memory format: a t-sorted structured numpy array
|
|
99
|
+
(`x: u2, y: u2, t: i8 (µs), p: i1`) plus sensor geometry. Every loader
|
|
100
|
+
normalizes into it; every filter/representation consumes it.
|
|
101
|
+
- Zero heavy dependencies in the core (`numpy` + `click`). Visualization,
|
|
102
|
+
AEDAT support, and dataset loaders are opt-in extras.
|
|
103
|
+
- Filters return copies; nothing mutates your data behind your back.
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pip install -e .[dev]
|
|
109
|
+
pytest
|
|
110
|
+
ruff check src tests
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
evlab/__init__.py,sha256=_Pz8LvYm4R1rXPNGh0NL7b15jO42H5lRFwNBiRiQQdE,223
|
|
2
|
+
evlab/cli.py,sha256=j6euP897eUiq6NbFdvTKgHsobCxPqYf4NLE4mZHL03k,7434
|
|
3
|
+
evlab/filters.py,sha256=ayJfkPHMV7HzpOfnffqbTSEE63REZtuur95j7T9HlKU,3663
|
|
4
|
+
evlab/formats.py,sha256=OYU6-04V7zKkRVfVxRKET7blf7iX5V3_37PODdQDusk,11204
|
|
5
|
+
evlab/metrics.py,sha256=bsmNuoaTZp1PpQxcnh9XktzbFnyusAf1In_O9WYzFjM,3531
|
|
6
|
+
evlab/representations.py,sha256=XyN8U_jMrJ6X0oZt9vkaPQJyl8vussOfLWkR4SusMNU,2652
|
|
7
|
+
evlab/synth.py,sha256=zw6P6mzL1VIixrogRJ7aMd_w7NxuQRpFpOfC3IeHlqo,2212
|
|
8
|
+
evlab/viz.py,sha256=73ZWECfh82a4XPTiCBXz-yQPyoKhZQuLGkqZXK_8MsE,2736
|
|
9
|
+
evlab-0.1.0a1.dist-info/METADATA,sha256=9DFfWf_AYm_YDOHd54-TzGhUFlTK-NndWKIpmzGKSxA,4101
|
|
10
|
+
evlab-0.1.0a1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
evlab-0.1.0a1.dist-info/entry_points.txt,sha256=rKIuow3Nrfn2ulRiMZv3ng_tu-nv0TNwG6ziXZNBC9U,41
|
|
12
|
+
evlab-0.1.0a1.dist-info/licenses/LICENSE,sha256=0fQlGFE3ScZzqnYgo-yrhRa6aj9CwMX6JUCZ2cZcV4I,1065
|
|
13
|
+
evlab-0.1.0a1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jacky Li
|
|
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.
|