Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f9119f9
feat(analysis): 레인 좌표/호모그래피 엔티티 추가
devpark435 Jul 7, 2026
3a8d553
feat(analysis): 4점 DLT 호모그래피 솔버 추가
devpark435 Jul 7, 2026
09e27a8
feat(analysis): 캘리브레이션 프로파일에 레퍼런스 이미지/탭 좌표 추가
devpark435 Jul 7, 2026
1249221
feat(analysis): YOLOv8n 볼 검출 서비스 포팅
devpark435 Jul 7, 2026
24e104a
fix(analysis): Task 5 포팅 중 삭제된 BallTracker 클래스 복원
devpark435 Jul 7, 2026
2a4874c
feat(analysis): 릴리즈 프레임 검출 서비스 포팅
devpark435 Jul 7, 2026
97e17b2
fix(analysis): 트림페이지 릴리즈 검출 호출부를 새 ReleaseDetectorService API로 갱신
devpark435 Jul 7, 2026
efee3d8
feat(analysis): 핀임팩트 휘도신호 복원 + 호모그래피 신호 교차검증
devpark435 Jul 7, 2026
1ec9e45
feat(analysis): 분석 단계 상태머신(FSM) 포팅
devpark435 Jul 7, 2026
18893ab
feat(analysis): NCC 패치매칭 기반 캘리브레이션 drift 보정
devpark435 Jul 7, 2026
aec63dc
feat(analysis): 속도 추정 서비스 포팅 — impact confidence-gate 반영
devpark435 Jul 7, 2026
8a97129
feat(analysis): M1 속도 전용 파이프라인 통합 (drift-gate + confidence-gate)
devpark435 Jul 7, 2026
b63a3ed
feat(analysis): 속도 confidence/실패사유 영속화 필드 추가
devpark435 Jul 7, 2026
59e1874
feat(analysis): 캘리브레이션 UI 포팅 + drift-gate/실패카피 배선
devpark435 Jul 7, 2026
a1cdebf
chore(analysis): 미사용 Gemini 분석 서비스 제거
devpark435 Jul 7, 2026
050de20
test(analysis): 골든 레퍼런스 속도 회귀 테스트 하네스 추가
devpark435 Jul 7, 2026
675e2ad
fix(analysis): drift-check 자기비교 버그 및 confidence 미반영 수정
devpark435 Jul 7, 2026
3a7aed6
chore(analysis): 미사용 코드 제거
devpark435 Jul 7, 2026
ecbcd0e
fix(analysis): 캘리브레이션 드리프트 게이트 실효화 버그 수정
devpark435 Jul 7, 2026
1071a8a
test(analysis): 실제 해상도 조건의 drift-checker 테스트 보강
devpark435 Jul 7, 2026
1b0cd08
fix(analysis): 캘리브레이션 탭-이미지 좌표계 불일치 수정
devpark435 Jul 7, 2026
b04aff8
fix(analysis): 분석 플로우 UX 정리 및 RPM 잔존 카피 제거
devpark435 Jul 7, 2026
f1f24b4
ci: 리뷰 워크플로 max-turns 15→40 상향
devpark435 Jul 7, 2026
851a132
feat(analysis): 바텀네비 분석 탭 재활성화 (테플 내부QA용)
devpark435 Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/claude-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 로 바꿔라(구독 한도 더 소모).
53 changes: 19 additions & 34 deletions lib/app/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<GoRouter>((ref) {
final authState = ref.watch(authNotifierProvider);
Expand Down Expand Up @@ -101,17 +99,6 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/analysis/camera',
builder: (context, state) => const AnalysisCameraPage(),
),
GoRoute(
path: '/analysis/result',
builder: (context, state) {
final extra = state.extra as Map<String, dynamic>;
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);
Expand Down Expand Up @@ -139,15 +126,14 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
],
),
// 분석 탭 임시 비활성화 (앱스토어 이슈)
// StatefulShellBranch(
// routes: [
// GoRoute(
// path: '/analysis',
// builder: (context, state) => const AnalysisTabPage(),
// ),
// ],
// ),
StatefulShellBranch(
routes: [
GoRoute(
path: '/analysis',
builder: (context, state) => const AnalysisTabPage(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
Expand Down Expand Up @@ -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),
),
],
),
Expand Down
8 changes: 7 additions & 1 deletion lib/features/analysis/data/models/analysis_result_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class AnalysisResultModel extends AnalysisResultEntity {
super.videoLocalPath,
super.linkedSessionId,
required super.createdAt,
super.speedConfidence,
super.speedFailureReason,
});

factory AnalysisResultModel.fromJson(Map<String, dynamic> json) {
Expand All @@ -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?,
);
}

Expand All @@ -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,
};
}
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> _framePointToJson(FramePoint p) => {'nx': p.nx, 'ny': p.ny};

FramePoint _framePointFromJson(Map<String, dynamic> json) => FramePoint(
nx: (json['nx'] as num).toDouble(),
ny: (json['ny'] as num).toDouble(),
);

Map<String, dynamic> _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<String, dynamic> 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<dynamic>).map((e) => (e as num).toDouble()).toList();
final homography = HomographyMatrix.fromRowMajor(homographyValues);
final framePoints = (json['framePoints'] as List<dynamic>)
.map((e) => _framePointFromJson(Map<String, dynamic>.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<Map<String, dynamic>> _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<String, dynamic>.from(e as Map)).toList();
} catch (_) {
return [];
}
}

Future<void> _writeRawList(List<Map<String, dynamic>> list) async {
await _prefs.setString(_profilesKey, jsonEncode(list));
}

@override
Future<List<CalibrationProfile>> listAll() async {
final result = <CalibrationProfile>[];
for (final json in _readRawList()) {
try {
result.add(_fromJson(json));
} catch (e) {
debugPrint('[CalibrationRepository] 프로파일 디코딩 실패 (id=${json['id']}): $e');
}
}
return result;
}

@override
Future<CalibrationProfile?> getById(String id) async {
final list = _readRawList();
for (final json in list) {
if (json['id'] == id) return _fromJson(json);
}
return null;
}

@override
Future<void> 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<void> 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<CalibrationProfile?> getDefault() async {
final defaultId = _prefs.getString(_defaultIdKey);
if (defaultId == null) return null;
return getById(defaultId);
}

@override
Future<void> setDefault(String id) async {
await _prefs.setString(_defaultIdKey, id);
}
}
135 changes: 135 additions & 0 deletions lib/features/analysis/data/services/analysis_pipeline.dart
Original file line number Diff line number Diff line change
@@ -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<AnalysisData> 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<BallDetection?> 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<img.Image?> _loadReferenceFrame(String path) async {
try {
final bytes = await File(path).readAsBytes();
return img.decodeImage(bytes);
} catch (_) {
return null;
}
}
}
Loading
Loading