Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
d181c36
chore: .superpowers/ 브레인스토밍 산출물 gitignore 추가
devpark435 Jul 9, 2026
2a46575
feat(analysis): AnalysisData에 프레임좌표 변환된 궤적 필드 추가
devpark435 Jul 9, 2026
9709f58
feat(analysis): BoxFit.cover 궤적 오버레이 위젯 추가
devpark435 Jul 9, 2026
c5dd2e1
feat(analysis): 결과 화면 영상 위에 궤적 오버레이 배선
devpark435 Jul 9, 2026
9f80f9e
docs(analysis): 궤적 오버레이 클리핑 관련 주석 정정
devpark435 Jul 9, 2026
f769b35
fix(analysis): 손 구간 궤적 오버레이 누출 및 브레이크포인트 오탐 임팩트 수정
devpark435 Jul 9, 2026
a3bb117
fix(analysis): 핀임팩트 씨드프레임 갭 불일치로 인한 오탐 수정 + 궤적 진단로그 추가
devpark435 Jul 9, 2026
15dcae6
debug(analysis): 핀임팩트 미감지 시 최대 변화율/프레임 진단로그 추가
devpark435 Jul 9, 2026
7762825
debug(analysis): VideoPlayerController 비디오 크기 진단로그 추가
devpark435 Jul 9, 2026
668d30b
feat(analysis): 궤적 오버레이 재생 위치 동기화 점진 렌더링
devpark435 Jul 9, 2026
179bdc8
fix(analysis): 핀존을 호모그래피 투영으로 산출해 핀 충돌 감지 실효화
devpark435 Jul 9, 2026
6d597ce
feat(analysis): 레인 평면 리본 궤적 + 궤적 정제 + 핀 탐색 근접 게이트
devpark435 Jul 9, 2026
0a76e32
feat(analysis): 궤적 곡선 피팅 + 코멧 스타일 렌더, 핀존 수직 스케일 수정
devpark435 Jul 10, 2026
976b737
fix(analysis): 궤적 중앙값 프리필터 + 핀 충돌 argmax 채택
devpark435 Jul 10, 2026
8851dc1
feat(analysis): 궤적 곡선을 릴리즈 직후까지 선형 연장
devpark435 Jul 10, 2026
0260750
fix(analysis): 속도 추정을 정제 궤적 선형회귀 기반으로 재작성
devpark435 Jul 10, 2026
008f474
feat(analysis): 레인 캘리브레이션 실시간 검증 그리드
devpark435 Jul 10, 2026
22689f5
fix(analysis): 공-레인 접점(bbox 바닥 중점) 투영으로 전환
devpark435 Jul 10, 2026
bb13882
feat(analysis): 볼 검출 모델 YOLO11s@640 교체 실험
devpark435 Jul 10, 2026
163bf80
fix(analysis): yolo11s NCHW 입력 레이아웃 대응
devpark435 Jul 10, 2026
328fd6c
fix(analysis): letterbox 전처리 도입 + 검출 점수 진단 로그
devpark435 Jul 10, 2026
f13ebd3
fix(analysis): 속도 임팩트 게이트 완화 + 핀 바닥값 4% + 궤적 끝 연장
devpark435 Jul 10, 2026
ddc9dd0
feat(analysis): 결과 화면에 구속 신뢰도 배지 표시
devpark435 Jul 10, 2026
243cc07
fix(auth): 시뮬레이터/로컬 빌드 Google 로그인 및 로그 노이즈 정리
devpark435 Jul 10, 2026
edceb91
feat(analysis): 핀 폭발 감지(영구변화+증가량 argmax) + 앵커 기반 구속
devpark435 Jul 10, 2026
4a9cb87
chore(analysis): 오버레이 재생동기 진단 로그 + 캘리브레이션 가이드 경험칙 반영
devpark435 Jul 11, 2026
31f96c7
feat(analysis): 임팩트 판정을 절대 y 게이트에서 해방
devpark435 Jul 11, 2026
3a59308
fix(analysis): 핀 폭발 탐색 시작을 공 도착 예상 지점으로 이동 + 무보정 구속 진단
devpark435 Jul 11, 2026
04d93f8
feat(analysis): 시간축 계측 로그 + 핀 폭발 램프-종료 판별(v3) + 분석 캡 7초
devpark435 Jul 11, 2026
5e39f6b
feat(analysis): 구속을 이벤트-시간 코어로 전환 (캘리브레이션 독립)
devpark435 Jul 12, 2026
d4fc14e
feat(analysis): 핀 행 자동 탐지(존 독립화) + 엔트리 앵글
devpark435 Jul 13, 2026
6c4aa01
fix(analysis): 핀 행 탐지에 틈-어두움 판별자 도입 (실영상 계측 검증)
devpark435 Jul 13, 2026
04e63cb
feat(analysis): 랜드마크 통과-시각 구속 코어 (원근 압축 제거)
devpark435 Jul 28, 2026
f166042
feat(analysis): 궤적 리본을 픽셀 공간 투영 모델로 전환
devpark435 Jul 28, 2026
f60419d
feat(analysis): 랜드마크 구속 배선 + 내부 QA 진단 뱃지
devpark435 Jul 28, 2026
9386cb0
Merge remote-tracking branch 'origin/dev' into worktree-idempotent-cr…
devpark435 Jul 28, 2026
cb7812d
chore: 버전 1.3.0+6로 bump
devpark435 Jul 28, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Miscellaneous
.superpowers/
*.class
*.log
*.pyc
Expand Down
Binary file added assets/models/yolo11s.tflite
Binary file not shown.
2 changes: 2 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>GIDClientID</key>
<string>7539952728-b2m6shd6amkn5qu40lkmhdb1290i8avp.apps.googleusercontent.com</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
Expand Down
4 changes: 4 additions & 0 deletions lib/core/services/app_update_service.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:new_version_plus/new_version_plus.dart';

Expand All @@ -7,6 +8,9 @@ class AppUpdateService {
static bool _hasChecked = false;

static Future<void> checkForUpdate(BuildContext context) async {
// debug 빌드는 스토어 미등록 번들 id 조회로 에러 로그만 만들고
// 업데이트 안내가 의미도 없으므로 건너뛴다.
if (kDebugMode) return;
if (_hasChecked) return;
_hasChecked = true;

Expand Down
257 changes: 249 additions & 8 deletions lib/features/analysis/data/services/analysis_pipeline.dart

Large diffs are not rendered by default.

112 changes: 104 additions & 8 deletions lib/features/analysis/data/services/ball_detection_service.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -62,18 +115,37 @@ 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() {
_interpreter?.close();
_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,
Expand All @@ -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<List<List<List<double>>>> _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,
Expand All @@ -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(
Expand Down
42 changes: 37 additions & 5 deletions lib/features/analysis/data/services/impact_detector_service.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<img.Image> 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;

Expand Down
Loading
Loading