improve motion detection examples

This commit is contained in:
Christos Falas 2025-05-16 00:41:31 +01:00
parent 6c33c8e37e
commit 7e983e0a64
No known key found for this signature in database
5 changed files with 126 additions and 92 deletions

View File

@ -3,24 +3,48 @@ This example shows how to use the CSI framework to connect to a FeitCSI host and
detect changes in the environment
"""
from queue import Queue
import numpy as np
import numpy.typing as npt
from where_fi.application import CSIApplication
from where_fi.collection.ingest import RealtimeCSIProducer
from where_fi.config import config
from where_fi.visualise import server as visualise
# Connect to a FeitCSI host
app = CSIApplication(visualise_raw=True)
# Register visualisations
app.register_figure(
"magn-diff",
visualise.figures.PerAntennaFigure(
[
visualise.figures.SimpleLineChart(
"Magnitude diff", "Subcarrier", "Magnitude"
),
],
[lambda x: x],
),
)
app.register_figure(
"phase-diff",
visualise.figures.PerAntennaFigure(
[
visualise.figures.SimpleLineChart("Phase diff", "Subcarrier", "Phase"),
],
[lambda x: x],
),
)
MAGN_THRESHOLD = 0.2
PHASE_THRESHOLD = 0.02
QUEUE_SIZE = 20
# Stores historical data for each receiving antenna, for each subcarrier
historical: dict[tuple[int, int], Queue[np.complex64]] = {}
MAGN_THRESHOLD = 20
PHASE_THRESHOLD = 0.5
QUEUE_SIZE = 20
historical = np.zeros(
(QUEUE_SIZE, config.subcarriers, config.antennas.count, 1), dtype=np.complex64
)
sample_position = 0
@app.on_process
@ -34,39 +58,20 @@ def _(sample: npt.NDArray[np.complex64]) -> None:
It is used to detect changes in the environment caused by motion, by comparing each
entry in the matrix with a moving average
"""
change = False
for antenna in range(sample.shape[1]):
for subcarrier in range(sample.shape[0]):
# Get the current subcarrier data
current = sample[subcarrier, antenna, 0]
global historical
global sample_position
# Get the historical data for this antenna and subcarrier
if (antenna, subcarrier) not in historical:
historical[(antenna, subcarrier)] = Queue(maxsize=QUEUE_SIZE)
historical_data = historical[(antenna, subcarrier)]
historical[sample_position] = sample
sample_position = (sample_position + 1) % QUEUE_SIZE
# Calculate the average of the historical data for this antenna and
# subcarrier
mean = np.mean(historical_data.queue)
mean = np.mean(historical, axis=0)
magn_diff = np.abs(mean - sample)
phase_diff = np.abs(np.angle(mean) - np.angle(sample))
# If we have enough historical data, compare it with the current data
if historical_data.full():
historical_data.get()
app.visualise_data(magn_diff, "magn-diff")
app.visualise_data(phase_diff, "phase-diff")
# Add the current sample to the historical data
historical_data.put(current)
# Compare the current data with the historical data
if (
np.abs(mean - current) > MAGN_THRESHOLD
or np.abs(np.angle(mean) - np.angle(current)) > PHASE_THRESHOLD
):
print(
f"Change detected in antenna {antenna}, subcarrier {subcarrier}: "
f"magn: {np.abs(mean - current)}, angle: {np.abs(np.angle(mean) - np.angle(current))}"
)
change = True
if change:
if (magn_diff > MAGN_THRESHOLD).any() or (phase_diff > PHASE_THRESHOLD).any():
print("Motion detected!")
else:
print("No motion detected!")

View File

