Human Follower

This example uses a servo motor to point at humans detected by the “People detection” model.

You should first use the Sensecraft AI web site to load this model into your Grove Vision AI V2 board.

Steps:

  1. Flash a person or face detection model from Sensecraft AI to the Grove Vision AI V2 using its USB-C connector. I used this model: https://sensecraft.seeed.cc/ai/view-model/60086-person-detection-swift-yolo

  2. Copy human_follower.mpy to your CIRCUITPY/ drive.

  3. Copy grove_vision_ai_v2.mpy to CIRCUITPY/lib/

  4. Create CIRCUITPY/code.py with the following content:

import humanfollower
examples/human_follower.py
  1# SPDX-FileCopyrightText: Copyright (c) 2025 Ned Konz for Metamagix
  2#
  3# SPDX-License-Identifier: MIT
  4"""Human Follower Example for Grove Vision AI V2.
  5
  6This example uses a pre-trained object detection model (person or face detector)
  7to control a servo motor that tracks detected people. The servo pans to follow
  8the nearest person detected in the camera's field of view.
  9
 10Hardware Requirements:
 11    - CircuitPython board (Seeed XIAO or Adafruit QtPy recommended)
 12    - Grove Vision AI V2 board with camera
 13    - Servo motor connected to D0
 14    - LED for visual feedback (uses LED_BLUE by default)
 15
 16Setup:
 17    1. Flash a person or face detection model from Sensecraft AI to the Grove Vision AI V2
 18       using its USB-C connector.
 19       I used this model:
 20       https://sensecraft.seeed.cc/ai/view-model/60086-person-detection-swift-yolo
 21
 22    2. Copy `human_follower.mpy` to your `CIRCUITPY/` drive.
 23
 24    3. Copy `grove_vision_ai_v2.mpy` to `CIRCUITPY/lib/`
 25
 26    4. Create `CIRCUITPY/code.py` with the following content:
 27
 28        import human_follower
 29
 30The servo uses exponential smoothing for smooth motion, and the LED indicates
 31when a person is detected.
 32"""
 33
 34from __future__ import annotations
 35
 36import time
 37
 38import board
 39import pwmio
 40from adafruit_motor import servo
 41from digitalio import DigitalInOut
 42from micropython import const
 43
 44from grove_vision_ai_v2 import CMD_OK, ATDevice, Box
 45
 46# Configuration
 47# Scaling (pixels to degrees)
 48# FOV 72 degrees over 240 pixels:
 49PIXEL_SCALE = -72.0 / 240
 50HALF_IMAGE_WIDTH = const(120)
 51
 52# Pin usage (change as necessary)
 53TX_PIN = board.TX
 54RX_PIN = board.RX
 55SERVO_PWM_PIN = board.D0
 56LED_PIN = board.D1  # active low
 57
 58SMOOTH_ALPHA = const(0.4)  # lower=slower
 59
 60# Globals
 61now = time.monotonic  # cached
 62ai = ATDevice(TX_PIN, RX_PIN)
 63pwm = pwmio.PWMOut(SERVO_PWM_PIN, duty_cycle=2**15, frequency=50)
 64motor = servo.Servo(pwm)
 65led = DigitalInOut(LED_PIN)
 66
 67
 68class Smoother:
 69    """A simple exponential smoother for numerical values.
 70
 71    Attributes:
 72        value: The current smoothed value.
 73        alpha: The smoothing factor (0.0 to 1.0). Higher alpha means less smoothing.
 74    """
 75
 76    def __init__(self, initial_value: float, alpha: float) -> None:
 77        if not 0.0 <= alpha <= 1.0:
 78            raise ValueError("Alpha must be between 0.0 and 1.0")
 79        self.value = initial_value
 80        self.alpha = alpha
 81
 82    def update(self, new_value: float) -> float:
 83        """Update the smoothed value with a new input.
 84
 85        Args:
 86            new_value: The new raw value to incorporate into the smoothing.
 87
 88        Returns:
 89            The newly smoothed value.
 90        """
 91        self.value = self.alpha * new_value + (1.0 - self.alpha) * self.value
 92        return self.value
 93
 94
 95smoothed_angle = Smoother(90, SMOOTH_ALPHA)
 96
 97
 98def enable_led(value: bool) -> None:
 99    """Enable or disable the indicator LED.
100
101    Args:
102        value: True to turn LED on, False to turn off.
103            Note: LED logic is inverted (low = on).
104    """
105    led.value = not bool(value)
106
107
108def set_motor(target_angle: float | None) -> None:
109    """Set the servo motor angle with exponential smoothing.
110
111    Args:
112        target_angle: Desired servo angle in degrees (0-180), or None to skip update.
113    """
114    if target_angle is not None:
115        motor.angle = smoothed_angle.update(target_angle)
116
117
118def centroid_to_angle(x: int) -> float:
119    """Convert image x-coordinate to servo angle.
120
121    Maps camera x-coordinate (0-240 pixels) to servo angle (0-180 degrees).
122    The center of the image (120 pixels) maps to 90 degrees (servo center).
123
124    Args:
125        x: X-coordinate in pixels (0-240).
126
127    Returns:
128        Servo angle in degrees (0-180).
129    """
130    return 90 + (x - HALF_IMAGE_WIDTH) * PIXEL_SCALE
131
132
133def get_best_box_angle(boxes: list[Box]) -> float | None:
134    """Select the best detection box and convert to servo angle.
135
136    Chooses the widest detection box (assumed to be the nearest person)
137    and returns the servo angle to center on it.
138
139    Args:
140        boxes: List of Box objects from AI inference.
141
142    Returns:
143        Servo angle in degrees, or None if no boxes detected.
144    """
145    if not boxes or len(boxes) == 0:
146        return None
147    # Choose the box with the best score
148    # boxes_sorted = sorted(boxes, key=lambda x: x.score, reverse=True)
149    # Choose the widest box (nearest person)
150    boxes_sorted = sorted(boxes, key=lambda x: x.w, reverse=True)
151    # Translate from x values 0-240 to angle values 180-0
152    return centroid_to_angle(boxes_sorted[0].x)
153
154
155# Turn on debug to watch communications
156# ai.debug = True
157
158# print(f"info={ai.info()}")
159# print(f"id={ai.id()}")
160# print(f"name={ai.name()}")
161
162
163def run():
164    led.switch_to_output(value=True)
165    target_angle = motor.angle = 90
166
167    while True:
168        try:
169            started = now()
170            err = ai.invoke(1, True, True)
171            duration = int((now() - started) * 1000)
172            if err != CMD_OK:
173                enable_led(False)
174                print(f"{duration} No response")
175            else:
176                best_angle = get_best_box_angle(ai.boxes)
177                if best_angle is not None:
178                    enable_led(True)
179                    target_angle = best_angle
180                    print(f"{duration} Boxes: {ai.boxes}, Perf: {ai.perf}")
181                else:
182                    enable_led(False)
183                    print(f"{duration} No boxes")
184        except ValueError as e:
185            print(e)
186        except KeyboardInterrupt:
187            target_angle = motor.angle = 90
188            break
189
190        set_motor(target_angle)
191
192
193run()