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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ scripts/
logs/
*.log

# ImGui layout state
imgui_*.ini

# OS
.DS_Store
Thumbs.db
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@
- `--replay` CLI flag is now optional; omitting it activates live camera mode
- Camera factory dispatch in `BuildPipeline`: `mjpeg` → `MjpegFrameSource`, replay path → `FileFrameSource`/`VideoFrameSource`
- MJPEG config example documented in `config/hardware.yaml`
- `resetting` FSM state between `pokemon_summary` and `load_game` to fix premature `load_game → game_start` transition on soft reset
- `analyze_frames.py` tool for frame-level pipeline inspection
- Integration test `XYStarterFennekinLive` for real camera footage (skipped when frames absent)

### Fixed

- Mutex UB in `MjpegFrameSource::TryReconnect` — `lock_guard` + raw `mutex.unlock()`/`lock()` caused double-unlock (UB); replaced with `unique_lock` passed by reference
- `MjpegFrameSource::Open()` now calls `capture.release()` before reopening, making it safe to call without an intervening `Close()`
- `DebugLayer` destructor now joins capture thread before releasing `videoWriter`
- Capture thread now exits cleanly when frame source is permanently closed (exhausted reconnects)
- `TestHuntProfiles` fixture was missing `"resetting"` state — `CreateXYStarterSRSucceedsWithCompleteParams` was incorrectly throwing
- Unnecessary `cv::Mat::clone()` in `DebugLayer::ProcessFrame` replaced with refcounted shallow copy (O(1))

## [0.1.0] - 2026-03-09

