From 7e983e0a64baea861c87325b2e235e58eb6b6bec Mon Sep 17 00:00:00 2001 From: Christos Falas Date: Fri, 16 May 2025 00:41:31 +0100 Subject: [PATCH] improve motion detection examples --- examples/motion_detector.py | 77 ++++++++++-------- examples/motion_detector_home_assistant.py | 95 ++++++++++++---------- where_fi/processing/preprocess.py | 2 +- where_fi/visualise/server/__init__.py | 6 +- where_fi/visualise/server/figures.py | 38 ++++++--- 5 files changed, 126 insertions(+), 92 deletions(-) diff --git a/examples/motion_detector.py b/examples/motion_detector.py index 82b49f2..da2b373 100644 --- a/examples/motion_detector.py +++ b/examples/motion_detector.py @@ -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!") diff --git a/examples/motion_detector_home_assistant.py b/examples/motion_detector_home_assistant.py index b057563..73aa72a 100644 --- a/examples/motion_detector_home_assistant.py +++ b/examples/motion_detector_home_assistant.py @@ -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) -app.start() +if __name__ == "__main__": + # Start the application + print("Starting app") + app.set_producer(RealtimeCSIProducer()) + app.start() diff --git a/where_fi/processing/preprocess.py b/where_fi/processing/preprocess.py index be37609..a9c18a8 100644 --- a/where_fi/processing/preprocess.py +++ b/where_fi/processing/preprocess.py @@ -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 diff --git a/where_fi/visualise/server/__init__.py b/where_fi/visualise/server/__init__.py index f1fa719..0e21c3e 100644 --- a/where_fi/visualise/server/__init__.py +++ b/where_fi/visualise/server/__init__.py @@ -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 diff --git a/where_fi/visualise/server/figures.py b/where_fi/visualise/server/figures.py index 5097acf..dd6f4ee 100644 --- a/where_fi/visualise/server/figures.py +++ b/where_fi/visualise/server/figures.py @@ -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):