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 ADDED
@@ -0,0 +1,6 @@
1
+ """evlab: inspect, clean, convert, and benchmark event-camera data."""
2
+
3
+ from .formats import EventData, from_arrays, load, save
4
+
5
+ __version__ = "0.1.0a1"
6
+ __all__ = ["EventData", "from_arrays", "load", "save", "__version__"]
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
+ }
@@ -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,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
+ evlab = evlab.cli:main
@@ -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.