mirror of
https://github.com/ruvnet/RuView
synced 2026-08-01 19:01:42 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a7af6d6f0 | |||
| 0a80b8e860 | |||
| bc5af07c15 |
@@ -41,12 +41,14 @@ static const char *TAG = "edge_proc";
|
|||||||
* ====================================================================== */
|
* ====================================================================== */
|
||||||
|
|
||||||
static edge_ring_buf_t s_ring;
|
static edge_ring_buf_t s_ring;
|
||||||
|
static uint32_t s_ring_drops; /* Frames dropped due to full ring buffer. */
|
||||||
|
|
||||||
static inline bool ring_push(const uint8_t *iq, uint16_t len,
|
static inline bool ring_push(const uint8_t *iq, uint16_t len,
|
||||||
int8_t rssi, uint8_t channel)
|
int8_t rssi, uint8_t channel)
|
||||||
{
|
{
|
||||||
uint32_t next = (s_ring.head + 1) % EDGE_RING_SLOTS;
|
uint32_t next = (s_ring.head + 1) % EDGE_RING_SLOTS;
|
||||||
if (next == s_ring.tail) {
|
if (next == s_ring.tail) {
|
||||||
|
s_ring_drops++;
|
||||||
return false; /* Full — drop frame. */
|
return false; /* Full — drop frame. */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,12 +790,13 @@ static void process_frame(const edge_ring_slot_t *slot)
|
|||||||
|
|
||||||
if ((s_frame_count % 200) == 0) {
|
if ((s_frame_count % 200) == 0) {
|
||||||
ESP_LOGI(TAG, "Vitals: br=%.1f hr=%.1f motion=%.4f pres=%s "
|
ESP_LOGI(TAG, "Vitals: br=%.1f hr=%.1f motion=%.4f pres=%s "
|
||||||
"fall=%s persons=%u frames=%lu",
|
"fall=%s persons=%u frames=%lu drops=%lu",
|
||||||
s_breathing_bpm, s_heartrate_bpm, s_motion_energy,
|
s_breathing_bpm, s_heartrate_bpm, s_motion_energy,
|
||||||
s_presence_detected ? "YES" : "no",
|
s_presence_detected ? "YES" : "no",
|
||||||
s_fall_detected ? "YES" : "no",
|
s_fall_detected ? "YES" : "no",
|
||||||
(unsigned)s_latest_pkt.n_persons,
|
(unsigned)s_latest_pkt.n_persons,
|
||||||
(unsigned long)s_frame_count);
|
(unsigned long)s_frame_count,
|
||||||
|
(unsigned long)s_ring_drops);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,18 +834,32 @@ static void edge_task(void *arg)
|
|||||||
|
|
||||||
edge_ring_slot_t slot;
|
edge_ring_slot_t slot;
|
||||||
|
|
||||||
|
/* Maximum frames to process before a longer yield. On busy LANs
|
||||||
|
* (corporate networks, many APs), the ring buffer fills continuously.
|
||||||
|
* Without a batch limit the task processes frames back-to-back with
|
||||||
|
* only 1-tick yields, which on high frame rates can still starve
|
||||||
|
* IDLE1 enough to trip the 5-second task watchdog. See #266, #321. */
|
||||||
|
const uint8_t BATCH_LIMIT = 4;
|
||||||
|
|
||||||
while (1) {
|
while (1) {
|
||||||
if (ring_pop(&slot)) {
|
uint8_t processed = 0;
|
||||||
|
|
||||||
|
while (processed < BATCH_LIMIT && ring_pop(&slot)) {
|
||||||
process_frame(&slot);
|
process_frame(&slot);
|
||||||
/* Yield after every frame to feed the Core 1 watchdog.
|
processed++;
|
||||||
* process_frame() is CPU-intensive (biquad filters, Welford stats,
|
/* 1-tick yield between frames within a batch. */
|
||||||
* BPM estimation, multi-person vitals) and can take several ms.
|
|
||||||
* Without this yield, edge_dsp at priority 5 starves IDLE1 at
|
|
||||||
* priority 0, triggering the task watchdog. See issue #266. */
|
|
||||||
vTaskDelay(1);
|
vTaskDelay(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processed > 0) {
|
||||||
|
/* Post-batch yield: 2 ticks (~20 ms at 100 Hz) so IDLE1 can
|
||||||
|
* run and feed the Core 1 watchdog even under sustained load.
|
||||||
|
* This is intentionally longer than the 1-tick inter-frame yield. */
|
||||||
|
vTaskDelay(2);
|
||||||
} else {
|
} else {
|
||||||
/* No frames available — yield briefly. */
|
/* No frames available — sleep one full tick.
|
||||||
vTaskDelay(pdMS_TO_TICKS(1));
|
* NOTE: pdMS_TO_TICKS(5) == 0 at 100 Hz, which would busy-spin. */
|
||||||
|
vTaskDelay(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -185,7 +185,7 @@ package-dir = {"" = "."}
|
|||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["."]
|
where = ["."]
|
||||||
include = ["wifi_densepose*", "src*"]
|
include = ["src*"]
|
||||||
exclude = ["tests*", "docs*", "scripts*"]
|
exclude = ["tests*", "docs*", "scripts*"]
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
|
|||||||
@@ -2820,6 +2820,90 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||||||
})) {
|
})) {
|
||||||
let _ = s.tx.send(json);
|
let _ = s.tx.send(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue #323: Also emit a sensing_update so the UI renders
|
||||||
|
// detections for ESP32 nodes running the edge DSP pipeline
|
||||||
|
// (Tier 2+). Without this, vitals arrive but the UI shows
|
||||||
|
// "no detection" because it only renders sensing_update msgs.
|
||||||
|
s.source = "esp32".to_string();
|
||||||
|
s.last_esp32_frame = Some(std::time::Instant::now());
|
||||||
|
s.tick += 1;
|
||||||
|
let tick = s.tick;
|
||||||
|
|
||||||
|
let motion_level = if vitals.motion { "present_moving" }
|
||||||
|
else if vitals.presence { "present_still" }
|
||||||
|
else { "absent" };
|
||||||
|
let motion_score = if vitals.motion { 0.8 }
|
||||||
|
else if vitals.presence { 0.3 }
|
||||||
|
else { 0.05 };
|
||||||
|
let est_persons = if vitals.presence {
|
||||||
|
(vitals.n_persons as usize).max(1)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let features = FeatureInfo {
|
||||||
|
mean_rssi: vitals.rssi as f64,
|
||||||
|
variance: vitals.motion_energy as f64,
|
||||||
|
motion_band_power: vitals.motion_energy as f64,
|
||||||
|
breathing_band_power: if vitals.presence { 0.5 } else { 0.0 },
|
||||||
|
dominant_freq_hz: vitals.breathing_rate_bpm / 60.0,
|
||||||
|
change_points: 0,
|
||||||
|
spectral_power: vitals.motion_energy as f64,
|
||||||
|
};
|
||||||
|
let classification = ClassificationInfo {
|
||||||
|
motion_level: motion_level.to_string(),
|
||||||
|
presence: vitals.presence,
|
||||||
|
confidence: vitals.presence_score as f64,
|
||||||
|
};
|
||||||
|
let signal_field = generate_signal_field(
|
||||||
|
vitals.rssi as f64, motion_score, vitals.breathing_rate_bpm / 60.0,
|
||||||
|
(vitals.presence_score as f64).min(1.0), &[],
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut update = SensingUpdate {
|
||||||
|
msg_type: "sensing_update".to_string(),
|
||||||
|
timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
|
||||||
|
source: "esp32".to_string(),
|
||||||
|
tick,
|
||||||
|
nodes: vec![NodeInfo {
|
||||||
|
node_id: vitals.node_id,
|
||||||
|
rssi_dbm: vitals.rssi as f64,
|
||||||
|
position: [2.0, 0.0, 1.5],
|
||||||
|
amplitude: vec![],
|
||||||
|
subcarrier_count: 0,
|
||||||
|
}],
|
||||||
|
features: features.clone(),
|
||||||
|
classification,
|
||||||
|
signal_field,
|
||||||
|
vital_signs: Some(VitalSigns {
|
||||||
|
breathing_rate_bpm: if vitals.breathing_rate_bpm > 0.0 { Some(vitals.breathing_rate_bpm) } else { None },
|
||||||
|
heart_rate_bpm: if vitals.heartrate_bpm > 0.0 { Some(vitals.heartrate_bpm) } else { None },
|
||||||
|
breathing_confidence: if vitals.presence { 0.7 } else { 0.0 },
|
||||||
|
heartbeat_confidence: if vitals.presence { 0.7 } else { 0.0 },
|
||||||
|
signal_quality: vitals.presence_score as f64,
|
||||||
|
}),
|
||||||
|
enhanced_motion: None,
|
||||||
|
enhanced_breathing: None,
|
||||||
|
posture: None,
|
||||||
|
signal_quality_score: None,
|
||||||
|
quality_verdict: None,
|
||||||
|
bssid_count: None,
|
||||||
|
pose_keypoints: None,
|
||||||
|
model_status: None,
|
||||||
|
persons: None,
|
||||||
|
estimated_persons: if est_persons > 0 { Some(est_persons) } else { None },
|
||||||
|
};
|
||||||
|
|
||||||
|
let persons = derive_pose_from_sensing(&update);
|
||||||
|
if !persons.is_empty() {
|
||||||
|
update.persons = Some(persons);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(json) = serde_json::to_string(&update) {
|
||||||
|
let _ = s.tx.send(json);
|
||||||
|
}
|
||||||
|
s.latest_update = Some(update);
|
||||||
s.edge_vitals = Some(vitals);
|
s.edge_vitals = Some(vitals);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
"""
|
|
||||||
WiFi-DensePose — WiFi-based human pose estimation using CSI data.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from wifi_densepose import WiFiDensePose
|
|
||||||
|
|
||||||
system = WiFiDensePose()
|
|
||||||
system.start()
|
|
||||||
poses = system.get_latest_poses()
|
|
||||||
system.stop()
|
|
||||||
"""
|
|
||||||
|
|
||||||
__version__ = "1.2.0"
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Allow importing the v1 src package when installed from the repo
|
|
||||||
_v1_src = os.path.join(os.path.dirname(os.path.dirname(__file__)), "v1")
|
|
||||||
if os.path.isdir(_v1_src) and _v1_src not in sys.path:
|
|
||||||
sys.path.insert(0, _v1_src)
|
|
||||||
|
|
||||||
|
|
||||||
class WiFiDensePose:
|
|
||||||
"""High-level facade for the WiFi-DensePose sensing system.
|
|
||||||
|
|
||||||
This is the primary entry point documented in the README Quick Start.
|
|
||||||
It wraps the underlying ServiceOrchestrator and exposes a simple
|
|
||||||
start / get_latest_poses / stop interface.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, host: str = "0.0.0.0", port: int = 3000, **kwargs):
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
self._config = kwargs
|
|
||||||
self._orchestrator = None
|
|
||||||
self._server_task = None
|
|
||||||
self._poses = []
|
|
||||||
self._running = False
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Public API (matches README Quick Start)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
"""Start the sensing system (blocking until ready)."""
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
loop = _get_or_create_event_loop()
|
|
||||||
loop.run_until_complete(self._async_start())
|
|
||||||
|
|
||||||
async def _async_start(self):
|
|
||||||
try:
|
|
||||||
from src.config.settings import get_settings
|
|
||||||
from src.services.orchestrator import ServiceOrchestrator
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
self._orchestrator = ServiceOrchestrator(settings)
|
|
||||||
await self._orchestrator.initialize()
|
|
||||||
await self._orchestrator.start()
|
|
||||||
self._running = True
|
|
||||||
logger.info("WiFiDensePose system started on %s:%s", self.host, self.port)
|
|
||||||
except ImportError:
|
|
||||||
raise ImportError(
|
|
||||||
"Core dependencies not found. Make sure you installed "
|
|
||||||
"from the repository root:\n"
|
|
||||||
" cd wifi-densepose && pip install -e .\n"
|
|
||||||
"Or install the v1 package:\n"
|
|
||||||
" cd wifi-densepose/v1 && pip install -e ."
|
|
||||||
)
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""Stop the sensing system."""
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
if self._orchestrator is not None:
|
|
||||||
loop = _get_or_create_event_loop()
|
|
||||||
loop.run_until_complete(self._orchestrator.shutdown())
|
|
||||||
self._running = False
|
|
||||||
logger.info("WiFiDensePose system stopped")
|
|
||||||
|
|
||||||
def get_latest_poses(self):
|
|
||||||
"""Return the most recent list of detected pose dicts."""
|
|
||||||
if self._orchestrator is None:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
loop = _get_or_create_event_loop()
|
|
||||||
return loop.run_until_complete(self._fetch_poses())
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def _fetch_poses(self):
|
|
||||||
try:
|
|
||||||
pose_svc = self._orchestrator.pose_service
|
|
||||||
if pose_svc and hasattr(pose_svc, "get_latest"):
|
|
||||||
return await pose_svc.get_latest()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return []
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Context-manager support
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
self.start()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *exc):
|
|
||||||
self.stop()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Convenience re-exports
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def version():
|
|
||||||
return __version__
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_event_loop():
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
try:
|
|
||||||
return asyncio.get_event_loop()
|
|
||||||
except RuntimeError:
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
return loop
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["WiFiDensePose", "__version__"]
|
|
||||||
Reference in New Issue
Block a user