diff --git a/.gitignore b/.gitignore
index cb67c4f..1fd6869 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
# Miscellaneous
+.superpowers/
*.class
*.log
*.pyc
diff --git a/assets/models/yolo11s.tflite b/assets/models/yolo11s.tflite
new file mode 100644
index 0000000..11c1094
Binary files /dev/null and b/assets/models/yolo11s.tflite differ
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index da4d087..b62096e 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -39,6 +39,8 @@
CFBundleVersion
$(FLUTTER_BUILD_NUMBER)
+ GIDClientID
+ 7539952728-b2m6shd6amkn5qu40lkmhdb1290i8avp.apps.googleusercontent.com
LSRequiresIPhoneOS
NSCameraUsageDescription
diff --git a/lib/core/services/app_update_service.dart b/lib/core/services/app_update_service.dart
index dab94fe..3513bc6 100644
--- a/lib/core/services/app_update_service.dart
+++ b/lib/core/services/app_update_service.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:new_version_plus/new_version_plus.dart';
@@ -7,6 +8,9 @@ class AppUpdateService {
static bool _hasChecked = false;
static Future checkForUpdate(BuildContext context) async {
+ // debug 빌드는 스토어 미등록 번들 id 조회로 에러 로그만 만들고
+ // 업데이트 안내가 의미도 없으므로 건너뛴다.
+ if (kDebugMode) return;
if (_hasChecked) return;
_hasChecked = true;
diff --git a/lib/features/analysis/data/services/analysis_pipeline.dart b/lib/features/analysis/data/services/analysis_pipeline.dart
index 46a251f..b99ca5f 100644
--- a/lib/features/analysis/data/services/analysis_pipeline.dart
+++ b/lib/features/analysis/data/services/analysis_pipeline.dart
@@ -1,5 +1,8 @@
+import 'dart:math' as math;
+
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';
@@ -9,7 +12,14 @@ import 'package:bowling_diary/features/analysis/domain/entities/homography_matri
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/analysis_state_machine.dart';
+import 'package:bowling_diary/features/analysis/domain/services/arrow_detector.dart';
+import 'package:bowling_diary/features/analysis/domain/services/lane_landmark_speed.dart';
+import 'package:bowling_diary/features/analysis/domain/services/pin_row_detector.dart';
+import 'package:bowling_diary/features/analysis/domain/services/projective_track_model.dart';
+import 'package:bowling_diary/features/analysis/domain/services/trajectory_curve.dart';
+import 'package:bowling_diary/features/analysis/domain/services/trajectory_refiner.dart';
import 'package:flutter/foundation.dart' show debugPrint;
+import 'package:image/image.dart' as img;
class AnalysisPipeline {
final VideoFrameExtractorService frameExtractor;
@@ -64,6 +74,127 @@ class AnalysisPipeline {
return ReleaseResult.notFound;
}
+ /// 핀 폭발 탐색 시작 프레임 (순수 함수, 단독 유닛테스트 가능).
+ ///
+ /// = 궤적 소실 프레임 + (공이 핀덱(18.29m)에 도착할 때까지의 예상 프레임 수 ÷ 2).
+ ///
+ /// 도착 예상은 마지막 y와 마지막 구간 기울기(끝 5개 샘플)로 외삽하는데,
+ /// 남은 거리와 기울기를 **같은 (왜곡됐을 수 있는) 캘리브레이션 자**로 재기
+ /// 때문에 스케일 오류가 비율에서 상당 부분 약분된다 — 절대 y 게이트(구
+ /// 16m 기준)와 달리 캘리브레이션이 틀어져도 동작한다. 절반만 더하는 이유:
+ /// 외삽 오차(±수 프레임)로 시작점이 실제 도착 뒤로 넘어가면 폭발 자체를
+ /// 놓치므로, 공 진입 Δ 스파이크만 피할 만큼 보수적으로 민다.
+ ///
+ /// 정제 궤적이 채택한 프레임들의 **픽셀** 관측을 모은다. 레인 좌표를 거치지
+ /// 않으므로 캘리브레이션과 무관하다 — 픽셀 공간 궤적 모델의 입력.
+ static List collectPixelTrack(
+ List detections,
+ List refined,
+ ) {
+ final keep = refined.map((s) => s.frame).toSet();
+ return [
+ for (var i = 0; i < detections.length; i++)
+ if (keep.contains(i) && detections[i] != null)
+ (frame: i, contact: detections[i]!.contactPoint, widthN: detections[i]!.bw),
+ ];
+ }
+
+ /// 픽셀 공간 궤적 리본. 캘리브레이션을 전혀 쓰지 않는다.
+ ///
+ /// 기존 레인 좌표 경로 대비 세 가지가 고쳐진다:
+ /// 1) 끝 연장이 **직선**이 아니라 투영 곡선(쌍곡선)이다. 직선 연장은 핀덱
+ /// 부근에서 소실점을 넘어 발산한다(단위 테스트로 고정).
+ /// 2) 연장 목표가 레인 y=18.29m(캘리브레이션 스케일에 물림)가 아니라 실측
+ /// **핀 충돌 프레임**이다.
+ /// 3) 리본 폭이 고정 0.22m 환산이 아니라 공의 실측 bbox 폭 + 원근 모델이다.
+ ///
+ /// 적합 실패 시 null — 호출부가 기존 레인 좌표 리본으로 폴백한다.
+ /// [rms]는 적합 잔차(정규화 단위)로, 내부 QA 뱃지에 노출한다.
+ static ({List ribbon, double rms})? buildPixelRibbon({
+ required List pixelTrack,
+ required int impactFrame,
+ required int releaseFrame,
+ }) {
+ final model = fitProjectiveTrack(pixelTrack);
+ if (model == null) return null;
+ final ribbon = buildProjectiveRibbon(
+ track: pixelTrack,
+ model: model,
+ endFrame: impactFrame,
+ startFrame: releaseFrame,
+ );
+ return ribbon.isEmpty ? null : (ribbon: ribbon, rms: model.rms);
+ }
+
+ /// 구속 채택 허용 범위(km/h). SpeedEstimatorService의 가드와 같은 값.
+ static const double minPlausibleKmh = 10;
+ static const double maxPlausibleKmh = 50;
+
+ /// 랜드마크 통과-시각 구속. 캘리브레이션(호모그래피)을 쓰지 않는다.
+ ///
+ /// [releaseFrame] 이미지에서 조준 화살표를 찾아 iso-u 선(양 끝 화살표,
+ /// 파울라인에서 12ft)을 만들고, 공이 그 선을 지난 프레임부터 핀 충돌
+ /// 프레임까지의 **시간**으로 구속을 낸다. 픽셀 깊이를 재지 않으므로 소실점
+ /// 근처의 원근 압축(기존 코어의 지배적 오차원)이 계산에서 빠진다.
+ ///
+ /// 화살표 검출·셰브론 검증 실패 시 null → 호출부가 기존 코어로 폴백한다.
+ /// 화살표 검출기 임계값은 실영상 1편 기준이라 일반화가 검증되지 않았다.
+ static double? estimateLandmarkSpeed({
+ required img.Image releaseFrame,
+ required List pixelTrack,
+ required int impactFrame,
+ required int sampleFps,
+ }) {
+ final line = arrowLineFromDetections(detectArrows(releaseFrame));
+ if (line == null) return null;
+ return estimateLandmarkSpeedKmh(
+ track: [for (final s in pixelTrack) (frame: s.frame, p: s.contact)],
+ line: line,
+ impactFrame: impactFrame.toDouble(),
+ sampleFps: sampleFps,
+ );
+ }
+
+ /// 두 코어의 값 중 무엇을 최종 구속으로 쓸지 정한다.
+ ///
+ /// 랜드마크 코어가 물리적으로 말이 되는 값을 냈으면 그것을 채택한다 —
+ /// 조건수가 기존 코어보다 두 자릿수 좋기 때문(에로우 선 ±15px → ±0.4km/h vs
+ /// 기존 방식의 원근 압축 민감도). 못 냈거나 범위를 벗어나면 기존 코어로 폴백.
+ static ({double kmh, SpeedSource source})? adoptSpeed({
+ required double? landmarkKmh,
+ required double? legacyKmh,
+ }) {
+ if (landmarkKmh != null &&
+ landmarkKmh >= minPlausibleKmh &&
+ landmarkKmh <= maxPlausibleKmh) {
+ return (kmh: landmarkKmh, source: SpeedSource.landmark);
+ }
+ if (legacyKmh != null) {
+ return (kmh: legacyKmh, source: SpeedSource.legacy);
+ }
+ return null;
+ }
+
+ /// 반환 null = 궤적 없음(탐색 시작 추정 불가 → detector가 legacy 규칙 사용).
+ static int? estimatePinSearchStart(List refined) {
+ if (refined.isEmpty) return null;
+ final last = refined.last;
+ if (refined.length < 2) return last.frame;
+
+ final anchorIdx = math.max(0, refined.length - 5);
+ final anchor = refined[anchorIdx];
+ final df = last.frame - anchor.frame;
+ final dy = last.lane.yM - anchor.lane.yM;
+ if (df <= 0 || dy <= 0) return last.frame;
+
+ final slopePerFrame = dy / df;
+ final remainingM = 18.29 - last.lane.yM;
+ if (remainingM <= 0) return last.frame;
+
+ final halfArrivalFrames = (remainingM / slopePerFrame / 2).round();
+ return last.frame + halfArrivalFrames.clamp(0, 60);
+ }
+
/// [homography]는 이 영상 전용으로 산출된 호모그래피다(레퍼런스 = 영상 자체이므로
/// drift 개념이 존재하지 않는다 — spec §10 참조).
Future run(String videoPath, HomographyMatrix homography) async {
@@ -95,6 +226,11 @@ class AnalysisPipeline {
detections.add(null);
}
}
+ // 진단 — "검출 0건"일 때 추론이 깨진 것(최고 점수 ~0)인지 임계값 바로
+ // 아래(전처리/클래스 문제)인지 즉시 구분 가능하게 한다.
+ debugPrint('[BallDetection] 검출 ${detections.whereType().length}'
+ '/${frames.length} 프레임, 전 구간 최고 점수 '
+ '${ballDetector.debugMaxScore.toStringAsFixed(3)} (임계값 0.3)');
} finally {
ballDetector.dispose();
}
@@ -104,40 +240,145 @@ class AnalysisPipeline {
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;
+ final lanePos = d != null ? homography.frameToLane(d.contactPoint) : null;
fsm.onFrame(frameIdx: i, detection: d, lanePos: lanePos);
}
final release = combineRelease(fsm.releaseFrame, detectorRelease);
- if (!release.isFound || fsm.impactFrame == null) {
+ final refined = refineTrajectory(fsm.trajectory);
+ debugPrint('[Trajectory] refined (frame:y) = ${refined.map((s) => "${s.frame}:${s.lane.yM.toStringAsFixed(1)}").join(" ")}');
+ final pixelTrack = collectPixelTrack(detections, refined);
+ final fitted = fitAndResample(refined);
+ // 엔트리 앵글은 끝 연장(extendCurveEnd) 전, 즉 실측 데이터 기반 곡선
+ // 기준으로 계산한다 — 연장은 선형이라 각도 자체는 동일하지만 실측
+ // 데이터 기준이 더 정직하다(연장 로직 변경에 영향받지 않음).
+ final entryAngle = entryAngleDeg(fitted);
+ // 정제가 초반 노이즈 구간을 잘라내도 릴리즈 직후부터 선이 보이도록,
+ // 원시 궤적의 실제 시작 y(최소 2.5m)까지 곡선을 선형 연장한다.
+ final started = fsm.trajectory.isEmpty
+ ? fitted
+ : extendCurveStart(fitted,
+ targetStartY: math.max(2.5, fsm.trajectory.first.lane.yM));
+ // 원거리(17m+)에서는 공이 화면상 수 픽셀이라 검출이 끊겨 곡선이 핀
+ // 직전에서 멈춘다 — 핀덱(18.29m)까지 끝 기울기를 그대로 연장한다.
+ final curve = extendCurveEnd(started, targetEndY: 18.29);
+ const ribbonHalfWidthM = 0.11; // 볼 반경(공 지름 0.218m)
+ final trajectory = curve
+ .map((e) => TrajectoryRibbonPoint(
+ frame: e.frame,
+ left: homography.laneToFrame(LanePoint(xM: e.lane.xM - ribbonHalfWidthM, yM: e.lane.yM)),
+ right: homography.laneToFrame(LanePoint(xM: e.lane.xM + ribbonHalfWidthM, yM: e.lane.yM)),
+ ))
+ .toList();
+ debugPrint('[Trajectory] raw ${fsm.trajectory.length} → refined ${refined.length} → curve ${curve.length}개 포인트, '
+ '프레임: ${refined.isEmpty ? "없음" : "${refined.first.frame}~${refined.last.frame}"}, '
+ '레인 y범위(연장 후): ${curve.isEmpty ? "없음" : "${curve.first.lane.yM.toStringAsFixed(2)}~${curve.last.lane.yM.toStringAsFixed(2)}m"}');
+
+ if (!release.isFound) {
return AnalysisData(
- speedFailure: !release.isFound ? SpeedFailure.releaseNotFound : SpeedFailure.impactNotFound,
+ speedFailure: SpeedFailure.releaseNotFound,
framesAnalyzed: frames.length,
fpsUsed: extracted.sampleFps,
+ trajectory: trajectory,
+ entryAngleDeg: entryAngle,
);
}
+ if (fsm.impactFrame == null) {
+ debugPrint('[AnalysisPipeline] FSM 임팩트 없음 — 핀 폭발 신호 단독으로 진행');
+ }
+
+ // 핀 존 산출 3단 체인(Phase 3 존 독립화): 캘리브레이션(호모그래피)이
+ // 흔들려도 핀 폭발 감지가 정확한 위치를 보도록, 릴리즈 시점 프레임(핀이
+ // 온전히 서 있고 공은 아직 근거리)에서 핀 행을 직접 탐지하는 것을
+ // 1순위로 한다. 실패하면 기존 호모그래피 투영, 그마저 실패하면
+ // (impactDetector 내부에서) legacy 상단 20% 존으로 폴백한다.
+ final releaseFrameIdx = release.frame.clamp(0, frames.length - 1);
+ final pinRowZone = detectPinRowZone(frames[releaseFrameIdx]);
+ final homographyZone = pinRowZone == null
+ ? PinImpactDetectorService.computePinZone(
+ homography,
+ frameAspect: frames[0].width / frames[0].height,
+ )
+ : null;
+ final pinZone = pinRowZone ?? homographyZone;
+ final zoneSourceLabel = pinRowZone != null ? '자동탐지' : (homographyZone != null ? '호모그래피' : 'legacy');
+ debugPrint('[PinZone] 소스: $zoneSourceLabel'
+ '${pinZone != null ? " LTRB(${pinZone.left.toStringAsFixed(2)},${pinZone.top.toStringAsFixed(2)},${pinZone.right.toStringAsFixed(2)},${pinZone.bottom.toStringAsFixed(2)})" : ""}');
+
+ // 핀 폭발 탐색 시작점: 궤적 소실 프레임에 "공이 핀덱에 도착할 때까지의
+ // 예상 프레임의 절반"을 더한다 (estimatePinSearchStart 참조). 소실 직후를
+ // 씨드로 쓰면 공이 핀존에 진입하는 순간의 날카로운 Δ가 폭발보다 먼저
+ // argmax에 잡히는 오탐이 3회 연속 실측됐다(115/115/117 vs 실제 ~128).
+ // 소실이 BP 등 중간 유실이어도 폭발 감지의 영구변화 게이트가 오탐을
+ // 막는다(폭발 없으면 null → 정직한 실패).
+ final pinSearchStart = estimatePinSearchStart(refined);
+
final impact = impactDetector.detect(
frames: frames,
releaseFrame: release.frame,
- homographyImpactFrame: fsm.impactFrame!,
+ homographyImpactFrame: fsm.impactFrame,
+ pinZone: pinZone,
+ pinSearchStart: pinSearchStart,
+ );
+
+ if (impact == null) {
+ return AnalysisData(
+ speedFailure: SpeedFailure.impactNotFound,
+ framesAnalyzed: frames.length,
+ fpsUsed: extracted.sampleFps,
+ trajectory: trajectory,
+ entryAngleDeg: entryAngle,
+ );
+ }
+
+ // 궤적 리본은 픽셀 공간 모델을 1순위로 쓴다(캘리브레이션 무관, 핀 충돌
+ // 프레임까지 투영 곡선으로 연장). 적합 실패 시 레인 좌표 리본으로 폴백.
+ final pixelRibbon = buildPixelRibbon(
+ pixelTrack: pixelTrack,
+ impactFrame: impact.frame,
+ releaseFrame: release.frame,
);
+ final finalTrajectory = pixelRibbon?.ribbon ?? trajectory;
+ final trajectorySource =
+ pixelRibbon != null ? TrajectorySource.projective : TrajectorySource.laneFallback;
+ debugPrint('[Trajectory] 리본 소스: ${trajectorySource.label} '
+ '(${finalTrajectory.length}개 단면, 픽셀관측 ${pixelTrack.length}개'
+ '${pixelRibbon != null ? ", rms ${pixelRibbon.rms.toStringAsFixed(5)}" : ""})');
final speed = speedEstimator.estimate(
release: release,
impact: impact,
- detections: detections,
- homography: homography,
+ refinedTrajectory: refined,
+ sampleFps: extracted.sampleFps,
+ );
+
+ // 랜드마크 통과-시각 구속 — 조준 화살표가 검출되면 이쪽이 1순위.
+ final landmarkKmh = estimateLandmarkSpeed(
+ releaseFrame: frames[releaseFrameIdx],
+ pixelTrack: pixelTrack,
+ impactFrame: impact.frame,
sampleFps: extracted.sampleFps,
);
+ final adopted = adoptSpeed(landmarkKmh: landmarkKmh, legacyKmh: speed.kmh);
+ debugPrint('[Speed] 랜드마크 ${landmarkKmh?.toStringAsFixed(1) ?? "없음"}km/h, '
+ '기존 ${speed.kmh?.toStringAsFixed(1) ?? "없음"}km/h → '
+ '채택 ${adopted == null ? "실패" : "${adopted.kmh.toStringAsFixed(1)}km/h(${adopted.source.label})"}');
return AnalysisData(
- speedKmh: speed.kmh,
+ speedKmh: adopted?.kmh,
speedConfidence: speed.confidence,
- speedFailure: speed.failure,
+ speedFailure: adopted == null ? speed.failure : null,
framesAnalyzed: frames.length,
fpsUsed: extracted.sampleFps,
+ trajectory: finalTrajectory,
+ entryAngleDeg: entryAngle,
+ trajectorySource: trajectorySource,
+ trajectoryFitRms: pixelRibbon?.rms,
+ speedSource: adopted?.source,
+ landmarkSpeedKmh: landmarkKmh,
+ legacySpeedKmh: speed.kmh,
);
}
}
diff --git a/lib/features/analysis/data/services/ball_detection_service.dart b/lib/features/analysis/data/services/ball_detection_service.dart
index 47b7ba4..4640dd5 100644
--- a/lib/features/analysis/data/services/ball_detection_service.dart
+++ b/lib/features/analysis/data/services/ball_detection_service.dart
@@ -1,9 +1,12 @@
import 'dart:io' show Platform;
+import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
import 'package:tflite_flutter/tflite_flutter.dart';
+import 'package:bowling_diary/features/analysis/domain/entities/coord.dart';
+
class BallDetection {
final double cx;
final double cy;
@@ -17,14 +20,64 @@ class BallDetection {
required this.bh,
required this.confidence,
});
+
+ /// 공-레인 접점(bbox 바닥 중점)의 프레임 좌표. 호모그래피는 레인 평면 전제라
+ /// 공 중심(바닥에서 반지름 ~11cm 위)을 넣으면 평면 밖 점이 실제보다 앞으로
+ /// 투영되는 계통 오차가 생긴다(거리에 비례해 증가 — 실측: 궤적 y가 핀덱
+ /// 18.29m를 넘는 18.65m까지 관측). 접점은 평면 위의 점이라 바이어스가 없다.
+ FramePoint get contactPoint => FramePoint(nx: cx, ny: (cy + bh / 2).clamp(0.0, 1.0));
+}
+
+/// letterbox 배치: 원본을 비율 유지로 [dstSize] 안에 맞췄을 때의 스케일 결과
+/// 크기와 패딩 오프셋. 순수 함수 — 위젯/인터프리터 없이 단위 테스트 가능.
+typedef LetterboxLayout = ({int scaledW, int scaledH, int padX, int padY});
+
+LetterboxLayout computeLetterbox(int srcW, int srcH, int dstSize) {
+ final scale = math.min(dstSize / srcW, dstSize / srcH);
+ final scaledW = (srcW * scale).round().clamp(1, dstSize);
+ final scaledH = (srcH * scale).round().clamp(1, dstSize);
+ return (
+ scaledW: scaledW,
+ scaledH: scaledH,
+ padX: (dstSize - scaledW) ~/ 2,
+ padY: (dstSize - scaledH) ~/ 2,
+ );
+}
+
+/// 모델 입력(letterbox 캔버스) 기준 정규화 좌표를 원본 프레임 기준 정규화
+/// 좌표로 역매핑한다.
+BallDetection unletterbox(BallDetection d, LetterboxLayout lb, int dstSize) {
+ return BallDetection(
+ cx: ((d.cx * dstSize - lb.padX) / lb.scaledW).clamp(0.0, 1.0),
+ cy: ((d.cy * dstSize - lb.padY) / lb.scaledH).clamp(0.0, 1.0),
+ bw: (d.bw * dstSize / lb.scaledW).clamp(0.0, 1.0),
+ bh: (d.bh * dstSize / lb.scaledH).clamp(0.0, 1.0),
+ confidence: d.confidence,
+ );
}
class BallDetectionService {
- static const _modelPath = 'assets/models/yolov8n.tflite';
- static const _inputSize = 320;
+ // YOLO11s @ 640 (COCO 80클래스, ultralytics tflite export — 출력 (1, 84, 8400),
+ // 84 = box4 + 클래스점수80, 좌표는 0~1 정규화 xywh). 이전 모델
+ // yolov8n.tflite(@320, 1클래스 전용학습, 출력 (1, 5, 2100))은 롤백용으로
+ // 애셋에 유지 — 아래 상수 5개를 (yolov8n, 320, 5, 2100, 무시)로 되돌리면 복귀.
+ // 교체 이유: far-lane에서 공이 3~5px로 뭉개지는 320 입력의 localization
+ // 지터(정제 드롭률 58%, 회귀 R² 0.67 실측)를 640 입력 + s급 모델로 개선 실험.
+ static const _modelPath = 'assets/models/yolo11s.tflite';
+ static const _inputSize = 640;
static const _confidenceThreshold = 0.3;
- static const _numDims = 5;
- static const _numAnchors = 2100;
+ static const _numDims = 84;
+ static const _numAnchors = 8400;
+
+ /// COCO 클래스 인덱스 32 = sports ball. 1클래스 모델(_numDims == 5)에서는
+ /// 사용되지 않는다(_parseBest 참조).
+ static const _ballClassIndex = 32;
+
+ /// yolo11s는 신형 litert 변환기 산출물이라 torch 채널-우선(NCHW
+ /// [1,3,640,640]) 입력을 요구한다 — NHWC로 넣으면 CONV_2D가 준비 단계에서
+ /// 실패(input_channel % filter_input_channel != 0, 실기기 확인). 구형 tf 경유
+ /// export(yolov8n)는 NHWC [1,320,320,3] — 롤백 시 false로 되돌릴 것.
+ static const _inputChannelFirst = true;
Interpreter? _interpreter;
@@ -62,6 +115,13 @@ class BallDetectionService {
final options = InterpreterOptions()..threads = 2;
_interpreter = await Interpreter.fromAsset(_modelPath, options: options);
}
+ // 모델 교체 시 출력 레이아웃 가정(_numDims x _numAnchors)이 맞는지
+ // 실기기 로그로 즉시 검증하기 위한 1회성 진단.
+ debugPrint('[BallDetection] 모델 로드: $_modelPath, '
+ '입력 shape ${_interpreter!.getInputTensor(0).shape} '
+ '(채널우선=$_inputChannelFirst), '
+ '출력 shape ${_interpreter!.getOutputTensor(0).shape} '
+ '(기대: [1, $_numDims, $_numAnchors])');
}
void dispose() {
@@ -69,11 +129,23 @@ class BallDetectionService {
_interpreter = null;
}
+ /// 진단용 — 임계값 미달 포함, 전 detect() 호출에서 관측된 최고 신뢰도.
+ /// "검출 0건"이 (a) 추론 자체가 깨져 점수가 ~0인지 (b) 실제 점수가 임계값
+ /// 바로 아래(전처리/클래스 문제)인지 구분하는 데 쓴다.
+ double debugMaxScore = 0;
+
BallDetection? detect(img.Image frame) {
if (_interpreter == null) return null;
- final resized = img.copyResize(frame, width: _inputSize, height: _inputSize);
- final input = _toFloat32Input(resized);
+ // letterbox: ultralytics 학습 전처리와 동일하게 비율 유지 + 회색 패딩.
+ // 스쿼시 리사이즈(비율 무시)는 480x853 포트레이트에서 공을 타원으로
+ // 찌그러뜨려 범용 COCO 모델의 점수를 크게 떨어뜨린다.
+ final lb = computeLetterbox(frame.width, frame.height, _inputSize);
+ final scaled = img.copyResize(frame, width: lb.scaledW, height: lb.scaledH);
+ final canvas = img.Image(width: _inputSize, height: _inputSize)
+ ..clear(img.ColorRgb8(114, 114, 114)); // ultralytics 표준 패딩색
+ img.compositeImage(canvas, scaled, dstX: lb.padX, dstY: lb.padY);
+ final input = _toFloat32Input(canvas);
final output = List.generate(
1,
@@ -82,10 +154,29 @@ class BallDetectionService {
_interpreter!.run(input, output);
- return _parseBest(output[0]);
+ final best = _parseBest(output[0]);
+ if (best == null) return null;
+ // letterbox 좌표(모델 입력 기준 0~1)를 원본 프레임 기준 0~1로 역매핑.
+ return unletterbox(best, lb, _inputSize);
}
List>>> _toFloat32Input(img.Image image) {
+ if (_inputChannelFirst) {
+ // NCHW: [1][3][H][W]
+ return [
+ List.generate(3, (c) {
+ return List.generate(
+ _inputSize,
+ (y) => List.generate(_inputSize, (x) {
+ final px = image.getPixel(x, y);
+ final v = c == 0 ? px.r : (c == 1 ? px.g : px.b);
+ return v / 255.0;
+ }),
+ );
+ })
+ ];
+ }
+ // NHWC: [1][H][W][3]
return [
List.generate(
_inputSize,
@@ -101,8 +192,13 @@ class BallDetectionService {
double maxConf = _confidenceThreshold;
BallDetection? best;
+ // 1클래스 전용학습 모델(5차원)은 row4가 곧 신뢰도, COCO 80클래스 모델은
+ // row4부터 클래스별 점수라 sports ball(_ballClassIndex) 행만 본다.
+ final confRow = _numDims == 5 ? 4 : 4 + _ballClassIndex;
+
for (int i = 0; i < _numAnchors; i++) {
- final conf = raw[4][i];
+ final conf = raw[confRow][i];
+ if (conf > debugMaxScore) debugMaxScore = conf;
if (conf <= maxConf) continue;
maxConf = conf;
best = BallDetection(
diff --git a/lib/features/analysis/data/services/impact_detector_service.dart b/lib/features/analysis/data/services/impact_detector_service.dart
index ba1bbb1..31c25ec 100644
--- a/lib/features/analysis/data/services/impact_detector_service.dart
+++ b/lib/features/analysis/data/services/impact_detector_service.dart
@@ -1,3 +1,5 @@
+import 'dart:ui';
+
import 'package:image/image.dart' as img;
import 'package:bowling_diary/features/analysis/data/services/pin_impact_detector_service.dart';
@@ -7,21 +9,51 @@ class ImpactDetectorService {
final PinImpactDetectorService pinImpactDetector;
const ImpactDetectorService({required this.pinImpactDetector});
- ImpactResult detect({
+ /// [homographyImpactFrame]은 FSM(절대 y 기준)이 잡은 임팩트 프레임이다.
+ /// 캘리브레이션 스케일이 사용자/시도마다 크게 흔들리는 게 실측으로
+ /// 확인돼(같은 영상이 최대 y 17.5m 또는 11.5m로 분석되는 사례), FSM이
+ /// 절대 y 게이트(18.29m 도달 또는 14m 이상에서 소실)를 못 넘겨 침묵할 수
+ /// 있다 — 이때도 null을 넘겨 받는다. 핀 폭발 감지(pinImpactDetector, 호모그래피
+ /// 존 경로)는 영구변화 게이트(창 끝 중앙값>=15%) + Δd argmax로 자체 오탐
+ /// 방어가 있어 FSM 없이도 독립 신호로 신뢰할 수 있다 — 임팩트 시간축을
+ /// 절대 y 스케일 오류에서 분리하는 것이 이 분기의 목적이다.
+ ImpactResult? detect({
required List frames,
required int releaseFrame,
- required int homographyImpactFrame,
+ required int? homographyImpactFrame,
+ Rect? pinZone,
+ int? pinSearchStart,
}) {
- final pinFrame = pinImpactDetector.findImpactFrame(frames, releaseFrame);
+ final pinFrame = pinImpactDetector.findImpactFrame(
+ frames,
+ releaseFrame,
+ pinZone: pinZone,
+ searchStartOverride: pinSearchStart,
+ );
if (pinFrame == null) {
+ if (homographyImpactFrame == null) return null;
return ImpactResult(frame: homographyImpactFrame, confidence: ImpactConfidence.low);
}
+ if (homographyImpactFrame == null) {
+ // FSM이 침묵(캘리브레이션 스케일 오류로 절대 y 게이트 미발동)해도 핀
+ // 폭발은 픽셀 증거(씨드 대비 지속 변화)라 시간축으로 신뢰할 수 있다 —
+ // 다만 교차검증 신호가 없는 단독 판정이므로 high가 아닌 medium.
+ return ImpactResult(frame: pinFrame, confidence: ImpactConfidence.medium);
+ }
+
+ // 허용 창(diff<=3 high / <=12 medium): pinImpactDetector가 이제 "진짜
+ // 핀 폭발"(씨드 대비 지속 변화)을 잡도록 재설계돼, 호모그래피 왜곡의
+ // 영향을 받는 FSM 신호와는 다소 벌어질 수 있다. 실측(같은 영상)에서
+ // FSM은 118을 잡았지만 실제 핀 폭발은 프레임 육안 판독 기준 ~127-129였다
+ // — 중·원거리 호모그래피 스케일 왜곡 때문에 FSM이 실제보다 이르게
+ // 잡히는 경향이 있어, 옛 창(diff<=2/<=5)이면 이런 정상 케이스도 low로
+ // 격하돼 FSM 프레임(왜곡 영향권)이 채택되는 역설이 생겼다.
final diff = (pinFrame - homographyImpactFrame).abs();
- final confidence = diff <= 2
+ final confidence = diff <= 3
? ImpactConfidence.high
- : diff <= 5
+ : diff <= 12
? ImpactConfidence.medium
: ImpactConfidence.low;
diff --git a/lib/features/analysis/data/services/pin_impact_detector_service.dart b/lib/features/analysis/data/services/pin_impact_detector_service.dart
index 916c796..f8ed989 100644
--- a/lib/features/analysis/data/services/pin_impact_detector_service.dart
+++ b/lib/features/analysis/data/services/pin_impact_detector_service.dart
@@ -1,43 +1,273 @@
+import 'dart:math' as math;
+import 'dart:ui';
+
import 'package:flutter/foundation.dart';
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/homography_matrix.dart';
+
class PinImpactDetectorService {
static const _pinZoneRatio = 0.20;
static const _changeThreshold = 0.15;
+ // 호모그래피 존 경로는 더 이상 프레임간(1-frame) diff의 argmax를 쓰지
+ // 않는다 — 실기기 실물 판독으로 argmax(직전 프레임 대비 최대 변화율)가
+ // 매번 "공이 존을 통과하는 순간"(프레임 117)을 "핀이 실제로 흩어지는
+ // 순간"(프레임 ~127-129)보다 먼저/크게 잡는다는 게 확인됐다. 물리적으로
+ // 공 통과와 핀 폭발은 존 면적 대비 변화율은 비슷해 보여도(공도 순간적으로
+ // 큰 diff를 만든다) "지속성"이 다르다: 공은 지나가면 존이 원상복구되지만
+ // 핀은 폭발하면 배치가 영구히 바뀐다.
+ //
+ // 그래서 두 단계로 재설계한다:
+ // (1) 영구 변화 게이트 — 탐색 창 끝(마지막 5프레임) diff의 중앙값이
+ // floor(15%) 이상이어야 폭발이 실제로 일어난 것으로 본다. 공 통과는
+ // 지나가면 존이 원상복구되어 끝 구간 diff가 낮게 유지되고, 1-프레임
+ // 스파이크도 여기서 걸러진다. 폭발은 핀 배치가 영구히 바뀌므로
+ // (실측: 폭발 후 씨드 대비 34%로 고착) 게이트를 통과한다.
+ // (2) 폭발 "시점" = 램프-종료(ramp-end) 판별 (v3). v2(씨드 대비 diff의
+ // 최대 단일 프레임 증가 Δd argmax)는 공 진입 Δ가 폭발 Δ보다 날카로워
+ // argmax가 진입 순간(프레임 117)을 잡는 오탐이 재현됐다(실제 핀
+ // 폭발은 프레임 ~128). 물리적 구분: 공 진입 후에는 diff가 계속
+ // 상승하지만(공이 존에 다가오는 램프), 핀이 실제로 흩어진 뒤에는
+ // diff가 정체/하강한다(플래토). 실측 곡선: 공 진입 Δ≈13%+이후
+ // 8프레임 +14%p 추가 상승(램프 지속) vs 핀 폭발 Δ≈36%+이후 8프레임
+ // −1.2%p(정체). 그래서 Δd가 floor(3%p)를 넘는 후보들을 순서대로
+ // 보되, "이후 8프레임 동안 추가 상승이 cap(4%p) 이하"인 최초 후보를
+ // 폭발 시점으로 채택한다 — 순증가가 계속되면 아직 램프 중이므로
+ // 기각하고 다음 후보를 본다. 후보가 탐색 창 끝 근처라 이후 8프레임을
+ // 다 볼 수 없으면(남은 프레임 2개 미만) 폭발이 창 끝에 걸린 것으로
+ // 보고 lookahead 검사 없이 채택한다. 후보가 하나도 채택되지 않으면
+ // (전부 램프로 보이면) v2 방식(Δd argmax)으로 폴백한다 — 그 경우
+ // 최대 점프가 최선의 추정이다.
+ static const _eruptionFloor = 0.15;
+ static const _eruptionTailWindow = 5;
+ static const _eruptionJumpFloor = 0.03;
+ static const _postEruptionRiseCap = 0.04;
+ static const _eruptionLookaheadFrames = 8;
static const double _pixelDiffThreshold = 30.0;
// 릴리즈 직후 볼 스윙 이벤트를 오탐하지 않도록 최소 탐색 시작 프레임
// 50 km/h 기준 18.29m 이동 = 1.3s = 39프레임 → 여유분 포함 20프레임
static const _minTravelFrames = 20;
- int? findImpactFrame(List frames, int releaseFrame) {
+ /// 호모그래피로 핀 영역을 프레임 정규화좌표 존으로 투영한다.
+ ///
+ /// 핀덱 라인(y=18.29m)의 좌우 끝과 1.29m 앞(y=17.0m)의 좌우 끝, 총 4점을
+ /// laneToFrame으로 투영해 핀덱의 화면상 위치(좌우 범위 산출용)를 얻는다.
+ ///
+ /// 수직(존 높이) 스케일은 더 이상 바닥 원근 압축(17→18.29m 투영 높이)에서
+ /// 유도하지 않는다 — 낮은 카메라 각에서 바닥 깊이는 원근으로 극도로
+ /// 압축되는 반면(실측 unit≈0.01) 서 있는 핀은 레인 평면 위 수직 물체라
+ /// 이 압축을 받지 않으므로, 그 스케일을 그대로 쓰면 존이 핀을 못 담는다
+ /// (실측 LTRB(0.50,0.41,0.75,0.43) — 높이 17px). 대신 압축 없는 **레인
+ /// 폭**(핀덱 라인의 화면상 수평 폭, 실제 1.05m)에서 "이 거리에서 1m가
+ /// 화면상 몇 ny인지"를 역산해 핀 높이(0.38m)를 존 높이로 환산한다.
+ /// nx/ny 정규화 기준(각각 프레임 폭/높이)이 다르므로 [frameAspect](width/height)로
+ /// 보정한다.
+ ///
+ /// 투영 결과가 화면 밖이거나(0~1 clamp 후) 존이 퇴화하면(빈 영역) null을
+ /// 반환한다 — 호출부는 null이면 legacy 존으로 폴백해야 한다.
+ static Rect? computePinZone(
+ HomographyMatrix homography, {
+ required double frameAspect,
+ }) {
+ final d0 = homography.laneToFrame(const LanePoint(xM: 0, yM: 18.29));
+ final d1 = homography.laneToFrame(const LanePoint(xM: 1.05, yM: 18.29));
+ final n0 = homography.laneToFrame(const LanePoint(xM: 0, yM: 17.0));
+ final n1 = homography.laneToFrame(const LanePoint(xM: 1.05, yM: 17.0));
+
+ for (final p in [d0, d1, n0, n1]) {
+ if (!p.nx.isFinite || !p.ny.isFinite) return null;
+ }
+ if (frameAspect <= 0 || !frameAspect.isFinite) return null;
+
+ final deckNy = (d0.ny + d1.ny) / 2;
+ final deckWidthNx = (d1.nx - d0.nx).abs();
+ if (deckWidthNx <= 0 || !deckWidthNx.isFinite) return null;
+
+ // nx는 프레임 폭, ny는 프레임 높이로 정규화돼 있으므로 aspect로 환산해
+ // 핀덱 거리에서 1m가 화면상 몇 ny인지 구한다.
+ final nyPerMeter = deckWidthNx * frameAspect / 1.05;
+ const pinHeightM = 0.38;
+ final pinHeightNy = pinHeightM * nyPerMeter;
+ if (pinHeightNy <= 0 || !pinHeightNy.isFinite) return null;
+
+ final xs = [d0.nx, d1.nx, n0.nx, n1.nx];
+ final left = (xs.reduce(math.min) - 0.02).clamp(0.0, 1.0);
+ final right = (xs.reduce(math.max) + 0.02).clamp(0.0, 1.0);
+ final top = (deckNy - pinHeightNy * 2.5).clamp(0.0, 1.0); // 서 있는 핀 + 튀는 핀 여유
+ final bottom = (deckNy + pinHeightNy * 0.5).clamp(0.0, 1.0); // 핀 베이스 아래 약간
+
+ if (right - left < 0.01 || bottom - top < 0.005) return null;
+ return Rect.fromLTRB(left, top, right, bottom);
+ }
+
+ /// [searchStartOverride]가 주어지면(예: 공이 실제로 핀 근처(≥16m)에 도달한
+ /// 분석 프레임) 그 지점부터 탐색을 시작한다 — 릴리즈 직후 볼러 팔로스루 등
+ /// 핀존에 걸리는 무관한 움직임의 오탐을 원천 차단하기 위함. 다만
+ /// override가 releaseFrame 이하면 물리적으로 말이 안 되므로(공이 아직
+ /// 핀 근처에 갈 시간이 없었다는 뜻) 무시하고 기존 releaseFrame +
+ /// minTravelFrames 규칙으로 폴백한다. override가 없으면 기존 규칙 그대로.
+ int? findImpactFrame(
+ List frames,
+ int releaseFrame, {
+ Rect? pinZone,
+ int? searchStartOverride,
+ }) {
if (frames.length < 2) return null;
if (releaseFrame >= frames.length) return null;
- final searchStart = releaseFrame + _minTravelFrames;
+ final searchStart = (searchStartOverride != null && searchStartOverride > releaseFrame)
+ ? searchStartOverride
+ : releaseFrame + _minTravelFrames;
if (searchStart >= frames.length) return null;
- // 릴리즈 프레임을 기준으로 prevZone 초기화 (릴리즈 이후 변화를 무시하기 위해)
- final seedFrame = frames[releaseFrame];
- final seedH = (seedFrame.height * _pinZoneRatio).round().clamp(1, seedFrame.height);
- img.Image prevZone = img.grayscale(
- img.copyCrop(seedFrame, x: 0, y: 0, width: seedFrame.width, height: seedH),
- );
+ // 검색 시작 프레임 자체를 씨드(기준)로 삼는다 — release~searchStart 구간의 자연스러운
+ // 드리프트(카메라 미동/조명 변화)를 비교 대상에서 아예 제외해, 그 구간을 건너뛰고
+ // 여기서부터의 "급격한" 변화만 감지한다. 과거(release=36, searchStart=56)에는 이 구간을
+ // 넘어 release 프레임과 비교하다가, 첫 비교 자체가 20프레임 격차라 자연드리프트만으로도
+ // 매번 임계값을 넘겨 검색 시작 지점에서 곧바로 오탐(핀 충돌 아님)이 발생했다.
+ //
+ // 씨드는 공이 16m(핀 근접) 도달 시점의 존이라 아직 충돌 전 상태다. 이후
+ // 모든 프레임의 존을 (직전 프레임이 아니라) 이 씨드와 비교한다 — 공이
+ // 존을 통과하는 동안의 "직전 프레임 대비" 스파이크(legacy argmax의
+ // 함정)를 배제하고, 씨드 상태에서 얼마나 영구적으로 달라졌는지만 본다.
+ final seedFrame = frames[searchStart];
+ final seedZone = img.grayscale(_cropZone(seedFrame, pinZone));
- for (int i = searchStart; i < frames.length; i++) {
- final frame = frames[i];
- final zoneH = (frame.height * _pinZoneRatio).round().clamp(1, frame.height);
- final zone = img.copyCrop(frame, x: 0, y: 0, width: frame.width, height: zoneH);
- final grayZone = img.grayscale(zone);
+ if (pinZone == null) {
+ // legacy(존 미지정) 경로는 기존 첫-돌파(직전 프레임 대비) 방식을
+ // 그대로 유지한다 — 존이 상단 20%로 크고 신호도 굵어 첫 돌파로도
+ // 안정적으로 잡힌다.
+ img.Image prevZone = seedZone;
+ double maxRatio = 0;
+ int maxRatioFrame = searchStart;
+
+ for (int i = searchStart + 1; i < frames.length; i++) {
+ final grayZone = img.grayscale(_cropZone(frames[i], pinZone));
+ final ratio = _changeRatio(prevZone, grayZone);
+ if (ratio > maxRatio) {
+ maxRatio = ratio;
+ maxRatioFrame = i;
+ }
+ if (ratio >= _changeThreshold) {
+ debugPrint('[PinImpact] 핀 충돌 프레임: $i (변화율: ${(ratio * 100).toStringAsFixed(1)}%, 존: legacy)');
+ return i;
+ }
+ prevZone = grayZone;
+ }
+
+ debugPrint('[PinImpact] 핀 충돌 미감지 (임계값 ${(_changeThreshold * 100).toStringAsFixed(0)}%, '
+ '탐색구간 최대 변화율 ${(maxRatio * 100).toStringAsFixed(1)}% @ 프레임 $maxRatioFrame, '
+ '핀존 비율 ${(_pinZoneRatio * 100).toStringAsFixed(0)}%)');
+ return null;
+ }
+
+ // 호모그래피 존 경로: 씨드 대비 diff를 프레임마다 계산해 저장한다.
+ final diffs = [];
+ for (int i = searchStart + 1; i < frames.length; i++) {
+ final grayZone = img.grayscale(_cropZone(frames[i], pinZone));
+ diffs.add(_changeRatio(seedZone, grayZone));
+ }
+
+ double maxRatio = 0;
+ int maxRatioFrame = searchStart;
+ for (var idx = 0; idx < diffs.length; idx++) {
+ if (diffs[idx] > maxRatio) {
+ maxRatio = diffs[idx];
+ maxRatioFrame = searchStart + 1 + idx;
+ }
+ }
+
+ final zoneDesc = '핀존(호모그래피) LTRB(${pinZone.left.toStringAsFixed(2)},${pinZone.top.toStringAsFixed(2)},'
+ '${pinZone.right.toStringAsFixed(2)},${pinZone.bottom.toStringAsFixed(2)})';
+
+ // (1) 영구 변화 게이트: 창 끝 diff 중앙값이 floor 미만이면 폭발 없음 —
+ // 공 통과/스파이크는 존이 원상복구돼 여기서 걸러진다 (클래스 doc 참조).
+ if (diffs.isEmpty) {
+ debugPrint('[PinImpact] 핀 폭발 미감지 (탐색 창 없음, $zoneDesc)');
+ return null;
+ }
+ final tail = [...diffs.sublist(math.max(0, diffs.length - _eruptionTailWindow))]..sort();
+ final tailMedian = tail[tail.length ~/ 2];
+ if (tailMedian < _eruptionFloor) {
+ debugPrint('[PinImpact] 핀 폭발 미감지 (창 끝 중앙값 ${(tailMedian * 100).toStringAsFixed(1)}% '
+ '< ${(_eruptionFloor * 100).toStringAsFixed(0)}%, 씨드 대비 최대 '
+ '${(maxRatio * 100).toStringAsFixed(1)}% @ 프레임 $maxRatioFrame, $zoneDesc)');
+ return null;
+ }
+
+ // (2) 폭발 시점 = 램프-종료 판별 (클래스 doc v3 참조).
+ // 첫 원소의 Δd는 씨드(=0) 대비 diffs[0] 자체.
+ int? rampEndIdx;
+ double? rampEndLookahead;
+ for (var idx = 0; idx < diffs.length; idx++) {
+ final delta = diffs[idx] - (idx == 0 ? 0 : diffs[idx - 1]);
+ if (delta < _eruptionJumpFloor) continue;
+
+ final framesAfter = (diffs.length - 1) - idx;
+ if (framesAfter < 2) {
+ // 창 끝에 걸친 후보 — 이후 구간을 다 볼 수 없으므로 lookahead 검사 없이 채택.
+ rampEndIdx = idx;
+ rampEndLookahead = null;
+ break;
+ }
- final ratio = _changeRatio(prevZone, grayZone);
- if (ratio >= _changeThreshold) {
- debugPrint('[PinImpact] 핀 충돌 프레임: $i (변화율: ${(ratio * 100).toStringAsFixed(1)}%)');
- return i;
+ final lookahead = diffs[math.min(idx + _eruptionLookaheadFrames, diffs.length - 1)] - diffs[idx];
+ if (lookahead <= _postEruptionRiseCap) {
+ rampEndIdx = idx;
+ rampEndLookahead = lookahead;
+ break;
}
- prevZone = grayZone;
+ // 순증가가 이어짐 = 아직 램프 중 → 이 후보는 기각하고 다음 후보를 본다.
}
- debugPrint('[PinImpact] 핀 충돌 미감지');
- return null;
+
+ int bestIdx;
+ double bestDelta;
+ String lookaheadDesc;
+ if (rampEndIdx != null) {
+ bestIdx = rampEndIdx;
+ bestDelta = diffs[bestIdx] - (bestIdx == 0 ? 0 : diffs[bestIdx - 1]);
+ lookaheadDesc = rampEndLookahead != null
+ ? '${(rampEndLookahead * 100).toStringAsFixed(1)}%p'
+ : '창 끝(면제)';
+ } else {
+ // 후보가 하나도 채택되지 않음(전부 램프로 보임) → v2 폴백: Δd argmax.
+ // 전부 램프로 보이면 최대 점프가 최선의 추정이다.
+ bestIdx = 0;
+ bestDelta = diffs[0];
+ for (var idx = 1; idx < diffs.length; idx++) {
+ final delta = diffs[idx] - diffs[idx - 1];
+ if (delta > bestDelta) {
+ bestDelta = delta;
+ bestIdx = idx;
+ }
+ }
+ final framesAfter = (diffs.length - 1) - bestIdx;
+ lookaheadDesc = framesAfter < 2
+ ? '창 끝(면제)'
+ : '${((diffs[math.min(bestIdx + _eruptionLookaheadFrames, diffs.length - 1)] - diffs[bestIdx]) * 100).toStringAsFixed(1)}%p';
+ }
+
+ final impactFrame = searchStart + 1 + bestIdx;
+ debugPrint('[PinImpact] 핀 폭발 프레임: $impactFrame '
+ '(Δ${(bestDelta * 100).toStringAsFixed(1)}%p, 이후8프레임 $lookaheadDesc, '
+ '창 끝 중앙값 ${(tailMedian * 100).toStringAsFixed(1)}%, $zoneDesc)');
+ return impactFrame;
+ }
+
+ img.Image _cropZone(img.Image frame, Rect? pinZone) {
+ if (pinZone == null) {
+ final zoneH = (frame.height * _pinZoneRatio).round().clamp(1, frame.height);
+ return img.copyCrop(frame, x: 0, y: 0, width: frame.width, height: zoneH);
+ }
+
+ final w = frame.width;
+ final h = frame.height;
+ final x = (pinZone.left * w).round().clamp(0, w - 1);
+ final y = (pinZone.top * h).round().clamp(0, h - 1);
+ final width = (pinZone.width * w).round().clamp(1, w - x);
+ final height = (pinZone.height * h).round().clamp(1, h - y);
+ return img.copyCrop(frame, x: x, y: y, width: width, height: height);
}
double _changeRatio(img.Image prev, img.Image curr) {
diff --git a/lib/features/analysis/data/services/release_detector_service.dart b/lib/features/analysis/data/services/release_detector_service.dart
index f357bbb..7d2e6c3 100644
--- a/lib/features/analysis/data/services/release_detector_service.dart
+++ b/lib/features/analysis/data/services/release_detector_service.dart
@@ -3,7 +3,6 @@ import 'dart:math' show sqrt;
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';
@@ -97,7 +96,7 @@ class ReleaseDetectorService {
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);
+ ys.add(h.frameToLane(d.contactPoint).yM);
}
if (ys.length < 3) return 0.0;
var inc = 0;
diff --git a/lib/features/analysis/data/services/speed_estimator_service.dart b/lib/features/analysis/data/services/speed_estimator_service.dart
index 6f8cac2..ca72ee9 100644
--- a/lib/features/analysis/data/services/speed_estimator_service.dart
+++ b/lib/features/analysis/data/services/speed_estimator_service.dart
@@ -1,77 +1,175 @@
-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';
+import 'package:bowling_diary/features/analysis/domain/services/trajectory_refiner.dart';
class SpeedEstimatorService {
- static const int _flightStartOffset = 8;
- static const int _flightEndOffset = 24;
static const int _minSamples = 8;
+ static const double _minYSpanM = 5.0;
+ static const int _minFrameSpan = 15;
static const double _minSpeed = 10.0;
static const double _maxSpeed = 50.0;
+ // 핀덱 물리 상수(레인 규격) — 파울라인부터 핀덱까지의 거리.
+ static const double _pinDeckYM = 18.29;
+ // 파울라인 통과 프레임(foulCrossFrame)의 유효성 윈도우 — 릴리즈 프레임
+ // 근방이어야 물리적으로 말이 된다(파울라인은 릴리즈 직전후에 지나간다).
+ // 크게 벗어나면 회귀가 오염된 것으로 보고 이벤트-시간을 포기한다.
+ static const int _foulCrossWindowBeforeRelease = 45;
+ static const int _foulCrossWindowAfterRelease = 15;
+ // 이벤트-시간-회귀 교차검증 허용 오차. 이 비율을 넘으면 두 방식이 유의하게
+ // 어긋난 것으로 보고 confidence에 추가 페널티를 준다.
+ static const double _crossCheckToleranceRatio = 0.15;
+ static const double _crossCheckMismatchPenalty = 0.75;
+ /// 정제 궤적([refineTrajectory] 결과 — 중앙값 필터+선두 트림+단조 필터+
+ /// 스무딩 완료)에 단순 선형회귀 y = a + b·frame을 적합해 구속을 산출한다.
+ /// x 방향(훅 횡이동)은 전체 이동거리의 2% 미만이라 무시하고 y(레인 진행방향)
+ /// 만으로 회귀한다.
+ ///
+ /// **이벤트-시간 코어가 primary인 이유**: 캘리브레이션(픽셀→미터 스케일)은
+ /// 시도마다 ±30% 요동해 구속 절대값의 근거로 신뢰할 수 없다. 이벤트-시간은
+ /// 구속을 물리 상수(핀덱 18.29m) ÷ (핀 폭발 시각 − 파울라인 통과 시각)으로
+ /// 정의해 이 문제를 수학적으로 우회한다. 파울라인 통과 시각은 회귀직선
+ /// y=a+b·f의 y=0 근(f₀ = −a/b)이다. 캘리브레이션 스케일에 오류 s가 있어
+ /// 측정 y'이 실제 y의 s배로 부풀거나 줄어들면(y' = s·y), 회귀는 그 스케일을
+ /// 그대로 흡수해 a'=s·a, b'=s·b가 된다. 근을 다시 구하면
+ /// −a'/b' = −(s·a)/(s·b) = −a/b — s가 완전히 약분되어 **근(f₀)은 스케일과
+ /// 무관하게 불변**이다. 핀 폭발 시각(impact.frame)도 호모그래피 투영이 아니라
+ /// 핀 자체의 시각적 변화(밝기 변화 등)로 감지하므로 마찬가지로 스케일과
+ /// 무관하다. 따라서 이벤트-시간 구속 = 18.29 / ((impact.frame − f₀) / fps)은
+ /// 두 항 모두 캘리브레이션 오류에 물들지 않는다. 캘리브레이션은 이제 궤적을
+ /// 화면에 그리는 시각화 전용으로 강등되고, 구속 산출과는 수학적으로 분리된다.
+ ///
+ /// 회귀 자체(a, b, R²)는 f₀ 산출의 재료이자 이벤트-시간을 못 쓸 때의
+ /// 폴백(impact.confidence가 low일 때, 또는 f₀가 물리적으로 말이 안 될 때)
+ /// 으로 유지한다.
SpeedResult estimate({
required ReleaseResult release,
required ImpactResult impact,
- required List detections,
- required HomographyMatrix homography,
+ required List refinedTrajectory,
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 n = refinedTrajectory.length;
+ if (n < _minSamples) {
+ debugPrint('[SpeedEstimator] 측정불가: 정제 궤적 포인트 부족 (n=$n, 최소 $_minSamples)');
+ return SpeedResult.failed(SpeedFailure.lowConfidence);
}
- final flightStart = release.frame + _flightStartOffset;
- final flightEnd = (release.frame + _flightEndOffset).clamp(0, detections.length - 1);
+ final ySpan = refinedTrajectory.last.lane.yM - refinedTrajectory.first.lane.yM;
+ final frameSpan = refinedTrajectory.last.frame - refinedTrajectory.first.frame;
+ if (ySpan < _minYSpanM || frameSpan < _minFrameSpan) {
+ debugPrint('[SpeedEstimator] 측정불가: 궤적 구간 부족 '
+ '(y스팬 ${ySpan.toStringAsFixed(2)}m, 프레임스팬 $frameSpan)');
+ return SpeedResult.failed(SpeedFailure.lowConfidence);
+ }
- if (flightStart >= flightEnd) {
- return SpeedResult.failed(SpeedFailure.outOfRange);
+ final fBar = refinedTrajectory.map((s) => s.frame).reduce((a, b) => a + b) / n;
+ final yBar = refinedTrajectory.map((s) => s.lane.yM).reduce((a, b) => a + b) / n;
+
+ var num = 0.0;
+ var den = 0.0;
+ for (final s in refinedTrajectory) {
+ final df = s.frame - fBar;
+ num += df * (s.lane.yM - yBar);
+ den += df * df;
}
+ final b = den == 0.0 ? 0.0 : num / den;
+
+ final speedMs = b * sampleFps;
+ final regressionKmh = speedMs * 3.6;
- 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);
+ final a = yBar - b * fBar;
+ var ssRes = 0.0;
+ var ssTot = 0.0;
+ for (final s in refinedTrajectory) {
+ final predicted = a + b * s.frame;
+ final residual = s.lane.yM - predicted;
+ ssRes += residual * residual;
+ final diff = s.lane.yM - yBar;
+ ssTot += diff * diff;
}
+ final rSquared = ssTot == 0.0 ? 0.0 : (1 - ssRes / ssTot);
- if (samples.length < _minSamples) {
- return SpeedResult.failed(SpeedFailure.lowConfidence);
+ // 이벤트-시간 구속: 파울라인 통과 프레임(f₀ = 회귀직선의 y=0 근)부터
+ // 실제 핀 폭발 시각(impact.frame)까지 걸린 시간으로 물리 상수(핀덱
+ // 18.29m)를 나눈다. impact.confidence가 low가 아닐 때만(= 핀 신호 기반
+ // 프레임일 때만) 신뢰할 수 있다. 유효성 가드:
+ // - b>0: 궤적이 전진(레인 진행방향) 중이어야 근이 의미 있다.
+ // - f₀ < impact.frame: 통과가 폭발보다 앞서야 비행시간이 양수다.
+ // - f₀가 릴리즈 프레임 근방(release-45 ~ release+15)이어야 한다. 파울
+ // 라인은 릴리즈 직전후에 지나가므로, 회귀가 오염돼 근이 크게 벗어나면
+ // (예: 궤적 후반 노이즈에 회귀가 끌려간 경우) 이벤트-시간을 포기한다.
+ double? eventKmh;
+ double? foulCrossFrame;
+ if (b != 0) {
+ // 진단 출력용으로 부호와 무관하게 근을 구해두되, 실제 채택(eventKmh)은
+ // 아래 유효성 가드를 모두 통과한 impact.confidence != low 케이스에서만.
+ foulCrossFrame = -a / b;
+ }
+ if (impact.confidence != ImpactConfidence.low && b > 0 && foulCrossFrame != null) {
+ final flightFrames = impact.frame - foulCrossFrame;
+ final windowStart = release.frame - _foulCrossWindowBeforeRelease;
+ final windowEnd = release.frame + _foulCrossWindowAfterRelease;
+ if (flightFrames > 0 && foulCrossFrame >= windowStart && foulCrossFrame <= windowEnd) {
+ eventKmh = _pinDeckYM / (flightFrames / sampleFps) * 3.6;
+ }
}
- 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;
+ // 채택 규칙: 이벤트-시간을 계산할 수 있으면 primary(캘리브레이션 스케일과
+ // 무관 — 클래스 docstring 참조)이고 회귀는 교차검증 신호로만 쓴다. 두
+ // 값이 유의하게(15% 초과) 어긋나면 자기일관성이 없다는 뜻이므로
+ // confidence에 추가 페널티를 준다. 이벤트-시간을 계산할 수 없으면(impact
+ // low, 또는 f₀가 물리적으로 말이 안 됨) 회귀를 그대로 primary로 쓴다.
+ final String method;
+ final double adoptedKmh;
+ var crossCheckPenalty = 1.0;
+ if (eventKmh != null) {
+ method = '이벤트시간';
+ adoptedKmh = eventKmh;
+ final mismatchRatio = (regressionKmh - eventKmh).abs() / eventKmh;
+ if (mismatchRatio > _crossCheckToleranceRatio) {
+ crossCheckPenalty = _crossCheckMismatchPenalty;
+ debugPrint('[SpeedEstimator] 회귀-이벤트시간 교차검증 불일치 '
+ '(회귀 ${regressionKmh.toStringAsFixed(1)}km/h vs 이벤트시간 '
+ '${eventKmh.toStringAsFixed(1)}km/h, 차이 ${(mismatchRatio * 100).toStringAsFixed(1)}% > '
+ '${(_crossCheckToleranceRatio * 100).toStringAsFixed(0)}%, confidence ×$_crossCheckMismatchPenalty)');
+ }
+ } else {
+ method = '회귀';
+ adoptedKmh = regressionKmh;
+ }
- if (kmh < _minSpeed || kmh > _maxSpeed) {
+ // 범위 가드는 최종 채택값(이벤트-시간 또는 회귀)에 적용한다.
+ if (adoptedKmh < _minSpeed || adoptedKmh > _maxSpeed) {
+ debugPrint('[SpeedEstimator] 측정불가: 채택 속도 범위 밖 '
+ '(방식 $method, ${adoptedKmh.toStringAsFixed(1)}km/h, 허용 10~50)');
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);
+ // 임팩트 신호는 "분석 전반이 자기일관적인가"를 보여주는 지표다.
+ // impact.confidence가 low이면 이벤트-시간 자체를 계산하지 않고 회귀로
+ // 폴백하므로(위 유효성 가드), 여기서는 이벤트-시간이 채택된 경우에도
+ // 그 근거(핀 감지 확실성)를 confidence에 추가로 반영한다.
+ final impactPenalty = switch (impact.confidence) {
+ ImpactConfidence.high => 1.0,
+ ImpactConfidence.medium => 0.85,
+ ImpactConfidence.low => 0.7,
+ };
+ final confidence =
+ (rSquared * impactPenalty * release.confidence * crossCheckPenalty).clamp(0.0, 1.0);
- final rounded = double.parse(kmh.toStringAsFixed(1));
+ final rounded = double.parse(adoptedKmh.toStringAsFixed(1));
+ debugPrint('[SpeedEstimator] 구속 ${rounded}km/h '
+ '(방식: $method, 파울라인통과 f=${foulCrossFrame == null ? "없음" : foulCrossFrame.toStringAsFixed(1)}, '
+ '폭발 f=${impact.frame}, 회귀 ${regressionKmh.toStringAsFixed(1)}, '
+ 'R² ${rSquared.toStringAsFixed(2)}, n=$n, impact ${impact.confidence.name}, '
+ 'confidence ${confidence.toStringAsFixed(2)})');
return SpeedResult.success(rounded, confidence);
}
}
diff --git a/lib/features/analysis/data/services/video_frame_extractor_service.dart b/lib/features/analysis/data/services/video_frame_extractor_service.dart
index d4571c3..eca7689 100644
--- a/lib/features/analysis/data/services/video_frame_extractor_service.dart
+++ b/lib/features/analysis/data/services/video_frame_extractor_service.dart
@@ -20,9 +20,14 @@ class FrameExtractionResult {
class VideoFrameExtractorService {
static const _sampleFps = 30;
- static const _maxFrames = 150; // 최대 5초 × 30fps
+ static const _maxFrames = 210; // 최대 7초 × 30fps — 실측 트림 영상 7.1초, 5초 캡이면
+ // 느린 공의 핀 임팩트가 분석 창 밖으로 잘린다. 메모리 영향: 480x853 RGB ≈
+ // 1.2MB/frame × 60 추가 ≈ +72MB 피크 — 분석 중 일시적이라 수용.
+ // 연속 중복 프레임 판정: 리사이즈 후 평균 휘도차가 이 미만이면 동일 프레임으로 본다.
+ static const _duplicateFrameLumaThreshold = 1.0;
Future extract(String videoPath) async {
+ await _logSourceMeta(videoPath);
final originalFps = await _getVideoFps(videoPath);
final tempDir = await getTemporaryDirectory();
@@ -57,6 +62,7 @@ class VideoFrameExtractorService {
}
debugPrint('[FrameExtractor] 추출 완료: ${frames.length}개 프레임, 원본 ${originalFps}fps, 샘플 ${_sampleFps}fps');
+ _logDuplicateFrames(frames);
return FrameExtractionResult(
frames: frames,
originalFps: originalFps,
@@ -91,4 +97,101 @@ class VideoFrameExtractorService {
}
return 30;
}
+
+ /// 시간축 왜곡(카톡 전송본 등에서 CFR 변환 시 프레임 재라벨) 계측용 로그.
+ /// 소스의 duration/avg_frame_rate/r_frame_rate/nb_frames와 nb_frames÷duration로
+ /// 구한 "실효 fps"를 남긴다. 동작에는 영향 없음 — 왜곡 여부 진단 전용.
+ Future _logSourceMeta(String videoPath) async {
+ try {
+ final session = await FFprobeKit.getMediaInformation(videoPath);
+ final info = session.getMediaInformation();
+
+ final durationStr = info?.getDuration();
+ final duration = durationStr != null ? double.tryParse(durationStr) : null;
+
+ String? avgFrameRateStr;
+ String? rFrameRateStr;
+ String? nbFramesStr;
+ final streams = info?.getStreams();
+ if (streams != null) {
+ for (final stream in streams) {
+ if (stream.getType() == 'video') {
+ avgFrameRateStr = stream.getAverageFrameRate();
+ rFrameRateStr = stream.getRealFrameRate();
+ nbFramesStr = stream.getStringProperty('nb_frames');
+ break;
+ }
+ }
+ }
+
+ final avgFps = _parseFraction(avgFrameRateStr);
+ final nbFrames = nbFramesStr != null ? int.tryParse(nbFramesStr) : null;
+ final effectiveFps = (duration != null && duration > 0 && nbFrames != null)
+ ? nbFrames / duration
+ : null;
+
+ debugPrint(
+ '[FrameExtractor] 소스 메타: '
+ 'duration=${duration != null ? '${duration.toStringAsFixed(2)}s' : '?'}, '
+ 'avg_frame_rate=${avgFrameRateStr ?? '?'}(=${avgFps != null ? avgFps.toStringAsFixed(2) : '?'}), '
+ 'r_frame_rate=${rFrameRateStr ?? '?'}, '
+ 'nb_frames=${nbFramesStr ?? '?'}, '
+ '실효fps(nb/duration)=${effectiveFps != null ? effectiveFps.toStringAsFixed(2) : '?'}',
+ );
+ } catch (e) {
+ debugPrint('[FrameExtractor] 소스 메타 조회 실패: $e');
+ }
+ }
+
+ double? _parseFraction(String? value) {
+ if (value == null) return null;
+ final parts = value.split('/');
+ if (parts.length != 2) return null;
+ final num = double.tryParse(parts[0]);
+ final den = double.tryParse(parts[1]);
+ if (num == null || den == null || den == 0) return null;
+ return num / den;
+ }
+
+ /// 연속 중복 프레임(직전 프레임과 거의 동일한 프레임) 비율을 계측한다.
+ /// dup 삽입(원본이 샘플fps 미만일 때 CFR 변환이 시간 보존을 위해 중복을
+ /// 끼움)이면 시간축은 정직한 것이고, 중복이 없는데 소스 실효 fps <
+ /// 샘플fps면 프레임이 재라벨돼 영상이 빨리 재생되는 것 — 구속이 그
+ /// 비율만큼 과대측정된다. 동작에는 영향 없음 — 왜곡 여부 진단 전용.
+ void _logDuplicateFrames(List frames) {
+ if (frames.length < 2) return;
+
+ var duplicateCount = 0;
+ img.Image prevSmall = _shrinkGray(frames.first);
+ for (var i = 1; i < frames.length; i++) {
+ final small = _shrinkGray(frames[i]);
+ if (_avgAbsLumaDiff(prevSmall, small) < _duplicateFrameLumaThreshold) {
+ duplicateCount++;
+ }
+ prevSmall = small;
+ }
+
+ final duplicateRatio = duplicateCount / frames.length;
+ final estimatedOriginalFps = _sampleFps * (1 - duplicateRatio);
+ debugPrint(
+ '[FrameExtractor] 연속 중복 프레임: $duplicateCount/${frames.length} '
+ '(중복률 ${(duplicateRatio * 100).toStringAsFixed(1)}% '
+ '— 원본 실효 fps ≈ ${estimatedOriginalFps.toStringAsFixed(1)})',
+ );
+ }
+
+ img.Image _shrinkGray(img.Image frame) =>
+ img.grayscale(img.copyResize(frame, width: 64));
+
+ double _avgAbsLumaDiff(img.Image a, img.Image b) {
+ final total = a.width * a.height;
+ if (total == 0) return 0;
+ var sum = 0.0;
+ for (var y = 0; y < a.height; y++) {
+ for (var x = 0; x < a.width; x++) {
+ sum += (img.getLuminance(a.getPixel(x, y)) - img.getLuminance(b.getPixel(x, y))).abs();
+ }
+ }
+ return sum / total;
+ }
}
diff --git a/lib/features/analysis/domain/entities/analysis_data.dart b/lib/features/analysis/domain/entities/analysis_data.dart
index 6ebe69a..574e56d 100644
--- a/lib/features/analysis/domain/entities/analysis_data.dart
+++ b/lib/features/analysis/domain/entities/analysis_data.dart
@@ -1,5 +1,38 @@
+import 'package:bowling_diary/features/analysis/domain/entities/coord.dart';
import 'package:bowling_diary/features/analysis/domain/entities/speed_result.dart';
+/// 궤적 리본을 어느 경로로 만들었는지.
+///
+/// 내부 QA용 — 테플(릴리즈 빌드)에서는 debugPrint를 볼 수 없어 결과 화면에
+/// 뱃지로 노출한다. 정식 릴리즈 전에 뱃지와 함께 걷어낼 수 있는 진단 필드다.
+enum TrajectorySource {
+ /// 픽셀 공간 투영 모델. 캘리브레이션 무관, 핀 충돌 프레임까지 곡선 연장.
+ projective('픽셀투영'),
+
+ /// 레인 좌표 폴백 — 투영 적합 실패. 연장 길이·리본 폭이 캘리브레이션 정확도에
+ /// 그대로 물린다(예전 동작).
+ laneFallback('레인폴백');
+
+ const TrajectorySource(this.label);
+
+ final String label;
+}
+
+/// 구속을 어느 코어가 냈는지. [TrajectorySource]와 같은 내부 QA용 진단값.
+enum SpeedSource {
+ /// 랜드마크 통과-시각 코어. 에로우 iso-u 선 통과 프레임 ~ 핀 충돌 프레임.
+ /// 원근 압축에 영향받지 않는다.
+ landmark('랜드마크'),
+
+ /// 기존 이벤트-시간/회귀 코어. 파울라인 통과를 궤적 회귀로 외삽하므로
+ /// 원근 압축이 그대로 실린다.
+ legacy('기존');
+
+ const SpeedSource(this.label);
+
+ final String label;
+}
+
class AnalysisData {
final double? speedKmh;
final double speedConfidence;
@@ -7,11 +40,55 @@ class AnalysisData {
final int framesAnalyzed;
final int fpsUsed;
+ /// release~flight 단계에서 추적된 볼 궤적을 정제(trajectory_refiner) 후
+ /// 레인 평면 리본으로 변환한 것. 레인 실측좌표(LanePoint)가 아니라 프레임
+ /// 정규화좌표(FramePoint)로 이미 변환되어 있다 — 파이프라인이
+ /// homography.laneToFrame()을 미리 적용해서 UI가 호모그래피를 알 필요 없게
+ /// 한다(spec §11). 각 단면은 공 폭만큼 좌우로 벌린 두 가장자리(left/right)를
+ /// 가져, 균일 두께 폴리라인이 아니라 레인에 투영된 리본(원근 반영)으로
+ /// 그릴 수 있다. 결과 화면 영상 위에 곧바로 오버레이 가능한 형태.
+ /// 각 단면은 관측된 분석 프레임 번호를 함께 보존해(TrajectoryRibbonPoint)
+ /// 재생 위치와 동기화된 점진적 렌더링(현재 프레임까지만 그리기)에 사용한다.
+ final List trajectory;
+
+ /// 핀덱 진입각(도). 피팅 곡선 끝(핀덱 쪽)의 기울기에서 산출 — 통상 3~6°.
+ /// x/y가 캘리브레이션 좌표라 스케일 왜곡의 영향을 받는다: 구속(이벤트-시간,
+ /// 캘리브레이션 독립)과 달리 참고치 성격. 산출 불가 시 null.
+ final double? entryAngleDeg;
+
+ // ── 내부 QA 진단 필드 ────────────────────────────────────────────
+ // 테플(릴리즈 빌드)에서 debugPrint를 볼 수 없어 결과 화면 뱃지로 노출한다.
+ // 정식 릴리즈 전에 뱃지와 함께 걷어낼 수 있다.
+
+ /// 궤적 리본 생성 경로.
+ final TrajectorySource? trajectorySource;
+
+ /// 픽셀 투영 모델의 ny 잔차 rms(정규화 단위 — 화면 높이 대비).
+ /// [TrajectorySource.laneFallback]이면 null.
+ final double? trajectoryFitRms;
+
+ /// 구속 채택 경로.
+ final SpeedSource? speedSource;
+
+ /// 랜드마크 통과-시각 코어가 낸 구속. 채택 여부와 무관하게 기록해
+ /// 기존 코어와 나란히 비교할 수 있게 한다. 산출 실패 시 null.
+ final double? landmarkSpeedKmh;
+
+ /// 기존(이벤트-시간/회귀) 코어가 낸 구속. 산출 실패 시 null.
+ final double? legacySpeedKmh;
+
const AnalysisData({
this.speedKmh,
this.speedConfidence = 0.0,
this.speedFailure,
required this.framesAnalyzed,
required this.fpsUsed,
+ this.trajectory = const [],
+ this.entryAngleDeg,
+ this.trajectorySource,
+ this.trajectoryFitRms,
+ this.speedSource,
+ this.landmarkSpeedKmh,
+ this.legacySpeedKmh,
});
}
diff --git a/lib/features/analysis/domain/entities/coord.dart b/lib/features/analysis/domain/entities/coord.dart
index 283ac50..48f0fc2 100644
--- a/lib/features/analysis/domain/entities/coord.dart
+++ b/lib/features/analysis/domain/entities/coord.dart
@@ -15,3 +15,16 @@ class FramePoint extends Equatable {
@override
List