@ -1,24 +1,51 @@
"""Motion Detection example
This example shows how to use the CSI framework to connect to a FeitCSI host and
detect changes in the environment
Once motion is detected, the application will log the change to Home Assistant
through an HTTP request.
"""
import os
from queue import Queue
import numpy as np
import numpy.typing as npt
import requests
from where_fi.application import CSIApplication
from where_fi.collection.ingest import RealtimeCSIProducer
from where_fi.config import config
from where_fi.visualise import server as visualise
# Connect to a FeitCSI host
producer = RealtimeCSIProducer()
app = CSIApplication(producer)
app = CSIApplication(visualise_raw=True)
# Register visualisations
app.register_figure(
"magn-diff",
visualise.figures.PerAntennaFigure(
[
visualise.figures.SimpleLineChart(
"Magnitude diff", "Subcarrier", "Magnitude"
),
],
[lambda x: x],
),
)
app.register_figure(
"phase-diff",
visualise.figures.PerAntennaFigure(
[
visualise.figures.SimpleLineChart("Phase diff", "Subcarrier", "Phase"),
],
[lambda x: x],
),
)
MAGN_THRESHOLD = 0.2
PHASE_THRESHOLD = 0.02
QUEUE_SIZE = 20
# Stores historical data for each receiving antenna, for each subcarrier
historical = np.zeros(
(QUEUE_SIZE, config.subcarriers, config.antennas.count, 1), dtype=np.complex64
)
sample_position = 0
class HomeAssistantBinarySensor:
@ -55,14 +82,8 @@ class HomeAssistantBinarySensor:
requests.post(self.url, json=data, headers=self.headers)
# Stores historical data for each receiving antenna, for each subcarrier
historical: dict[tuple[int, int], Queue[np.complex64]] = {}
sensor = HomeAssistantBinarySensor("motion_detector", "Room Motion Detector")
MAGN_THRESHOLD = 20
PHASE_THRESHOLD = 0.5
QUEUE_SIZE = 2000
@app.on_process
def _(sample: npt.NDArray[np.complex64]) -> None:
@ -75,36 +96,28 @@ def _(sample: npt.NDArray[np.complex64]) -> None:
It is used to detect changes in the environment caused by motion, by comparing each
entry in the matrix with a moving average
"""
change = False
for antenna in range(sample.shape[1]):
for subcarrier in range(sample.shape[0]):
# Get the current subcarrier data
current = sample[subcarrier, antenna, 0]
global sample_position
# Get the historical data for this antenna and subcarrier
if (antenna, subcarrier) not in historical:
historical[(antenna, subcarrier)] = Queue(maxsize=QUEUE_SIZE)
historical_data = historical[(antenna, subcarrier)]
historical[sample_position] = sample
sample_position = (sample_position + 1) % QUEUE_SIZE
# Calculate the average of the historical data for this antenna and
# subcarrier
mean = np.mean(historical_data.queue)
mean = np.mean(historical, axis=0)
magn_diff = np.abs(mean - sample)
phase_diff = np.abs(np.angle(mean) - np.angle(sample))
# If we have enough historical data, compare it with the current data
if historical_data.full():
historical_data.get()
app.visualise_data(magn_diff, "magn-diff")
app.visualise_data(phase_diff, "phase-diff")
# Add the current sample to the historical data
historical_data.put(current)
# Compare the current data with the historical data
if (
np.abs(mean - current) > MAGN_THRESHOLD
or np.abs(np.angle(mean) - np.angle(current)) > PHASE_THRESHOLD
):
change = True
sensor.update(change)
if (magn_diff > MAGN_THRESHOLD).any() or (phase_diff > PHASE_THRESHOLD).any():
print("Motion detected!")
sensor.update(True)
else:
print("No motion detected!")
sensor.update(False)
if __name__ == "__main__":
# Start the application
print("Starting app")
app.set_producer(RealtimeCSIProducer())
app.start()

View File

@ -222,7 +222,7 @@ class Preprocessor:
case "bandpass":
h_hat = self.bandpass(h_hat)
logger.info(f"CSI shape: {h_hat.shape}")
logger.debug(f"CSI shape: {h_hat.shape}")
self._last_sample = h_hat

View File

@ -67,6 +67,10 @@ class Webapp:
def add_data(
self, dtype: figures.FigureId, new_data: npt.NDArray[np.complex128]
) -> None:
self.logger.debug(f"Adding data to figure server {dtype}")
if dtype not in figures.all_figures:
self.logger.error(f"Figure {dtype} not found")
return
updates = figures.all_figures[dtype].update(new_data)
for fig_id, update in updates.items():
for client in self.figure_server.clients.get(fig_id, []):
@ -76,7 +80,7 @@ class Webapp:
while self.active:
self.logger.debug("Listening for data")
try:
data = data_queue.get(timeout=0.5)
data = data_queue.get(timeout=0.1)
self.add_data(data.dtype, data.data)
except queue.Empty:
pass

View File

@ -103,7 +103,28 @@ class PerSubcarrierFigure(SpecificFigure):
return reduce((lambda a, b: a | b), updates)
class PerAntennaFigure(SpecificFigure):
class LabelledMultiLineChart(SpecificFigure):
def __init__(
self,
figures: Sequence[SimpleLineChart],
funcs: list[Callable[[npt.NDArray[Any]], npt.NDArray[Any]]],
labels: Sequence[str],
) -> None:
self.charts = figures
self.figures = [figure.figure for figure in figures]
self.funcs = funcs
self.labels = labels
def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate:
data_by_antenna = new_data[:, :, 0].T
updates = [
figure.update(func(data_by_antenna), labels=self.labels)
for func, figure in zip(self.funcs, self.charts, strict=True)
]
return reduce((lambda a, b: a | b), updates)
class PerAntennaFigure(LabelledMultiLineChart):
"""A figure that plots data for each antenna separately.
This allows creating multiple figures, each having one line per antenna.
@ -118,18 +139,9 @@ class PerAntennaFigure(SpecificFigure):
figures: Sequence[SimpleLineChart],
funcs: list[Callable[[npt.NDArray[Any]], npt.NDArray[Any]]],
) -> None:
self.charts = figures
self.figures = [figure.figure for figure in figures]
self.funcs = funcs
def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate:
data_by_antenna = new_data[:, :, 0].T
antenna_labels = [f"Antenna {i}" for i in range(data_by_antenna.shape[0])]
updates = [
figure.update(func(data_by_antenna), labels=antenna_labels)
for func, figure in zip(self.funcs, self.charts, strict=True)
]
return reduce((lambda a, b: a | b), updates)
super().__init__(
figures, funcs, [f"Antenna {i + 1}" for i in range(config.antennas.count)]
)
class MusicEigenvalueHistogram(SpecificFigure):