evlab 0.1.0a1__tar.gz
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-0.1.0a1/.github/workflows/ci.yml +27 -0
- evlab-0.1.0a1/.github/workflows/release.yml +36 -0
- evlab-0.1.0a1/.gitignore +7 -0
- evlab-0.1.0a1/LICENSE +21 -0
- evlab-0.1.0a1/PKG-INFO +115 -0
- evlab-0.1.0a1/README.md +83 -0
- evlab-0.1.0a1/pyproject.toml +45 -0
- evlab-0.1.0a1/src/evlab/__init__.py +6 -0
- evlab-0.1.0a1/src/evlab/cli.py +204 -0
- evlab-0.1.0a1/src/evlab/filters.py +109 -0
- evlab-0.1.0a1/src/evlab/formats.py +315 -0
- evlab-0.1.0a1/src/evlab/metrics.py +100 -0
- evlab-0.1.0a1/src/evlab/representations.py +74 -0
- evlab-0.1.0a1/src/evlab/synth.py +62 -0
- evlab-0.1.0a1/src/evlab/viz.py +82 -0
- evlab-0.1.0a1/tests/test_evlab.py +109 -0
- evlab-0.1.0a1/tests/test_formats_extra.py +174 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.13"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- name: Install
|
|
21
|
+
run: pip install -e .[dev]
|
|
22
|
+
- name: Lint
|
|
23
|
+
run: |
|
|
24
|
+
ruff check src tests
|
|
25
|
+
ruff format --check src tests
|
|
26
|
+
- name: Test
|
|
27
|
+
run: pytest -v
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: Release to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.12"
|
|
16
|
+
- name: Build distributions
|
|
17
|
+
run: |
|
|
18
|
+
pip install build
|
|
19
|
+
python -m build
|
|
20
|
+
- uses: actions/upload-artifact@v4
|
|
21
|
+
with:
|
|
22
|
+
name: dist
|
|
23
|
+
path: dist/
|
|
24
|
+
|
|
25
|
+
publish:
|
|
26
|
+
needs: build
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
environment: pypi
|
|
29
|
+
permissions:
|
|
30
|
+
id-token: write # trusted publishing (OIDC)
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/download-artifact@v4
|
|
33
|
+
with:
|
|
34
|
+
name: dist
|
|
35
|
+
path: dist/
|
|
36
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
evlab-0.1.0a1/.gitignore
ADDED
evlab-0.1.0a1/LICENSE
ADDED
|
@@ -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.
|
evlab-0.1.0a1/PKG-INFO
ADDED
|
@@ -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
|
evlab-0.1.0a1/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# evlab
|
|
2
|
+
|
|
3
|
+
**Inspect, clean, convert, and benchmark event-camera data from the command line.**
|
|
4
|
+
|
|
5
|
+
Event-based vision has a preprocessing problem: every sensor and dataset ships a
|
|
6
|
+
different container (AEDAT, ROS bags, CSV, NPZ, proprietary `.dat`), every paper
|
|
7
|
+
wants a different representation (voxel grids, time surfaces, frames), and every
|
|
8
|
+
pipeline reimplements the same denoising filters. `evlab` is the small, boring
|
|
9
|
+
tool that handles that mess so you can get to the actual work.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install evlab[viz]
|
|
13
|
+
|
|
14
|
+
evlab info recording.npz # what is this file?
|
|
15
|
+
evlab convert events.aedat4 events.npz # normalize the container
|
|
16
|
+
evlab convert drive.dat events.npz # Prophesee .dat works too
|
|
17
|
+
evlab convert flight.bag events.npz # ...and ROS bags (dvs_msgs)
|
|
18
|
+
evlab denoise events.npz clean.npz --filter baf --window 5000
|
|
19
|
+
evlab voxel clean.npz voxels.npy --bins 10
|
|
20
|
+
evlab visualize clean.npz preview.gif --mode gif
|
|
21
|
+
evlab benchmark events.npz clean.npz # what did the filter do?
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
How good is a denoising filter, really? Generate a labeled stream and score it:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
evlab synth labeled.npz --signal-rate 20000 --noise-rate 8000
|
|
28
|
+
evlab denoise-bench labeled.npz --filter baf --window 3000
|
|
29
|
+
# precision : 99.2%
|
|
30
|
+
# recall : 37.8%
|
|
31
|
+
# f1 : 0.548
|
|
32
|
+
# noise removed : 99.2%
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Status
|
|
36
|
+
|
|
37
|
+
Early alpha. Works and is tested: the canonical representation; loading
|
|
38
|
+
NPZ/CSV/TXT, AEDAT4 (`[aedat]` extra), Prophesee legacy `.dat` (with
|
|
39
|
+
timestamp-wrap handling), and ROS1/ROS2 bags with `EventArray` topics
|
|
40
|
+
(`[ros]` extra); BAF/refractory denoising; voxel grids, time surfaces,
|
|
41
|
+
accumulate frames; synthetic labeled streams and precision/recall filter
|
|
42
|
+
scoring; and the eight CLI commands above. Planned next: Prophesee EVT3
|
|
43
|
+
`.raw`, dataset-aware loaders (via [Tonic]), streaming via [Faery], and
|
|
44
|
+
more filters (STCF, IE/YNoise) under `denoise-bench`.
|
|
45
|
+
|
|
46
|
+
[Tonic]: https://github.com/neuromorphs/tonic
|
|
47
|
+
[Faery]: https://github.com/aestream/faery
|
|
48
|
+
|
|
49
|
+
## Python API
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import evlab
|
|
53
|
+
|
|
54
|
+
data = evlab.load("recording.npz") # EventData: (x, y, t, p) + sensor size
|
|
55
|
+
print(data.event_rate)
|
|
56
|
+
|
|
57
|
+
from evlab.filters import background_activity_filter
|
|
58
|
+
clean = background_activity_filter(data, time_window_us=5000)
|
|
59
|
+
|
|
60
|
+
from evlab.representations import voxel_grid
|
|
61
|
+
grid = voxel_grid(clean, bins=10) # (10, H, W) float32, ready for torch
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Design notes
|
|
65
|
+
|
|
66
|
+
- One canonical in-memory format: a t-sorted structured numpy array
|
|
67
|
+
(`x: u2, y: u2, t: i8 (µs), p: i1`) plus sensor geometry. Every loader
|
|
68
|
+
normalizes into it; every filter/representation consumes it.
|
|
69
|
+
- Zero heavy dependencies in the core (`numpy` + `click`). Visualization,
|
|
70
|
+
AEDAT support, and dataset loaders are opt-in extras.
|
|
71
|
+
- Filters return copies; nothing mutates your data behind your back.
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install -e .[dev]
|
|
77
|
+
pytest
|
|
78
|
+
ruff check src tests
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "evlab"
|
|
7
|
+
version = "0.1.0a1"
|
|
8
|
+
description = "Inspect, clean, convert, and benchmark event-camera data from the command line"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "Jacky Li", email = "jackydli95@gmail.com" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"numpy>=1.24",
|
|
15
|
+
"click>=8.1",
|
|
16
|
+
]
|
|
17
|
+
keywords = ["event-camera", "neuromorphic", "dvs", "event-based-vision"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Image Processing",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
viz = ["matplotlib>=3.7", "pillow>=10.0"]
|
|
27
|
+
aedat = ["aedat>=2.0"]
|
|
28
|
+
ros = ["rosbags>=0.10"]
|
|
29
|
+
dev = ["pytest>=7.0", "ruff>=0.4", "matplotlib>=3.7", "pillow>=10.0", "rosbags>=0.10"]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
evlab = "evlab.cli:main"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/JPL11/evlab"
|
|
36
|
+
Issues = "https://github.com/JPL11/evlab/issues"
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 100
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint]
|
|
42
|
+
select = ["E", "F", "I", "W", "UP"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
@@ -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()
|
|
@@ -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
|
+
}
|