Code

What each piece does,
and where it bites

Four firmware variants and six ROS nodes, read side by side. Line references point at the committed source — every claim below is checkable against the file it names.

01 — Layout

Repository map

PathRoleNotes
arduino_codes/Production firmwareDrive ×2, Shoot ×2, plus the two *_with_auto variants that add the autonomous path
ros_depthCamera/src/basketball_robot/Vision + bridge packageThe live stack: detector, PID, serial bridge, launch files
joy_py/ · joy_serial_ws/Earlier joystick bridgesNear-duplicates of each other; joy_py is the newer of the two
Detection/Standalone experimentsHSV colour-threshold detector and the trained last.pt
esp32_project_env/Committed virtualenvAlso holds an older set of shoot controllers under Servo_codes/
test_d455.pyCamera smoke testStreams 30 frames, prints centre-pixel range
Three generations live in the tree at once

joy_serial_wsjoy_pyros_depthCamera is the actual chronology, and all three still contain a joy_serial_bridge package. Only the last one is wired to the camera. When something behaves unexpectedly, checking which bridge is running is the first thing worth doing.

02 — Walkthrough

The five files that matter

realsense_basket_detector.py 499 lines

Owns the camera, the model and both controllers. It publishes four topics but only two of them close the loop. The depth sampling is the nicest part of the file — an 11×11 patch, zero returns discarded, sorted, extremes trimmed, mean taken:

# realsense_basket_detector.py:292
distances = []
sample_size = 5
for dx in range(-sample_size, sample_size + 1):
    for dy in range(-sample_size, sample_size + 1):
        px, py = center_x + dx, center_y + dy
        if 0 <= px < 640 and 0 <= py < 480:
            d = depth_frame.get_distance(px, py)
            if d > 0: distances.append(d)

distances.sort()
if len(distances) > 3: distances = distances[1:-1]  # trim outliers
distance = sum(distances) / len(distances)

Publishes /basket_detection, /basket_distance, /value_x, /value_y and, when cv_bridge is importable, /detection_image.

smart_drive_bridge.py 203 lines

The arbiter. It holds the last Joy message and the two PID values, and on a 20 Hz timer decides which to serialise. The staleness guard is a good instinct — if no joystick message has arrived in 500 ms it sends nothing at all:

# smart_drive_bridge.py:121
if time.time() - self.last_joy_time > 0.5:
    return

with self.serial_lock:
    if self.auto_mode and not self.override:
        fake_axes = list(self.joy_data.axes)
        if len(fake_axes) >= 2:          # ← guards 2, writes 3
            fake_axes[2] = self.pid_x
            fake_axes[3] = self.pid_y

On shutdown it does the right thing: it writes a packet of zeroed axes with the override button forced high, waits 100 ms, then closes the port — so a Ctrl-C stops the wheels rather than leaving the last command latched.

Drive_with_auto.ino 359 lines

The drive firmware plus an autonomous branch. Two independent debounced toggles — override on button 9, auto on button 7 — both with a 200 ms window and a press/release latch. The ladder itself is covered in the architecture; what matters here is that pid() is only reached when every translation and rotation test above it fails.

_shoot_with_auto.ino 508 lines

The largest single file. It accepts two different input languages on the same port — line-oriented ROS commands and joystick packets — and dispatches on prefix:

// _shoot_with_auto.ino:113
if (inputBuffer.startsWith("SHOOT:") ||
    inputBuffer.startsWith("AUTO_MODE:") ||
    inputBuffer.startsWith("STOP_SHOOT")) {
  processRosCommand(inputBuffer);
} else {
  processPS4Data(inputBuffer);        // "axes;buttons"
}

No node in the repository ever sends SHOOT: or AUTO_MODE:. The command interface is implemented and reachable, but nothing upstream drives it — autonomous shots are currently only triggerable by hand over a serial monitor.

basketball_detector.py 290 lines

An earlier detector with one idea the live one lacks: it degrades instead of dying. If pyrealsense2, cv2 or ultralytics fail to import it drops into a simulation thread that publishes plausible values, so the rest of the graph can be exercised on a laptop with no camera attached. Worth porting forward.

03 — Review

Twelve findings

Ordered by how much they affect behaviour on the field. Severity is my read; the line references are facts.

1 · The robot parks at the wrong distance HIGH

target_ground_distance is computed as 5.726 × cos(55°) = 3.284 m (realsense_basket_detector.py:122), but the matching cosine on the measured side is commented out (:311):

ground_distance = distance #* math.cos(math.radians(self.shooting_angle))

So a slant range is compared against a horizontal projection. The loop converges on 3.28 m of measured range, not the 5.726 m the parameter names. Either restore the cosine on line 311 or drop it from line 122 — but the two must agree.

2 · The PID controllers advance twice per frame HIGH

compute() mutates previous_error, integral and last_time. It is called once to produce the published value (:372) and then a second time, on the same frame, purely to build an overlay string (:414):

pid_text = f"PID Control: X={self.x_pid.compute(horiz):.3f}, Y={self.y_pid.compute(error):.3f}"

The second call sees dt ≈ 0, which inflates the derivative term and double-counts the integral. It is currently harmless only because Ki and Kd are both zero — the moment anyone tunes them, the loop will behave differently from the numbers on screen. Cache the outputs and render those.

3 · The auto mixer drives the wrong wheel pair HIGH

