diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index b6c037e..3a508d4 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -33,5 +33,6 @@ jobs: 한국어로 작성. claude_args: | --model claude-sonnet-4-6 - --max-turns 15 + --max-turns 40 + # 대형 PR(수십 커밋)에서 15턴으로는 리뷰가 max-turns로 죽음 → 40으로 상향. # 더 강한 리뷰 원하면 위 모델을 claude-opus-4-8 로 바꿔라(구독 한도 더 소모). diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 0fa83fc..7b376aa 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -14,11 +14,9 @@ import 'package:bowling_diary/features/balls/presentation/pages/ball_form_page.d import 'package:bowling_diary/features/balls/presentation/pages/balls_page.dart'; import 'package:bowling_diary/features/settings/presentation/pages/settings_page.dart'; import 'package:bowling_diary/features/admin/presentation/pages/catalog_manage_page.dart'; -// import 'package:bowling_diary/features/analysis/presentation/pages/analysis_tab_page.dart'; // 임시 비활성화 +import 'package:bowling_diary/features/analysis/presentation/pages/analysis_tab_page.dart'; import 'package:bowling_diary/features/analysis/presentation/pages/analysis_guide_page.dart'; import 'package:bowling_diary/features/analysis/presentation/pages/analysis_camera_page.dart'; -import 'package:bowling_diary/features/analysis/presentation/pages/analysis_result_page.dart'; -import 'package:bowling_diary/features/analysis/data/services/video_analysis_service.dart'; final appRouterProvider = Provider((ref) { final authState = ref.watch(authNotifierProvider); @@ -101,17 +99,6 @@ final appRouterProvider = Provider((ref) { path: '/analysis/camera', builder: (context, state) => const AnalysisCameraPage(), ), - GoRoute( - path: '/analysis/result', - builder: (context, state) { - final extra = state.extra as Map; - return AnalysisResultPage( - analysisData: extra['analysisData'] as AnalysisData, - videoPath: extra['videoPath'] as String, - recordedAt: extra['recordedAt'] as DateTime, - ); - }, - ), StatefulShellRoute( builder: (context, state, navigationShell) { return ScaffoldWithNavBar(navigationShell: navigationShell); @@ -139,15 +126,14 @@ final appRouterProvider = Provider((ref) { ), ], ), - // 분석 탭 임시 비활성화 (앱스토어 이슈) - // StatefulShellBranch( - // routes: [ - // GoRoute( - // path: '/analysis', - // builder: (context, state) => const AnalysisTabPage(), - // ), - // ], - // ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/analysis', + builder: (context, state) => const AnalysisTabPage(), + ), + ], + ), StatefulShellBranch( routes: [ GoRoute( @@ -223,21 +209,20 @@ class _CustomNavBar extends StatelessWidget { isActive: currentIndex == 1, onTap: () => onTap(1), ), - // 분석 탭 임시 비활성화 (앱스토어 이슈) - // _NavItem( - // icon: PhosphorIconsRegular.videoCamera, - // activeIcon: PhosphorIconsFill.videoCamera, - // label: '분석', - // isActive: currentIndex == 2, - // onTap: () => onTap(2), - // showBeta: true, - // ), + _NavItem( + icon: PhosphorIconsRegular.videoCamera, + activeIcon: PhosphorIconsFill.videoCamera, + label: '분석', + isActive: currentIndex == 2, + onTap: () => onTap(2), + showBeta: true, + ), _NavItem( icon: PhosphorIconsRegular.user, activeIcon: PhosphorIconsFill.user, label: '마이페이지', - isActive: currentIndex == 2, - onTap: () => onTap(2), + isActive: currentIndex == 3, + onTap: () => onTap(3), ), ], ), diff --git a/lib/features/analysis/data/models/analysis_result_model.dart b/lib/features/analysis/data/models/analysis_result_model.dart index f081c17..12b9a4d 100644 --- a/lib/features/analysis/data/models/analysis_result_model.dart +++ b/lib/features/analysis/data/models/analysis_result_model.dart @@ -11,6 +11,8 @@ class AnalysisResultModel extends AnalysisResultEntity { super.videoLocalPath, super.linkedSessionId, required super.createdAt, + super.speedConfidence, + super.speedFailureReason, }); factory AnalysisResultModel.fromJson(Map json) { @@ -20,10 +22,12 @@ class AnalysisResultModel extends AnalysisResultEntity { recordedAt: DateTime.parse(json['recorded_at'] as String), speedKmh: (json['speed_kmh'] as num?)?.toDouble(), rpmEstimated: json['rpm_estimated'] as int?, - fpsUsed: json['fps_used'] as int, + fpsUsed: (json['fps_used'] as num?)?.toInt() ?? 30, videoLocalPath: json['video_local_path'] as String?, linkedSessionId: json['linked_session_id'] as String?, createdAt: DateTime.parse(json['created_at'] as String), + speedConfidence: (json['speed_confidence'] as num?)?.toDouble() ?? 0.0, + speedFailureReason: json['speed_failure_reason'] as String?, ); } @@ -36,5 +40,7 @@ class AnalysisResultModel extends AnalysisResultEntity { 'fps_used': fpsUsed, if (videoLocalPath != null) 'video_local_path': videoLocalPath, if (linkedSessionId != null) 'linked_session_id': linkedSessionId, + 'speed_confidence': speedConfidence, + if (speedFailureReason != null) 'speed_failure_reason': speedFailureReason, }; } diff --git a/lib/features/analysis/data/repositories/calibration_repository_impl.dart b/lib/features/analysis/data/repositories/calibration_repository_impl.dart new file mode 100644 index 0000000..9ea1f60 --- /dev/null +++ b/lib/features/analysis/data/repositories/calibration_repository_impl.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; +import 'package:bowling_diary/features/analysis/domain/entities/calibration_profile.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/homography_matrix.dart'; +import 'package:bowling_diary/features/analysis/domain/repositories/calibration_repository.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class CalibrationRepositoryImpl implements CalibrationRepository { + static const _profilesKey = 'calibration_profiles_v1'; + static const _defaultIdKey = 'calibration_default_id_v1'; + final SharedPreferences _prefs; + CalibrationRepositoryImpl(this._prefs); + + Map _framePointToJson(FramePoint p) => {'nx': p.nx, 'ny': p.ny}; + + FramePoint _framePointFromJson(Map json) => FramePoint( + nx: (json['nx'] as num).toDouble(), + ny: (json['ny'] as num).toDouble(), + ); + + Map _toJson(CalibrationProfile profile) => { + 'id': profile.id, + 'name': profile.name, + 'viewpoint': profile.viewpoint.name, + 'homography': profile.homography.toRowMajorList(), + 'createdAt': profile.createdAt.toIso8601String(), + 'referenceImagePath': profile.referenceImagePath, + 'framePoints': profile.framePoints.map(_framePointToJson).toList(), + }; + + CalibrationProfile _fromJson(Map json) { + final viewpoint = CameraViewpoint.values.firstWhere( + (e) => e.name == json['viewpoint'] as String, + orElse: () => throw FormatException('알 수 없는 viewpoint: ${json['viewpoint']}'), + ); + final homographyValues = + (json['homography'] as List).map((e) => (e as num).toDouble()).toList(); + final homography = HomographyMatrix.fromRowMajor(homographyValues); + final framePoints = (json['framePoints'] as List) + .map((e) => _framePointFromJson(Map.from(e as Map))) + .toList(); + return CalibrationProfile( + id: json['id'] as String, + name: json['name'] as String, + viewpoint: viewpoint, + homography: homography, + createdAt: DateTime.parse(json['createdAt'] as String), + referenceImagePath: json['referenceImagePath'] as String, + framePoints: framePoints, + ); + } + + List> _readRawList() { + final raw = _prefs.getString(_profilesKey); + if (raw == null) return []; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return []; + return decoded.map((e) => Map.from(e as Map)).toList(); + } catch (_) { + return []; + } + } + + Future _writeRawList(List> list) async { + await _prefs.setString(_profilesKey, jsonEncode(list)); + } + + @override + Future> listAll() async { + final result = []; + for (final json in _readRawList()) { + try { + result.add(_fromJson(json)); + } catch (e) { + debugPrint('[CalibrationRepository] 프로파일 디코딩 실패 (id=${json['id']}): $e'); + } + } + return result; + } + + @override + Future getById(String id) async { + final list = _readRawList(); + for (final json in list) { + if (json['id'] == id) return _fromJson(json); + } + return null; + } + + @override + Future save(CalibrationProfile profile) async { + final list = _readRawList(); + final idx = list.indexWhere((e) => e['id'] == profile.id); + final json = _toJson(profile); + if (idx >= 0) { + list[idx] = json; + } else { + list.add(json); + } + await _writeRawList(list); + } + + @override + Future delete(String id) async { + final list = _readRawList(); + list.removeWhere((e) => e['id'] == id); + await _writeRawList(list); + final defaultId = _prefs.getString(_defaultIdKey); + if (defaultId == id) await _prefs.remove(_defaultIdKey); + } + + @override + Future getDefault() async { + final defaultId = _prefs.getString(_defaultIdKey); + if (defaultId == null) return null; + return getById(defaultId); + } + + @override + Future setDefault(String id) async { + await _prefs.setString(_defaultIdKey, id); + } +} diff --git a/lib/features/analysis/data/services/analysis_pipeline.dart b/lib/features/analysis/data/services/analysis_pipeline.dart new file mode 100644 index 0000000..c58fa54 --- /dev/null +++ b/lib/features/analysis/data/services/analysis_pipeline.dart @@ -0,0 +1,135 @@ +import 'dart:io'; + +import 'package:image/image.dart' as img; + +import 'package:bowling_diary/features/analysis/data/services/ball_detection_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/impact_detector_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/release_detector_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/speed_estimator_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/video_frame_extractor_service.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/analysis_data.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/calibration_profile.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/drift_check_result.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/speed_result.dart'; +import 'package:bowling_diary/features/analysis/domain/services/analysis_state_machine.dart'; +import 'package:bowling_diary/features/analysis/domain/services/calibration_drift_checker.dart'; + +class AnalysisPipeline { + final VideoFrameExtractorService frameExtractor; + final BallDetectionService ballDetector; + final ReleaseDetectorService releaseDetector; + final ImpactDetectorService impactDetector; + final SpeedEstimatorService speedEstimator; + final CalibrationDriftChecker driftChecker; + + AnalysisPipeline({ + required this.frameExtractor, + required this.ballDetector, + required this.releaseDetector, + required this.impactDetector, + required this.speedEstimator, + required this.driftChecker, + }); + + Future run(String videoPath, CalibrationProfile profile) async { + final extracted = await frameExtractor.extract(videoPath); + final frames = extracted.frames; + if (frames.isEmpty) { + return AnalysisData( + speedFailure: SpeedFailure.lowConfidence, + driftStatus: DriftStatus.ok, + framesAnalyzed: 0, + fpsUsed: extracted.sampleFps, + ); + } + + final referenceFrame = await _loadReferenceFrame(profile.referenceImagePath); + + final driftResult = referenceFrame != null + ? driftChecker.check( + referenceFrame: referenceFrame, + currentFrame: frames.first, + referencePoints: profile.framePoints, + homography: profile.homography, + ) + : DriftCheckResult( + status: DriftStatus.recalibrationRequired, + homography: profile.homography, + driftScoreNormalized: 1.0, + ); + + if (driftResult.status == DriftStatus.recalibrationRequired) { + return AnalysisData( + driftStatus: driftResult.status, + framesAnalyzed: 0, + fpsUsed: extracted.sampleFps, + ); + } + + final homography = driftResult.homography; + + List detections; + try { + await ballDetector.init(); + detections = frames.map((f) => ballDetector.detect(f)).toList(); + } finally { + ballDetector.dispose(); + } + + final release = releaseDetector.findRelease(detections, homography: homography); + + final fsm = AnalysisStateMachine(); + for (var i = 0; i < detections.length; i++) { + final d = detections[i]; + final lanePos = d != null ? homography.frameToLane(FramePoint(nx: d.cx, ny: d.cy)) : null; + fsm.onFrame(frameIdx: i, detection: d, lanePos: lanePos); + } + + if (!release.isFound || fsm.impactFrame == null) { + return AnalysisData( + speedFailure: !release.isFound ? SpeedFailure.releaseNotFound : SpeedFailure.impactNotFound, + driftStatus: driftResult.status, + framesAnalyzed: frames.length, + fpsUsed: extracted.sampleFps, + ); + } + + final impact = impactDetector.detect( + frames: frames, + releaseFrame: release.frame, + homographyImpactFrame: fsm.impactFrame!, + ); + + final speed = speedEstimator.estimate( + release: release, + impact: impact, + detections: detections, + homography: homography, + sampleFps: extracted.sampleFps, + ); + + final driftPenalty = driftResult.status == DriftStatus.autoCorrected ? 0.9 : 1.0; + final adjustedConfidence = (speed.confidence * driftPenalty).clamp(0.0, 1.0); + + return AnalysisData( + speedKmh: speed.kmh, + speedConfidence: adjustedConfidence, + speedFailure: speed.failure, + driftStatus: driftResult.status, + framesAnalyzed: frames.length, + fpsUsed: extracted.sampleFps, + ); + } + + /// 캘리브레이션 시점 레퍼런스 이미지를 로드/디코드한다. + /// 파일이 없거나 디코드 실패 시 null을 반환 — 호출부에서 recalibrationRequired로 처리(fail safe). + Future _loadReferenceFrame(String path) async { + try { + final bytes = await File(path).readAsBytes(); + return img.decodeImage(bytes); + } catch (_) { + return null; + } + } +} diff --git a/lib/features/analysis/data/services/ball_detection_service.dart b/lib/features/analysis/data/services/ball_detection_service.dart index db76f0b..47b7ba4 100644 --- a/lib/features/analysis/data/services/ball_detection_service.dart +++ b/lib/features/analysis/data/services/ball_detection_service.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:flutter/foundation.dart'; import 'package:image/image.dart' as img; import 'package:tflite_flutter/tflite_flutter.dart'; @@ -21,25 +23,50 @@ class BallDetectionService { static const _modelPath = 'assets/models/yolov8n.tflite'; static const _inputSize = 320; static const _confidenceThreshold = 0.3; - // 커스텀 모델: 클래스 1개 → 출력 [1, 5, 2100] (4bbox + 1class) static const _numDims = 5; static const _numAnchors = 2100; Interpreter? _interpreter; - int _frameCount = 0; + + GpuDelegate _gpuDelegateIOS() { + return GpuDelegate( + options: GpuDelegateOptions(allowPrecisionLoss: true, waitType: 0), + ); + } + + GpuDelegateV2 _gpuDelegateAndroid() { + return GpuDelegateV2( + options: GpuDelegateOptionsV2( + isPrecisionLossAllowed: true, + inferencePreference: 0, + inferencePriority1: 2, + inferencePriority2: 0, + inferencePriority3: 0, + ), + ); + } Future init() async { - final options = InterpreterOptions()..threads = 2; - _interpreter = await Interpreter.fromAsset(_modelPath, options: options); - final inShape = _interpreter!.getInputTensor(0).shape; - final outShape = _interpreter!.getOutputTensor(0).shape; - debugPrint('[BallDetection] 모델 로드 | 입력: $inShape | 출력: $outShape'); + try { + final options = InterpreterOptions(); + if (Platform.isIOS) { + options.addDelegate(_gpuDelegateIOS()); + } else if (Platform.isAndroid) { + options.addDelegate(_gpuDelegateAndroid()); + } else { + options.threads = 2; + } + _interpreter = await Interpreter.fromAsset(_modelPath, options: options); + } catch (e) { + debugPrint('[BallDetection] GPU 실패, CPU fallback: $e'); + final options = InterpreterOptions()..threads = 2; + _interpreter = await Interpreter.fromAsset(_modelPath, options: options); + } } void dispose() { _interpreter?.close(); _interpreter = null; - _frameCount = 0; } BallDetection? detect(img.Image frame) { @@ -54,15 +81,15 @@ class BallDetectionService { ); _interpreter!.run(input, output); - _frameCount++; - return _parseBest(output[0], verbose: _frameCount == 1); + return _parseBest(output[0]); } List>>> _toFloat32Input(img.Image image) { return [ - List.generate(_inputSize, (y) => - List.generate(_inputSize, (x) { + List.generate( + _inputSize, + (y) => List.generate(_inputSize, (x) { final px = image.getPixel(x, y); return [px.r / 255.0, px.g / 255.0, px.b / 255.0]; }), @@ -70,12 +97,12 @@ class BallDetectionService { ]; } - BallDetection? _parseBest(List> raw, {bool verbose = false}) { + BallDetection? _parseBest(List> raw) { double maxConf = _confidenceThreshold; BallDetection? best; for (int i = 0; i < _numAnchors; i++) { - final conf = raw[4][i]; // class 0 = bowling ball + final conf = raw[4][i]; if (conf <= maxConf) continue; maxConf = conf; best = BallDetection( @@ -86,42 +113,6 @@ class BallDetectionService { confidence: conf, ); } - - if (verbose) { - final globalMax = List.generate(_numAnchors, (i) => raw[4][i]).reduce((a, b) => a > b ? a : b); - debugPrint('[BallDetection] 볼 최대신뢰도: ${globalMax.toStringAsFixed(3)}'); - } - return best; } } - -class BallTracker { - static const _laneLength = 18.29; - - static double? calcSpeedKmh(List detections, double fps) { - final detected = detections.asMap().entries.where((e) => e.value != null).toList(); - - if (detected.length < 3) { - debugPrint('[BallTracker] 볼 감지 부족: ${detected.length}프레임'); - return null; - } - - final releaseFrame = detected.first.key; - final impactFrame = detected.last.key; - final elapsed = (impactFrame - releaseFrame) / fps; - - debugPrint('[BallTracker] 프레임 $releaseFrame→$impactFrame, elapsed=${elapsed.toStringAsFixed(2)}s'); - - if (elapsed <= 0) return null; - final rawSpeed = (_laneLength / elapsed) * 3.6; - - if (rawSpeed < 10 || rawSpeed > 50) { - debugPrint('[BallTracker] 속도 범위 초과(${rawSpeed.toStringAsFixed(1)}km/h) → 측정불가'); - return null; - } - - debugPrint('[BallTracker] 구속: ${rawSpeed.toStringAsFixed(1)}km/h'); - return double.parse(rawSpeed.toStringAsFixed(1)); - } -} diff --git a/lib/features/analysis/data/services/gemini_analysis_service.dart b/lib/features/analysis/data/services/gemini_analysis_service.dart deleted file mode 100644 index 7200145..0000000 --- a/lib/features/analysis/data/services/gemini_analysis_service.dart +++ /dev/null @@ -1,450 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'package:image/image.dart' as img; -import 'package:bowling_diary/core/constants/app_config.dart'; -import 'package:bowling_diary/features/analysis/data/services/ball_detection_service.dart'; -import 'package:bowling_diary/features/analysis/data/services/video_analysis_service.dart'; -import 'package:bowling_diary/features/analysis/data/services/video_frame_extractor_service.dart'; - -List _encodeFrames(List frames) { - return frames.map((f) => base64Encode(img.encodeJpg(f, quality: 65))).toList(); -} - - -class GeminiAnalysisService { - static const _baseUrl = 'https://generativelanguage.googleapis.com'; - static const _sampleFps = 10.0; - static const _maxFrames = 20; - - final _frameExtractor = VideoFrameExtractorService(); - - Future analyzeVideo(String videoPath, int fps) async { - final apiKey = AppConfig.geminiApiKey; - if (apiKey.isEmpty) throw Exception('Gemini API 키 없음'); - - // 1. 프레임 추출 - debugPrint('[GeminiAnalysis] 프레임 추출 시작'); - final extracted = await _frameExtractor.extract(videoPath); - final allFrames = extracted.frames; - - if (allFrames.isEmpty) { - debugPrint('[GeminiAnalysis] 프레임 추출 실패'); - return AnalysisData(framesAnalyzed: 0, fpsUsed: fps); - } - - // 최대 프레임 수 제한 (균등 샘플링) — effective fps 계산 - final frames = allFrames.length <= _maxFrames - ? allFrames - : _subsample(allFrames, _maxFrames); - final effectiveFps = _sampleFps * frames.length / allFrames.length; - final frameIntervalSec = 1.0 / effectiveFps; - - debugPrint('[GeminiAnalysis] ${frames.length}개 프레임 분석 시작 (간격=${frameIntervalSec.toStringAsFixed(3)}s)'); - - // 2. 프레임 → JPEG base64 파트 구성 (isolate에서 인코딩) - final encodedFrames = await compute(_encodeFrames, frames); - final parts = >[]; - for (int i = 0; i < encodedFrames.length; i++) { - final t = (i * frameIntervalSec).toStringAsFixed(2); - parts.add({'text': '[프레임 $i | t=${t}s]'}); - parts.add({ - 'inline_data': {'mime_type': 'image/jpeg', 'data': encodedFrames[i]}, - }); - } - parts.add({'text': _buildPrompt(encodedFrames.length, frameIntervalSec)}); - - final requestBody = jsonEncode({ - 'contents': [{'parts': parts}], - 'generationConfig': {'responseMimeType': 'application/json'}, - }); - - // 3. Gemini 요청 (503 시 1회 재시도) - http.Response res = await http.post( - Uri.parse('$_baseUrl/v1beta/models/gemini-2.5-flash:generateContent?key=$apiKey'), - headers: {'Content-Type': 'application/json'}, - body: requestBody, - ).timeout(const Duration(seconds: 120)); - - if (res.statusCode == 503) { - debugPrint('[GeminiAnalysis] 503 → 3초 후 재시도'); - await Future.delayed(const Duration(seconds: 3)); - res = await http.post( - Uri.parse('$_baseUrl/v1beta/models/gemini-2.5-flash:generateContent?key=$apiKey'), - headers: {'Content-Type': 'application/json'}, - body: requestBody, - ).timeout(const Duration(seconds: 120)); - } - - debugPrint('[GeminiAnalysis] 분석 응답: ${res.statusCode}'); - - if (res.statusCode == 429) throw const GeminiQuotaExceededException(); - if (res.statusCode != 200) throw GeminiApiException('API 오류: ${res.statusCode}'); - - return _parseResponse(res.body, fps, frames.length, frameIntervalSec); - } - - List _subsample(List frames, int maxCount) { - final step = frames.length / maxCount; - return List.generate(maxCount, (i) => frames[(i * step).round().clamp(0, frames.length - 1)]); - } - - String _buildPrompt(int frameCount, double intervalSec) => ''' -위는 볼링 투구 프레임 시퀀스입니다 (프레임 간격 ${intervalSec.toStringAsFixed(2)}초, 총 $frameCount장, 프레임 번호 0부터 시작). -JSON만 반환하세요. - -[분석] -1. "foul_line_frame": 볼링공이 파울라인을 완전히 통과하는 프레임 번호. 불명확 시 null. -2. "arrows_frame": 볼링공이 어프로치 화살표 마크(레인의 삼각형 7개)를 통과하는 프레임 번호. 불명확 시 null. -3. "headpin_frame": 볼링공이 헤드핀(1번 핀)에 처음 닿는 프레임 번호. 불명확 시 null. -4. "rotation_count": 볼 표면 로고·텍스처의 총 회전수 (foul_line_frame ~ 마지막 식별 프레임 기준). 불가 시 null. - -{ - "foul_line_frame": null, - "arrows_frame": null, - "headpin_frame": null, - "rotation_count": null -}'''; - - AnalysisData _parseResponse(String body, int fps, int frameCount, double frameIntervalSec) { - try { - final json = jsonDecode(body); - final text = json['candidates'][0]['content']['parts'][0]['text'] as String; - final data = jsonDecode(text) as Map; - - final foulLineFrame = data['foul_line_frame'] as int?; - final arrowsFrame = data['arrows_frame'] as int?; - final headpinFrame = data['headpin_frame'] as int?; - final rotationCount = (data['rotation_count'] as num?)?.toDouble(); - - debugPrint('[GeminiAnalysis] 프레임 식별: 파울라인=$foulLineFrame, 화살표=$arrowsFrame, 헤드핀=$headpinFrame, 회전수=$rotationCount'); - - double? speedKmh; - int? rpm; - - // 구속 계산 — 우선순위: 파울라인↔헤드핀(18.29m) > 파울라인↔화살표(4.57m) > 화살표↔헤드핀(13.72m) - int? startFrame, endFrame; - double? distance; - - if (foulLineFrame != null && headpinFrame != null && headpinFrame > foulLineFrame) { - startFrame = foulLineFrame; endFrame = headpinFrame; distance = 18.29; - } else if (foulLineFrame != null && arrowsFrame != null && arrowsFrame > foulLineFrame) { - startFrame = foulLineFrame; endFrame = arrowsFrame; distance = 4.57; - } else if (arrowsFrame != null && headpinFrame != null && headpinFrame > arrowsFrame) { - startFrame = arrowsFrame; endFrame = headpinFrame; distance = 13.72; - } - - if (startFrame != null && endFrame != null && distance != null) { - final elapsed = (endFrame - startFrame) * frameIntervalSec; - final minElapsed = distance / (50.0 / 3.6); - final maxElapsed = distance / (10.0 / 3.6); - - debugPrint('[GeminiAnalysis] 프레임 $startFrame→$endFrame, elapsed=${elapsed.toStringAsFixed(2)}s, distance=${distance}m'); - - if (elapsed >= minElapsed && elapsed <= maxElapsed) { - speedKmh = double.parse(((distance / elapsed) * 3.6).toStringAsFixed(1)); - debugPrint('[GeminiAnalysis] 구속: $speedKmh km/h'); - } else { - debugPrint('[GeminiAnalysis] elapsed 비정상(${elapsed.toStringAsFixed(2)}s, 허용: ${minElapsed.toStringAsFixed(2)}~${maxElapsed.toStringAsFixed(2)}s) → 측정불가'); - } - - // RPM 계산 - if (rotationCount != null && rotationCount > 0) { - final rpmElapsed = (endFrame - startFrame) * frameIntervalSec; - final rawRpm = (rotationCount / rpmElapsed) * 60; - if (rawRpm >= 50 && rawRpm <= 500) { - rpm = rawRpm.round(); - debugPrint('[GeminiAnalysis] RPM: $rpm (회전수=${rotationCount.toStringAsFixed(1)})'); - } else { - debugPrint('[GeminiAnalysis] RPM 범위 초과(${rawRpm.toStringAsFixed(0)}) → 측정불가'); - } - } - } else { - debugPrint('[GeminiAnalysis] 프레임 식별 실패 → 측정불가'); - } - - debugPrint('[GeminiAnalysis] 결과: ${speedKmh?.toStringAsFixed(1) ?? '측정불가'}km/h, RPM=$rpm'); - return AnalysisData(speedKmh: speedKmh, rpmEstimated: rpm, framesAnalyzed: frameCount, fpsUsed: fps); - } catch (e) { - debugPrint('[GeminiAnalysis] 파싱 오류: $e\n$body'); - return AnalysisData(framesAnalyzed: 0, fpsUsed: fps); - } - } - - /// 프레임 리스트에서 RPM만 추정 (구속은 로컬 분석 담당) - Future analyzeRpm(List allFrames) async { - final apiKey = AppConfig.geminiApiKey; - if (apiKey.isEmpty) return null; - if (allFrames.isEmpty) return null; - - final frames = allFrames.length <= _maxFrames - ? allFrames - : _subsample(allFrames, _maxFrames); - final intervalSec = 1.0 / (_sampleFps * frames.length / allFrames.length); - - final encodedFrames = await compute(_encodeFrames, frames); - final parts = >[]; - for (int i = 0; i < encodedFrames.length; i++) { - parts.add({'text': '[프레임 $i | t=${(i * intervalSec).toStringAsFixed(2)}s]'}); - parts.add({'inline_data': {'mime_type': 'image/jpeg', 'data': encodedFrames[i]}}); - } - parts.add({'text': ''' -볼링공 프레임 시퀀스입니다 (${intervalSec.toStringAsFixed(2)}초 간격, 총 ${frames.length}장). -볼 표면의 로고·텍스처·광택 패턴 변화를 분석해 분당 회전수(RPM)를 추정하세요. -추정 불가 시 null. JSON만 반환: -{"rpm_estimate": null}'''}); - - http.Response res = await http.post( - Uri.parse('$_baseUrl/v1beta/models/gemini-2.5-flash:generateContent?key=$apiKey'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'contents': [{'parts': parts}], - 'generationConfig': {'responseMimeType': 'application/json'}, - }), - ).timeout(const Duration(seconds: 120)); - - if (res.statusCode == 503) { - await Future.delayed(const Duration(seconds: 3)); - res = await http.post( - Uri.parse('$_baseUrl/v1beta/models/gemini-2.5-flash:generateContent?key=$apiKey'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'contents': [{'parts': parts}], - 'generationConfig': {'responseMimeType': 'application/json'}, - }), - ).timeout(const Duration(seconds: 120)); - } - - if (res.statusCode == 429) throw const GeminiQuotaExceededException(); - if (res.statusCode != 200) throw GeminiApiException('API 오류: ${res.statusCode}'); - - try { - final json = jsonDecode(res.body); - final text = json['candidates'][0]['content']['parts'][0]['text'] as String; - final data = jsonDecode(text) as Map; - final rawRpm = (data['rpm_estimate'] as num?)?.toDouble(); - if (rawRpm != null && rawRpm >= 50 && rawRpm <= 500) { - debugPrint('[GeminiAnalysis] RPM: ${rawRpm.round()}'); - return rawRpm.round(); - } - } catch (e) { - debugPrint('[GeminiAnalysis] RPM 파싱 오류: $e'); - } - return null; - } - - /// 속도(전체 프레임 랜드마크) + RPM(크롭 볼 이미지) 통합 단일 API 호출 - Future analyzeUnified({ - required List frames, - required List ballDetections, - required int releaseFrame, - required int sampleFps, - }) async { - final apiKey = AppConfig.geminiApiKey; - if (apiKey.isEmpty) return AnalysisData(framesAnalyzed: frames.length, fpsUsed: sampleFps); - if (frames.isEmpty) return AnalysisData(framesAnalyzed: 0, fpsUsed: sampleFps); - - // 30→40장으로 증가: 핀 충돌 구간(후반부) 포함 확률 향상 - const maxFullFrames = 40; - final fullFrames = frames.length <= maxFullFrames - ? frames - : _subsample(frames, maxFullFrames); - final fullIntervalSec = frames.length / (sampleFps.toDouble() * fullFrames.length); - - const maxCropFrames = 15; - final cropFrames = _buildCropFrames(frames, ballDetections, releaseFrame, maxCropFrames); - const cropIntervalSec = 1.0 / 30.0; - - debugPrint('[GeminiAnalysis] 통합 분석: 전체 ${fullFrames.length}장, 크롭 ${cropFrames.length}장'); - - final encodedFull = await compute(_encodeFrames, fullFrames); - final encodedCrops = cropFrames.isNotEmpty ? await compute(_encodeFrames, cropFrames) : []; - - final parts = >[]; - - parts.add({'text': '[섹션 1: 전체 투구 시퀀스 — ${fullFrames.length}장, 간격 ${fullIntervalSec.toStringAsFixed(3)}s]'}); - for (int i = 0; i < encodedFull.length; i++) { - parts.add({'text': '[프레임 $i | t=${(i * fullIntervalSec).toStringAsFixed(2)}s]'}); - parts.add({'inline_data': {'mime_type': 'image/jpeg', 'data': encodedFull[i]}}); - } - - if (encodedCrops.isNotEmpty) { - parts.add({'text': '[섹션 2: 볼링공 크롭 시퀀스 — ${cropFrames.length}장, 30fps 연속]'}); - for (int i = 0; i < encodedCrops.length; i++) { - parts.add({'text': '[크롭 $i | t=${(i * cropIntervalSec).toStringAsFixed(3)}s]'}); - parts.add({'inline_data': {'mime_type': 'image/jpeg', 'data': encodedCrops[i]}}); - } - } - - parts.add({'text': _buildUnifiedPrompt(fullFrames.length, fullIntervalSec, cropFrames.length)}); - - final requestBody = jsonEncode({ - 'contents': [{'parts': parts}], - 'generationConfig': {'responseMimeType': 'application/json'}, - }); - - http.Response res = await http.post( - Uri.parse('$_baseUrl/v1beta/models/gemini-2.5-flash:generateContent?key=$apiKey'), - headers: {'Content-Type': 'application/json'}, - body: requestBody, - ).timeout(const Duration(seconds: 120)); - - if (res.statusCode == 503) { - debugPrint('[GeminiAnalysis] 503 → 3초 후 재시도'); - await Future.delayed(const Duration(seconds: 3)); - res = await http.post( - Uri.parse('$_baseUrl/v1beta/models/gemini-2.5-flash:generateContent?key=$apiKey'), - headers: {'Content-Type': 'application/json'}, - body: requestBody, - ).timeout(const Duration(seconds: 120)); - } - - debugPrint('[GeminiAnalysis] 통합 응답: ${res.statusCode}'); - if (res.statusCode == 429) throw const GeminiQuotaExceededException(); - if (res.statusCode != 200) throw GeminiApiException('API 오류: ${res.statusCode}'); - - return _parseUnifiedResponse( - res.body, sampleFps, frames.length, - fullIntervalSec, cropFrames.length, cropIntervalSec, - ); - } - - /// [detections]는 [frames]와 동일한 길이여야 함 (프레임당 감지 결과 1개, 없으면 null) - List _buildCropFrames( - List frames, - List detections, - int releaseFrame, - int maxCount, - ) { - final crops = []; - final startIdx = (releaseFrame - 5).clamp(0, frames.length - 1); - for (int i = startIdx; i < frames.length && crops.length < maxCount; i++) { - if (i >= detections.length) break; - final det = detections[i]; - if (det == null) continue; - final crop = _cropBallForGemini(frames[i], det); - if (crop != null) crops.add(crop); - } - return crops; - } - - img.Image? _cropBallForGemini(img.Image frame, BallDetection det) { - final fw = frame.width.toDouble(); - final fh = frame.height.toDouble(); - const pad = 1.5; - final bw = (det.bw * fw * pad).round(); - final bh = (det.bh * fh * pad).round(); - final x = ((det.cx * fw) - bw / 2).round().clamp(0, frame.width - 1); - final y = ((det.cy * fh) - bh / 2).round().clamp(0, frame.height - 1); - final w = bw.clamp(1, frame.width - x); - final h = bh.clamp(1, frame.height - y); - if (w < 20 || h < 20) return null; - final cropped = img.copyCrop(frame, x: x, y: y, width: w, height: h); - if (cropped.width < 100 || cropped.height < 100) { - return img.copyResize(cropped, width: 100, height: 100); - } - return cropped; - } - - String _buildUnifiedPrompt(int fullCount, double fullIntervalSec, int cropCount) => ''' -위는 볼링 투구 분석 데이터입니다. - -섹션 1은 전체 투구 시퀀스($fullCount장, ${fullIntervalSec.toStringAsFixed(3)}초 간격)입니다. -섹션 2는 릴리즈 이후 볼링공 근접 크롭 이미지${cropCount > 0 ? '($cropCount장, 30fps 연속)' : '(없음)'}입니다. - -[섹션 1 분석] 다음 프레임 번호를 찾으세요 (불명확하면 null): -- foul_line_frame: 볼이 파울라인(레인 시작 경계선, foul line)을 완전히 통과하는 프레임 -- arrows_frame: 볼이 레인의 삼각형 화살표 마크 7개(arrows, 파울라인에서 약 4.5m 지점)를 통과하는 프레임 -- headpin_frame: 볼이 레인 끝 핀 배열에 처음 닿는 프레임 (headpin #1, the front pin). 핀이 움직이거나 쓰러지기 시작하는 첫 번째 프레임. 영상 후반부에 위치함 - -[섹션 2 분석] 크롭 이미지에서 볼 표면(로고·텍스처·광택 반사 패턴) 변화를 추적하세요: -- rotation_count: 첫 크롭~마지막 크롭 사이 볼의 총 회전수 (소수 가능, 예: 2.5회전) - -JSON만 반환: -{ - "foul_line_frame": null, - "arrows_frame": null, - "headpin_frame": null, - "rotation_count": null -}'''; - - AnalysisData _parseUnifiedResponse( - String body, - int sampleFps, - int totalFrames, - double fullIntervalSec, - int cropFrameCount, - double cropIntervalSec, - ) { - try { - final json = jsonDecode(body); - final text = json['candidates'][0]['content']['parts'][0]['text'] as String; - final data = jsonDecode(text) as Map; - - final foulLineFrame = data['foul_line_frame'] as int?; - final arrowsFrame = data['arrows_frame'] as int?; - final headpinFrame = data['headpin_frame'] as int?; - final rotationCount = (data['rotation_count'] as num?)?.toDouble(); - - debugPrint('[GeminiAnalysis] 식별: 파울라인=$foulLineFrame, 화살표=$arrowsFrame, 헤드핀=$headpinFrame, 회전수=$rotationCount'); - - double? speedKmh; - int? startFrame, endFrame; - double? distance; - - // 우선순위: 파울라인↔화살표(4.57m, 안정적) > 파울라인↔헤드핀(18.29m) > 화살표↔헤드핀(13.72m) - if (foulLineFrame != null && arrowsFrame != null && arrowsFrame > foulLineFrame) { - startFrame = foulLineFrame; endFrame = arrowsFrame; distance = 4.57; - } else if (foulLineFrame != null && headpinFrame != null && headpinFrame > foulLineFrame) { - startFrame = foulLineFrame; endFrame = headpinFrame; distance = 18.29; - } else if (arrowsFrame != null && headpinFrame != null && headpinFrame > arrowsFrame) { - startFrame = arrowsFrame; endFrame = headpinFrame; distance = 13.72; - } - - if (startFrame != null && endFrame != null && distance != null) { - final elapsed = (endFrame - startFrame) * fullIntervalSec; - final minElapsed = distance / (50.0 / 3.6); - final maxElapsed = distance / (10.0 / 3.6); - debugPrint('[GeminiAnalysis] 프레임 $startFrame→$endFrame, elapsed=${elapsed.toStringAsFixed(2)}s, distance=${distance}m'); - if (elapsed >= minElapsed && elapsed <= maxElapsed) { - speedKmh = double.parse(((distance / elapsed) * 3.6).toStringAsFixed(1)); - debugPrint('[GeminiAnalysis] 구속: $speedKmh km/h'); - } else { - debugPrint('[GeminiAnalysis] elapsed 비정상(${elapsed.toStringAsFixed(2)}s, 허용: ${minElapsed.toStringAsFixed(2)}~${maxElapsed.toStringAsFixed(2)}s) → 측정불가'); - } - } else { - debugPrint('[GeminiAnalysis] 랜드마크 식별 실패 → 구속 측정불가'); - } - - int? rpm; - // cropFrameCount > 1 필수: 프레임이 1개면 duration=0 → RPM 계산 불가 - if (rotationCount != null && rotationCount > 0 && cropFrameCount > 1) { - final durationSec = (cropFrameCount - 1) * cropIntervalSec; - final rawRpm = (rotationCount / durationSec) * 60; - if (rawRpm >= 50 && rawRpm <= 500) { - rpm = rawRpm.round(); - debugPrint('[GeminiAnalysis] RPM: $rpm (회전수=${rotationCount.toStringAsFixed(1)}, duration=${durationSec.toStringAsFixed(2)}s)'); - } else { - debugPrint('[GeminiAnalysis] RPM 범위 초과(${rawRpm.toStringAsFixed(0)}) → 측정불가'); - } - } - - debugPrint('[GeminiAnalysis] 결과: ${speedKmh?.toStringAsFixed(1) ?? '측정불가'}km/h, RPM=$rpm'); - return AnalysisData(speedKmh: speedKmh, rpmEstimated: rpm, framesAnalyzed: totalFrames, fpsUsed: sampleFps); - } catch (e) { - debugPrint('[GeminiAnalysis] 파싱 오류: $e\n$body'); - return AnalysisData(framesAnalyzed: totalFrames, fpsUsed: sampleFps); - } - } -} - -class GeminiQuotaExceededException implements Exception { - const GeminiQuotaExceededException(); -} - -class GeminiApiException implements Exception { - final String message; - const GeminiApiException(this.message); - @override - String toString() => message; -} diff --git a/lib/features/analysis/data/services/impact_detector_service.dart b/lib/features/analysis/data/services/impact_detector_service.dart new file mode 100644 index 0000000..ba1bbb1 --- /dev/null +++ b/lib/features/analysis/data/services/impact_detector_service.dart @@ -0,0 +1,31 @@ +import 'package:image/image.dart' as img; + +import 'package:bowling_diary/features/analysis/data/services/pin_impact_detector_service.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/impact_result.dart'; + +class ImpactDetectorService { + final PinImpactDetectorService pinImpactDetector; + const ImpactDetectorService({required this.pinImpactDetector}); + + ImpactResult detect({ + required List frames, + required int releaseFrame, + required int homographyImpactFrame, + }) { + final pinFrame = pinImpactDetector.findImpactFrame(frames, releaseFrame); + + if (pinFrame == null) { + return ImpactResult(frame: homographyImpactFrame, confidence: ImpactConfidence.low); + } + + final diff = (pinFrame - homographyImpactFrame).abs(); + final confidence = diff <= 2 + ? ImpactConfidence.high + : diff <= 5 + ? ImpactConfidence.medium + : ImpactConfidence.low; + + final frame = confidence == ImpactConfidence.low ? homographyImpactFrame : pinFrame; + return ImpactResult(frame: frame, confidence: confidence); + } +} diff --git a/lib/features/analysis/data/services/release_detector_service.dart b/lib/features/analysis/data/services/release_detector_service.dart index 821bcf7..48880a7 100644 --- a/lib/features/analysis/data/services/release_detector_service.dart +++ b/lib/features/analysis/data/services/release_detector_service.dart @@ -1,50 +1,168 @@ import 'dart:math' show sqrt; -import 'package:flutter/foundation.dart'; +import 'package:flutter/foundation.dart' show debugPrint; import 'package:bowling_diary/features/analysis/data/services/ball_detection_service.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/homography_matrix.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/release_result.dart'; class ReleaseDetectorService { - static const _minConsecutive = 3; - // 볼이 보유/arm swing 상태와 구분하는 프레임당 최소 이동 거리 (정규화 좌표 0~1) - static const double _moveThreshold = 0.015; + static const _windowSize = 5; + static const _minConsecutive = 2; + static const double _absSpeedFloor = 0.015; + static const double _peakRatio = 0.35; + static const double _backswingDetectRatio = 1.4; - int? findReleaseFrame(List detections) { + ReleaseResult findRelease( + List detections, { + HomographyMatrix? homography, + }) { + final velocities = _smoothedVelocities(detections); + if (velocities.length < _minConsecutive + 1) { + return ReleaseResult.notFound; + } + + final peakV = velocities.reduce((a, b) => a > b ? a : b); + if (peakV < _absSpeedFloor) { + return ReleaseResult.notFound; + } + + final threshold = (peakV * _peakRatio).clamp(_absSpeedFloor, 1.0); + + final backswingPeakFrame = _findBackswingPeak(detections); + final minStart = (velocities.length * 0.25).round().clamp(1, velocities.length); + final rawStart = backswingPeakFrame ?? 1; + final searchStart = + rawStart >= minStart ? rawStart.clamp(1, velocities.length) : minStart; + + final segments = <(int start, int len)>[]; int consecutive = 0; - int? start; - BallDetection? prev; + int? startFrame; - for (int i = 0; i < detections.length; i++) { - final det = detections[i]; - if (det == null) { - consecutive = 0; - start = null; - prev = null; - continue; + void closeSegment() { + if (consecutive >= _minConsecutive && startFrame != null) { + segments.add((startFrame!, consecutive)); } + consecutive = 0; + startFrame = null; + } - if (prev != null && _disp(prev, det) >= _moveThreshold) { - start ??= i - 1; + for (int i = searchStart; i < velocities.length; i++) { + final v = velocities[i]; + final a = v - velocities[i - 1]; + final ok = v >= threshold && a >= 0; + if (ok) { consecutive++; - if (consecutive >= _minConsecutive) { - debugPrint('[ReleaseDetector] 릴리즈 프레임: $start (연속 $_minConsecutive프레임)'); - return start; - } + if (consecutive == 1) startFrame = i; } else { - consecutive = 0; - start = null; + closeSegment(); } + } + closeSegment(); + + if (segments.isEmpty) { + return ReleaseResult.notFound; + } - prev = det; + int? bestStart; + int bestLen = 0; + double bestScore = double.negativeInfinity; + for (final seg in segments) { + final shrinkScore = _postReleaseBboxShrinkScore(detections, seg.$1); + final laneFwdScore = + homography != null ? _laneForwardScore(detections, homography, seg.$1) : 0.0; + final score = seg.$2.toDouble() + shrinkScore * 5 + laneFwdScore * 5; + if (score > bestScore) { + bestScore = score; + bestStart = seg.$1; + bestLen = seg.$2; + } } - debugPrint('[ReleaseDetector] 릴리즈 프레임 미감지'); - return null; + final confidence = (bestLen / _windowSize).clamp(0.0, 1.0); + debugPrint('[ReleaseDetector] release=$bestStart, conf=$confidence'); + return ReleaseResult(frame: bestStart!, confidence: confidence); + } + + double _laneForwardScore(List detections, HomographyMatrix h, int startFrame) { + final ys = []; + for (var i = startFrame; i < startFrame + 10 && i < detections.length; i++) { + final d = detections[i]; + if (d == null) continue; + ys.add(h.frameToLane(FramePoint(nx: d.cx, ny: d.cy)).yM); + } + if (ys.length < 3) return 0.0; + var inc = 0; + var total = 0; + for (var i = 1; i < ys.length; i++) { + if (ys[i] > ys[i - 1]) inc++; + total++; + } + return (inc / total) * 2 - 1; } - double _disp(BallDetection a, BallDetection b) { - final dx = b.cx - a.cx; - final dy = b.cy - a.cy; - return sqrt(dx * dx + dy * dy); + double _postReleaseBboxShrinkScore(List detections, int startFrame) { + double avgArea(int from, int to) { + double sum = 0; + int count = 0; + for (int i = from; i < to && i < detections.length; i++) { + if (i < 0) continue; + final d = detections[i]; + if (d == null) continue; + sum += d.bw * d.bh; + count++; + } + return count > 0 ? sum / count : 0; + } + + final pre = avgArea(startFrame - 5, startFrame); + final post = avgArea(startFrame, startFrame + 10); + if (pre <= 0 || post <= 0) return 0; + final ratio = (pre - post) / pre; + return ratio.clamp(-1.0, 1.0); + } + + int? _findBackswingPeak(List detections) { + final areas = <(int, double)>[]; + for (int i = 0; i < detections.length; i++) { + final d = detections[i]; + if (d == null) continue; + areas.add((i, d.bw * d.bh)); + } + if (areas.length < 5) return null; + final maxArea = areas.map((e) => e.$2).reduce((a, b) => a > b ? a : b); + final minArea = areas.map((e) => e.$2).reduce((a, b) => a < b ? a : b); + if (minArea <= 0 || maxArea / minArea < _backswingDetectRatio) { + return null; + } + final peakEntry = areas.firstWhere((e) => e.$2 == maxArea); + return peakEntry.$1; + } + + List _smoothedVelocities(List detections) { + final raw = [0.0]; + for (int i = 1; i < detections.length; i++) { + final prev = detections[i - 1]; + final curr = detections[i]; + if (prev == null || curr == null) { + raw.add(0.0); + } else { + final dx = curr.cx - prev.cx; + final dy = curr.cy - prev.cy; + raw.add(sqrt(dx * dx + dy * dy)); + } + } + final smoothed = []; + for (int i = 0; i < raw.length; i++) { + final start = (i - _windowSize ~/ 2).clamp(0, raw.length - 1); + final end = (i + _windowSize ~/ 2 + 1).clamp(0, raw.length); + double sum = 0; + for (int j = start; j < end; j++) { + sum += raw[j]; + } + smoothed.add(sum / (end - start)); + } + return smoothed; } } diff --git a/lib/features/analysis/data/services/speed_estimator_service.dart b/lib/features/analysis/data/services/speed_estimator_service.dart new file mode 100644 index 0000000..6f8cac2 --- /dev/null +++ b/lib/features/analysis/data/services/speed_estimator_service.dart @@ -0,0 +1,77 @@ +import 'dart:math' show sqrt; + +import 'package:flutter/foundation.dart'; + +import 'package:bowling_diary/features/analysis/data/services/ball_detection_service.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/homography_matrix.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/impact_result.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/release_result.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/speed_result.dart'; + +class SpeedEstimatorService { + static const int _flightStartOffset = 8; + static const int _flightEndOffset = 24; + static const int _minSamples = 8; + static const double _minSpeed = 10.0; + static const double _maxSpeed = 50.0; + + SpeedResult estimate({ + required ReleaseResult release, + required ImpactResult impact, + required List detections, + required HomographyMatrix homography, + required int sampleFps, + }) { + if (!release.isFound) { + return SpeedResult.failed(SpeedFailure.releaseNotFound); + } + + if (impact.confidence == ImpactConfidence.low) { + debugPrint('[SpeedEstimator] impact 신호 불일치(low confidence) — 강제 채택 안 함'); + return SpeedResult.failed(SpeedFailure.anchorMismatch); + } + + final flightStart = release.frame + _flightStartOffset; + final flightEnd = (release.frame + _flightEndOffset).clamp(0, detections.length - 1); + + if (flightStart >= flightEnd) { + return SpeedResult.failed(SpeedFailure.outOfRange); + } + + final samples = []; + for (var i = flightStart + 1; i <= flightEnd; i++) { + final prev = detections[i - 1]; + final curr = detections[i]; + if (prev == null || curr == null) continue; + final prevLane = homography.frameToLane(FramePoint(nx: prev.cx, ny: prev.cy)); + final currLane = homography.frameToLane(FramePoint(nx: curr.cx, ny: curr.cy)); + final dx = currLane.xM - prevLane.xM; + final dy = currLane.yM - prevLane.yM; + final distM = sqrt(dx * dx + dy * dy); + samples.add(distM * sampleFps); + } + + if (samples.length < _minSamples) { + return SpeedResult.failed(SpeedFailure.lowConfidence); + } + + samples.sort(); + final mid = samples.length ~/ 2; + final medianMs = + samples.length.isOdd ? samples[mid] : (samples[mid - 1] + samples[mid]) / 2.0; + final kmh = medianMs * 3.6; + + if (kmh < _minSpeed || kmh > _maxSpeed) { + return SpeedResult.failed(SpeedFailure.outOfRange); + } + + final windowSize = flightEnd - flightStart; + final sampleCoverage = (samples.length / windowSize).clamp(0.0, 1.0); + final impactPenalty = impact.confidence == ImpactConfidence.medium ? 0.85 : 1.0; + final confidence = (sampleCoverage * impactPenalty * release.confidence).clamp(0.0, 1.0); + + final rounded = double.parse(kmh.toStringAsFixed(1)); + return SpeedResult.success(rounded, confidence); + } +} diff --git a/lib/features/analysis/data/services/video_analysis_service.dart b/lib/features/analysis/data/services/video_analysis_service.dart deleted file mode 100644 index 6c021ae..0000000 --- a/lib/features/analysis/data/services/video_analysis_service.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'package:camera/camera.dart'; -import 'package:flutter/foundation.dart'; -import 'package:image/image.dart' as img; - -class AnalysisData { - final double? speedKmh; - final int? rpmEstimated; - final int framesAnalyzed; - final int fpsUsed; - - const AnalysisData({ - this.speedKmh, - this.rpmEstimated, - required this.framesAnalyzed, - required this.fpsUsed, - }); -} - -class VideoAnalysisService { - static const _laneLength = 18.29; // 미터 (파울라인 → 헤드핀) - - AnalysisData analyze(List frames, int fps) { - debugPrint('[Analysis] 분석 시작: ${frames.length}개 프레임, ${fps}fps'); - - if (frames.length < 5) { - debugPrint('[Analysis] 프레임 부족 → 기본값 반환'); - return AnalysisData(framesAnalyzed: frames.length, fpsUsed: fps); - } - - final positions = <_BallPosition?>[]; - img.Image? prevGray; - - for (int i = 0; i < frames.length; i++) { - final gray = _toGrayscale(frames[i]); - if (gray == null) { positions.add(null); continue; } - - if (prevGray != null) { - final pos = _detectBallByMotion(prevGray, gray); - positions.add(pos); - } else { - positions.add(null); - } - prevGray = gray; - } - - final detected = positions.asMap().entries - .where((e) => e.value != null) - .toList(); - - if (detected.length < 3) { - debugPrint('[Analysis] 볼 감지 실패 → 속도 0 반환'); - return AnalysisData(framesAnalyzed: frames.length, fpsUsed: fps); - } - - final releaseIdx = detected.first.key; - final impactIdx = detected.last.key; - - // 샘플링 간격(10프레임) 고려한 실제 프레임 수 역산 - final actualFrameCount = (impactIdx - releaseIdx) * 10; - final elapsedSec = actualFrameCount / fps; - - if (elapsedSec <= 0) { - return AnalysisData(framesAnalyzed: frames.length, fpsUsed: fps); - } - - final rawSpeed = (_laneLength / elapsedSec) * 3.6; - final speedKmh = (rawSpeed >= 10 && rawSpeed <= 50) - ? double.parse(rawSpeed.toStringAsFixed(1)) - : null; - debugPrint('[Analysis] 속도: ${rawSpeed.toStringAsFixed(1)}km/h (${elapsedSec.toStringAsFixed(2)}초)${speedKmh == null ? ' → 범위 초과, 측정불가' : ''}'); - - const int? rpm = null; - - return AnalysisData( - speedKmh: speedKmh, - rpmEstimated: rpm, - framesAnalyzed: frames.length, - fpsUsed: fps, - ); - } - - /// 갤러리 영상 프레임(img.Image) 분석 — sampleFps는 추출 시 사용한 fps - /// releaseFrame: YOLO person 감지로 특정한 릴리즈 프레임 (0이면 자동 감지) - AnalysisData analyzeImages( - List frames, - int originalFps, { - int sampleFps = 30, - int releaseFrame = 0, - }) { - debugPrint('[Analysis] 갤러리 분석 시작: ${frames.length}개 프레임, 원본 ${originalFps}fps, 샘플 ${sampleFps}fps, 릴리즈=$releaseFrame'); - - if (frames.length < 5) { - return AnalysisData(framesAnalyzed: frames.length, fpsUsed: originalFps); - } - - final positions = <_BallPosition?>[]; - img.Image? prevGray; - - for (final frame in frames) { - final gray = img.grayscale(frame); - if (prevGray != null) { - positions.add(_detectBallByMotion(prevGray, gray)); - } else { - positions.add(null); - } - prevGray = gray; - } - - // releaseFrame 이후 감지된 것만 사용 - final detected = positions.asMap().entries - .where((e) => e.value != null && e.key >= releaseFrame) - .toList(); - - if (detected.length < 3) { - debugPrint('[Analysis] 볼 감지 실패 → 속도 0 반환'); - return AnalysisData(framesAnalyzed: frames.length, fpsUsed: originalFps); - } - - // releaseFrame을 릴리즈 기준점으로 고정 (YOLO가 준 경우) - final releaseIdx = releaseFrame > 0 ? releaseFrame : detected.first.key; - final impactIdx = detected.last.key; - final sampleInterval = (originalFps / sampleFps).round().clamp(1, 999); - final actualFrameCount = (impactIdx - releaseIdx) * sampleInterval; - final elapsedSec = actualFrameCount / originalFps; - - if (elapsedSec <= 0) { - return AnalysisData(framesAnalyzed: frames.length, fpsUsed: originalFps); - } - - final rawSpeed = (_laneLength / elapsedSec) * 3.6; - final speedKmh = (rawSpeed >= 10 && rawSpeed <= 50) - ? double.parse(rawSpeed.toStringAsFixed(1)) - : null; - debugPrint('[Analysis] 속도: ${rawSpeed.toStringAsFixed(1)}km/h${speedKmh == null ? ' → 범위 초과, 측정불가' : ''}'); - - const int? rpm = null; - - return AnalysisData( - speedKmh: speedKmh, - rpmEstimated: rpm, - framesAnalyzed: frames.length, - fpsUsed: originalFps, - ); - } - - /// CameraImage(YUV420) → 그레이스케일 (Y 채널만 사용) - img.Image? _toGrayscale(CameraImage frame) { - try { - final yPlane = frame.planes[0]; - final w = frame.width; - final h = frame.height; - final result = img.Image(width: w, height: h); - - for (int y = 0; y < h; y++) { - for (int x = 0; x < w; x++) { - final yVal = yPlane.bytes[y * yPlane.bytesPerRow + x]; - result.setPixelRgb(x, y, yVal, yVal, yVal); - } - } - return result; - } catch (e) { - debugPrint('[Analysis] 그레이스케일 변환 실패: $e'); - return null; - } - } - - /// 프레임 차분으로 볼 중심 좌표 반환 - _BallPosition? _detectBallByMotion(img.Image prev, img.Image curr) { - final w = curr.width; - final h = curr.height; - final yStart = h ~/ 3; - - int sumX = 0, sumY = 0, count = 0; - - for (int y = yStart; y < h; y++) { - for (int x = 0; x < w; x++) { - final prevLum = img.getLuminance(prev.getPixel(x, y)); - final currLum = img.getLuminance(curr.getPixel(x, y)); - if ((currLum - prevLum).abs() > 25) { - sumX += x; - sumY += y; - count++; - } - } - } - - if (count < 200) return null; - return _BallPosition(sumX / count, sumY / count); - } - -} - -class _BallPosition { - final double x; - final double y; - const _BallPosition(this.x, this.y); -} diff --git a/lib/features/analysis/domain/entities/analysis_data.dart b/lib/features/analysis/domain/entities/analysis_data.dart new file mode 100644 index 0000000..93527b6 --- /dev/null +++ b/lib/features/analysis/domain/entities/analysis_data.dart @@ -0,0 +1,20 @@ +import 'package:bowling_diary/features/analysis/domain/entities/drift_check_result.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/speed_result.dart'; + +class AnalysisData { + final double? speedKmh; + final double speedConfidence; + final SpeedFailure? speedFailure; + final DriftStatus driftStatus; + final int framesAnalyzed; + final int fpsUsed; + + const AnalysisData({ + this.speedKmh, + this.speedConfidence = 0.0, + this.speedFailure, + required this.driftStatus, + required this.framesAnalyzed, + required this.fpsUsed, + }); +} diff --git a/lib/features/analysis/domain/entities/analysis_result_entity.dart b/lib/features/analysis/domain/entities/analysis_result_entity.dart index 50963da..bb40008 100644 --- a/lib/features/analysis/domain/entities/analysis_result_entity.dart +++ b/lib/features/analysis/domain/entities/analysis_result_entity.dart @@ -8,6 +8,8 @@ class AnalysisResultEntity { final String? videoLocalPath; final String? linkedSessionId; final DateTime createdAt; + final double speedConfidence; + final String? speedFailureReason; const AnalysisResultEntity({ required this.id, @@ -19,5 +21,7 @@ class AnalysisResultEntity { this.videoLocalPath, this.linkedSessionId, required this.createdAt, + this.speedConfidence = 0.0, + this.speedFailureReason, }); } diff --git a/lib/features/analysis/domain/entities/calibration_profile.dart b/lib/features/analysis/domain/entities/calibration_profile.dart new file mode 100644 index 0000000..989bf36 --- /dev/null +++ b/lib/features/analysis/domain/entities/calibration_profile.dart @@ -0,0 +1,30 @@ +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/homography_matrix.dart'; + +enum CameraViewpoint { backRight, backLeft, sideRight, sideLeft } + +class CalibrationProfile { + final String id; + final String name; + final CameraViewpoint viewpoint; + final HomographyMatrix homography; + final DateTime createdAt; + + /// 캘리브레이션 시점에 사용한 레퍼런스 이미지 경로. + /// drift-check(CalibrationDriftChecker)가 촬영 영상 첫 프레임과 비교할 때 사용. + final String referenceImagePath; + + /// 사용자가 탭한 4개 프레임 좌표 (foul-left, foul-right, pin-right, pin-left 순서). + /// drift-check가 각 점 주변 패치를 재검출할 때 사용. + final List framePoints; + + const CalibrationProfile({ + required this.id, + required this.name, + required this.viewpoint, + required this.homography, + required this.createdAt, + required this.referenceImagePath, + required this.framePoints, + }); +} diff --git a/lib/features/analysis/domain/entities/coord.dart b/lib/features/analysis/domain/entities/coord.dart new file mode 100644 index 0000000..283ac50 --- /dev/null +++ b/lib/features/analysis/domain/entities/coord.dart @@ -0,0 +1,17 @@ +import 'package:equatable/equatable.dart'; + +class LanePoint extends Equatable { + final double xM; // 0 ~ 1.05m (레인 너비) + final double yM; // 0 ~ 18.29m (파울라인 → 핀덱) + const LanePoint({required this.xM, required this.yM}); + @override + List get props => [xM, yM]; +} + +class FramePoint extends Equatable { + final double nx; // 0=좌, 1=우 + final double ny; // 0=상단, 1=하단 + const FramePoint({required this.nx, required this.ny}); + @override + List get props => [nx, ny]; +} diff --git a/lib/features/analysis/domain/entities/drift_check_result.dart b/lib/features/analysis/domain/entities/drift_check_result.dart new file mode 100644 index 0000000..cd2c100 --- /dev/null +++ b/lib/features/analysis/domain/entities/drift_check_result.dart @@ -0,0 +1,14 @@ +import 'package:bowling_diary/features/analysis/domain/entities/homography_matrix.dart'; + +enum DriftStatus { ok, autoCorrected, recalibrationRequired } + +class DriftCheckResult { + final DriftStatus status; + final HomographyMatrix homography; + final double driftScoreNormalized; // 프레임 대각선 대비 평균 오프셋 비율 + const DriftCheckResult({ + required this.status, + required this.homography, + required this.driftScoreNormalized, + }); +} diff --git a/lib/features/analysis/domain/entities/homography_matrix.dart b/lib/features/analysis/domain/entities/homography_matrix.dart new file mode 100644 index 0000000..cc8ac34 --- /dev/null +++ b/lib/features/analysis/domain/entities/homography_matrix.dart @@ -0,0 +1,65 @@ +import 'dart:typed_data'; +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; + +class HomographyMatrix { + final Float64List _m; + final Float64List _inv; + HomographyMatrix._(this._m, this._inv); + + factory HomographyMatrix.identity() { + final m = Float64List(9); + m[0] = 1; m[4] = 1; m[8] = 1; + final inv = Float64List.fromList(m); + return HomographyMatrix._(m, inv); + } + + factory HomographyMatrix.fromRowMajor(List values) { + if (values.length != 9) { + throw ArgumentError('행렬 원소는 정확히 9개여야 합니다. 현재 길이: ${values.length}'); + } + final m = Float64List.fromList(values); + final inv = _computeInverse(m); + return HomographyMatrix._(m, inv); + } + + LanePoint frameToLane(FramePoint p) { + final (x, y) = _applyHomography(_m, p.nx, p.ny); + return LanePoint(xM: x, yM: y); + } + + FramePoint laneToFrame(LanePoint p) { + final (x, y) = _applyHomography(_inv, p.xM, p.yM); + return FramePoint(nx: x, ny: y); + } + + List toRowMajorList() => List.unmodifiable(_m); + + static (double, double) _applyHomography(Float64List h, double x, double y) { + final xp = h[0] * x + h[1] * y + h[2]; + final yp = h[3] * x + h[4] * y + h[5]; + final wp = h[6] * x + h[7] * y + h[8]; + return (xp / wp, yp / wp); + } + + static Float64List _computeInverse(Float64List m) { + final a = m[0], b = m[1], c = m[2]; + final d = m[3], e = m[4], f = m[5]; + final g = m[6], h = m[7], i = m[8]; + final det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); + if (det.abs() < 1e-10) { + throw ArgumentError('특이 행렬(det ≈ 0)은 역행렬을 구할 수 없습니다. det=$det'); + } + final invDet = 1.0 / det; + final inv = Float64List(9); + inv[0] = (e * i - f * h) * invDet; + inv[1] = (c * h - b * i) * invDet; + inv[2] = (b * f - c * e) * invDet; + inv[3] = (f * g - d * i) * invDet; + inv[4] = (a * i - c * g) * invDet; + inv[5] = (c * d - a * f) * invDet; + inv[6] = (d * h - e * g) * invDet; + inv[7] = (b * g - a * h) * invDet; + inv[8] = (a * e - b * d) * invDet; + return inv; + } +} diff --git a/lib/features/analysis/domain/entities/impact_result.dart b/lib/features/analysis/domain/entities/impact_result.dart new file mode 100644 index 0000000..b09f2d9 --- /dev/null +++ b/lib/features/analysis/domain/entities/impact_result.dart @@ -0,0 +1,7 @@ +enum ImpactConfidence { high, medium, low } + +class ImpactResult { + final int frame; + final ImpactConfidence confidence; + const ImpactResult({required this.frame, required this.confidence}); +} diff --git a/lib/features/analysis/domain/entities/release_result.dart b/lib/features/analysis/domain/entities/release_result.dart new file mode 100644 index 0000000..aaa998d --- /dev/null +++ b/lib/features/analysis/domain/entities/release_result.dart @@ -0,0 +1,7 @@ +class ReleaseResult { + final int frame; + final double confidence; + const ReleaseResult({required this.frame, required this.confidence}); + static const ReleaseResult notFound = ReleaseResult(frame: 0, confidence: 0.0); + bool get isFound => confidence > 0; +} diff --git a/lib/features/analysis/domain/entities/speed_result.dart b/lib/features/analysis/domain/entities/speed_result.dart new file mode 100644 index 0000000..58c179e --- /dev/null +++ b/lib/features/analysis/domain/entities/speed_result.dart @@ -0,0 +1,13 @@ +enum SpeedFailure { releaseNotFound, impactNotFound, anchorMismatch, outOfRange, lowConfidence } + +class SpeedResult { + final double? kmh; + final double confidence; + final SpeedFailure? failure; + const SpeedResult({required this.kmh, required this.confidence, required this.failure}); + + factory SpeedResult.success(double kmh, double confidence) => + SpeedResult(kmh: kmh, confidence: confidence, failure: null); + factory SpeedResult.failed(SpeedFailure failure) => + SpeedResult(kmh: null, confidence: 0.0, failure: failure); +} diff --git a/lib/features/analysis/domain/repositories/calibration_repository.dart b/lib/features/analysis/domain/repositories/calibration_repository.dart new file mode 100644 index 0000000..5140318 --- /dev/null +++ b/lib/features/analysis/domain/repositories/calibration_repository.dart @@ -0,0 +1,10 @@ +import 'package:bowling_diary/features/analysis/domain/entities/calibration_profile.dart'; + +abstract class CalibrationRepository { + Future> listAll(); + Future getById(String id); + Future save(CalibrationProfile profile); + Future delete(String id); + Future getDefault(); + Future setDefault(String id); +} diff --git a/lib/features/analysis/domain/services/analysis_state_machine.dart b/lib/features/analysis/domain/services/analysis_state_machine.dart new file mode 100644 index 0000000..4d52576 --- /dev/null +++ b/lib/features/analysis/domain/services/analysis_state_machine.dart @@ -0,0 +1,160 @@ +import 'package:flutter/foundation.dart' show debugPrint; + +import 'package:bowling_diary/features/analysis/data/services/ball_detection_service.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; + +enum AnalysisPhase { idle, approach, release, flight, impact, settle } + +class AnalysisStateMachine { + AnalysisPhase _phase = AnalysisPhase.idle; + int _phaseStartFrame = 0; + + int? _releaseFrame; + int? _impactFrame; + + final List<({int frame, LanePoint lane})> _trajectory = []; + + final List<({int frame, double area})> _recentAreas = []; + static const _areaWindowSize = 5; + + int _flightNullCount = 0; + + final List _recentLaneY = []; + static const _laneYWindowSize = 3; + + AnalysisPhase get phase => _phase; + int? get releaseFrame => _releaseFrame; + int? get impactFrame => _impactFrame; + List<({int frame, LanePoint lane})> get trajectory => List.unmodifiable(_trajectory); + + void onFrame({ + required int frameIdx, + required BallDetection? detection, + required LanePoint? lanePos, + }) { + switch (_phase) { + case AnalysisPhase.idle: + _handleIdle(frameIdx, detection, lanePos); + case AnalysisPhase.approach: + _handleApproach(frameIdx, detection, lanePos); + case AnalysisPhase.release: + _handleRelease(frameIdx, detection, lanePos); + case AnalysisPhase.flight: + _handleFlight(frameIdx, detection, lanePos); + case AnalysisPhase.impact: + _handleImpact(frameIdx); + case AnalysisPhase.settle: + _handleSettle(frameIdx); + } + } + + void reset() { + _phase = AnalysisPhase.idle; + _phaseStartFrame = 0; + _releaseFrame = null; + _impactFrame = null; + _trajectory.clear(); + _recentAreas.clear(); + _recentLaneY.clear(); + _flightNullCount = 0; + } + + void _handleIdle(int frameIdx, BallDetection? detection, LanePoint? lanePos) { + if (detection != null) { + _transitionTo(AnalysisPhase.approach, frameIdx); + _recentAreas.clear(); + _recentLaneY.clear(); + _addArea(frameIdx, detection); + _addLaneY(lanePos); + } + } + + void _handleApproach(int frameIdx, BallDetection? detection, LanePoint? lanePos) { + if (detection == null) return; + _addArea(frameIdx, detection); + _addLaneY(lanePos); + if (_shouldTransitionToRelease()) { + _releaseFrame = frameIdx; + _transitionTo(AnalysisPhase.release, frameIdx); + } + } + + void _handleRelease(int frameIdx, BallDetection? detection, LanePoint? lanePos) { + if (lanePos != null) { + _trajectory.add((frame: frameIdx, lane: lanePos)); + } + if (frameIdx - _phaseStartFrame >= 4) { + _transitionTo(AnalysisPhase.flight, frameIdx); + } + } + + void _handleFlight(int frameIdx, BallDetection? detection, LanePoint? lanePos) { + if (lanePos != null) { + _trajectory.add((frame: frameIdx, lane: lanePos)); + } + if (lanePos != null && lanePos.yM >= 18.29) { + _impactFrame = frameIdx; + _transitionTo(AnalysisPhase.impact, frameIdx); + return; + } + if (detection == null) { + _flightNullCount++; + if (_flightNullCount >= 5) { + _impactFrame = frameIdx; + _transitionTo(AnalysisPhase.impact, frameIdx); + } + } else { + _flightNullCount = 0; + } + } + + void _handleImpact(int frameIdx) { + if (frameIdx - _phaseStartFrame >= 30) { + _transitionTo(AnalysisPhase.settle, frameIdx); + } + } + + void _handleSettle(int frameIdx) { + if (frameIdx - _phaseStartFrame >= 60) { + _transitionTo(AnalysisPhase.idle, frameIdx); + _trajectory.clear(); + } + } + + void _transitionTo(AnalysisPhase next, int frameIdx) { + debugPrint('[AnalysisFSM] ${_phase.name} → ${next.name} (frame $frameIdx)'); + _phase = next; + _phaseStartFrame = frameIdx; + } + + void _addArea(int frameIdx, BallDetection detection) { + final area = detection.bw * detection.bh; + _recentAreas.add((frame: frameIdx, area: area)); + if (_recentAreas.length > _areaWindowSize) _recentAreas.removeAt(0); + } + + void _addLaneY(LanePoint? lanePos) { + if (lanePos == null) return; + _recentLaneY.add(lanePos.yM); + if (_recentLaneY.length > _laneYWindowSize) _recentLaneY.removeAt(0); + } + + bool _shouldTransitionToRelease() { + if (_recentAreas.length < _areaWindowSize) return false; + final currentArea = _recentAreas.last.area; + final maxArea = _recentAreas.map((e) => e.area).reduce((a, b) => a > b ? a : b); + final pastPeak = currentArea < maxArea; + if (!pastPeak) return false; + if (_recentLaneY.length >= _laneYWindowSize) { + bool monotonicallyIncreasing = true; + for (int i = 1; i < _recentLaneY.length; i++) { + if (_recentLaneY[i] <= _recentLaneY[i - 1]) { + monotonicallyIncreasing = false; + break; + } + } + return monotonicallyIncreasing; + } + return true; + } +} diff --git a/lib/features/analysis/domain/services/calibration_drift_checker.dart b/lib/features/analysis/domain/services/calibration_drift_checker.dart new file mode 100644 index 0000000..aefd3de --- /dev/null +++ b/lib/features/analysis/domain/services/calibration_drift_checker.dart @@ -0,0 +1,161 @@ +import 'dart:math' show sqrt; + +import 'package:image/image.dart' as img; + +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/drift_check_result.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/homography_matrix.dart'; +import 'package:bowling_diary/features/analysis/domain/services/homography_solver.dart'; + +class CalibrationDriftChecker { + static const int _patchHalf = 15; // 31x31 패치 + static const int _searchRadius = 12; + static const double _autoCorrectThreshold = 0.01; // 프레임 대각선의 1% + static const double _recalibrationThreshold = 0.05; // 프레임 대각선의 5% + // NCC 최저 기준. 이 아래면 "매칭됐다"고 볼 수 없는 잡음/오매칭 — 카메라가 + // 크게 움직였거나 장면이 바뀐 상황과 구분이 안 되므로 재캘리브레이션으로 취급한다. + static const double _nccFloor = 0.5; + // 탐색 범위 경계(최대 offset)에서 최적점이 나오면, 진짜 최적점이 탐색창 밖에 + // 있을 가능성이 높다는 뜻 — 이 역시 신뢰 불가로 취급한다. + static const int _unreliablePointsForRecalibration = 2; + + DriftCheckResult check({ + required img.Image referenceFrame, + required img.Image currentFrame, + required List referencePoints, + required HomographyMatrix homography, + }) { + if (referencePoints.length != 4) { + throw ArgumentError('캘리브레이션 기준점은 4개여야 합니다. 현재: ${referencePoints.length}'); + } + + // 레퍼런스 이미지(캘리브레이션 시점 원본, 예: 갤러리 사진 3024px 폭)와 현재 프레임 + // (ffmpeg로 추출된 분석 프레임, 예: 480px 폭)은 해상도가 다른 것이 일반적이다. + // 기준점은 정규화 좌표(nx/ny)로 저장되므로, 두 이미지를 같은 크기로 맞춰야만 + // 픽셀 좌표가 서로 비교 가능해진다 — 여기서 리사이즈해 알고리즘 경계에서 불변식을 보장한다. + final resizedReference = (referenceFrame.width != currentFrame.width || + referenceFrame.height != currentFrame.height) + ? img.copyResize(referenceFrame, width: currentFrame.width, height: currentFrame.height) + : referenceFrame; + + final refGray = img.grayscale(resizedReference); + final curGray = img.grayscale(currentFrame); + final diagonal = sqrt( + currentFrame.width * currentFrame.width + currentFrame.height * currentFrame.height.toDouble(), + ); + + final measuredPoints = []; + double totalOffset = 0; + var unreliableCount = 0; + + for (final refPoint in referencePoints) { + final refX = (refPoint.nx * resizedReference.width).round(); + final refY = (refPoint.ny * resizedReference.height).round(); + + var bestDx = 0; + var bestDy = 0; + var bestScore = double.negativeInfinity; + + for (var dy = -_searchRadius; dy <= _searchRadius; dy++) { + for (var dx = -_searchRadius; dx <= _searchRadius; dx++) { + final candX = refX + dx; + final candY = refY + dy; + if (!_patchFits(candX, candY, curGray.width, curGray.height)) continue; + final score = _patchNcc(refGray, refX, refY, curGray, candX, candY); + if (score > bestScore) { + bestScore = score; + bestDx = dx; + bestDy = dy; + } + } + } + + // 최적 매칭 품질이 낮거나(NCC 바닥 미만), 탐색창 경계에서 최적점이 나온 경우 + // (=진짜 매칭이 탐색 범위 밖에 있을 가능성) 이 포인트는 신뢰할 수 없다. + final atSearchBoundary = bestDx.abs() == _searchRadius || bestDy.abs() == _searchRadius; + if (bestScore < _nccFloor || atSearchBoundary) { + unreliableCount++; + } + + final offsetPx = sqrt((bestDx * bestDx + bestDy * bestDy).toDouble()); + totalOffset += offsetPx; + + final movedX = (refX + bestDx) / currentFrame.width; + final movedY = (refY + bestDy) / currentFrame.height; + measuredPoints.add(FramePoint(nx: movedX, ny: movedY)); + } + + final avgOffsetPx = totalOffset / referencePoints.length; + final driftScoreNormalized = avgOffsetPx / diagonal; + + // 측정 가능한 최대 offset은 탐색 반경(≈17px)으로 제한되므로, 그 안에서 계산된 + // driftScoreNormalized만으로는 "재캘리브레이션 필요"에 절대 도달할 수 없다. + // 신뢰 불가 포인트가 다수면 오프셋 크기와 무관하게 재캘리브레이션으로 판단한다. + if (unreliableCount >= _unreliablePointsForRecalibration) { + return DriftCheckResult( + status: DriftStatus.recalibrationRequired, + homography: homography, + driftScoreNormalized: driftScoreNormalized, + ); + } + + if (driftScoreNormalized < _autoCorrectThreshold) { + return DriftCheckResult( + status: DriftStatus.ok, + homography: homography, + driftScoreNormalized: driftScoreNormalized, + ); + } + + if (driftScoreNormalized <= _recalibrationThreshold) { + final laneCorners = referencePoints + .map((p) => homography.frameToLane(p)) + .toList(); + final corrected = HomographySolver.solve4Point(measuredPoints, laneCorners); + return DriftCheckResult( + status: DriftStatus.autoCorrected, + homography: corrected, + driftScoreNormalized: driftScoreNormalized, + ); + } + + return DriftCheckResult( + status: DriftStatus.recalibrationRequired, + homography: homography, + driftScoreNormalized: driftScoreNormalized, + ); + } + + bool _patchFits(int cx, int cy, int width, int height) { + return cx - _patchHalf >= 0 && + cx + _patchHalf < width && + cy - _patchHalf >= 0 && + cy + _patchHalf < height; + } + + double _patchNcc(img.Image a, int ax, int ay, img.Image b, int bx, int by) { + if (!_patchFits(ax, ay, a.width, a.height)) return double.negativeInfinity; + double sumA = 0, sumB = 0, sumAB = 0, sumA2 = 0, sumB2 = 0; + var n = 0; + for (var dy = -_patchHalf; dy <= _patchHalf; dy++) { + for (var dx = -_patchHalf; dx <= _patchHalf; dx++) { + final va = img.getLuminance(a.getPixel(ax + dx, ay + dy)).toDouble(); + final vb = img.getLuminance(b.getPixel(bx + dx, by + dy)).toDouble(); + sumA += va; + sumB += vb; + sumAB += va * vb; + sumA2 += va * va; + sumB2 += vb * vb; + n++; + } + } + final meanA = sumA / n; + final meanB = sumB / n; + final numerator = sumAB - n * meanA * meanB; + final denomA = sumA2 - n * meanA * meanA; + final denomB = sumB2 - n * meanB * meanB; + final denom = sqrt(denomA * denomB); + if (denom < 1e-6) return 0.0; + return numerator / denom; + } +} diff --git a/lib/features/analysis/domain/services/homography_solver.dart b/lib/features/analysis/domain/services/homography_solver.dart new file mode 100644 index 0000000..4b988e9 --- /dev/null +++ b/lib/features/analysis/domain/services/homography_solver.dart @@ -0,0 +1,70 @@ +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/homography_matrix.dart'; + +class HomographySolver { + HomographySolver._(); + + static HomographyMatrix solve4Point(List frame, List lane) { + if (frame.length != 4 || lane.length != 4) { + throw ArgumentError( + '대응점은 정확히 4쌍이어야 합니다. frame.length=${frame.length}, lane.length=${lane.length}', + ); + } + final a = List.generate(8, (_) => List.filled(9, 0.0)); + for (var i = 0; i < 4; i++) { + final xi = frame[i].nx; + final yi = frame[i].ny; + final Xi = lane[i].xM; + final Yi = lane[i].yM; + a[2 * i][0] = xi; a[2 * i][1] = yi; a[2 * i][2] = 1.0; + a[2 * i][3] = 0.0; a[2 * i][4] = 0.0; a[2 * i][5] = 0.0; + a[2 * i][6] = -Xi * xi; a[2 * i][7] = -Xi * yi; a[2 * i][8] = Xi; + a[2 * i + 1][0] = 0.0; a[2 * i + 1][1] = 0.0; a[2 * i + 1][2] = 0.0; + a[2 * i + 1][3] = xi; a[2 * i + 1][4] = yi; a[2 * i + 1][5] = 1.0; + a[2 * i + 1][6] = -Yi * xi; a[2 * i + 1][7] = -Yi * yi; a[2 * i + 1][8] = Yi; + } + final h = _gaussianElimination(a); + final values = [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0]; + return HomographyMatrix.fromRowMajor(values); + } + + static List _gaussianElimination(List> augmented) { + const n = 8; + final a = [for (final row in augmented) List.of(row)]; + for (var col = 0; col < n; col++) { + var maxRow = col; + var maxVal = a[col][col].abs(); + for (var row = col + 1; row < n; row++) { + if (a[row][col].abs() > maxVal) { + maxVal = a[row][col].abs(); + maxRow = row; + } + } + if (maxVal < 1e-12) { + throw ArgumentError( + '호모그래피를 풀 수 없습니다: 행렬이 특이합니다 (열 $col의 피벗 ≈ 0). ' + '4개 대응점이 일반 위치(general position)에 있는지 확인하세요.', + ); + } + if (maxRow != col) { + final tmp = a[col]; a[col] = a[maxRow]; a[maxRow] = tmp; + } + final pivot = a[col][col]; + for (var row = col + 1; row < n; row++) { + final factor = a[row][col] / pivot; + for (var j = col; j <= n; j++) { + a[row][j] -= factor * a[col][j]; + } + } + } + final x = List.filled(n, 0.0); + for (var i = n - 1; i >= 0; i--) { + x[i] = a[i][n]; + for (var j = i + 1; j < n; j++) { + x[i] -= a[i][j] * x[j]; + } + x[i] /= a[i][i]; + } + return x; + } +} diff --git a/lib/features/analysis/presentation/pages/analysis_camera_page.dart b/lib/features/analysis/presentation/pages/analysis_camera_page.dart index 88357e5..49ee738 100644 --- a/lib/features/analysis/presentation/pages/analysis_camera_page.dart +++ b/lib/features/analysis/presentation/pages/analysis_camera_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; @@ -24,6 +26,9 @@ class _AnalysisCameraPageState extends State { String? _analyzingVideoPath; String? _error; + Timer? _recordingTimer; + int _recordingSeconds = 0; + @override void initState() { super.initState(); @@ -39,8 +44,9 @@ class _AnalysisCameraPageState extends State { _isInitialized = true; }); } catch (e) { + debugPrint('카메라 초기화 실패: $e'); if (!mounted) return; - setState(() => _error = '카메라 초기화 실패: $e'); + setState(() => _error = '카메라를 열 수 없어요. 앱 권한을 확인해 주세요'); } } @@ -50,11 +56,20 @@ class _AnalysisCameraPageState extends State { } else { await _cameraService.startRecording(); if (!mounted) return; - setState(() => _isRecording = true); + setState(() { + _isRecording = true; + _recordingSeconds = 0; + }); + _recordingTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() => _recordingSeconds++); + }); } } Future _stopAndAnalyze() async { + _recordingTimer?.cancel(); + _recordingTimer = null; setState(() { _isRecording = false; _isAnalyzing = true; @@ -75,31 +90,65 @@ class _AnalysisCameraPageState extends State { ), ); } catch (e) { + debugPrint('분석 중 오류: $e'); if (!mounted) return; setState(() { _isAnalyzing = false; - _error = '분석 중 오류: $e'; + _error = '녹화 처리 중 문제가 발생했어요. 다시 시도해 주세요'; }); } } + String _formatElapsed(int seconds) { + final m = (seconds ~/ 60).toString().padLeft(2, '0'); + final s = (seconds % 60).toString().padLeft(2, '0'); + return '$m:$s'; + } + @override void dispose() { + _recordingTimer?.cancel(); _cameraService.dispose(); super.dispose(); } + Widget _buildCloseButton() { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(8), + child: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + style: IconButton.styleFrom( + backgroundColor: Colors.black.withValues(alpha: 0.4), + shape: const CircleBorder(), + ), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ); + } + @override Widget build(BuildContext context) { if (_error != null) { return Scaffold( - body: Center(child: Text(_error!, style: AppTextStyles.bodyMedium)), + body: Stack( + children: [ + Center(child: Text(_error!, style: AppTextStyles.bodyMedium)), + Align(alignment: Alignment.topLeft, child: _buildCloseButton()), + ], + ), ); } if (!_isInitialized || _controller == null) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), + return Scaffold( + body: Stack( + children: [ + const Center(child: CircularProgressIndicator()), + Align(alignment: Alignment.topLeft, child: _buildCloseButton()), + ], + ), ); } @@ -115,6 +164,40 @@ class _AnalysisCameraPageState extends State { children: [ CameraPreview(_controller!), CameraGuideOverlay(isRecording: _isRecording), + Align(alignment: Alignment.topLeft, child: _buildCloseButton()), + if (_isRecording) + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Center( + child: Container( + margin: const EdgeInsets.only(top: 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + _formatElapsed(_recordingSeconds), + style: AppTextStyles.bodySmall.copyWith(color: Colors.white), + ), + ], + ), + ), + ), + ), + ), Positioned( bottom: 60, left: 0, diff --git a/lib/features/analysis/presentation/pages/analysis_detail_page.dart b/lib/features/analysis/presentation/pages/analysis_detail_page.dart index 96e7422..73bb8a9 100644 --- a/lib/features/analysis/presentation/pages/analysis_detail_page.dart +++ b/lib/features/analysis/presentation/pages/analysis_detail_page.dart @@ -79,7 +79,6 @@ class _AnalysisDetailPageState extends State Widget build(BuildContext context) { final r = widget.result; final hasSpeed = r.speedKmh != null; - final hasRpm = r.rpmEstimated != null; final dateStr = DateFormat('yyyy.MM.dd HH:mm').format(r.recordedAt); return Scaffold( @@ -209,140 +208,74 @@ class _AnalysisDetailPageState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 구속 - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (hasSpeed) ...[ - Text( - r.speedKmh!.toStringAsFixed(1), - style: TextStyle( - fontSize: 56, - fontWeight: FontWeight.w800, - color: AppColors.neonOrange, - height: 1.0, - letterSpacing: -1.5, + // 구속 (단독 지표 — 중앙 정렬) + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (hasSpeed) ...[ + Text( + r.speedKmh!.toStringAsFixed(1), + style: TextStyle( + fontSize: 56, + fontWeight: FontWeight.w800, + color: AppColors.neonOrange, + height: 1.0, + letterSpacing: -1.5, + ), ), - ), - Padding( - padding: const EdgeInsets.only( - bottom: 8, left: 6), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text('km/h', - style: TextStyle( - fontSize: 15, - color: AppColors.neonOrange - .withValues(alpha: 0.75), - fontWeight: FontWeight.w500, - )), - const Text('구속', - style: TextStyle( - fontSize: 10, - color: Colors.white38, - letterSpacing: 1.2, - )), - ], + Padding( + padding: const EdgeInsets.only( + bottom: 8, left: 6), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text('km/h', + style: TextStyle( + fontSize: 15, + color: AppColors.neonOrange + .withValues(alpha: 0.75), + fontWeight: FontWeight.w500, + )), + const Text('구속', + style: TextStyle( + fontSize: 10, + color: Colors.white38, + letterSpacing: 1.2, + )), + ], + ), ), - ), - ] else - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '측정불가', - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.w600, - color: Colors.white24, - height: 1.0, - letterSpacing: -0.5, - ), - ), - const SizedBox(height: 2), - const Text('구속', + ] else + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '측정불가', style: TextStyle( - fontSize: 10, + fontSize: 32, + fontWeight: FontWeight.w600, color: Colors.white24, - letterSpacing: 1.2, - )), - ], - ), - ), - ], - ), - - Divider( - color: Colors.white.withValues(alpha: 0.1), - height: 20, - ), - - // RPM - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (hasRpm) ...[ - Text( - '${r.rpmEstimated}', - style: const TextStyle( - fontSize: 44, - fontWeight: FontWeight.w800, - color: Colors.white, - height: 1.0, - letterSpacing: -1, - ), - ), - const Padding( - padding: EdgeInsets.only(bottom: 6, left: 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('rpm', - style: TextStyle( - fontSize: 14, - color: Colors.white60, - fontWeight: FontWeight.w500, - )), - Text('RPM 추정값', - style: TextStyle( - fontSize: 10, - color: Colors.white30, - letterSpacing: 0.5, - )), - ], - ), - ), - ] else - const Padding( - padding: EdgeInsets.only(bottom: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '측정불가', - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.w600, - color: Colors.white24, - height: 1.0, - letterSpacing: -0.5, + height: 1.0, + letterSpacing: -0.5, + ), ), - ), - SizedBox(height: 2), - Text('RPM', - style: TextStyle( - fontSize: 10, - color: Colors.white24, - letterSpacing: 0.5, - )), - ], + const SizedBox(height: 2), + const Text('구속', + style: TextStyle( + fontSize: 10, + color: Colors.white24, + letterSpacing: 1.2, + )), + ], + ), ), - ), - ], + ], + ), ), if (r.linkedSessionId != null) ...[ diff --git a/lib/features/analysis/presentation/pages/analysis_result_page.dart b/lib/features/analysis/presentation/pages/analysis_result_page.dart index e98cbde..fd991f7 100644 --- a/lib/features/analysis/presentation/pages/analysis_result_page.dart +++ b/lib/features/analysis/presentation/pages/analysis_result_page.dart @@ -2,14 +2,16 @@ import 'dart:io' as dart_io; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:uuid/uuid.dart'; import 'package:video_player/video_player.dart'; import 'package:bowling_diary/app/theme/app_colors.dart'; import 'package:bowling_diary/app/theme/app_text_styles.dart'; -import 'package:bowling_diary/features/analysis/data/services/video_analysis_service.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/analysis_data.dart'; import 'package:bowling_diary/features/analysis/domain/entities/analysis_result_entity.dart'; import 'package:bowling_diary/features/analysis/presentation/providers/analysis_provider.dart'; +import 'package:bowling_diary/features/analysis/presentation/utils/speed_failure_copy.dart'; import 'package:bowling_diary/features/auth/presentation/providers/auth_provider.dart'; import 'package:bowling_diary/features/record/domain/entities/session_entity.dart'; @@ -80,11 +82,12 @@ class _AnalysisResultPageState extends ConsumerState userId: auth.user.id, recordedAt: widget.recordedAt, speedKmh: widget.analysisData.speedKmh, - rpmEstimated: widget.analysisData.rpmEstimated, fpsUsed: widget.analysisData.fpsUsed, videoLocalPath: widget.videoPath, linkedSessionId: linkedSessionId, createdAt: DateTime.now(), + speedConfidence: widget.analysisData.speedConfidence, + speedFailureReason: widget.analysisData.speedFailure?.name, ); await ref.read(analysisRepositoryProvider).save(entity); @@ -279,6 +282,42 @@ class _AnalysisResultPageState extends ConsumerState child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (data.speedKmh == null) ...[ + Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + speedFailureUserMessage(data.speedFailure, data.driftStatus), + style: AppTextStyles.bodySmall.copyWith(color: Colors.white70), + textAlign: TextAlign.center, + ), + ), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.neonOrange, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), + onPressed: () => context.go('/analysis/camera'), + child: Text( + '다시 촬영하기', + style: AppTextStyles.bodyLarge.copyWith( + fontWeight: FontWeight.w700), + ), + ), + ), + const SizedBox(height: 10), + ], // 구속 _StatRow( label: '구속', @@ -286,20 +325,6 @@ class _AnalysisResultPageState extends ConsumerState unit: 'km/h', highlight: true, ), - const SizedBox(height: 2), - // 구분선 - Divider( - color: Colors.white.withValues(alpha: 0.1), - height: 16, - ), - // RPM - _StatRow( - label: 'RPM', - value: data.rpmEstimated?.toString(), - unit: 'rpm', - highlight: false, - badge: '추정값', - ), const SizedBox(height: 12), Text( '* AI 측정으로 정확하지 않을 수 있습니다', @@ -310,10 +335,34 @@ class _AnalysisResultPageState extends ConsumerState ), ), const SizedBox(height: 16), - // 저장 버튼 + // 저장 버튼 (측정 실패 시 다시 촬영하기가 primary이므로 secondary로) SizedBox( width: double.infinity, - child: ElevatedButton( + child: data.speedKmh == null + ? OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.neonOrange, + side: BorderSide(color: AppColors.neonOrange), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: _isSaving ? null : _onSavePressed, + child: _isSaving + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.neonOrange)) + : Text( + '저장하기', + style: AppTextStyles.bodyLarge.copyWith( + fontWeight: FontWeight.w700), + ), + ) + : ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: AppColors.neonOrange, foregroundColor: Colors.black, diff --git a/lib/features/analysis/presentation/pages/analysis_selection_page.dart b/lib/features/analysis/presentation/pages/analysis_selection_page.dart index a7e75e7..c452be0 100644 --- a/lib/features/analysis/presentation/pages/analysis_selection_page.dart +++ b/lib/features/analysis/presentation/pages/analysis_selection_page.dart @@ -97,9 +97,9 @@ class AnalysisSelectionPage extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.05), + color: AppColors.darkSurface, borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + border: Border.all(color: AppColors.darkDivider), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -126,7 +126,7 @@ class AnalysisSelectionPage extends StatelessWidget { ), const SizedBox(height: 6), Text( - '💡 단색 공이라면 인서트 테이프를 붙이면 회전수 측정 정확도가 높아져요.', + '💡 공이 릴리즈부터 핀까지 한 화면에 나오면 측정 정확도가 높아져요.', style: AppTextStyles.bodySmall .copyWith(color: AppColors.textSecondary), ), diff --git a/lib/features/analysis/presentation/pages/analysis_trim_page.dart b/lib/features/analysis/presentation/pages/analysis_trim_page.dart index fbe2bb6..c960a36 100644 --- a/lib/features/analysis/presentation/pages/analysis_trim_page.dart +++ b/lib/features/analysis/presentation/pages/analysis_trim_page.dart @@ -1,18 +1,24 @@ import 'dart:io'; import 'package:ffmpeg_kit_flutter_new/ffmpeg_kit.dart'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:video_player/video_player.dart'; import 'package:bowling_diary/app/theme/app_colors.dart'; import 'package:bowling_diary/app/theme/app_text_styles.dart'; +import 'package:bowling_diary/features/analysis/data/repositories/calibration_repository_impl.dart'; +import 'package:bowling_diary/features/analysis/data/services/analysis_pipeline.dart'; import 'package:bowling_diary/features/analysis/data/services/ball_detection_service.dart'; -import 'package:bowling_diary/features/analysis/data/services/ball_rotation_tracker_service.dart'; -import 'package:bowling_diary/features/analysis/data/services/gemini_analysis_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/impact_detector_service.dart'; import 'package:bowling_diary/features/analysis/data/services/pin_impact_detector_service.dart'; import 'package:bowling_diary/features/analysis/data/services/release_detector_service.dart'; -import 'package:bowling_diary/features/analysis/data/services/video_analysis_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/speed_estimator_service.dart'; import 'package:bowling_diary/features/analysis/data/services/video_frame_extractor_service.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/calibration_profile.dart'; +import 'package:bowling_diary/features/analysis/domain/services/calibration_drift_checker.dart'; import 'package:bowling_diary/features/analysis/presentation/pages/analysis_result_page.dart'; +import 'package:bowling_diary/features/analysis/presentation/pages/calibration_page.dart'; import 'package:bowling_diary/features/analysis/presentation/widgets/analysis_loading_widget.dart'; class AnalysisTrimPage extends StatefulWidget { @@ -30,24 +36,69 @@ class AnalysisTrimPage extends StatefulWidget { } class _AnalysisTrimPageState extends State { - final _geminiService = GeminiAnalysisService(); - final _frameExtractor = VideoFrameExtractorService(); - final _ballDetector = BallDetectionService(); - final _releaseDetector = ReleaseDetectorService(); - final _pinImpactDetector = PinImpactDetectorService(); - final _rotationTracker = BallRotationTrackerService(); - VideoPlayerController? _controller; double _startSec = 0; double _endSec = 0; double _totalSec = 0; bool _isAnalyzing = false; String? _trimmedPath; + CalibrationProfile? _calibrationProfile; @override void initState() { super.initState(); _initVideo(); + _loadDefaultCalibrationProfile(); + } + + Future _loadDefaultCalibrationProfile() async { + final prefs = await SharedPreferences.getInstance(); + final profile = await CalibrationRepositoryImpl(prefs).getDefault(); + if (mounted) setState(() => _calibrationProfile = profile); + } + + Future _openCalibration() async { + final picker = ImagePicker(); + final picked = await picker.pickImage(source: ImageSource.gallery); + if (picked == null) return; + if (!mounted) return; + final profile = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => CalibrationPage(referenceImagePath: picked.path)), + ); + if (profile != null) { + await _loadDefaultCalibrationProfile(); + } + } + + Future _showCalibrationRequiredDialog() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: AppColors.darkCard, + title: Text( + '레인 캘리브레이션이 필요해요', + style: AppTextStyles.headingSmall.copyWith(color: AppColors.textPrimary), + ), + content: Text( + '첫 분석 전에 레인 위치를 한 번만 알려주면 돼요.\n레인이 잘 보이는 사진으로 4개 지점을 탭합니다.', + style: AppTextStyles.bodyMedium.copyWith(color: AppColors.textSecondary), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text('취소', style: AppTextStyles.bodyMedium.copyWith(color: AppColors.textSecondary)), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text('캘리브레이션 하기', style: AppTextStyles.bodyMedium.copyWith(color: AppColors.neonOrange, fontWeight: FontWeight.w600)), + ), + ], + ), + ); + if (confirmed == true && mounted) { + await _openCalibration(); + } } Future _initVideo() async { @@ -99,14 +150,16 @@ class _AnalysisTrimPageState extends State { } Future _startAnalysis() async { - setState(() => _isAnalyzing = true); + final profile = _calibrationProfile; + if (profile == null) { + await _showCalibrationRequiredDialog(); + return; + } + setState(() => _isAnalyzing = true); try { - // 1. ffmpeg로 선택 구간 자르기 final tempDir = await getTemporaryDirectory(); - final trimmedPath = - '${tempDir.path}/trimmed_${DateTime.now().millisecondsSinceEpoch}.mp4'; - + final trimmedPath = '${tempDir.path}/trimmed_${DateTime.now().millisecondsSinceEpoch}.mp4'; final session = await FFmpegKit.execute( '-i "${widget.videoPath}" -ss $_startSec -to $_endSec -c copy "$trimmedPath"', ); @@ -115,97 +168,28 @@ class _AnalysisTrimPageState extends State { if (mounted) setState(() => _trimmedPath = trimmedPath); - // 2. 프레임 추출 (30fps) - final extracted = await _frameExtractor.extract(trimmedPath); - - // 3. YOLO 볼 감지 (전 프레임) - List ballDetections = []; - try { - await _ballDetector.init(); - ballDetections = extracted.frames.map((f) => _ballDetector.detect(f)).toList(); - } catch (e) { - debugPrint('[Trim] YOLO 오류: $e'); - } finally { - _ballDetector.dispose(); - } - - // 4. 릴리즈 프레임 감지 - final releaseFrame = _releaseDetector.findReleaseFrame(ballDetections) ?? 0; - debugPrint('[Trim] 릴리즈 프레임: $releaseFrame'); - - // 5. 핀 충돌 프레임 감지 - final impactFrame = _pinImpactDetector.findImpactFrame(extracted.frames, releaseFrame); - debugPrint('[Trim] 핀 충돌 프레임: $impactFrame'); - - // 6. Gemini 통합 분석 (속도 + RPM) — 1차 - double? speedKmh; - int? rpm; - - try { - final geminiResult = await _geminiService.analyzeUnified( - frames: extracted.frames, - ballDetections: ballDetections, - releaseFrame: releaseFrame, - sampleFps: extracted.sampleFps, - ); - speedKmh = geminiResult.speedKmh; - rpm = geminiResult.rpmEstimated; - debugPrint('[Trim] Gemini 통합 결과: ${speedKmh?.toStringAsFixed(1) ?? '측정불가'}km/h, RPM=$rpm'); - } on GeminiQuotaExceededException { - debugPrint('[Trim] Gemini 할당량 초과 → 로컬 폴백'); - } catch (e) { - debugPrint('[Trim] Gemini 오류: $e → 로컬 폴백'); - } - - // 속도 폴백: 이벤트 기반 → YOLO - if (speedKmh == null) { - if (impactFrame != null && impactFrame > releaseFrame) { - const laneLength = 18.29; - final elapsedSec = (impactFrame - releaseFrame) / extracted.sampleFps.toDouble(); - final raw = (laneLength / elapsedSec) * 3.6; - if (raw >= 10 && raw <= 50) { - speedKmh = double.parse(raw.toStringAsFixed(1)); - debugPrint('[Trim] 이벤트 기반 구속 폴백: ${speedKmh}km/h'); - } - } - speedKmh ??= BallTracker.calcSpeedKmh(ballDetections, extracted.sampleFps.toDouble()); - debugPrint('[Trim] YOLO 폴백 구속: ${speedKmh?.toStringAsFixed(1) ?? '측정불가'}km/h'); - } - - // RPM 폴백: 지공 홀 추적 - if (rpm == null) { - rpm = _rotationTracker.trackRpm( - extracted.frames, - ballDetections, - releaseFrame, - extracted.sampleFps, - ); - debugPrint('[Trim] 로컬 RPM 폴백: $rpm'); - } - - final analysisData = AnalysisData( - speedKmh: speedKmh, - rpmEstimated: rpm, - framesAnalyzed: extracted.frames.length, - fpsUsed: extracted.sampleFps, + final pipeline = AnalysisPipeline( + frameExtractor: VideoFrameExtractorService(), + ballDetector: BallDetectionService(), + releaseDetector: ReleaseDetectorService(), + impactDetector: ImpactDetectorService(pinImpactDetector: PinImpactDetectorService()), + speedEstimator: SpeedEstimatorService(), + driftChecker: CalibrationDriftChecker(), ); + final analysisData = await pipeline.run(trimmedPath, profile); if (!mounted) return; - await Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => AnalysisResultPage( - analysisData: analysisData, - videoPath: trimmedPath, - recordedAt: DateTime.now(), - ), + await Navigator.pushReplacement(context, MaterialPageRoute( + builder: (_) => AnalysisResultPage( + analysisData: analysisData, videoPath: trimmedPath, recordedAt: DateTime.now(), ), - ); + )); } catch (e) { + debugPrint('분석 실패: $e'); if (!mounted) return; setState(() => _isAnalyzing = false); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('분석 실패: $e')), + const SnackBar(content: Text('분석 중 문제가 발생했어요. 다시 시도해 주세요')), ); } } @@ -242,7 +226,16 @@ class _AnalysisTrimPageState extends State { final selectedDuration = _endSec - _startSec; return Scaffold( - appBar: AppBar(title: const Text('구간 선택')), + appBar: AppBar( + title: const Text('구간 선택'), + actions: [ + IconButton( + icon: const Icon(Icons.straighten), + tooltip: '레인 캘리브레이션', + onPressed: _openCalibration, + ), + ], + ), body: Column( children: [ // 영상 미리보기 (최대 화면 45%) diff --git a/lib/features/analysis/presentation/pages/calibration_page.dart b/lib/features/analysis/presentation/pages/calibration_page.dart new file mode 100644 index 0000000..db973cc --- /dev/null +++ b/lib/features/analysis/presentation/pages/calibration_page.dart @@ -0,0 +1,183 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image/image.dart' as img; +import 'package:bowling_diary/app/theme/app_colors.dart'; +import 'package:bowling_diary/app/theme/app_text_styles.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/calibration_profile.dart'; +import 'package:bowling_diary/features/analysis/presentation/providers/calibration_providers.dart'; +import 'package:bowling_diary/features/analysis/presentation/widgets/calibration_overlay.dart'; + +const _stepGuides = [ + '① 파울라인 왼쪽 끝을 탭하세요', + '② 파울라인 오른쪽 끝을 탭하세요', + '③ 핀 쪽 레인 오른쪽 끝을 탭하세요', + '④ 핀 쪽 레인 왼쪽 끝을 탭하세요', +]; + +class CalibrationPage extends ConsumerStatefulWidget { + final String referenceImagePath; + const CalibrationPage({super.key, required this.referenceImagePath}); + + @override + ConsumerState createState() => _CalibrationPageState(); +} + +class _CalibrationPageState extends ConsumerState { + Size? _imageSize; + bool _imageLoadFailed = false; + + @override + void initState() { + super.initState(); + _loadImageSize(); + } + + Future _loadImageSize() async { + try { + final bytes = await File(widget.referenceImagePath).readAsBytes(); + final decoded = img.decodeImage(bytes); + if (!mounted) return; + if (decoded == null) { + setState(() => _imageLoadFailed = true); + return; + } + setState(() { + _imageSize = Size(decoded.width.toDouble(), decoded.height.toDouble()); + }); + } catch (_) { + if (!mounted) return; + setState(() => _imageLoadFailed = true); + } + } + + @override + Widget build(BuildContext context) { + final vm = ref.watch(calibrationVMProvider.notifier); + final state = ref.watch(calibrationVMProvider); + final repoAsync = ref.watch(calibrationRepoProvider); + + if (repoAsync.isLoading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + if (repoAsync.hasError) { + return Scaffold( + body: Center(child: Text('저장소 초기화 실패: ${repoAsync.error}', style: AppTextStyles.bodyMedium)), + ); + } + if (_imageLoadFailed) { + return Scaffold( + body: Center(child: Text('이미지를 불러올 수 없습니다', style: AppTextStyles.bodyMedium)), + ); + } + + final step = state.framePoints.length; + + return Scaffold( + backgroundColor: AppColors.darkBg, + appBar: AppBar( + backgroundColor: AppColors.darkCard, + title: Text('레인 캘리브레이션', style: AppTextStyles.headingSmall.copyWith(color: AppColors.textPrimary)), + iconTheme: IconThemeData(color: AppColors.textPrimary), + actions: [ + IconButton(icon: const Icon(Icons.undo), tooltip: '마지막 점 제거', onPressed: vm.undo), + ], + ), + body: Column( + children: [ + Expanded( + child: _imageSize == null + ? const Center(child: CircularProgressIndicator()) + : Stack( + fit: StackFit.expand, + children: [ + Image.file(File(widget.referenceImagePath), fit: BoxFit.contain), + CalibrationOverlay( + points: state.framePoints, + onTap: vm.addPoint, + imageSize: _imageSize!, + ), + ], + ), + ), + Container( + color: AppColors.darkCard, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + step < 4 + ? _stepGuides[step] + : '4점 입력 완료. 이름과 시점 선택 후 저장.', + style: AppTextStyles.bodySmall.copyWith( + color: step < 4 ? AppColors.neonOrange : AppColors.mint, + ), + textAlign: TextAlign.center, + ), + if (step == 4) ...[ + const SizedBox(height: 12), + TextField( + onChanged: vm.setName, + style: AppTextStyles.bodyMedium.copyWith(color: AppColors.textPrimary), + decoration: InputDecoration( + labelText: '프로파일 이름', + labelStyle: AppTextStyles.bodySmall.copyWith(color: AppColors.textHint), + filled: true, + fillColor: AppColors.darkSurface, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + ), + const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: state.viewpoint, + onChanged: (v) { if (v != null) vm.setViewpoint(v); }, + dropdownColor: AppColors.darkSurface, + style: AppTextStyles.bodyMedium.copyWith(color: AppColors.textPrimary), + decoration: InputDecoration( + labelText: '카메라 시점', + labelStyle: AppTextStyles.bodySmall.copyWith(color: AppColors.textHint), + filled: true, + fillColor: AppColors.darkSurface, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + items: const [ + DropdownMenuItem(value: CameraViewpoint.backRight, child: Text('뒤편 우측')), + DropdownMenuItem(value: CameraViewpoint.backLeft, child: Text('뒤편 좌측')), + DropdownMenuItem(value: CameraViewpoint.sideRight, child: Text('측면 우측')), + DropdownMenuItem(value: CameraViewpoint.sideLeft, child: Text('측면 좌측')), + ], + ), + const SizedBox(height: 14), + ElevatedButton( + onPressed: (state.saving || state.name.trim().isEmpty) + ? null + : () async { + final profile = await vm.save(referenceImagePath: widget.referenceImagePath); + if (profile != null && context.mounted) { + Navigator.of(context).pop(profile); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.neonOrange, + disabledBackgroundColor: AppColors.darkDivider, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: state.saving + ? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : Text('저장', style: AppTextStyles.bodyMedium.copyWith(color: Colors.white, fontWeight: FontWeight.w600)), + ), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/analysis/presentation/providers/calibration_providers.dart b/lib/features/analysis/presentation/providers/calibration_providers.dart new file mode 100644 index 0000000..2b19cc9 --- /dev/null +++ b/lib/features/analysis/presentation/providers/calibration_providers.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:bowling_diary/features/analysis/data/repositories/calibration_repository_impl.dart'; +import 'package:bowling_diary/features/analysis/domain/repositories/calibration_repository.dart'; +import 'package:bowling_diary/features/analysis/presentation/viewmodels/calibration_view_model.dart'; + +final calibrationRepoProvider = FutureProvider((ref) async { + final prefs = await SharedPreferences.getInstance(); + return CalibrationRepositoryImpl(prefs); +}); + +final calibrationVMProvider = + StateNotifierProvider.autoDispose((ref) { + final repoAsync = ref.watch(calibrationRepoProvider); + final repo = repoAsync.maybeWhen(data: (r) => r, orElse: () => null); + if (repo == null) { + throw StateError('캘리브레이션 저장소 로드 실패'); + } + return CalibrationViewModel(repo); +}); diff --git a/lib/features/analysis/presentation/utils/speed_failure_copy.dart b/lib/features/analysis/presentation/utils/speed_failure_copy.dart new file mode 100644 index 0000000..e1df9f0 --- /dev/null +++ b/lib/features/analysis/presentation/utils/speed_failure_copy.dart @@ -0,0 +1,19 @@ +import 'package:bowling_diary/features/analysis/domain/entities/drift_check_result.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/speed_result.dart'; + +String speedFailureUserMessage(SpeedFailure? failure, DriftStatus driftStatus) { + if (driftStatus == DriftStatus.recalibrationRequired) { + return '카메라 위치를 다시 확인하고 촬영해 주세요'; + } + switch (failure) { + case SpeedFailure.releaseNotFound: + case SpeedFailure.impactNotFound: + case SpeedFailure.lowConfidence: + return '공을 인식하지 못했어요. 좀 더 밝은 곳에서 다시 찍어 주세요'; + case SpeedFailure.anchorMismatch: + case SpeedFailure.outOfRange: + return '분석에 실패했어요. 다시 촬영해 주세요'; + case null: + return ''; + } +} diff --git a/lib/features/analysis/presentation/viewmodels/calibration_view_model.dart b/lib/features/analysis/presentation/viewmodels/calibration_view_model.dart new file mode 100644 index 0000000..88edafa --- /dev/null +++ b/lib/features/analysis/presentation/viewmodels/calibration_view_model.dart @@ -0,0 +1,81 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/calibration_profile.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/repositories/calibration_repository.dart'; +import 'package:bowling_diary/features/analysis/domain/services/homography_solver.dart'; + +class CalibrationState { + final List framePoints; + final CameraViewpoint viewpoint; + final String name; + final bool saving; + + const CalibrationState({ + this.framePoints = const [], + this.viewpoint = CameraViewpoint.backRight, + this.name = '', + this.saving = false, + }); + + CalibrationState copyWith({ + List? framePoints, + CameraViewpoint? viewpoint, + String? name, + bool? saving, + }) { + return CalibrationState( + framePoints: framePoints ?? this.framePoints, + viewpoint: viewpoint ?? this.viewpoint, + name: name ?? this.name, + saving: saving ?? this.saving, + ); + } +} + +class CalibrationViewModel extends StateNotifier { + final CalibrationRepository repo; + CalibrationViewModel(this.repo) : super(const CalibrationState()); + + void addPoint(FramePoint p) { + if (state.framePoints.length >= 4) return; + state = state.copyWith(framePoints: [...state.framePoints, p]); + } + + void undo() { + if (state.framePoints.isEmpty) return; + state = state.copyWith(framePoints: state.framePoints.sublist(0, state.framePoints.length - 1)); + } + + void setViewpoint(CameraViewpoint v) => state = state.copyWith(viewpoint: v); + void setName(String n) => state = state.copyWith(name: n); + + /// [referenceImagePath]는 drift-check(CalibrationDriftChecker)가 나중에 재사용할 + /// 캘리브레이션 시점 프레임 이미지 경로다. + Future save({required String referenceImagePath}) async { + if (state.framePoints.length < 4 || state.name.trim().isEmpty) return null; + + state = state.copyWith(saving: true); + try { + const lanePts = [ + LanePoint(xM: 0, yM: 0), LanePoint(xM: 1.05, yM: 0), + LanePoint(xM: 1.05, yM: 18.29), LanePoint(xM: 0, yM: 18.29), + ]; + final homography = HomographySolver.solve4Point(state.framePoints, lanePts); + final profile = CalibrationProfile( + id: const Uuid().v4(), + name: state.name.trim(), + viewpoint: state.viewpoint, + homography: homography, + createdAt: DateTime.now(), + referenceImagePath: referenceImagePath, + framePoints: state.framePoints, + ); + await repo.save(profile); + await repo.setDefault(profile.id); + return profile; + } finally { + state = state.copyWith(saving: false); + } + } +} diff --git a/lib/features/analysis/presentation/widgets/analysis_history_card.dart b/lib/features/analysis/presentation/widgets/analysis_history_card.dart index 453941e..cfc58c8 100644 --- a/lib/features/analysis/presentation/widgets/analysis_history_card.dart +++ b/lib/features/analysis/presentation/widgets/analysis_history_card.dart @@ -16,7 +16,6 @@ class AnalysisHistoryCard extends StatelessWidget { final date = DateFormat('MM.dd').format(result.recordedAt); final time = DateFormat('HH:mm').format(result.recordedAt); final hasSpeed = result.speedKmh != null; - final hasRpm = result.rpmEstimated != null; return GestureDetector( onTap: onTap, @@ -99,14 +98,6 @@ class AnalysisHistoryCard extends StatelessWidget { ), ], ), - const SizedBox(height: 2), - Text( - hasRpm ? '${result.rpmEstimated} RPM' : 'RPM 미측정', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - fontSize: 12, - ), - ), ], ), ), diff --git a/lib/features/analysis/presentation/widgets/analysis_loading_widget.dart b/lib/features/analysis/presentation/widgets/analysis_loading_widget.dart index 905f453..d89b5bd 100644 --- a/lib/features/analysis/presentation/widgets/analysis_loading_widget.dart +++ b/lib/features/analysis/presentation/widgets/analysis_loading_widget.dart @@ -25,11 +25,10 @@ class _AnalysisLoadingWidgetState extends State static const _steps = [ ('영상 업로드 중...', 'AI 분석을 준비하고 있어요'), ('구속 측정 중...', '파울라인~헤드핀 구간을 추적해요'), - ('회전수 측정 중...', '볼 표면 회전 패턴을 분석해요'), ('결과 화면 만드는 중...', '거의 다 됐어요!'), ]; - static const _stepDurations = [8, 10, 10, 999]; + static const _stepDurations = [8, 14, 999]; @override void initState() { diff --git a/lib/features/analysis/presentation/widgets/calibration_overlay.dart b/lib/features/analysis/presentation/widgets/calibration_overlay.dart new file mode 100644 index 0000000..a4b481f --- /dev/null +++ b/lib/features/analysis/presentation/widgets/calibration_overlay.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; + +/// [BoxFit.contain]으로 렌더링된 이미지가 [containerSize] 안에서 실제로 차지하는 +/// 사각형을 계산한다. 레터박스(여백) 영역을 제외한 이미지 고유 영역만 반환한다. +/// +/// 순수 함수 — 위젯 트리 없이 단위 테스트 가능. +Rect computeContainRect(Size containerSize, Size imageSize) { + if (imageSize.width <= 0 || + imageSize.height <= 0 || + containerSize.width <= 0 || + containerSize.height <= 0) { + return Offset.zero & containerSize; + } + + final containerAspect = containerSize.width / containerSize.height; + final imageAspect = imageSize.width / imageSize.height; + + double width; + double height; + if (imageAspect > containerAspect) { + // 이미지가 컨테이너보다 상대적으로 넓다 → 상/하 레터박스 + width = containerSize.width; + height = width / imageAspect; + } else { + // 이미지가 컨테이너보다 상대적으로 좁다 → 좌/우 레터박스 + height = containerSize.height; + width = height * imageAspect; + } + + final left = (containerSize.width - width) / 2; + final top = (containerSize.height - height) / 2; + return Rect.fromLTWH(left, top, width, height); +} + +class CalibrationOverlay extends StatelessWidget { + final List points; + final ValueChanged onTap; + + /// 참조 이미지의 고유(intrinsic) 픽셀 크기. [BoxFit.contain] 렌더링 영역을 + /// 계산하기 위해 필요하다. + final Size imageSize; + + const CalibrationOverlay({ + super.key, + required this.points, + required this.onTap, + required this.imageSize, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final containerSize = constraints.biggest; + final imageRect = computeContainRect(containerSize, imageSize); + + return GestureDetector( + onTapUp: (details) { + if (points.length >= 4) return; + final pos = details.localPosition; + // 레터박스(이미지 밖) 영역의 탭은 무시한다. + if (!imageRect.contains(pos)) return; + + final nx = ((pos.dx - imageRect.left) / imageRect.width) + .clamp(0.0, 1.0); + final ny = ((pos.dy - imageRect.top) / imageRect.height) + .clamp(0.0, 1.0); + onTap(FramePoint(nx: nx, ny: ny)); + }, + child: CustomPaint( + size: containerSize, + painter: _MarkerPainter(points, imageRect), + ), + ); + }, + ); + } +} + +class _MarkerPainter extends CustomPainter { + final List points; + final Rect imageRect; + _MarkerPainter(this.points, this.imageRect); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.orangeAccent + ..style = PaintingStyle.fill; + for (var i = 0; i < points.length; i++) { + // 이미지-상대 정규화 좌표를 화면 좌표로 역변환한다. + final offset = Offset( + imageRect.left + points[i].nx * imageRect.width, + imageRect.top + points[i].ny * imageRect.height, + ); + canvas.drawCircle(offset, 12, paint); + + final textPainter = TextPainter( + text: TextSpan( + text: '${i + 1}', + style: const TextStyle( + color: Colors.black, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + textPainter.paint( + canvas, + offset - Offset(textPainter.width / 2, textPainter.height / 2), + ); + } + } + + @override + bool shouldRepaint(covariant _MarkerPainter oldDelegate) => + oldDelegate.points != points || oldDelegate.imageRect != imageRect; +} diff --git a/pubspec.lock b/pubspec.lock index df63b96..454db97 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -410,13 +410,13 @@ packages: source: hosted version: "0.3.1" equatable: - dependency: transitive + dependency: "direct main" description: name: equatable - sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.1.0" fake_async: dependency: transitive description: @@ -1569,10 +1569,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.3" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b6baf69..0fc4aac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,7 +31,7 @@ dependencies: # 유틸리티 intl: ^0.19.0 - uuid: ^4.5.1 + uuid: ^4.5.3 shared_preferences: ^2.3.3 google_sign_in: ^7.2.0 sign_in_with_apple: ^7.0.1 @@ -49,6 +49,7 @@ dependencies: path_provider: ^2.1.5 phosphor_flutter: ^2.1.0 tflite_flutter: ^0.12.1 + equatable: ^2.1.0 dev_dependencies: flutter_test: diff --git a/test/features/analysis/data/repositories/calibration_repository_impl_test.dart b/test/features/analysis/data/repositories/calibration_repository_impl_test.dart new file mode 100644 index 0000000..0662844 --- /dev/null +++ b/test/features/analysis/data/repositories/calibration_repository_impl_test.dart @@ -0,0 +1,61 @@ +import 'package:bowling_diary/features/analysis/data/repositories/calibration_repository_impl.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/calibration_profile.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/homography_matrix.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + CalibrationProfile buildProfile(String id) => CalibrationProfile( + id: id, + name: '테스트 프로파일', + viewpoint: CameraViewpoint.backRight, + homography: HomographyMatrix.identity(), + createdAt: DateTime(2026, 1, 1), + referenceImagePath: '/tmp/ref_$id.jpg', + framePoints: const [ + FramePoint(nx: 0.1, ny: 0.1), FramePoint(nx: 0.9, ny: 0.1), + FramePoint(nx: 0.9, ny: 0.9), FramePoint(nx: 0.1, ny: 0.9), + ], + ); + + test('저장 후 getById로 동일 필드(referenceImagePath/framePoints 포함) 조회', () async { + final prefs = await SharedPreferences.getInstance(); + final repo = CalibrationRepositoryImpl(prefs); + final profile = buildProfile('p1'); + + await repo.save(profile); + final loaded = await repo.getById('p1'); + + expect(loaded, isNotNull); + expect(loaded!.referenceImagePath, '/tmp/ref_p1.jpg'); + expect(loaded.framePoints, profile.framePoints); + expect(loaded.homography.toRowMajorList(), profile.homography.toRowMajorList()); + }); + + test('setDefault/getDefault 라운드트립', () async { + final prefs = await SharedPreferences.getInstance(); + final repo = CalibrationRepositoryImpl(prefs); + await repo.save(buildProfile('p1')); + await repo.setDefault('p1'); + + final def = await repo.getDefault(); + expect(def?.id, 'p1'); + }); + + test('delete 시 기본값도 함께 제거', () async { + final prefs = await SharedPreferences.getInstance(); + final repo = CalibrationRepositoryImpl(prefs); + await repo.save(buildProfile('p1')); + await repo.setDefault('p1'); + + await repo.delete('p1'); + + expect(await repo.getById('p1'), isNull); + expect(await repo.getDefault(), isNull); + }); +} diff --git a/test/features/analysis/data/services/analysis_pipeline_test.dart b/test/features/analysis/data/services/analysis_pipeline_test.dart new file mode 100644 index 0000000..b8b83e9 --- /dev/null +++ b/test/features/analysis/data/services/analysis_pipeline_test.dart @@ -0,0 +1,196 @@ +import 'dart:io'; + +import 'package:bowling_diary/features/analysis/data/services/analysis_pipeline.dart'; +import 'package:bowling_diary/features/analysis/data/services/ball_detection_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/impact_detector_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/pin_impact_detector_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/release_detector_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/speed_estimator_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/video_frame_extractor_service.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/calibration_profile.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/drift_check_result.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/homography_matrix.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/speed_result.dart'; +import 'package:bowling_diary/features/analysis/domain/services/calibration_drift_checker.dart'; +import 'package:bowling_diary/features/analysis/domain/services/homography_solver.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; + +class _FakeFrameExtractor implements VideoFrameExtractorService { + final FrameExtractionResult result; + _FakeFrameExtractor(this.result); + @override + Future extract(String videoPath) async => result; +} + +class _FakeBallDetector implements BallDetectionService { + final List sequence; + int _i = 0; + _FakeBallDetector(this.sequence); + @override + Future init() async {} + @override + void dispose() {} + @override + BallDetection? detect(img.Image frame) => _i < sequence.length ? sequence[_i++] : null; +} + +class _FakeDriftChecker implements CalibrationDriftChecker { + final DriftCheckResult result; + _FakeDriftChecker(this.result); + @override + DriftCheckResult check({ + required img.Image referenceFrame, + required img.Image currentFrame, + required List referencePoints, + required HomographyMatrix homography, + }) => result; +} + +img.Image _blankFrame() { + final image = img.Image(width: 20, height: 20); + img.fill(image, color: img.ColorRgb8(10, 10, 10)); + return image; +} + +CalibrationProfile _profile(homography, {required String referenceImagePath}) => CalibrationProfile( + id: 'p1', name: '테스트', viewpoint: CameraViewpoint.backRight, + homography: homography, createdAt: DateTime(2026, 1, 1), + referenceImagePath: referenceImagePath, + framePoints: const [ + FramePoint(nx: 0, ny: 0), FramePoint(nx: 1, ny: 0), + FramePoint(nx: 1, ny: 1), FramePoint(nx: 0, ny: 1), + ], + ); + +void main() { + final homography = HomographySolver.solve4Point( + const [ + FramePoint(nx: 0, ny: 0), FramePoint(nx: 1, ny: 0), + FramePoint(nx: 1, ny: 1), FramePoint(nx: 0, ny: 1), + ], + const [ + LanePoint(xM: 0, yM: 0), LanePoint(xM: 1.05, yM: 0), + LanePoint(xM: 1.05, yM: 18.29), LanePoint(xM: 0, yM: 18.29), + ], + ); + + late String refImagePath; + + setUpAll(() async { + final file = File( + '${Directory.systemTemp.path}/analysis_pipeline_test_ref_${DateTime.now().microsecondsSinceEpoch}.png', + ); + await file.writeAsBytes(img.encodePng(_blankFrame())); + refImagePath = file.path; + }); + + tearDownAll(() async { + final file = File(refImagePath); + if (await file.exists()) await file.delete(); + }); + + test('레퍼런스 이미지 파일이 없으면 recalibrationRequired로 안전하게 실패(fail safe)', () async { + final frames = List.generate(5, (_) => _blankFrame()); + final pipeline = AnalysisPipeline( + frameExtractor: _FakeFrameExtractor( + FrameExtractionResult(frames: frames, originalFps: 30, sampleFps: 30), + ), + ballDetector: _FakeBallDetector(List.filled(5, null)), + releaseDetector: ReleaseDetectorService(), + impactDetector: ImpactDetectorService(pinImpactDetector: PinImpactDetectorService()), + speedEstimator: SpeedEstimatorService(), + // 실제(non-faked) driftChecker: 레퍼런스 파일이 없으니 이 checker는 아예 호출되지 않아야 함. + driftChecker: CalibrationDriftChecker(), + ); + + final result = await pipeline.run( + 'fake.mp4', + _profile(homography, referenceImagePath: '/tmp/does_not_exist_${DateTime.now().microsecondsSinceEpoch}.jpg'), + ); + + expect(result.driftStatus, DriftStatus.recalibrationRequired); + expect(result.speedKmh, isNull); + expect(result.framesAnalyzed, 0); + }); + + test('프레임이 하나도 추출되지 않으면 lowConfidence speedFailure로 반환', () async { + final pipeline = AnalysisPipeline( + frameExtractor: _FakeFrameExtractor( + const FrameExtractionResult(frames: [], originalFps: 30, sampleFps: 30), + ), + ballDetector: _FakeBallDetector(const []), + releaseDetector: ReleaseDetectorService(), + impactDetector: ImpactDetectorService(pinImpactDetector: PinImpactDetectorService()), + speedEstimator: SpeedEstimatorService(), + driftChecker: _FakeDriftChecker( + DriftCheckResult(status: DriftStatus.ok, homography: homography, driftScoreNormalized: 0.0), + ), + ); + + final result = await pipeline.run( + 'fake.mp4', + _profile(homography, referenceImagePath: refImagePath), + ); + + expect(result.framesAnalyzed, 0); + expect(result.speedFailure, SpeedFailure.lowConfidence); + }); + + test('drift가 recalibrationRequired면 파이프라인은 검출을 돌리지 않고 즉시 반환', () async { + final frames = List.generate(5, (_) => _blankFrame()); + final pipeline = AnalysisPipeline( + frameExtractor: _FakeFrameExtractor( + FrameExtractionResult(frames: frames, originalFps: 30, sampleFps: 30), + ), + ballDetector: _FakeBallDetector(List.filled(5, null)), + releaseDetector: ReleaseDetectorService(), + impactDetector: ImpactDetectorService(pinImpactDetector: PinImpactDetectorService()), + speedEstimator: SpeedEstimatorService(), + driftChecker: _FakeDriftChecker( + DriftCheckResult( + status: DriftStatus.recalibrationRequired, + homography: homography, + driftScoreNormalized: 0.2, + ), + ), + ); + + final result = await pipeline.run( + 'fake.mp4', + _profile(homography, referenceImagePath: refImagePath), + ); + + expect(result.driftStatus, DriftStatus.recalibrationRequired); + expect(result.speedKmh, isNull); + expect(result.framesAnalyzed, 0); + }); + + test('drift가 ok면 detection→release→impact→speed 전체 파이프라인 실행', () async { + final detections = [ + for (var i = 0; i < 60; i++) BallDetection(cx: 0.5, cy: 0.05 + i * 0.01266, bw: 0.02 + (i < 20 ? i * 0.001 : 0), bh: 0.02, confidence: 0.9), + ]; + final frames = List.generate(detections.length, (_) => _blankFrame()); + final pipeline = AnalysisPipeline( + frameExtractor: _FakeFrameExtractor( + FrameExtractionResult(frames: frames, originalFps: 30, sampleFps: 30), + ), + ballDetector: _FakeBallDetector(detections), + releaseDetector: ReleaseDetectorService(), + impactDetector: ImpactDetectorService(pinImpactDetector: PinImpactDetectorService()), + speedEstimator: SpeedEstimatorService(), + driftChecker: _FakeDriftChecker( + DriftCheckResult(status: DriftStatus.ok, homography: homography, driftScoreNormalized: 0.0), + ), + ); + + final result = await pipeline.run( + 'fake.mp4', + _profile(homography, referenceImagePath: refImagePath), + ); + + expect(result.driftStatus, DriftStatus.ok); + expect(result.framesAnalyzed, detections.length); + }); +} diff --git a/test/features/analysis/data/services/gemini_analysis_service_test.dart b/test/features/analysis/data/services/gemini_analysis_service_test.dart deleted file mode 100644 index 65ef168..0000000 --- a/test/features/analysis/data/services/gemini_analysis_service_test.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'dart:convert'; -import 'package:flutter_test/flutter_test.dart'; - -// 파싱 로직 직접 테스트용 헬퍼 (API 호출 없음) -Map? _parseGeminiJson(String responseBody) { - try { - final json = jsonDecode(responseBody); - final text = json['candidates'][0]['content']['parts'][0]['text'] as String; - return jsonDecode(text) as Map; - } catch (_) { - return null; - } -} - -double? _calcSpeed({ - required int? foulLineFrame, - required int? arrowsFrame, - required int? headpinFrame, - required double intervalSec, -}) { - int? startFrame, endFrame; - double? distance; - - if (foulLineFrame != null && headpinFrame != null && headpinFrame > foulLineFrame) { - startFrame = foulLineFrame; endFrame = headpinFrame; distance = 18.29; - } else if (foulLineFrame != null && arrowsFrame != null && arrowsFrame > foulLineFrame) { - startFrame = foulLineFrame; endFrame = arrowsFrame; distance = 4.57; - } else if (arrowsFrame != null && headpinFrame != null && headpinFrame > arrowsFrame) { - startFrame = arrowsFrame; endFrame = headpinFrame; distance = 13.72; - } - - if (startFrame == null || endFrame == null || distance == null) return null; - final elapsed = (endFrame - startFrame) * intervalSec; - final minElapsed = distance / (50.0 / 3.6); - final maxElapsed = distance / (10.0 / 3.6); - if (elapsed < minElapsed || elapsed > maxElapsed) return null; - return double.parse(((distance / elapsed) * 3.6).toStringAsFixed(1)); -} - -double? _calcRpm({required double? rotationCount, required int cropFrameCount, required double cropIntervalSec}) { - if (rotationCount == null || rotationCount <= 0 || cropFrameCount <= 1) return null; - final durationSec = (cropFrameCount - 1) * cropIntervalSec; - final rawRpm = (rotationCount / durationSec) * 60; - if (rawRpm < 50 || rawRpm > 500) return null; - return rawRpm; -} - -void main() { - group('_parseGeminiJson', () { - test('정상 응답 파싱', () { - final body = jsonEncode({ - 'candidates': [ - { - 'content': { - 'parts': [ - { - 'text': jsonEncode({ - 'foul_line_frame': 5, - 'arrows_frame': 8, - 'headpin_frame': 18, - 'rotation_count': 3.5, - }) - } - ] - } - } - ] - }); - final result = _parseGeminiJson(body); - expect(result, isNotNull); - expect(result!['foul_line_frame'], equals(5)); - expect(result['rotation_count'], equals(3.5)); - }); - - test('빈/잘못된 응답 → null', () { - expect(_parseGeminiJson('{}'), isNull); - expect(_parseGeminiJson('invalid'), isNull); - }); - }); - - group('_calcSpeed', () { - test('파울라인↔헤드핀 → 18.29m 기준 계산', () { - // 프레임 5→18, 간격 0.2s → elapsed=2.6s → 18.29/2.6*3.6=25.3 km/h - final speed = _calcSpeed( - foulLineFrame: 5, - arrowsFrame: null, - headpinFrame: 18, - intervalSec: 0.2, - ); - expect(speed, isNotNull); - expect(speed!, closeTo(25.3, 0.5)); - }); - - test('랜드마크 모두 null → null', () { - final speed = _calcSpeed( - foulLineFrame: null, arrowsFrame: null, headpinFrame: null, intervalSec: 0.1); - expect(speed, isNull); - }); - - test('elapsed 범위 초과 → null', () { - // 18.29m / 0.1s = 658 km/h → 범위 초과 - final speed = _calcSpeed( - foulLineFrame: 0, arrowsFrame: null, headpinFrame: 1, intervalSec: 0.1); - expect(speed, isNull); - }); - - test('화살표↔헤드핀 폴백', () { - // arrows=5, headpin=18, interval=0.3s → elapsed=3.9s → 13.72/3.9*3.6=12.7 km/h ✓ - final speed = _calcSpeed( - foulLineFrame: null, arrowsFrame: 5, headpinFrame: 18, intervalSec: 0.3); - expect(speed, isNotNull); - }); - }); - - group('_calcRpm', () { - test('3회전 / 0.5s = 360 RPM', () { - // 3 / (15 * 1/30) * 60 = 3/0.5*60 = 360 - final rpm = _calcRpm(rotationCount: 3.0, cropFrameCount: 16, cropIntervalSec: 1/30); - expect(rpm, isNotNull); - expect(rpm!, closeTo(360, 1)); - }); - - test('0회전 → null', () { - expect(_calcRpm(rotationCount: 0.0, cropFrameCount: 10, cropIntervalSec: 1/30), isNull); - }); - - test('600 RPM 범위 초과 → null', () { - final rpm = _calcRpm(rotationCount: 10.0, cropFrameCount: 31, cropIntervalSec: 1/30); - expect(rpm, isNull); - }); - }); -} diff --git a/test/features/analysis/data/services/impact_detector_service_test.dart b/test/features/analysis/data/services/impact_detector_service_test.dart new file mode 100644 index 0000000..7229e30 --- /dev/null +++ b/test/features/analysis/data/services/impact_detector_service_test.dart @@ -0,0 +1,52 @@ +import 'package:bowling_diary/features/analysis/data/services/impact_detector_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/pin_impact_detector_service.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/impact_result.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; + +List _framesWithFlashAt(int flashFrame, {int total = 40}) { + return List.generate(total, (i) { + final image = img.Image(width: 20, height: 20); + final brightness = i == flashFrame ? 255 : 10; + img.fill(image, color: img.ColorRgb8(brightness, brightness, brightness)); + return image; + }); +} + +void main() { + group('ImpactDetectorService', () { + test('두 신호가 0~2프레임 이내로 일치하면 high', () { + final frames = _framesWithFlashAt(30); + final sut = ImpactDetectorService(pinImpactDetector: PinImpactDetectorService()); + final result = sut.detect(frames: frames, releaseFrame: 5, homographyImpactFrame: 31); + expect(result.confidence, ImpactConfidence.high); + }); + + test('두 신호가 3~5프레임 차이나면 medium', () { + final frames = _framesWithFlashAt(30); + final sut = ImpactDetectorService(pinImpactDetector: PinImpactDetectorService()); + final result = sut.detect(frames: frames, releaseFrame: 5, homographyImpactFrame: 34); + expect(result.confidence, ImpactConfidence.medium); + }); + + test('두 신호가 6프레임 이상 차이나면 low, 호모그래피 값을 최종 채택', () { + final frames = _framesWithFlashAt(30); + final sut = ImpactDetectorService(pinImpactDetector: PinImpactDetectorService()); + final result = sut.detect(frames: frames, releaseFrame: 5, homographyImpactFrame: 39); + expect(result.confidence, ImpactConfidence.low); + expect(result.frame, 39); + }); + + test('핀존 휘도 신호가 아예 없으면(플래시 없음) low, 호모그래피 값만 채택', () { + final frames = List.generate(40, (i) { + final image = img.Image(width: 20, height: 20); + img.fill(image, color: img.ColorRgb8(10, 10, 10)); + return image; + }); + final sut = ImpactDetectorService(pinImpactDetector: PinImpactDetectorService()); + final result = sut.detect(frames: frames, releaseFrame: 5, homographyImpactFrame: 32); + expect(result.confidence, ImpactConfidence.low); + expect(result.frame, 32); + }); + }); +} diff --git a/test/features/analysis/data/services/release_detector_service_test.dart b/test/features/analysis/data/services/release_detector_service_test.dart index c830cdc..fa1c224 100644 --- a/test/features/analysis/data/services/release_detector_service_test.dart +++ b/test/features/analysis/data/services/release_detector_service_test.dart @@ -2,49 +2,49 @@ import 'package:bowling_diary/features/analysis/data/services/ball_detection_ser import 'package:bowling_diary/features/analysis/data/services/release_detector_service.dart'; import 'package:flutter_test/flutter_test.dart'; -BallDetection _det(double cx, double cy) => BallDetection( - cx: cx, cy: cy, bw: 0.05, bh: 0.05, confidence: 0.9); +BallDetection _det(double cx, double cy) => + BallDetection(cx: cx, cy: cy, bw: 0.05, bh: 0.05, confidence: 0.9); void main() { late ReleaseDetectorService sut; - setUp(() => sut = ReleaseDetectorService()); - test('볼이 정지 상태면 null 반환', () { - final detections = List.generate(20, (_) => _det(0.5, 0.5)); - expect(sut.findReleaseFrame(detections), isNull); - }); + group('ReleaseDetectorService', () { + test('볼이 계속 정지 상태면 notFound', () { + final detections = List.generate(20, (_) => _det(0.5, 0.5)); + expect(sut.findRelease(detections).isFound, isFalse); + }); - test('초반 정지 후 빠른 이동 시 올바른 프레임 반환', () { - final detections = [ - ...List.generate(5, (_) => _det(0.5, 0.5)), - _det(0.52, 0.52), // disp ≈ 0.028 > 0.015 - _det(0.54, 0.54), - _det(0.56, 0.56), - _det(0.58, 0.58), - ]; - // 이동 시작 직전 프레임(index 4)이 반환되어야 함 - expect(sut.findReleaseFrame(detections), equals(4)); - }); + test('가속 구간이 있으면 release 감지', () { + final detections = [ + for (var i = 0; i < 30; i++) _det(0.5, 0.1 + i * 0.02), + ]; + final result = sut.findRelease(detections); + expect(result.isFound, isTrue); + expect(result.confidence, greaterThan(0)); + }); - test('감지 없는 구간 후 이동하면 null 아님', () { - final detections = [ - ...List.generate(5, (_) => null), - _det(0.5, 0.5), - _det(0.52, 0.52), - _det(0.54, 0.54), - _det(0.56, 0.56), - ]; - expect(sut.findReleaseFrame(detections), isNotNull); - }); + test('null gap이 섞여도 감지 유지', () { + final detections = [ + for (var i = 0; i < 30; i++) (i % 7 == 0) ? null : _det(0.5, 0.1 + i * 0.02), + ]; + final result = sut.findRelease(detections); + expect(result.isFound, isTrue); + }); + + test('데이터가 너무 적으면 notFound', () { + final detections = [_det(0.5, 0.5), _det(0.5, 0.51)]; + expect(sut.findRelease(detections).isFound, isFalse); + }); - test('연속 이동이 3프레임 미만이면 null', () { - final detections = [ - _det(0.5, 0.5), - _det(0.52, 0.52), // moving - _det(0.5, 0.5), // stopped → reset - _det(0.5, 0.5), - ]; - expect(sut.findReleaseFrame(detections), isNull); + test('동일 입력에 대해 결정적(deterministic)', () { + final detections = [ + for (var i = 0; i < 30; i++) _det(0.5, 0.1 + i * 0.02), + ]; + final r1 = sut.findRelease(detections); + final r2 = sut.findRelease(detections); + expect(r1.frame, r2.frame); + expect(r1.confidence, r2.confidence); + }); }); } diff --git a/test/features/analysis/data/services/speed_estimator_service_test.dart b/test/features/analysis/data/services/speed_estimator_service_test.dart new file mode 100644 index 0000000..d9b4a6c --- /dev/null +++ b/test/features/analysis/data/services/speed_estimator_service_test.dart @@ -0,0 +1,88 @@ +import 'package:bowling_diary/features/analysis/data/services/ball_detection_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/speed_estimator_service.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/homography_matrix.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/impact_result.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/release_result.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/speed_result.dart'; +import 'package:bowling_diary/features/analysis/domain/services/homography_solver.dart'; +import 'package:flutter_test/flutter_test.dart'; + +HomographyMatrix _testHomography() { + return HomographySolver.solve4Point( + const [ + FramePoint(nx: 0, ny: 0), FramePoint(nx: 1, ny: 0), + FramePoint(nx: 1, ny: 1), FramePoint(nx: 0, ny: 1), + ], + const [ + LanePoint(xM: 0, yM: 0), LanePoint(xM: 1.05, yM: 0), + LanePoint(xM: 1.05, yM: 18.29), LanePoint(xM: 0, yM: 18.29), + ], + ); +} + +const _highConfImpact = ImpactResult(frame: 40, confidence: ImpactConfidence.high); +const _lowConfImpact = ImpactResult(frame: 40, confidence: ImpactConfidence.low); + +void main() { + final svc = SpeedEstimatorService(); + final h = _testHomography(); + + group('SpeedEstimatorService', () { + test('등속 25km/h 시뮬 + high-confidence impact: 측정 정확', () { + final detections = [ + for (var i = 0; i < 60; i++) BallDetection(cx: 0.5, cy: 0.05 + i * 0.01266, bw: 0.02, bh: 0.02, confidence: 0.9), + ]; + final r = svc.estimate( + release: const ReleaseResult(frame: 5, confidence: 0.8), + impact: _highConfImpact, + detections: detections, homography: h, sampleFps: 30, + ); + expect(r.kmh, isNotNull); + expect(r.kmh!, closeTo(25.0, 0.5)); + }); + + test('release 없으면 releaseNotFound', () { + final r = svc.estimate( + release: ReleaseResult.notFound, impact: _highConfImpact, + detections: const [], homography: h, sampleFps: 30, + ); + expect(r.failure, SpeedFailure.releaseNotFound); + }); + + test('impact confidence가 low면 anchorMismatch로 실패 (강제로 값 채택 안 함)', () { + final detections = [ + for (var i = 0; i < 60; i++) BallDetection(cx: 0.5, cy: 0.05 + i * 0.01266, bw: 0.02, bh: 0.02, confidence: 0.9), + ]; + final r = svc.estimate( + release: const ReleaseResult(frame: 5, confidence: 0.8), + impact: _lowConfImpact, + detections: detections, homography: h, sampleFps: 30, + ); + expect(r.failure, SpeedFailure.anchorMismatch); + expect(r.kmh, isNull); + }); + + test('비행 윈도우 detections null이면 lowConfidence', () { + final detections = [for (var i = 0; i < 30; i++) null]; + final r = svc.estimate( + release: const ReleaseResult(frame: 0, confidence: 0.8), + impact: _highConfImpact, + detections: detections, homography: h, sampleFps: 30, + ); + expect(r.failure, SpeedFailure.lowConfidence); + }); + + test('비현실적으로 빠른 속도는 outOfRange', () { + final detections = [ + for (var i = 0; i < 60; i++) BallDetection(cx: 0.5, cy: 0.05 + i * 0.0506, bw: 0.02, bh: 0.02, confidence: 0.9), + ]; + final r = svc.estimate( + release: const ReleaseResult(frame: 5, confidence: 0.8), + impact: _highConfImpact, + detections: detections, homography: h, sampleFps: 30, + ); + expect(r.failure, SpeedFailure.outOfRange); + }); + }); +} diff --git a/test/features/analysis/domain/entities/homography_matrix_test.dart b/test/features/analysis/domain/entities/homography_matrix_test.dart new file mode 100644 index 0000000..9a7c405 --- /dev/null +++ b/test/features/analysis/domain/entities/homography_matrix_test.dart @@ -0,0 +1,27 @@ +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/homography_matrix.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('HomographyMatrix', () { + test('identity는 좌표를 그대로 반환', () { + final h = HomographyMatrix.identity(); + final result = h.frameToLane(const FramePoint(nx: 0.3, ny: 0.7)); + expect(result.xM, closeTo(0.3, 1e-9)); + expect(result.yM, closeTo(0.7, 1e-9)); + }); + + test('frameToLane 후 laneToFrame은 원래 좌표로 복원', () { + final h = HomographyMatrix.fromRowMajor([2, 0, 1, 0, 3, 1, 0, 0, 1]); + const original = FramePoint(nx: 0.4, ny: 0.6); + final lane = h.frameToLane(original); + final restored = h.laneToFrame(lane); + expect(restored.nx, closeTo(original.nx, 1e-9)); + expect(restored.ny, closeTo(original.ny, 1e-9)); + }); + + test('9개가 아닌 리스트는 ArgumentError', () { + expect(() => HomographyMatrix.fromRowMajor([1, 2, 3]), throwsArgumentError); + }); + }); +} diff --git a/test/features/analysis/domain/services/analysis_state_machine_test.dart b/test/features/analysis/domain/services/analysis_state_machine_test.dart new file mode 100644 index 0000000..c8a5cf2 --- /dev/null +++ b/test/features/analysis/domain/services/analysis_state_machine_test.dart @@ -0,0 +1,55 @@ +import 'package:bowling_diary/features/analysis/data/services/ball_detection_service.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/services/analysis_state_machine.dart'; +import 'package:flutter_test/flutter_test.dart'; + +BallDetection _det({double bw = 0.05, double bh = 0.05}) => + BallDetection(cx: 0.5, cy: 0.3, bw: bw, bh: bh, confidence: 0.9); + +void main() { + group('AnalysisStateMachine', () { + test('초기 상태는 idle', () { + final fsm = AnalysisStateMachine(); + expect(fsm.phase, AnalysisPhase.idle); + }); + + test('검출 없으면 idle 유지', () { + final fsm = AnalysisStateMachine(); + for (var i = 0; i < 10; i++) { + fsm.onFrame(frameIdx: i, detection: null, lanePos: null); + } + expect(fsm.phase, AnalysisPhase.idle); + }); + + test('bbox 면적이 peak 지난 후 감소 + lane-y 단조증가 시 release 전이', () { + final fsm = AnalysisStateMachine(); + var frameIdx = 0; + // approach: 면적 증가 (백스윙→어프로치 peak) + 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), + ); + } + // release: 면적 감소 + lane-y 계속 증가 + 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), + ); + } + expect(fsm.releaseFrame, isNotNull); + }); + + test('reset 후 idle로 복귀하고 trajectory 비워짐', () { + final fsm = AnalysisStateMachine(); + fsm.onFrame(frameIdx: 0, detection: _det(), lanePos: const LanePoint(xM: 0.5, yM: 1.0)); + fsm.reset(); + expect(fsm.phase, AnalysisPhase.idle); + expect(fsm.trajectory, isEmpty); + expect(fsm.releaseFrame, isNull); + }); + }); +} diff --git a/test/features/analysis/domain/services/calibration_drift_checker_test.dart b/test/features/analysis/domain/services/calibration_drift_checker_test.dart new file mode 100644 index 0000000..9275b8d --- /dev/null +++ b/test/features/analysis/domain/services/calibration_drift_checker_test.dart @@ -0,0 +1,141 @@ +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/drift_check_result.dart'; +import 'package:bowling_diary/features/analysis/domain/services/calibration_drift_checker.dart'; +import 'package:bowling_diary/features/analysis/domain/services/homography_solver.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; + +const _refPoints = [ + FramePoint(nx: 0.2, ny: 0.2), FramePoint(nx: 0.8, ny: 0.2), + FramePoint(nx: 0.8, ny: 0.8), FramePoint(nx: 0.2, ny: 0.8), +]; +const _lanePoints = [ + LanePoint(xM: 0, yM: 0), LanePoint(xM: 1.05, yM: 0), + LanePoint(xM: 1.05, yM: 18.29), LanePoint(xM: 0, yM: 18.29), +]; + +img.Image _checkerboardWithMarkers(int size, List markers, {int offsetX = 0, int offsetY = 0}) { + return _frameWithMarkers(size, size, markers, offsetX: offsetX, offsetY: offsetY); +} + +img.Image _frameWithMarkers( + int width, + int height, + List markers, { + int offsetX = 0, + int offsetY = 0, + int markerHalf = 8, +}) { + final image = img.Image(width: width, height: height); + img.fill(image, color: img.ColorRgb8(40, 40, 40)); + for (final m in markers) { + final cx = (m.nx * width).round() + offsetX; + final cy = (m.ny * height).round() + offsetY; + for (var dy = -markerHalf; dy <= markerHalf; dy++) { + for (var dx = -markerHalf; dx <= markerHalf; dx++) { + final x = cx + dx, y = cy + dy; + if (x >= 0 && x < width && y >= 0 && y < height) { + image.setPixelRgb(x, y, 240, 240, 240); + } + } + } + } + return image; +} + +void main() { + group('CalibrationDriftChecker', () { + test('동일 프레임 비교 시 drift 없음(ok)', () { + final frame = _checkerboardWithMarkers(200, _refPoints); + final homography = HomographySolver.solve4Point(_refPoints, _lanePoints); + final sut = CalibrationDriftChecker(); + + final result = sut.check( + referenceFrame: frame, + currentFrame: frame, + referencePoints: _refPoints, + homography: homography, + ); + + expect(result.status, DriftStatus.ok); + expect(result.driftScoreNormalized, lessThan(0.01)); + }); + + test('작은 오프셋(3px)은 자동보정(autoCorrected)', () { + final reference = _checkerboardWithMarkers(200, _refPoints); + final current = _checkerboardWithMarkers(200, _refPoints, offsetX: 3, offsetY: 3); + final homography = HomographySolver.solve4Point(_refPoints, _lanePoints); + final sut = CalibrationDriftChecker(); + + final result = sut.check( + referenceFrame: reference, + currentFrame: current, + referencePoints: _refPoints, + homography: homography, + ); + + expect(result.status, DriftStatus.autoCorrected); + }); + + test('큰 오프셋(40px)은 재캘리브레이션 요구', () { + final reference = _checkerboardWithMarkers(200, _refPoints); + final current = _checkerboardWithMarkers(200, _refPoints, offsetX: 40, offsetY: 40); + final homography = HomographySolver.solve4Point(_refPoints, _lanePoints); + final sut = CalibrationDriftChecker(); + + final result = sut.check( + referenceFrame: reference, + currentFrame: current, + referencePoints: _refPoints, + homography: homography, + ); + + expect(result.status, DriftStatus.recalibrationRequired); + }); + + group('실제 파이프라인 해상도 (레퍼런스 갤러리 사진 vs ffmpeg 480px 추출 프레임)', () { + // 캘리브레이션 레퍼런스는 고해상도 갤러리 사진(예: 3024px), 분석 프레임은 + // ffmpeg `scale=480:-1`로 추출된 저해상도 프레임 — 해상도가 완전히 다르다. + const currentWidth = 480; + const currentHeight = 270; + const referenceWidth = currentWidth * 3; + const referenceHeight = currentHeight * 3; + + test('동일 장면·다른 해상도 → 리사이즈 후 offset ≈0 (ok)', () { + // 레퍼런스는 3배 고해상도이므로, 다운스케일 후 currentFrame과 같은 물리적 + // 크기로 보이도록 마커도 3배 크게 그린다(실제 사진 속 물체 크기 불변과 동일한 조건). + final reference = _frameWithMarkers(referenceWidth, referenceHeight, _refPoints, markerHalf: 24); + final current = _frameWithMarkers(currentWidth, currentHeight, _refPoints); + final homography = HomographySolver.solve4Point(_refPoints, _lanePoints); + final sut = CalibrationDriftChecker(); + + final result = sut.check( + referenceFrame: reference, + currentFrame: current, + referencePoints: _refPoints, + homography: homography, + ); + + expect(result.status, DriftStatus.ok); + expect(result.driftScoreNormalized, lessThan(0.01)); + }); + + test('현재 프레임이 백지(마커 없음) → NCC 바닥 미달로 재캘리브레이션 요구', () { + final reference = _frameWithMarkers(referenceWidth, referenceHeight, _refPoints); + final blankCurrent = img.Image(width: currentWidth, height: currentHeight); + img.fill(blankCurrent, color: img.ColorRgb8(40, 40, 40)); + final homography = HomographySolver.solve4Point(_refPoints, _lanePoints); + final sut = CalibrationDriftChecker(); + + final result = sut.check( + referenceFrame: reference, + currentFrame: blankCurrent, + referencePoints: _refPoints, + homography: homography, + ); + + expect(result.status, DriftStatus.recalibrationRequired); + }); + }); + }); +} diff --git a/test/features/analysis/domain/services/homography_solver_test.dart b/test/features/analysis/domain/services/homography_solver_test.dart new file mode 100644 index 0000000..b5d448b --- /dev/null +++ b/test/features/analysis/domain/services/homography_solver_test.dart @@ -0,0 +1,52 @@ +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/services/homography_solver.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('HomographySolver', () { + test('레인 4코너 매핑 시 각 코너가 정확히 대응', () { + final h = HomographySolver.solve4Point( + const [ + FramePoint(nx: 0, ny: 0), FramePoint(nx: 1, ny: 0), + FramePoint(nx: 1, ny: 1), FramePoint(nx: 0, ny: 1), + ], + const [ + LanePoint(xM: 0, yM: 0), LanePoint(xM: 1.05, yM: 0), + LanePoint(xM: 1.05, yM: 18.29), LanePoint(xM: 0, yM: 18.29), + ], + ); + final topLeft = h.frameToLane(const FramePoint(nx: 0, ny: 0)); + expect(topLeft.xM, closeTo(0, 1e-6)); + expect(topLeft.yM, closeTo(0, 1e-6)); + final bottomRight = h.frameToLane(const FramePoint(nx: 1, ny: 1)); + expect(bottomRight.xM, closeTo(1.05, 1e-6)); + expect(bottomRight.yM, closeTo(18.29, 1e-6)); + }); + + test('4쌍이 아니면 ArgumentError', () { + expect( + () => HomographySolver.solve4Point( + const [FramePoint(nx: 0, ny: 0)], + const [LanePoint(xM: 0, yM: 0)], + ), + throwsArgumentError, + ); + }); + + test('일직선 위 4점(특이 케이스)은 ArgumentError', () { + expect( + () => HomographySolver.solve4Point( + const [ + FramePoint(nx: 0, ny: 0), FramePoint(nx: 0.3, ny: 0), + FramePoint(nx: 0.6, ny: 0), FramePoint(nx: 1, ny: 0), + ], + const [ + LanePoint(xM: 0, yM: 0), LanePoint(xM: 0.3, yM: 0), + LanePoint(xM: 0.6, yM: 0), LanePoint(xM: 1, yM: 0), + ], + ), + throwsArgumentError, + ); + }); + }); +} diff --git a/test/features/analysis/presentation/utils/speed_failure_copy_test.dart b/test/features/analysis/presentation/utils/speed_failure_copy_test.dart new file mode 100644 index 0000000..6765e92 --- /dev/null +++ b/test/features/analysis/presentation/utils/speed_failure_copy_test.dart @@ -0,0 +1,27 @@ +import 'package:bowling_diary/features/analysis/domain/entities/drift_check_result.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/speed_result.dart'; +import 'package:bowling_diary/features/analysis/presentation/utils/speed_failure_copy.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('재캘리브레이션 필요 시 카메라 위치 안내', () { + final msg = speedFailureUserMessage(null, DriftStatus.recalibrationRequired); + expect(msg, contains('카메라 위치')); + }); + + test('검출 실패 계열은 조명 안내', () { + final msg = speedFailureUserMessage(SpeedFailure.releaseNotFound, DriftStatus.ok); + expect(msg, contains('인식하지 못했')); + }); + + test('신호 불일치 계열은 재촬영 안내, enum 이름 노출 안 함', () { + final msg = speedFailureUserMessage(SpeedFailure.anchorMismatch, DriftStatus.ok); + expect(msg, contains('다시 촬영')); + expect(msg, isNot(contains('anchorMismatch'))); + }); + + test('성공(null failure, drift ok)이면 빈 문자열', () { + final msg = speedFailureUserMessage(null, DriftStatus.ok); + expect(msg, isEmpty); + }); +} diff --git a/test/features/analysis/presentation/widgets/calibration_overlay_test.dart b/test/features/analysis/presentation/widgets/calibration_overlay_test.dart new file mode 100644 index 0000000..5c0ff99 --- /dev/null +++ b/test/features/analysis/presentation/widgets/calibration_overlay_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/presentation/widgets/calibration_overlay.dart'; + +void main() { + group('computeContainRect', () { + test('가로로 넓은 이미지 → 좁은 컨테이너에서 상/하 레터박스 발생', () { + // 이미지 2:1, 컨테이너 1:1(400x400) → 표시 영역은 400x200, 상하 각 100씩 여백. + const containerSize = Size(400, 400); + const imageSize = Size(1600, 800); + + final rect = computeContainRect(containerSize, imageSize); + + expect(rect.left, closeTo(0, 0.001)); + expect(rect.width, closeTo(400, 0.001)); + expect(rect.height, closeTo(200, 0.001)); + expect(rect.top, closeTo(100, 0.001)); + expect(rect.bottom, closeTo(300, 0.001)); + }); + + test('세로로 좁은 이미지 → 넓은 컨테이너에서 좌/우 레터박스 발생', () { + // 이미지 1:2, 컨테이너 1:1(400x400) → 표시 영역은 200x400, 좌우 각 100씩 여백. + const containerSize = Size(400, 400); + const imageSize = Size(800, 1600); + + final rect = computeContainRect(containerSize, imageSize); + + expect(rect.top, closeTo(0, 0.001)); + expect(rect.height, closeTo(400, 0.001)); + expect(rect.width, closeTo(200, 0.001)); + expect(rect.left, closeTo(100, 0.001)); + expect(rect.right, closeTo(300, 0.001)); + }); + + test('이미지-컨테이너 비율 일치 시 레터박스 없이 컨테이너 전체를 채움', () { + const containerSize = Size(300, 300); + const imageSize = Size(1200, 1200); + + final rect = computeContainRect(containerSize, imageSize); + + expect(rect, const Rect.fromLTWH(0, 0, 300, 300)); + }); + + test('컨테이너 또는 이미지 크기가 0이면 컨테이너 전체를 그대로 반환', () { + const containerSize = Size(300, 300); + + final rect = computeContainRect(containerSize, Size.zero); + + expect(rect, const Rect.fromLTWH(0, 0, 300, 300)); + }); + }); + + group('CalibrationOverlay 탭 처리', () { + // 이미지 2:1, 컨테이너 400x400 → 표시 영역 (0,100)-(400,300), 상하 100씩 레터박스. + const containerSize = Size(400, 400); + const imageSize = Size(1600, 800); + + Widget buildOverlay({ + required List points, + required ValueChanged onTap, + }) { + return MaterialApp( + home: Scaffold( + body: SizedBox( + width: containerSize.width, + height: containerSize.height, + child: CalibrationOverlay( + points: points, + onTap: onTap, + imageSize: imageSize, + ), + ), + ), + ); + } + + testWidgets('레터박스 영역(상단 여백) 탭은 무시된다', (tester) async { + FramePoint? tapped; + await tester.pumpWidget( + buildOverlay(points: const [], onTap: (p) => tapped = p), + ); + + // 상단 레터박스 영역(y=50) 탭. + await tester.tapAt(const Offset(200, 50)); + await tester.pump(); + + expect(tapped, isNull); + }); + + testWidgets('이미지 표시 영역 내부 탭은 이미지-상대 정규화 좌표로 변환된다', (tester) async { + FramePoint? tapped; + await tester.pumpWidget( + buildOverlay(points: const [], onTap: (p) => tapped = p), + ); + + // 표시 영역 (0,100)-(400,300) 내부 중앙점(200,200) 탭 → nx=0.5, ny=0.5. + await tester.tapAt(const Offset(200, 200)); + await tester.pump(); + + expect(tapped, isNotNull); + expect(tapped!.nx, closeTo(0.5, 0.001)); + expect(tapped!.ny, closeTo(0.5, 0.001)); + }); + + testWidgets('4점이 모두 입력되면 추가 탭은 무시된다', (tester) async { + FramePoint? tapped; + final existing = List.generate(4, (_) => const FramePoint(nx: 0.1, ny: 0.1)); + await tester.pumpWidget( + buildOverlay(points: existing, onTap: (p) => tapped = p), + ); + + await tester.tapAt(const Offset(200, 200)); + await tester.pump(); + + expect(tapped, isNull); + }); + }); +} diff --git a/test/golden_reference/fixtures/README.md b/test/golden_reference/fixtures/README.md new file mode 100644 index 0000000..4406298 --- /dev/null +++ b/test/golden_reference/fixtures/README.md @@ -0,0 +1,8 @@ +# 골든 레퍼런스 fixture + +각 fixture는 다음 3개 파일로 구성: +- `.mp4`: 트리밍된 투구 영상 +- `.calibration.json`: 해당 촬영 위치의 CalibrationProfile JSON (CalibrationRepositoryImpl._toJson 포맷) +- `.expected.json`: `{"groundTruthKmh": <구속계 실측값>, "toleranceKmh": <허용오차>}` + +이 디렉토리는 기본적으로 비어있다. 실제 투구 영상 fixture를 추가해야 speed_regression_test.dart가 회귀 비교를 실행한다. diff --git a/test/golden_reference/speed_regression_test.dart b/test/golden_reference/speed_regression_test.dart new file mode 100644 index 0000000..ceaa0ac --- /dev/null +++ b/test/golden_reference/speed_regression_test.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:bowling_diary/features/analysis/data/services/analysis_pipeline.dart'; +import 'package:bowling_diary/features/analysis/data/services/ball_detection_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/impact_detector_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/pin_impact_detector_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/release_detector_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/speed_estimator_service.dart'; +import 'package:bowling_diary/features/analysis/data/services/video_frame_extractor_service.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/calibration_profile.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/coord.dart'; +import 'package:bowling_diary/features/analysis/domain/entities/homography_matrix.dart'; +import 'package:bowling_diary/features/analysis/domain/services/calibration_drift_checker.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final fixturesDir = Directory('test/golden_reference/fixtures'); + final mp4Files = fixturesDir.existsSync() + ? fixturesDir.listSync().whereType().where((f) => f.path.endsWith('.mp4')).toList() + : []; + + if (mp4Files.isEmpty) { + test('골든 레퍼런스 fixture 없음 — 회귀 테스트 skip', () {}, skip: 'test/golden_reference/fixtures/에 fixture를 추가하면 실행됨'); + return; + } + + for (final mp4 in mp4Files) { + final name = mp4.uri.pathSegments.last.replaceAll('.mp4', ''); + test('$name 속도 회귀 비교', () async { + final calibJson = jsonDecode( + File('${fixturesDir.path}/$name.calibration.json').readAsStringSync(), + ) as Map; + final expected = jsonDecode( + File('${fixturesDir.path}/$name.expected.json').readAsStringSync(), + ) as Map; + + final homography = HomographyMatrix.fromRowMajor( + (calibJson['homography'] as List).map((e) => (e as num).toDouble()).toList(), + ); + final framePoints = (calibJson['framePoints'] as List) + .map((e) => FramePoint(nx: (e['nx'] as num).toDouble(), ny: (e['ny'] as num).toDouble())) + .toList(); + final profile = CalibrationProfile( + id: calibJson['id'] as String, + name: calibJson['name'] as String, + viewpoint: CameraViewpoint.values.firstWhere((v) => v.name == calibJson['viewpoint']), + homography: homography, + createdAt: DateTime.parse(calibJson['createdAt'] as String), + referenceImagePath: calibJson['referenceImagePath'] as String, + framePoints: framePoints, + ); + + final pipeline = AnalysisPipeline( + frameExtractor: VideoFrameExtractorService(), + ballDetector: BallDetectionService(), + releaseDetector: ReleaseDetectorService(), + impactDetector: ImpactDetectorService(pinImpactDetector: PinImpactDetectorService()), + speedEstimator: SpeedEstimatorService(), + driftChecker: CalibrationDriftChecker(), + ); + + final result = await pipeline.run(mp4.path, profile); + final groundTruth = (expected['groundTruthKmh'] as num).toDouble(); + final tolerance = (expected['toleranceKmh'] as num).toDouble(); + + expect(result.speedKmh, isNotNull, reason: '측정 실패: ${result.speedFailure}'); + expect(result.speedKmh!, closeTo(groundTruth, tolerance)); + }); + } +}