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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ class AnalysisStateMachine {

final List<({int frame, LanePoint lane})> _trajectory = [];

/// 직전에 **완주한** 투구의 궤적.
///
/// 이 FSM은 실시간 세션용이라 settle→idle에서 다음 투구를 위해 궤적을 비운다.
/// 그런데 파이프라인은 영상 전체를 다 먹인 **뒤에** [trajectory]를 읽는 배치
/// 사용자다. 영상이 임팩트 후 90프레임 이상 더 이어지면 마지막 프레임 근처에서
/// idle로 되돌아가며 궤적이 통째로 지워져, 같은 영상인데도 임팩트 도달 여부에
/// 따라 결과가 37포인트 또는 0포인트로 갈렸다(실측). 완주분을 따로 붙잡아
/// 배치 사용자가 잃지 않게 한다.
List<({int frame, LanePoint lane})> _completedTrajectory = const [];

final List<({int frame, double area})> _recentAreas = [];
static const _areaWindowSize = 5;

Expand All @@ -32,7 +42,10 @@ class AnalysisStateMachine {
AnalysisPhase get phase => _phase;
int? get releaseFrame => _releaseFrame;
int? get impactFrame => _impactFrame;
List<({int frame, LanePoint lane})> get trajectory => List.unmodifiable(_trajectory);
/// 진행 중인 궤적이 있으면 그것을, 없으면 직전 완주 투구의 궤적을 준다.
List<({int frame, LanePoint lane})> get trajectory => List.unmodifiable(
_trajectory.isNotEmpty ? _trajectory : _completedTrajectory,
);

void onFrame({
required int frameIdx,
Expand Down Expand Up @@ -61,6 +74,7 @@ class AnalysisStateMachine {
_releaseFrame = null;
_impactFrame = null;
_trajectory.clear();
_completedTrajectory = const [];
_recentAreas.clear();
_recentLaneY.clear();
_flightNullCount = 0;
Expand Down Expand Up @@ -129,6 +143,9 @@ class AnalysisStateMachine {
void _handleSettle(int frameIdx) {
if (frameIdx - _phaseStartFrame >= 60) {
_transitionTo(AnalysisPhase.idle, frameIdx);
// 실시간 오버레이가 지난 투구 선을 계속 그리지 않도록 진행분은 비우되,
// 배치 사용자가 읽을 수 있게 완주분은 남긴다.
_completedTrajectory = List.of(_trajectory);
_trajectory.clear();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,48 @@ BallDetection _det({double bw = 0.05, double bh = 0.05}) =>

void main() {
group('AnalysisStateMachine', () {
test('임팩트 후 영상이 길게 이어져 idle로 되돌아가도 궤적이 남는다', () {
// 실측 회귀: 210프레임 영상에서 settle→idle이 frame 209에 발생하자
// _trajectory가 통째로 지워져, 같은 영상인데도 임팩트 도달 여부에 따라
// 결과가 37포인트 / 0포인트로 갈렸다. 배치 사용자는 전 프레임을 먹인
// 뒤에 trajectory를 읽으므로 완주분이 살아있어야 한다.
final fsm = AnalysisStateMachine();
var frameIdx = 0;

for (var i = 0; i < 6; i++) {
fsm.onFrame(
frameIdx: frameIdx++,
detection: _det(bw: 0.03 + i * 0.01, bh: 0.03 + i * 0.01),
lanePos: LanePoint(xM: 0.5, yM: 1.0 + i * 0.3),
);
}
for (var i = 0; i < 6; i++) {
fsm.onFrame(
frameIdx: frameIdx++,
detection: _det(bw: 0.08 - i * 0.01, bh: 0.08 - i * 0.01),
lanePos: LanePoint(xM: 0.5, yM: 2.8 + i * 0.3),
);
}
// flight: 핀덱까지 주행 → 임팩트 전이
for (var y = 5.0; y <= 18.5; y += 0.5) {
fsm.onFrame(
frameIdx: frameIdx++,
detection: _det(),
lanePos: LanePoint(xM: 0.5, yM: y),
);
}
expect(fsm.phase, AnalysisPhase.impact);
final duringFlight = fsm.trajectory.length;
expect(duringFlight, greaterThan(8));

// impact(30) + settle(60) 을 넘겨 idle 복귀시킨다.
for (var i = 0; i < 100; i++) {
fsm.onFrame(frameIdx: frameIdx++, detection: null, lanePos: null);
}
expect(fsm.phase, AnalysisPhase.idle);
expect(fsm.trajectory.length, duringFlight);
});

test('초기 상태는 idle', () {
final fsm = AnalysisStateMachine();
expect(fsm.phase, AnalysisPhase.idle);
Expand Down
Loading