From 45e025ee6030f01adb9d983817208f24b8a53c99 Mon Sep 17 00:00:00 2001 From: devpark435 Date: Thu, 30 Jul 2026 17:58:08 +0900 Subject: [PATCH] =?UTF-8?q?fix(analysis):=20=ED=99=94=EC=82=B4=ED=91=9C=20?= =?UTF-8?q?=EA=B2=80=EC=B6=9C=EC=97=90=20=EC=9B=90=EB=B3=B8=20=ED=95=B4?= =?UTF-8?q?=EC=83=81=EB=8F=84=20=ED=94=84=EB=A0=88=EC=9E=84=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20+=20=EC=A7=84=EB=8B=A8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실기기 1회차 성공 로그에서 landmark_speed_kmh=null로 랜드마크 코어가 죽고 legacy 32.7km/h가 채택됐다. 실패 원인이 "검출 0개"인지 "선 구성 실패"인지 구분되지 않아 튜닝 방향을 정할 수 없었다. [analysis_pipeline] - run()에 landmarkFrame 옵션 추가. 분석 프레임은 폭 480 + jpeg q5로 뽑혀 화살표가 뭉개진다. 화살표는 레인 고정 마킹이라 어느 프레임이든 무방하므로 호출부가 이미 원본 해상도로 뽑아둔 첫 프레임을 넘긴다 - [Arrow] 로그 — 입력 해상도, 검출 개수, 각 좌표, 선 성립 여부 - [Trajectory] 리본 끝점 로그 — 리본 f범위와 ny 범위, 마지막 관측 프레임, 핀존 ny를 한 줄에 모아 "선이 BP에서 끝난다"가 연장 실패인지 원근 압축인지 판정 가능하게 함 [analysis_trim_page] - 레인 확인용으로 이미 디코딩해둔 원본 프레임을 landmarkFrame으로 전달 - metrics에 trajectory 첫/끝 프레임과 ny 추가 — SQL만으로 리본 도달 여부 판정 --- .../data/services/analysis_pipeline.dart | 35 +++++++++++++++++-- .../pages/analysis_trim_page.dart | 22 +++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/lib/features/analysis/data/services/analysis_pipeline.dart b/lib/features/analysis/data/services/analysis_pipeline.dart index b99ca5f..7d5e11a 100644 --- a/lib/features/analysis/data/services/analysis_pipeline.dart +++ b/lib/features/analysis/data/services/analysis_pipeline.dart @@ -145,7 +145,14 @@ class AnalysisPipeline { required int impactFrame, required int sampleFps, }) { - final line = arrowLineFromDetections(detectArrows(releaseFrame)); + // 실패가 "검출 0개"인지 "검출은 됐는데 선을 못 세움"인지 구분되지 않으면 + // 튜닝 방향을 정할 수 없다 — 실기기 1회 실패에 빌드 1회를 쓰게 된다. + final arrows = detectArrows(releaseFrame); + final line = arrowLineFromDetections(arrows); + debugPrint('[Arrow] 입력 ${releaseFrame.width}x${releaseFrame.height}, ' + '검출 ${arrows.length}개' + '${arrows.isEmpty ? "" : " ${arrows.map((a) => "(${a.nx.toStringAsFixed(3)},${a.ny.toStringAsFixed(3)})").join(" ")}"}, ' + '선 ${line == null ? "실패" : "성립"}'); if (line == null) return null; return estimateLandmarkSpeedKmh( track: [for (final s in pixelTrack) (frame: s.frame, p: s.contact)], @@ -197,7 +204,16 @@ class AnalysisPipeline { /// [homography]는 이 영상 전용으로 산출된 호모그래피다(레퍼런스 = 영상 자체이므로 /// drift 개념이 존재하지 않는다 — spec §10 참조). - Future run(String videoPath, HomographyMatrix homography) async { + /// [landmarkFrame]은 조준 화살표 검출 전용 고해상도 프레임이다. 분석 프레임은 + /// 폭 480 + jpeg q5로 뽑히는데, 화살표는 그 해상도에서 뭉개져 검출이 깨진다. + /// 화살표는 레인에 고정된 마킹이라 어느 프레임을 써도 무방하므로, 호출부가 + /// 이미 원본 해상도로 뽑아둔 첫 프레임을 그대로 넘긴다. 없으면 릴리즈 프레임 + /// 폴백. + Future run( + String videoPath, + HomographyMatrix homography, { + img.Image? landmarkFrame, + }) async { final extracted = await frameExtractor.extract(videoPath); final frames = extracted.frames; if (frames.isEmpty) { @@ -346,6 +362,19 @@ class AnalysisPipeline { debugPrint('[Trajectory] 리본 소스: ${trajectorySource.label} ' '(${finalTrajectory.length}개 단면, 픽셀관측 ${pixelTrack.length}개' '${pixelRibbon != null ? ", rms ${pixelRibbon.rms.toStringAsFixed(5)}" : ""})'); + // 리본이 핀까지 닿는지는 끝점 ny를 핀존 ny와 직접 비교해야 판정된다. + // "선이 BP에서 끝난다"가 연장 실패인지 원근 압축인지 이 한 줄로 갈린다. + if (finalTrajectory.isNotEmpty) { + final head = finalTrajectory.first; + final tail = finalTrajectory.last; + final lastObserved = pixelTrack.isEmpty ? null : pixelTrack.last; + debugPrint('[Trajectory] 리본 f${head.frame}~f${tail.frame} ' + 'ny ${((head.left.ny + head.right.ny) / 2).toStringAsFixed(3)}' + '→${((tail.left.ny + tail.right.ny) / 2).toStringAsFixed(3)}, ' + '마지막 관측 f${lastObserved?.frame} ny ' + '${lastObserved?.contact.ny.toStringAsFixed(3) ?? "없음"}, ' + '핀존 ny ${pinZone == null ? "없음" : "${pinZone.top.toStringAsFixed(3)}~${pinZone.bottom.toStringAsFixed(3)}"}'); + } final speed = speedEstimator.estimate( release: release, @@ -356,7 +385,7 @@ class AnalysisPipeline { // 랜드마크 통과-시각 구속 — 조준 화살표가 검출되면 이쪽이 1순위. final landmarkKmh = estimateLandmarkSpeed( - releaseFrame: frames[releaseFrameIdx], + releaseFrame: landmarkFrame ?? frames[releaseFrameIdx], pixelTrack: pixelTrack, impactFrame: impact.frame, sampleFps: extracted.sampleFps, diff --git a/lib/features/analysis/presentation/pages/analysis_trim_page.dart b/lib/features/analysis/presentation/pages/analysis_trim_page.dart index c91461d..5f91117 100644 --- a/lib/features/analysis/presentation/pages/analysis_trim_page.dart +++ b/lib/features/analysis/presentation/pages/analysis_trim_page.dart @@ -174,7 +174,10 @@ class _AnalysisTrimPageState extends State { impactDetector: ImpactDetectorService(pinImpactDetector: PinImpactDetectorService()), speedEstimator: SpeedEstimatorService(), ); - final analysisData = await pipeline.run(trimmedPath, homography); + // 화살표 검출은 분석 프레임(폭 480, jpeg q5)에선 뭉개진다 — 레인 확인용으로 + // 이미 원본 해상도로 뽑아둔 첫 프레임을 그대로 넘긴다. + final analysisData = + await pipeline.run(trimmedPath, homography, landmarkFrame: frame); // 성공 케이스도 남긴다 — 구속 두 코어 값이 매 투구 쌓여야 표본 1개가 // 아닌 분포로 정확도를 판단할 수 있다. await하지 않는다(결과 화면 지연 방지). @@ -192,6 +195,23 @@ class _AnalysisTrimPageState extends State { 'trajectory_source': analysisData.trajectorySource?.name, 'trajectory_fit_rms': analysisData.trajectoryFitRms, 'trajectory_points': analysisData.trajectory.length, + // 리본이 핀까지 닿는지 SQL만으로 판정하기 위한 끝점(깊이축 정규화 좌표). + 'trajectory_first_frame': analysisData.trajectory.isEmpty + ? null + : analysisData.trajectory.first.frame, + 'trajectory_last_frame': analysisData.trajectory.isEmpty + ? null + : analysisData.trajectory.last.frame, + 'trajectory_first_ny': analysisData.trajectory.isEmpty + ? null + : (analysisData.trajectory.first.left.ny + + analysisData.trajectory.first.right.ny) / + 2, + 'trajectory_last_ny': analysisData.trajectory.isEmpty + ? null + : (analysisData.trajectory.last.left.ny + + analysisData.trajectory.last.right.ny) / + 2, 'entry_angle_deg': analysisData.entryAngleDeg, 'trim_start_sec': _startSec, 'trim_end_sec': _endSec,