blambert commited on
Commit
f9a8eeb
·
1 Parent(s): 5547235

feat: tune detection sensitivity live from the settings page

Browse files

Add 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 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 tracked blob boxed in green, the raw
 
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
- Detection constants live at the top of `movement_tracker/motion.py`:
 
 
 
36
 
37
  | Constant | Meaning |
38
  |-----------------|------------------------------------------------------------------|
39
- | `VAR_THRESHOLD` | MOG2 sensitivity; raise it to ignore noise and flicker. |
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
- # latest (frame, raw mask, cleaned mask, blob box) for the debug view;
20
- # replaced as a whole so readers on other threads see a consistent set
 
 
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=VAR_THRESHOLD, detectShadows=False
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
- self.snapshot = (small, raw, mask, blob and blob[0])
53
- if blob is None:
 
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
- cv2.rectangle(frame, (x, y), (x + w, y + h), BLOB_COLOR, 3)
 
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 centroid of the largest blob, if big enough."""
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 in green. The raw
16
- mask is the background subtraction output; the cleaned mask is what picks the
17
- blob.</p>
 
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
  }