Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Software/CalibrateCameraDistortions/take_calibration_shots.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
# Takes numPics pictures, each about 1 second apart
# Before running, make sure the system is setup to NOT require
# external triggering (particularly for camera 2).
# If running in single-pi mode, the /boot/firmware/config.txt
# file should have the following lines commented out:
# dtoverlay=imx296,cam0
# dtoverlay=imx296,sync-sink
#
# In addition, if using camera 2, the IR filter must be removed because
# If the config.txt overlay uses always-on (not sync-sink), just run:
# sudo /usr/lib/pitrac/ImageProcessing/CameraTools/setCameraTriggerInternal.sh
# to switch to free-running mode. No reboot needed.
#
# If using camera 2, the IR filter must be removed because
# there will be no strobing.
#

Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
#!/bin/sh
echo 1 > /sys/module/imx296/parameters/trigger_mode
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
exec "$SCRIPT_DIR/imx296_trigger" 4 1
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
#!/bin/sh
echo 0 > /sys/module/imx296/parameters/trigger_mode
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
exec "$SCRIPT_DIR/imx296_trigger" 4 0
24 changes: 4 additions & 20 deletions Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1027,8 +1027,8 @@ LibcameraJpegApp* ConfigureForLibcameraStill(const GolfSimCamera& camera) {

LibcameraJpegApp* app = lci::libcamera_app_[hardware_camera_index];

if (app != nullptr || lci::libcamera_configuration_[hardware_camera_index] == lci::CameraConfiguration::kStillPicture) {
return lci::libcamera_app_[camera_number];
if (app != nullptr && lci::libcamera_configuration_[hardware_camera_index] == lci::CameraConfiguration::kStillPicture) {
return app;
}

if (app != nullptr) {
Expand Down Expand Up @@ -1489,24 +1489,8 @@ bool PerformCameraSystemStartup() {

SetLibCameraLoggingOff();

// Setup the Pi Camera to be internally or externally triggered as appropriate

SystemMode mode = GolfSimOptions::GetCommandLineOptions().system_mode_;

switch (mode) {

case SystemMode::kCamera1:
case SystemMode::kCamera1TestStandalone:
case SystemMode::kTestSpin: {
// Camera triggering is configured via firmware config.txt dtoverlays,
// not programmatically. Nothing to do here.
}
break;

case SystemMode::kTest:
default:
break;
}
// Defensive reset in case a prior crashed run left the sensor in external trigger.
SetImx296TriggerModeViaI2C(0);

return true;
}
Expand Down
18 changes: 18 additions & 0 deletions Software/LMSourceCode/ImageProcessing/libcamera_jpeg.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ using namespace std::placeholders;
using libcamera::Stream;
namespace gs = golf_sim;

void SetImx296TriggerModeViaI2C(int mode) {
if (mode != 0 && mode != 1) {
GS_LOG_MSG(error, "Invalid trigger mode: " + std::to_string(mode) + " (must be 0 or 1)");
return;
}
const std::string cmd = "$PITRAC_ROOT/ImageProcessing/CameraTools/imx296_trigger 4 " + std::to_string(mode);
int rc = system(cmd.c_str());
if (rc != 0) {
GS_LOG_MSG(warning, "imx296_trigger 4 " + std::to_string(mode) + " failed (rc=" + std::to_string(rc) + ")");
return;
}
GS_LOG_TRACE_MSG(trace, "Set IMX296 trigger mode via I2C: " + std::to_string(mode));
}

enum FlightCameraState {
kUninitialized,
kWaitingForFirstPrimingPulseGroup,
Expand Down Expand Up @@ -89,6 +103,10 @@ void SetExternalTrigger(bool& flag) {
// Calls StartCamera at entry and StopCamera when the final image arrives.
bool cam2_run_event_loop(LibcameraJpegApp& app, cv::Mat& returnImg, bool send_priming_pulses)
{
struct TriggerModeResetGuard {
~TriggerModeResetGuard() { SetImx296TriggerModeViaI2C(0); }
} trigger_reset_guard;

app.StartCamera();
GS_LOG_TRACE_MSG(trace, "cam2_run_event_loop: camera started, waiting for triggers");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,7 @@ bool still_image_event_loop(LibcameraJpegApp& app, cv::Mat& returnImg);

bool ball_flight_camera_event_loop(LibcameraJpegApp& app, cv::Mat& returnImg);

// 0=free-running, 1=external trigger (XTRIG)
void SetImx296TriggerModeViaI2C(int mode);

#endif // #ifdef __unix__ // Ignore in Windows environment
60 changes: 60 additions & 0 deletions Software/web-server/calibration_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import logging
import os
import shutil
import subprocess
import uuid
from datetime import datetime
from pathlib import Path
Expand All @@ -34,6 +35,9 @@
RPICAM_TUNING_FILE = "/usr/share/libcamera/ipa/rpi/pisp/imx296_noir.json"
RPICAM_CAL_SHUTTER_US = 11000

IMX296_TRIGGER_BINARY = "/usr/lib/pitrac/ImageProcessing/CameraTools/imx296_trigger"
IMX296_I2C_BUS = "4"


class CalibrationManager:
"""Manages calibration processes for PiTrac cameras"""
Expand Down Expand Up @@ -71,6 +75,58 @@ def __init__(self, config_manager, pitrac_binary: str = "/usr/lib/pitrac/pitrac_
# Keys: camera_index (int) -> latest BGR numpy frame
self._shared_frames: Dict[int, Any] = {}

# Reference count for Camera 2 free-running mode requests.
# When >0, trigger_mode is set to 0 (free-running) so rpicam-still
# and cv2.VideoCapture can capture without external triggers.
self._free_running_refs = 0
self._trigger_mode_lock = asyncio.Lock()

def _set_trigger_mode(self, mode: int) -> bool:
"""Set IMX296 trigger mode (0=free-running, 1=external). Returns True on success."""
if mode not in (0, 1):
logger.error(f"Invalid trigger_mode: {mode}")
return False
try:
result = subprocess.run(
[IMX296_TRIGGER_BINARY, IMX296_I2C_BUS, str(mode)],
capture_output=True,
text=True,
timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e:
logger.warning(f"Could not invoke imx296_trigger: {e}")
return False
if result.returncode != 0:
logger.warning(
f"imx296_trigger {IMX296_I2C_BUS} {mode} failed "
f"(rc={result.returncode}): {result.stderr.strip()}"
)
return False
logger.debug(f"Set IMX296 trigger_mode={mode} via I2C")
return True

async def request_free_running(self, camera_index: int) -> None:
"""Request Camera 2 be in free-running mode (ref-counted)."""
if camera_index != 1:
return
async with self._trigger_mode_lock:
# Increment only on hardware success: refcount must mirror physical state.
if self._free_running_refs == 0 and not self._set_trigger_mode(0):
return
self._free_running_refs += 1

async def release_free_running(self, camera_index: int) -> None:
"""Release a free-running request for Camera 2 (ref-counted)."""
if camera_index != 1:
return
async with self._trigger_mode_lock:
if self._free_running_refs <= 0:
logger.warning("release_free_running called with refcount already at 0")
return
self._free_running_refs -= 1
if self._free_running_refs == 0:
self._set_trigger_mode(1)

def _on_calibration_update(self, key: str, value: Any) -> None:
"""
Callback invoked when calibration config is updated via API.
Expand Down Expand Up @@ -1041,6 +1097,8 @@ def friendly_rejection(reasons: str) -> str:
}

camera_index = 0 if camera == "camera1" else 1
await self.request_free_running(camera_index)

config = self.config_manager.get_config()
slot_key = "slot1" if camera == "camera1" else "slot2"
slot_config = config.get("cameras", {}).get(slot_key, {})
Expand Down Expand Up @@ -1316,6 +1374,8 @@ def bin_for(size: float) -> str:
self.calibration_status[camera]["message"] = str(e)
self._write_distortion_log(log_file, log_lines)
return {"status": "error", "message": str(e), "log_file": str(log_file)}
finally:
await self.release_free_running(camera_index)

def _detect_capture_backend(self) -> str:
"""Auto-detect whether to use rpicam-still (Pi) or cv2.VideoCapture (webcam)."""
Expand Down
10 changes: 10 additions & 0 deletions Software/web-server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,8 +652,11 @@ async def distortion_camera_feed(websocket: WebSocket) -> None:
square_length=CHARUCO_SQUARE_LENGTH, marker_length=CHARUCO_MARKER_LENGTH,
)

await self.calibration_manager.request_free_running(camera_index)

cap = await asyncio.to_thread(open_camera, camera_index, 1456, 1088)
if cap is None:
await self.calibration_manager.release_free_running(camera_index)
await websocket.send_json({"error": f"Cannot open camera {camera_index}"})
await websocket.close()
return
Expand Down Expand Up @@ -745,6 +748,7 @@ async def distortion_camera_feed(websocket: WebSocket) -> None:
if camera_index is not None:
self._active_cameras.pop(camera_index, None)
self.calibration_manager.clear_shared_frame(camera_index)
await self.calibration_manager.release_free_running(camera_index)

@self.app.websocket("/ws/undistort-preview")
async def undistort_preview_feed(websocket: WebSocket) -> None:
Expand Down Expand Up @@ -783,8 +787,11 @@ async def undistort_preview_feed(websocket: WebSocket) -> None:
await websocket.close()
return

await self.calibration_manager.request_free_running(camera_index)

cap = await asyncio.to_thread(open_camera, camera_index, 1456, 1088)
if cap is None:
await self.calibration_manager.release_free_running(camera_index)
await websocket.send_json({"error": f"Cannot open camera {camera_index}"})
await websocket.close()
return
Expand Down Expand Up @@ -870,6 +877,7 @@ async def listen_for_mode():
await asyncio.to_thread(cap.release)
if camera_index is not None:
self._active_cameras.pop(camera_index, None)
await self.calibration_manager.release_free_running(camera_index)

@self.app.post("/api/calibration/distortion/{camera}")
async def run_distortion_calibration(camera: str, request: Request) -> Dict[str, Any]:
Expand All @@ -878,6 +886,8 @@ async def run_distortion_calibration(camera: str, request: Request) -> Dict[str,
return {"status": "error", "message": "Invalid camera"}
if self.calibration_manager.loop is None:
return {"status": "error", "message": "Server still starting up, please retry in a moment"}
if self.pitrac_manager.is_running():
return {"status": "error", "message": "Stop PiTrac before running distortion calibration"}

target_images = 40
try:
Expand Down
Loading
Loading