Skip to content

Event Filtering

Raw event streams carry sensor noise, hot pixels and regions or time ranges you do not need. evlib.filtering provides lazy, Polars-based filters for each of these problems, plus a preprocess_events pipeline that runs them in one call.

Overview

Every function in the module takes a Polars LazyFrame or DataFrame (the result of evlib.load_events) and returns a LazyFrame. Nothing is computed until you call .collect(), so filters chain together into a single query plan and can run on the CPU, the streaming engine, or the cudf GPU engine via the engine argument.

import evlib
import evlib.filtering as evf

events = evlib.load_events("data/slider_depth/events.txt")
print(f"Loaded {len(events.collect()):,} events")

# Keep only the first second, then only positive-polarity events.
# Chaining stays lazy until the final .collect().
filtered = evf.filter_by_time(events, t_start=0.0, t_end=1.0)
filtered = evf.filter_by_polarity(filtered, polarity=1)
print(f"After filtering: {len(filtered.collect()):,} events")
Loaded 1,078,541 events
After filtering: 125,930 events

The slider_depth text file encodes polarity as 0/1 (0 for negative, 1 for positive), not -1/1. Check df["polarity"].unique() on data you have not seen before, since the encoding varies between sources.

filter_by_time

filter_by_time(events, t_start=None, t_end=None, engine="auto") keeps events whose timestamp falls in [t_start, t_end], given in seconds. Either bound can be None to leave that side open. Internally the seconds are converted to microseconds to compare against the t Duration column, so you never have to do that conversion yourself.

import evlib
import evlib.filtering as evf

events = evlib.load_events("data/slider_depth/events.txt")

# Events between 0.1s and 0.5s
windowed = evf.filter_by_time(events, t_start=0.1, t_end=0.5)
print(f"Events in [0.1s, 0.5s]: {len(windowed.collect()):,}")

# Open-ended: everything from 3.0s onward
tail = evf.filter_by_time(events, t_start=3.0)
print(f"Events from 3.0s onward: {len(tail.collect()):,}")

filter_by_roi

filter_by_roi(events, x_min, x_max, y_min, y_max, engine="auto") keeps events whose x and y both fall inside the given bounds, inclusive on both ends. slider_depth is a 240x180 sensor, so a centred region of interest looks like this:

import evlib
import evlib.filtering as evf

events = evlib.load_events("data/slider_depth/events.txt")

roi = evf.filter_by_roi(events, x_min=50, x_max=180, y_min=40, y_max=140)
print(f"Events in ROI: {len(roi.collect()):,}")

Passing x_min > x_max or y_min > y_max raises ValueError immediately, rather than silently returning zero rows.

filter_by_polarity

filter_by_polarity(events, polarity, engine="auto") keeps events whose polarity column matches the given value, or any value in a list. The values to pass depend on how the source data encodes polarity: slider_depth uses 0/1, other sources may use -1/1.

import evlib
import evlib.filtering as evf

events = evlib.load_events("data/slider_depth/events.txt")

positive = evf.filter_by_polarity(events, polarity=1)
negative = evf.filter_by_polarity(events, polarity=0)
print(f"Positive: {len(positive.collect()):,}, negative: {len(negative.collect()):,}")

# A list keeps events matching any of the given values.
both = evf.filter_by_polarity(events, polarity=[0, 1])
print(f"Both polarities: {len(both.collect()):,}")

filter_hot_pixels

Some pixels fire far more often than their neighbours because of sensor defects rather than real scene motion. filter_hot_pixels(events, threshold_percentile=None, engine="auto") counts events per (x, y) coordinate, then drops every event at a coordinate whose count exceeds the given percentile of the per-pixel count distribution. The default threshold is the 99.9th percentile.

import evlib
import evlib.filtering as evf

events = evlib.load_events("data/slider_depth/events.txt")

before = len(events.collect())
cleaned = evf.filter_hot_pixels(events, threshold_percentile=99.9)
after = len(cleaned.collect())
print(f"Removed {before - after:,} events from hot pixels ({before:,} -> {after:,})")

A lower threshold_percentile (for example 95.0) removes more pixels and is more aggressive.

filter_noise

filter_noise(events, method=None, refractory_period_us=None, engine="auto") implements refractory-period denoising: at each pixel, an event is dropped if it arrives within refractory_period_us microseconds of the previous event at that same pixel. "refractory" is the only implemented method, and is used by default. The default period is 1000us (1ms), which barely touches slider_depth; a longer period makes the effect visible.

import evlib
import evlib.filtering as evf

events = evlib.load_events("data/slider_depth/events.txt")

before = len(events.collect())
denoised = evf.filter_noise(events, method="refractory", refractory_period_us=10_000)
after = len(denoised.collect())
print(f"Removed {before - after:,} events within a 10ms refractory period")
Removed 218,294 events within a 10ms refractory period

filter_noise sorts by t internally, so you do not need to pre-sort the input (evlib.load_events already sorts by default).

The figure below shows both filters applied together: a per-pixel event-count heatmap of the raw stream (left, hot pixels visible as bright points) next to the same window after filter_hot_pixels and filter_noise(method="refractory") (right).

Per-pixel event-count heatmap before and after filter_hot_pixels and filter_noise

filter_multiple_rois

filter_multiple_rois(events, rois, engine="auto") is the multi-region form of filter_by_roi: it keeps events that fall inside any of a list of (x_min, x_max, y_min, y_max) tuples, combined with logical OR.

import evlib
import evlib.filtering as evf

events = evlib.load_events("data/slider_depth/events.txt")

rois = [
    (0, 100, 0, 90),      # top-left quadrant
    (140, 239, 90, 179),  # bottom-right quadrant
]
corners = evf.filter_multiple_rois(events, rois=rois)
print(f"Events in either ROI: {len(corners.collect()):,}")

preprocess_events

preprocess_events combines the filters above into a single preprocessing pipeline, applied in a fixed order chosen for performance: time, then ROI, then polarity, then hot-pixel removal, then noise filtering. Every stage is optional and controlled by its own arguments.

import evlib
import evlib.filtering as evf

events = evlib.load_events("data/slider_depth/events.txt")

processed = evf.preprocess_events(
    events,
    t_start=0.1, t_end=1.0,
    roi=(0, 239, 0, 179),
    polarity=1,
    remove_hot_pixels=True,
    hot_pixel_threshold=99.9,
    denoise=True,
    refractory_period_us=1000.0,
)
print(f"Preprocessed: {len(processed.collect()):,} events")
Preprocessed: 115,113 events

The filtering order matters: time and ROI filters run first because they cut down the row count cheaply, before the more expensive hot-pixel and noise stages run on a smaller frame.

Choosing an engine

Every function accepts an engine argument: "auto" (default), "streaming" for large files that do not fit comfortably in memory, or "gpu" (or a pl.GPUEngine(...)) to run on cudf-polars where CUDA is available. The argument only affects how Polars plans the eventual .collect(); the filter itself stays lazy either way, so you can freely chain functions with different engine hints and only the final collect matters.

import evlib
import evlib.filtering as evf

events = evlib.load_events("data/slider_depth/events.txt")
filtered = evf.filter_by_time(events, t_start=0.0, t_end=1.0, engine="streaming")
df = filtered.collect(engine="streaming")
print(f"Streaming collect: {len(df):,} events")

Next steps