Expand Down
7 changes: 7 additions & 0 deletions CMakePresets.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@
"outputOnFailure": true
}
},
{
"name": "ninja-release",
"configurePreset": "ninja-release",
"output": {
"outputOnFailure": true
}
},
{
"name": "visual-studio-debug",
"configurePreset": "visual-studio",
Expand Down
9 changes: 7 additions & 2 deletions config/hardware.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ camera:
# reconnect_delay_ms: 2000
# max_reconnect_attempts: 10
# grab_timeout_ms: 5000
# rotation_degrees: 0 # 0 / 90 / 180 / 270 clockwise
# USB camera (future): type: "usb", uri: "0"
type: "file"
uri: "video_replays/fennekin_normal/images"
type: "mjpeg"
uri: "http://192.168.0.111:8080/video"
reconnect_delay_ms: 2000
max_reconnect_attempts: 5
grab_timeout_ms: 5000
rotation_degrees: 90

console:
type: "luma3ds"
Expand Down
20 changes: 19 additions & 1 deletion config/hunts/xy_starter_sr_fennekin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,15 @@ fsm_states:
load_game:
top:
roi: "top_full"
method: "intensity_event"
method: "always_true"
threshold: 0.5
bottom:
roi: "top_full"
method: "color_histogram"
hsv_lower: [0, 0, 180]
hsv_upper: [179, 60, 255]
pixel_ratio_min: 0.4
pixel_ratio_max: 1.0
threshold: 0.5

game_start:
Expand Down Expand Up @@ -106,6 +114,16 @@ fsm_states:
method: "always_true"
threshold: 0.5

resetting:
top:
roi: "top_full"
method: "color_histogram"
hsv_lower: [0, 0, 0]
hsv_upper: [179, 255, 50]
pixel_ratio_min: 0.3
pixel_ratio_max: 1.0
threshold: 0.5

# Shiny detector (dominant color method)
# Based on projected hardware shift (Gamma 1.3, WB)
# Normal (Yellowish) -> Shifts to Cyan/Blue (~110)
Expand Down
25 changes: 16 additions & 9 deletions src/App/DebugLayer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,7 @@ namespace SH3DS::App

DebugLayer::~DebugLayer()
{
if (isRecording)
{
videoWriter.release();
LOG_INFO("Recording saved to {}", recordPath);
}

// Stop capture thread before tearing down OpenGL/ImGui
// Stop capture thread first — ProcessFrame must not run after we release resources
if (captureRunning)
{
captureRunning = false;
Expand All @@ -84,6 +78,12 @@ namespace SH3DS::App
}
}

if (isRecording)
{
videoWriter.release();
LOG_INFO("Recording saved to {}", recordPath);
}

ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();
Expand Down Expand Up @@ -188,7 +188,7 @@ namespace SH3DS::App

void DebugLayer::ProcessFrame(const Core::Frame &frame)
{
currentRawFrame = frame.image.clone();
currentRawFrame = frame.image; // shallow refcounted copy — O(1), data lifetime managed by refcount
rawWidth = currentRawFrame.cols;
rawHeight = currentRawFrame.rows;

Expand Down Expand Up @@ -319,9 +319,16 @@ namespace SH3DS::App
}
++totalFramesGrabbed;
}
else if (!source->IsOpen())
{
// Source permanently closed (exhausted reconnects) — exit capture thread
LOG_WARN("DebugLayer: frame source closed, stopping capture thread");
captureRunning = false;
break;
}
else
{
// Avoid busy-spin on error / stream end
// Transient failure — avoid busy-spin
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
Expand Down
11 changes: 6 additions & 5 deletions src/App/DebugLayer.h
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,12 @@ namespace SH3DS::App
PlaybackController playback; ///< Playback state controller

// Live mode
bool isLiveSource = false; ///< True when seeker == nullptr (live camera)
std::thread captureThread; ///< Background capture thread (live only)
std::atomic<bool> captureRunning{ false }; ///< Signals capture thread to stop
std::mutex latestFrameMutex; ///< Guards latestFrame
std::optional<Core::Frame> latestFrame; ///< Latest frame from capture thread
bool isLiveSource = false; ///< True when seeker == nullptr (live camera)
std::thread captureThread; ///< Background capture thread (live only)
std::atomic<bool> captureRunning{ false }; ///< Signals capture thread to stop
std::mutex latestFrameMutex; ///< Guards latestFrame
std::optional<Core::Frame> latestFrame; ///< Latest frame from capture thread; single-slot — older frames are
///< dropped if render thread hasn't consumed them yet
std::atomic<size_t> totalFramesGrabbed{ 0 }; ///< Monotonically increasing grab counter
float liveGrabFps = 0.0f; ///< Estimated grab FPS (render thread)
std::chrono::steady_clock::time_point lastFpsTime; ///< Timestamp for FPS estimation
Expand Down
29 changes: 19 additions & 10 deletions src/Capture/MjpegFrameSource.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ namespace SH3DS::Capture
{
std::lock_guard<std::mutex> lock(mutex);

capture.release();
permanentlyFailed = false;
currentReconnectAttempts = 0;
frameCounter = 0;
Expand Down Expand Up @@ -51,7 +52,7 @@ namespace SH3DS::Capture

std::optional<Core::Frame> MjpegFrameSource::Grab()
{
std::lock_guard<std::mutex> lock(mutex);
std::unique_lock<std::mutex> lock(mutex);

if (!isOpen || permanentlyFailed)
{
Expand All @@ -62,8 +63,12 @@ namespace SH3DS::Capture
if (!capture.read(image) || image.empty())
{
LOG_WARN("MjpegFrameSource: Read failed, attempting reconnect...");
// mutex is already held — TryReconnect must not re-lock
if (!TryReconnect())
if (!TryReconnect(lock))
{
return std::nullopt;
}
// Re-check state: Close() may have run during the reconnect sleep
if (!isOpen || permanentlyFailed)
{
return std::nullopt;
}
Expand Down Expand Up @@ -95,9 +100,9 @@ namespace SH3DS::Capture
return "MjpegFrameSource(" + uri + ")";
}

bool MjpegFrameSource::TryReconnect()
bool MjpegFrameSource::TryReconnect(std::unique_lock<std::mutex> &lock)
{
// Called with mutex already held.
// Called with lock already held.
for (int attempt = 1; attempt <= maxReconnectAttempts; ++attempt)
{
currentReconnectAttempts = attempt;
Expand All @@ -107,12 +112,16 @@ namespace SH3DS::Capture

if (reconnectDelayMs > 0)
{
// Temporarily release the mutex while sleeping to avoid blocking other threads.
// We re-check state after re-acquiring — but since we are the only producer this
// is safe for the single-slot live capture pattern.
mutex.unlock();
// Temporarily release the lock while sleeping so Close() / IsOpen() are not blocked.
// Re-check state after reacquire — Close() may have run during the sleep.
lock.unlock();
std::this_thread::sleep_for(std::chrono::milliseconds(reconnectDelayMs));
mutex.lock();
lock.lock();

if (!isOpen || permanentlyFailed)
{
return false;
}
}

capture.set(cv::CAP_PROP_OPEN_TIMEOUT_MSEC, static_cast<double>(grabTimeoutMs));
Expand Down
24 changes: 12 additions & 12 deletions src/Capture/MjpegFrameSource.h
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
#pragma once

#include "FrameSource.h"

#include "Core/Config.h"
#include "FrameSource.h"

#include <opencv2/videoio.hpp>

Expand Down Expand Up @@ -45,20 +44,21 @@ namespace SH3DS::Capture
private:
/**
* @brief Attempts to reconnect up to maxReconnectAttempts times.
* @param lock The unique_lock already held by the caller (may be temporarily released during sleep).
* @return True if reconnection succeeded.
*/
bool TryReconnect();
bool TryReconnect(std::unique_lock<std::mutex> &lock);

std::string uri; ///< Stream URI (HTTP URL or local file path)
int reconnectDelayMs; ///< Delay between reconnect attempts in ms
int maxReconnectAttempts; ///< Maximum number of reconnect attempts
int grabTimeoutMs; ///< Open/read timeout in ms (passed to VideoCapture)
cv::VideoCapture capture; ///< OpenCV capture object
mutable std::mutex mutex; ///< Guards capture and frame counter
size_t frameCounter = 0; ///< Monotonically increasing sequence number
std::string uri; ///< Stream URI (HTTP URL or local file path)
int reconnectDelayMs; ///< Delay between reconnect attempts in ms
int maxReconnectAttempts; ///< Maximum number of reconnect attempts
int grabTimeoutMs; ///< Open/read timeout in ms (passed to VideoCapture)
cv::VideoCapture capture; ///< OpenCV capture object
mutable std::mutex mutex; ///< Guards capture and frame counter
size_t frameCounter = 0; ///< Monotonically increasing sequence number
int currentReconnectAttempts = 0; ///< Reconnect attempts used in last failure run
bool isOpen = false; ///< Whether capture is currently open
bool permanentlyFailed = false; ///< True after exhausting all reconnect attempts
bool isOpen = false; ///< Whether capture is currently open
bool permanentlyFailed = false; ///< True after exhausting all reconnect attempts
};

} // namespace SH3DS::Capture
2 changes: 2 additions & 0 deletions src/FSM/CXXStateTreeFSM.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ namespace SH3DS::FSM
pendingFrameCount = 0;
raisesAtLastTransition = topIntensityDetector.GetEvents().size();

LOG_INFO(
"FSM transition: {} -> {} at intensity frame {}", transition.from, transition.to, intensityFrameCounter);
RecordTransition(transition);

return transition;
Expand Down
9 changes: 8 additions & 1 deletion src/FSM/HuntProfiles.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,19 @@ namespace SH3DS::FSM

builder.AddState({
.id = "pokemon_summary",
.transitionsTo = { "load_game" },
.transitionsTo = { "resetting" },
.maxDurationS = 20,
.shinyCheck = true,
.detectionParameters = RequireState(params, "pokemon_summary"),
});

builder.AddState({
.id = "resetting",
.transitionsTo = { "load_game" },
.maxDurationS = 30,
.detectionParameters = RequireState(params, "resetting"),
});

return builder.Build();
}
} // namespace SH3DS::FSM
2 changes: 1 addition & 1 deletion src/Vision/IntensityEventDetector.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ namespace SH3DS::Vision
*/
struct IntensityEventConfig
{
double dropThreshold = 0.40; ///< V < dropThreshold * vMax -> DROP
double dropThreshold = 0.30; ///< V < dropThreshold * vMax -> DROP
double raiseThreshold = 0.50; ///< V > raiseThreshold * vMax -> RAISE
double vMaxDecay =
0.9995; ///< Per-frame exponential decay of the baseline (initialised to 0 — ramps up from first frame)
Expand Down
Loading
Loading