diff --git a/lib/features/analysis/domain/services/analysis_state_machine.dart b/lib/features/analysis/domain/services/analysis_state_machine.dart index 7cd47d2..6f29809 100644 --- a/lib/features/analysis/domain/services/analysis_state_machine.dart +++ b/lib/features/analysis/domain/services/analysis_state_machine.dart @@ -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; @@ -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, @@ -61,6 +74,7 @@ class AnalysisStateMachine { _releaseFrame = null; _impactFrame = null; _trajectory.clear(); + _completedTrajectory = const []; _recentAreas.clear(); _recentLaneY.clear(); _flightNullCount = 0; @@ -129,6 +143,9 @@ class AnalysisStateMachine { void _handleSettle(int frameIdx) { if (frameIdx - _phaseStartFrame >= 60) { _transitionTo(AnalysisPhase.idle, frameIdx); + // 실시간 오버레이가 지난 투구 선을 계속 그리지 않도록 진행분은 비우되, + // 배치 사용자가 읽을 수 있게 완주분은 남긴다. + _completedTrajectory = List.of(_trajectory); _trajectory.clear(); } } diff --git a/test/features/analysis/domain/services/analysis_state_machine_test.dart b/test/features/analysis/domain/services/analysis_state_machine_test.dart index ae70dfa..87ac524 100644 --- a/test/features/analysis/domain/services/analysis_state_machine_test.dart +++ b/test/features/analysis/domain/services/analysis_state_machine_test.dart @@ -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);