Spaces:
Running
Running
feat: tune detection sensitivity live from the settings page
Browse filesAdd sliders for the MOG2 change threshold and minimum blob size, and box
the largest blob in red on the motion map when it is too small to track.
- README.md +7 -3
- movement_tracker/main.py +19 -1
- movement_tracker/motion.py +22 -14
- movement_tracker/static/index.html +17 -3
- movement_tracker/static/main.js +26 -0
- movement_tracker/static/style.css +21 -0
README.md
CHANGED
|
@@ -25,18 +25,22 @@ as soon as it sees something.
|
|
| 25 |
## Motion Map
|
| 26 |
|
| 27 |
The settings page at `http://<robot>:8042` shows a live motion map for checking
|
| 28 |
-
detection: the camera view with the
|
|
|
|
| 29 |
background-subtraction mask, and the cleaned mask used to pick the blob. Above
|
| 30 |
it is what the robot is doing (learning the background, watching, tracking, or
|
| 31 |
scanning).
|
| 32 |
|
| 33 |
## Tuning
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
| Constant | Meaning |
|
| 38 |
|-----------------|------------------------------------------------------------------|
|
| 39 |
-
| `VAR_THRESHOLD` | MOG2
|
| 40 |
| `MIN_BLOB_FRAC` | Smallest blob, as a fraction of the image, that triggers a turn. |
|
| 41 |
| `WARMUP` | Frames used to learn the background before reacting. |
|
| 42 |
|
|
|
|
| 25 |
## Motion Map
|
| 26 |
|
| 27 |
The settings page at `http://<robot>:8042` shows a live motion map for checking
|
| 28 |
+
detection: the camera view with the largest blob boxed (green if the robot will
|
| 29 |
+
turn toward it, red if it is below the minimum blob size), the raw
|
| 30 |
background-subtraction mask, and the cleaned mask used to pick the blob. Above
|
| 31 |
it is what the robot is doing (learning the background, watching, tracking, or
|
| 32 |
scanning).
|
| 33 |
|
| 34 |
## Tuning
|
| 35 |
|
| 36 |
+
The settings page has sliders for the change threshold and the minimum blob
|
| 37 |
+
size, so detection can be tuned live while watching the motion map. Changes last
|
| 38 |
+
until the app restarts; the defaults are the constants at the top of
|
| 39 |
+
`movement_tracker/motion.py`:
|
| 40 |
|
| 41 |
| Constant | Meaning |
|
| 42 |
|-----------------|------------------------------------------------------------------|
|
| 43 |
+
| `VAR_THRESHOLD` | MOG2 change threshold; raise it to ignore noise and flicker. |
|
| 44 |
| `MIN_BLOB_FRAC` | Smallest blob, as a fraction of the image, that triggers a turn. |
|
| 45 |
| `WARMUP` | Frames used to learn the background before reacting. |
|
| 46 |
|
movement_tracker/main.py
CHANGED
|
@@ -5,7 +5,7 @@ import time
|
|
| 5 |
import cv2
|
| 6 |
import numpy as np
|
| 7 |
from fastapi import Response
|
| 8 |
-
from pydantic import BaseModel
|
| 9 |
from reachy_mini import ReachyMini, ReachyMiniApp
|
| 10 |
from reachy_mini.utils import create_head_pose
|
| 11 |
|
|
@@ -26,6 +26,11 @@ class StateResp(BaseModel):
|
|
| 26 |
state: str
|
| 27 |
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
class MovementTracker(ReachyMiniApp):
|
| 30 |
# Optional: URL to a custom configuration page for the app
|
| 31 |
# eg. "http://localhost:8042"
|
|
@@ -47,6 +52,19 @@ class MovementTracker(ReachyMiniApp):
|
|
| 47 |
def get_state() -> StateResp:
|
| 48 |
return StateResp(state=self.state)
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
@self.settings_app.get("/motion.jpg")
|
| 51 |
def motion_jpg() -> Response:
|
| 52 |
img = self.detector.debug_image()
|
|
|
|
| 5 |
import cv2
|
| 6 |
import numpy as np
|
| 7 |
from fastapi import Response
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
from reachy_mini import ReachyMini, ReachyMiniApp
|
| 10 |
from reachy_mini.utils import create_head_pose
|
| 11 |
|
|
|
|
| 26 |
state: str
|
| 27 |
|
| 28 |
|
| 29 |
+
class Sensitivity(BaseModel):
|
| 30 |
+
var_threshold: float = Field(ge=1, le=100)
|
| 31 |
+
min_blob_frac: float = Field(ge=0, le=0.1)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
class MovementTracker(ReachyMiniApp):
|
| 35 |
# Optional: URL to a custom configuration page for the app
|
| 36 |
# eg. "http://localhost:8042"
|
|
|
|
| 52 |
def get_state() -> StateResp:
|
| 53 |
return StateResp(state=self.state)
|
| 54 |
|
| 55 |
+
@self.settings_app.get("/sensitivity")
|
| 56 |
+
def get_sensitivity() -> Sensitivity:
|
| 57 |
+
d = self.detector
|
| 58 |
+
return Sensitivity(
|
| 59 |
+
var_threshold=d.var_threshold, min_blob_frac=d.min_blob_frac
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
@self.settings_app.post("/sensitivity")
|
| 63 |
+
def set_sensitivity(req: Sensitivity) -> Sensitivity:
|
| 64 |
+
self.detector.set_var_threshold(req.var_threshold)
|
| 65 |
+
self.detector.min_blob_frac = req.min_blob_frac
|
| 66 |
+
return req
|
| 67 |
+
|
| 68 |
@self.settings_app.get("/motion.jpg")
|
| 69 |
def motion_jpg() -> Response:
|
| 70 |
img = self.detector.debug_image()
|
movement_tracker/motion.py
CHANGED
|
@@ -2,12 +2,14 @@ import cv2
|
|
| 2 |
import numpy as np
|
| 3 |
|
| 4 |
WIDTH = 320 # frames are downscaled to this width before processing
|
|
|
|
| 5 |
VAR_THRESHOLD = 25 # MOG2 sensitivity; higher ignores subtler changes
|
| 6 |
MIN_BLOB_FRAC = 0.005 # smallest blob, as a fraction of the image, worth reacting to
|
| 7 |
WARMUP = 15 # frames MOG2 needs to learn the background before its mask is useful
|
| 8 |
|
| 9 |
KERNEL = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
| 10 |
BLOB_COLOR = (0, 255, 0)
|
|
|
|
| 11 |
LABEL_COLOR = (0, 200, 255)
|
| 12 |
DEBUG_WIDTH = 1280 # panels are upscaled so labels and boxes stay crisp
|
| 13 |
|
|
@@ -16,17 +18,24 @@ class MotionDetector:
|
|
| 16 |
"""Find the largest moving blob in a stream of frames from a still camera."""
|
| 17 |
|
| 18 |
def __init__(self) -> None:
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
| 21 |
self.snapshot: tuple | None = None
|
| 22 |
self.reset()
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
def reset(self) -> None:
|
| 25 |
"""Forget the learned background, e.g. after the camera moved."""
|
| 26 |
# shadow detection also flags anything moderately darker than the background
|
| 27 |
# (e.g. gray clothes against a white wall), so treat every change as motion
|
| 28 |
self.sub = cv2.createBackgroundSubtractorMOG2(
|
| 29 |
-
varThreshold=
|
| 30 |
)
|
| 31 |
self.n = 0
|
| 32 |
|
|
@@ -41,7 +50,7 @@ class MotionDetector:
|
|
| 41 |
raw = self.sub.apply(small)
|
| 42 |
self.n += 1
|
| 43 |
if self.n < WARMUP:
|
| 44 |
-
self.snapshot = (small, raw, None, None)
|
| 45 |
return None
|
| 46 |
|
| 47 |
# drop speckle, then join fragments of the same object
|
|
@@ -49,8 +58,9 @@ class MotionDetector:
|
|
| 49 |
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, KERNEL, iterations=2)
|
| 50 |
|
| 51 |
blob = largest_blob(mask)
|
| 52 |
-
|
| 53 |
-
|
|
|
|
| 54 |
return None
|
| 55 |
|
| 56 |
x, y = blob[1]
|
|
@@ -61,12 +71,13 @@ class MotionDetector:
|
|
| 61 |
if self.snapshot is None:
|
| 62 |
return None
|
| 63 |
|
| 64 |
-
small, raw, mask, box = self.snapshot
|
| 65 |
frame = upscale(small, DEBUG_WIDTH, cv2.INTER_LINEAR)
|
| 66 |
if box is not None:
|
| 67 |
f = DEBUG_WIDTH / small.shape[1]
|
| 68 |
x, y, w, h = (round(v * f) for v in box)
|
| 69 |
-
|
|
|
|
| 70 |
|
| 71 |
# during warmup there is no cleaned mask yet
|
| 72 |
mask = np.zeros_like(raw) if mask is None else mask
|
|
@@ -101,16 +112,13 @@ def label(img: np.ndarray, text: str) -> np.ndarray:
|
|
| 101 |
|
| 102 |
def largest_blob(
|
| 103 |
mask: np.ndarray,
|
| 104 |
-
) -> tuple[tuple[int, ...], tuple[float, float]] | None:
|
| 105 |
-
"""Return the (x, y, w, h) box and
|
| 106 |
n, _, stats, centroids = cv2.connectedComponentsWithStats(mask)
|
| 107 |
if n < 2:
|
| 108 |
return None
|
| 109 |
|
| 110 |
# label 0 is the background
|
| 111 |
i = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
|
| 112 |
-
if stats[i, cv2.CC_STAT_AREA] < MIN_BLOB_FRAC * mask.size:
|
| 113 |
-
return None
|
| 114 |
-
|
| 115 |
box = tuple(int(v) for v in stats[i, :4])
|
| 116 |
-
return box, tuple(centroids[i])
|
|
|
|
| 2 |
import numpy as np
|
| 3 |
|
| 4 |
WIDTH = 320 # frames are downscaled to this width before processing
|
| 5 |
+
# defaults; both can be changed live from the settings page
|
| 6 |
VAR_THRESHOLD = 25 # MOG2 sensitivity; higher ignores subtler changes
|
| 7 |
MIN_BLOB_FRAC = 0.005 # smallest blob, as a fraction of the image, worth reacting to
|
| 8 |
WARMUP = 15 # frames MOG2 needs to learn the background before its mask is useful
|
| 9 |
|
| 10 |
KERNEL = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
| 11 |
BLOB_COLOR = (0, 255, 0)
|
| 12 |
+
SMALL_BLOB_COLOR = (0, 0, 255) # largest blob, but below MIN_BLOB_FRAC
|
| 13 |
LABEL_COLOR = (0, 200, 255)
|
| 14 |
DEBUG_WIDTH = 1280 # panels are upscaled so labels and boxes stay crisp
|
| 15 |
|
|
|
|
| 18 |
"""Find the largest moving blob in a stream of frames from a still camera."""
|
| 19 |
|
| 20 |
def __init__(self) -> None:
|
| 21 |
+
self.var_threshold = VAR_THRESHOLD
|
| 22 |
+
self.min_blob_frac = MIN_BLOB_FRAC
|
| 23 |
+
# latest (frame, raw mask, cleaned mask, blob box, big enough) for the debug
|
| 24 |
+
# view; replaced as a whole so readers on other threads see a consistent set
|
| 25 |
self.snapshot: tuple | None = None
|
| 26 |
self.reset()
|
| 27 |
|
| 28 |
+
def set_var_threshold(self, value: float) -> None:
|
| 29 |
+
"""Change MOG2 sensitivity without forgetting the learned background."""
|
| 30 |
+
self.var_threshold = value
|
| 31 |
+
self.sub.setVarThreshold(value)
|
| 32 |
+
|
| 33 |
def reset(self) -> None:
|
| 34 |
"""Forget the learned background, e.g. after the camera moved."""
|
| 35 |
# shadow detection also flags anything moderately darker than the background
|
| 36 |
# (e.g. gray clothes against a white wall), so treat every change as motion
|
| 37 |
self.sub = cv2.createBackgroundSubtractorMOG2(
|
| 38 |
+
varThreshold=self.var_threshold, detectShadows=False
|
| 39 |
)
|
| 40 |
self.n = 0
|
| 41 |
|
|
|
|
| 50 |
raw = self.sub.apply(small)
|
| 51 |
self.n += 1
|
| 52 |
if self.n < WARMUP:
|
| 53 |
+
self.snapshot = (small, raw, None, None, False)
|
| 54 |
return None
|
| 55 |
|
| 56 |
# drop speckle, then join fragments of the same object
|
|
|
|
| 58 |
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, KERNEL, iterations=2)
|
| 59 |
|
| 60 |
blob = largest_blob(mask)
|
| 61 |
+
ok = blob is not None and blob[2] >= self.min_blob_frac * mask.size
|
| 62 |
+
self.snapshot = (small, raw, mask, blob and blob[0], ok)
|
| 63 |
+
if not ok:
|
| 64 |
return None
|
| 65 |
|
| 66 |
x, y = blob[1]
|
|
|
|
| 71 |
if self.snapshot is None:
|
| 72 |
return None
|
| 73 |
|
| 74 |
+
small, raw, mask, box, ok = self.snapshot
|
| 75 |
frame = upscale(small, DEBUG_WIDTH, cv2.INTER_LINEAR)
|
| 76 |
if box is not None:
|
| 77 |
f = DEBUG_WIDTH / small.shape[1]
|
| 78 |
x, y, w, h = (round(v * f) for v in box)
|
| 79 |
+
color = BLOB_COLOR if ok else SMALL_BLOB_COLOR
|
| 80 |
+
cv2.rectangle(frame, (x, y), (x + w, y + h), color, 3)
|
| 81 |
|
| 82 |
# during warmup there is no cleaned mask yet
|
| 83 |
mask = np.zeros_like(raw) if mask is None else mask
|
|
|
|
| 112 |
|
| 113 |
def largest_blob(
|
| 114 |
mask: np.ndarray,
|
| 115 |
+
) -> tuple[tuple[int, ...], tuple[float, float], int] | None:
|
| 116 |
+
"""Return the (x, y, w, h) box, centroid and area of the largest blob."""
|
| 117 |
n, _, stats, centroids = cv2.connectedComponentsWithStats(mask)
|
| 118 |
if n < 2:
|
| 119 |
return None
|
| 120 |
|
| 121 |
# label 0 is the background
|
| 122 |
i = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
|
|
|
|
|
|
|
|
|
|
| 123 |
box = tuple(int(v) for v in stats[i, :4])
|
| 124 |
+
return box, tuple(centroids[i]), int(stats[i, cv2.CC_STAT_AREA])
|
movement_tracker/static/index.html
CHANGED
|
@@ -11,10 +11,24 @@
|
|
| 11 |
<body>
|
| 12 |
<h1>Movement Tracker</h1>
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
<h2>Motion map</h2>
|
| 15 |
-
<p class="legend">The camera view boxes the largest moving blob
|
| 16 |
-
|
| 17 |
-
|
|
|
|
| 18 |
<div id="state"></div>
|
| 19 |
<img id="motion" alt="Motion map">
|
| 20 |
<script src="/static/main.js"></script>
|
|
|
|
| 11 |
<body>
|
| 12 |
<h1>Movement Tracker</h1>
|
| 13 |
|
| 14 |
+
<h2>Sensitivity</h2>
|
| 15 |
+
<p class="legend">Lower values make detection more sensitive.</p>
|
| 16 |
+
<div class="slider">
|
| 17 |
+
<label for="var_threshold">Change threshold</label>
|
| 18 |
+
<input type="range" id="var_threshold" min="4" max="64" step="1">
|
| 19 |
+
<output id="var_threshold_out"></output>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="slider">
|
| 22 |
+
<label for="min_blob_frac">Minimum blob size</label>
|
| 23 |
+
<input type="range" id="min_blob_frac" min="0.0002" max="0.02" step="0.0002">
|
| 24 |
+
<output id="min_blob_frac_out"></output>
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
<h2>Motion map</h2>
|
| 28 |
+
<p class="legend">The camera view boxes the largest moving blob: green if the
|
| 29 |
+
robot will turn toward it, red if it is smaller than the minimum blob size. The
|
| 30 |
+
raw mask is the background subtraction output; the cleaned mask is what picks
|
| 31 |
+
the blob.</p>
|
| 32 |
<div id="state"></div>
|
| 33 |
<img id="motion" alt="Motion map">
|
| 34 |
<script src="/static/main.js"></script>
|
movement_tracker/static/main.js
CHANGED
|
@@ -14,3 +14,29 @@ setInterval(async () => {
|
|
| 14 |
state.textContent = "Robot: unreachable";
|
| 15 |
}
|
| 16 |
}, 500);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
state.textContent = "Robot: unreachable";
|
| 15 |
}
|
| 16 |
}, 500);
|
| 17 |
+
|
| 18 |
+
// sensitivity sliders: show values while dragging, send them on release
|
| 19 |
+
const sliders = {
|
| 20 |
+
var_threshold: (v) => `${v}`,
|
| 21 |
+
min_blob_frac: (v) => `${(v * 100).toFixed(2)}% of image`,
|
| 22 |
+
};
|
| 23 |
+
const input = (k) => document.getElementById(k);
|
| 24 |
+
const show = (k) => (input(`${k}_out`).textContent = sliders[k](Number(input(k).value)));
|
| 25 |
+
|
| 26 |
+
async function sensitivity(method) {
|
| 27 |
+
const body = method === "POST"
|
| 28 |
+
? JSON.stringify(Object.fromEntries(Object.keys(sliders).map((k) => [k, Number(input(k).value)])))
|
| 29 |
+
: undefined;
|
| 30 |
+
const resp = await fetch("/sensitivity", { method, body, headers: { "Content-Type": "application/json" } });
|
| 31 |
+
const values = await resp.json();
|
| 32 |
+
for (const k in sliders) {
|
| 33 |
+
input(k).value = values[k];
|
| 34 |
+
show(k);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
for (const k in sliders) {
|
| 39 |
+
input(k).addEventListener("input", () => show(k));
|
| 40 |
+
input(k).addEventListener("change", () => sensitivity("POST"));
|
| 41 |
+
}
|
| 42 |
+
sensitivity("GET");
|
movement_tracker/static/style.css
CHANGED
|
@@ -19,4 +19,25 @@ body {
|
|
| 19 |
#state {
|
| 20 |
margin-bottom: 8px;
|
| 21 |
font-family: monospace;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
}
|
|
|
|
| 19 |
#state {
|
| 20 |
margin-bottom: 8px;
|
| 21 |
font-family: monospace;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.slider {
|
| 25 |
+
display: flex;
|
| 26 |
+
flex-wrap: wrap;
|
| 27 |
+
align-items: center;
|
| 28 |
+
gap: 12px;
|
| 29 |
+
margin-bottom: 8px;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.slider label {
|
| 33 |
+
width: 10em;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.slider input {
|
| 37 |
+
flex: 1;
|
| 38 |
+
max-width: 400px;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.slider output {
|
| 42 |
+
font-family: monospace;
|
| 43 |
}
|