In controlMotors(), M2/M4 are the pair driven by the forward axis and M1/M3 by the lateral one. But pid() sends axes[2] — the horizontal alignment error — to M2/M4 (Drive_with_auto.ino:311), and the range error to M1/M3. The two corrections are swapped: a hoop off to the left makes the robot drive backwards. Watch it happen in the alignment simulator.

4 · The range branch is unreachable HIGH

pid() is an if/else chain that tests axes[3] only after both axes[2] < 0 and axes[2] > 0 have failed — that is, only when axes[2] is exactly 0.0. A float coming off a proportional controller is essentially never exactly zero, so distance correction never executes. And when it does, those branches compute M2/M4 from axes[2] rather than axes[3] (Drive_with_auto.ino:328) — the wrong variable.

5 · Override can only be toggled once HIGH

check_override() latches on button 9 but clears the latch on button 4 (Drive_1_for_new_controllers.ino:246):

if (buttons[9] == 1 && last_override_button == 0 && !toggled_on_this_press) { ... }
if (buttons[4] == 0) { toggled_on_this_press = false; }   // ← 4, not 9

In practice button 4 usually sits at 0, which masks the bug — but the two are swapped in Drive_2 (toggles on 4, clears on 9), so the same physical button does different things on the two robots. For a safety control, that is the one place you want no ambiguity.

6 · Blocking delays stall the serial reader MEDIUM

Rotate_angle() sweeps the servo 5° at a time with delay(15) inside a while loop, and shoot() adds a flat delay(1000) (_shoot_with_auto.ino:442). A full fire sequence blocks the main loop for well over a second while the host keeps writing at 20 Hz. The Mega's 64-byte receive buffer holds roughly one packet, so the rest are dropped mid-line — and a truncated packet still parses, leaving stale values in the slots it never reached.

7 · Rotation triggers are quantised to three levels MEDIUM

Arduino's map() takes long arguments, so the float trigger value is truncated toward zero before the mapping runs (Drive_1_for_new_controllers.ino:96). The result is that rotAnticlock can only ever be 0, 50 or 100 — proportional rotation is not available, and any partially-held trigger gives exactly half speed.

There is a sharper edge here: if the joystick driver reports the triggers as 0.0 before their first press — which several Linux drivers do — then map() returns 50 with the sticks centred, and the robot rotates on its own as soon as Override is cleared. Multiply by 1.0 in floating point instead.

8 · Auto-toggle edge detection reads the wrong button MEDIUM

The auto-mode toggle tracks button 7 throughout, then stores the override button's state as its "previous" value (Drive_with_auto.ino:289):

last_auto_toggle = buttons[9];   // should be buttons[7]

Edge detection against an unrelated signal — auto mode can toggle on a frame where button 7 was already held, if button 9 happened to change.

9 · A short axis list raises IndexError MEDIUM

smart_drive_bridge.py:131 guards on len(fake_axes) >= 2 and then assigns indices 2 and 3. Any pad reporting two or three axes throws inside the timer callback, which kills the 20 Hz writer while the joystick keeps publishing — the robot holds its last command. The guard should be >= 4.

10 · The two joy bridges disagree on button numbers MEDIUM

For the same three named functions, joy_to_ros.py uses indices 7, 8 and 9 while joy_to_ros_safe.py uses 6, 7 and 8 — and both label them "Button 7/8/9" in their log strings. Whichever is running silently shifts every control by one.

11 · The detector loads a model that isn't the trained one MEDIUM

drive_serial_bridge.py looks for basketball_hoop.pt, falls back to stock yolov8n.pt, and then matches on COCO class names — "sports ball", "person" (drive_serial_bridge.py:296). Meanwhile the trained weights that actually detect the hoop, last.pt, sit next to it in the package. The live node uses them; this one never will.

Related: the live node hardcodes /workspace/Robocon25_codes/… as its default model path (realsense_basket_detector.py:90) — a directory name that does not match this repository. It only runs where that absolute path happens to exist.

12 · Smaller things worth a pass LOW
  • Mislabelled telemetry. /basket_detection publishes the distance error under a field named ground_dist (realsense_basket_detector.py:358).
  • Precedence. drive_serial_bridge.py:173 mixes and/or without parentheses in the button-7 edge test; it parses as (A and B) or C, which is probably not what was meant.
  • Divergent twins. Shoot_1 reads buttons[6] where Shoot_2 reads axes[6] for the same angle control.
  • Unbounded trim. Buttons 11/12 accumulate ±10 PWM with no clamp before analogWrite.
  • GUI in a callback. cv2.imshow + waitKey run inside the ROS timer and inside a worker thread elsewhere; on some builds that is a hard crash rather than a warning.
  • Repository weight. A full virtualenv (esp32_project_env/) and 100+ colcon build logs are committed — roughly 270 MB of the clone. A .gitignore for log/ build/ install/ and the venv would cut that to a few MB.
04 — If you only change three things

Highest value per line edited

1. Make the mixer match the axes

Swap the pairs in pid() and turn the exact-zero test into a threshold so both corrections run every tick. This is the difference between a loop that centres and a loop that arrives.

2. Agree on the cosine

One line, either side. Until setpoint and measurement use the same geometry, every distance number in the logs is off by a factor of cos(55°).

3. Take the delays out of the shooter

The stage machine already exists — processAutonomousShoot() is non-blocking. Move the servo sweep into it and the firmware stops dropping packets during a shot.