diff --git a/lib/features/analysis/domain/services/arrow_detector.dart b/lib/features/analysis/domain/services/arrow_detector.dart index b8cd6e1..a729a15 100644 --- a/lib/features/analysis/domain/services/arrow_detector.dart +++ b/lib/features/analysis/domain/services/arrow_detector.dart @@ -37,13 +37,27 @@ const double _maxDepth = 0.95; const double _minLateral = 0.08; const double _maxLateral = 0.92; +/// 화살표 무리가 가로축으로 차지해야 하는 최소 폭(정규화). board 5~35에 걸치므로 +/// 화면 가로의 상당 부분을 덮는다. 실측 0.38 — 잡음이 뭉친 좁은 무리를 배제한다. +const double _minLateralSpan = 0.15; + +/// 깊이 폭 / 가로 폭 상한. 화살표는 12~15ft로 깊이가 거의 같다(실측 0.076). +/// 잡음이 섞이면 깊이 폭이 급격히 커진다. +const double _maxDepthSpanRatio = 0.25; + /// 같은 무리로 볼 크기 비율(중앙값 대비). const double _sizeClusterLow = 0.6; const double _sizeClusterHigh = 1.6; -/// 조준 화살표 후보 개수 범위. 규격은 7개지만 일부 마모/가려짐을 허용한다. -const int _minArrows = 5; -const int _maxArrows = 9; +/// 조준 화살표 개수 — 규격 7개를 **정확히** 요구한다. +/// +/// 일부 누락을 허용하면 안 된다. 구속 계산은 "최대분리 쌍 = 양 끝 화살표(board +/// 5·35, 둘 다 12ft)"라는 가정 위에 서 있는데, 끝 화살표가 하나라도 빠지면 그 +/// 쌍이 board 10·30(13ft)이 되어 기준 거리가 조용히 틀어진다. 실측에서 5개만 +/// 잡힌 케이스가 그대로 "선 성립"으로 통과했다. 못 찾으면 빈 리스트를 내고 +/// 기존 코어로 폴백하는 편이 정직하다. +const int _minArrows = 7; +const int _maxArrows = 7; /// 프레임에서 레인 조준 화살표(targeting arrows)들의 중심을 검출한다. /// @@ -85,24 +99,93 @@ List detectArrows(img.Image frame) { final blobs = _findBlobs(dark, w, h); if (blobs.length < _minArrows) return const []; - // 어두운 순으로 추리고, 서로 크기가 비슷한 무리만 남긴다. - blobs.sort((a, b) => b.meanDark.compareTo(a.meanDark)); - final pool = blobs.take(_maxArrows * 3).toList(); - final sizes = pool.map((b) => b.size).toList()..sort(); - final medianSize = sizes[sizes.length ~/ 2]; - final cluster = pool - .where((b) => - b.size >= medianSize * _sizeClusterLow && b.size <= medianSize * _sizeClusterHigh) - .toList(); - - if (cluster.length < _minArrows || cluster.length > _maxArrows) return const []; + final depthIsX = w >= h; + + // 크기 무리를 **하나만** 시도하면 안 된다. 실촬영 첫 프레임에서 필터를 통과한 + // 블롭 20개 중 화살표 7개(변 5~6)와 잡음 13개(변 8~18)가 섞여 전체 중앙값이 + // 8로 잡혔고, ±비율 창이 둘 다 삼켜 14개 → 개수 초과로 통째로 버려졌다. + // 각 블롭 크기를 씨앗으로 후보 무리를 열거하고, 무리가 넘치면 깊이축으로 + // 가장 조밀한 부분집합부터 좁혀가며 셰브론 검증에 건다 — 화살표는 레인 + // 깊이가 12~15ft로 거의 같아 깊이 폭이 좁고, 셰브론 검증이 강한 판별자다. + // 셰브론을 통과한 후보를 전부 모아 게이트 두 개로 거른 뒤 **개수가 가장 많은** + // 것을 고른다. 첫 통과를 그냥 반환하면 잡음 2개가 낀 9개 집합이 채택돼 + // 최대분리 쌍(= 바깥 화살표라는 가정)이 깨진다. 반대로 깊이 폭만 최소화하면 + // 셰브론 꼭짓점이 정의상 깊이를 벌리므로 꼭짓점부터 버려진다. + // + // 게이트: 화살표는 board 5~35에 걸쳐 **가로로 길게** 퍼지고(실측 정규화 0.38), + // 깊이는 12~15ft로 거의 같아 폭이 좁다(실측 가로 폭의 0.076배). + List? best; + var bestCount = 0; + var bestRatio = double.infinity; + + final seeds = {for (final b in blobs) b.side}.toList()..sort(); + for (final seed in seeds) { + final group = blobs + .where((b) => + b.size >= seed * _sizeClusterLow && b.size <= seed * _sizeClusterHigh) + .toList(); + if (group.length < _minArrows) continue; + + group.sort((a, b) => (depthIsX ? a.cx : a.cy).compareTo(depthIsX ? b.cx : b.cy)); + + for (var take = math.min(group.length, _maxArrows); take >= _minArrows; take--) { + // 깊이축으로 정렬된 연속 구간 중 폭이 가장 좁은 take개를 고른다. + var bestStart = 0; + var bestSpan = double.infinity; + for (var s = 0; s + take <= group.length; s++) { + final lo = depthIsX ? group[s].cx : group[s].cy; + final hi = depthIsX ? group[s + take - 1].cx : group[s + take - 1].cy; + final span = hi - lo; + if (span < bestSpan) { + bestSpan = span; + bestStart = s; + } + } + final subset = group.sublist(bestStart, bestStart + take); + final points = + subset.map((b) => FramePoint(nx: b.cx / w, ny: b.cy / h)).toList(); + if (!isChevron(points)) continue; + + final depths = points.map((p) => depthIsX ? p.nx : p.ny).toList(); + final laterals = points.map((p) => depthIsX ? p.ny : p.nx).toList(); + final depthSpan = depths.reduce(math.max) - depths.reduce(math.min); + final lateralSpan = laterals.reduce(math.max) - laterals.reduce(math.min); + if (lateralSpan < _minLateralSpan) continue; + if (depthSpan > lateralSpan * _maxDepthSpanRatio) continue; + if (!_evenlySpaced(laterals)) continue; + + final ratio = depthSpan / lateralSpan; + if (points.length > bestCount || + (points.length == bestCount && ratio < bestRatio)) { + bestCount = points.length; + bestRatio = ratio; + best = points; + } + } + } - final points = cluster - .map((b) => FramePoint(nx: b.cx / w, ny: b.cy / h)) - .toList(); + return best ?? const []; +} - if (!isChevron(points)) return const []; - return points; +/// 가로축 좌표들이 **등간격**인지 본다. +/// +/// 규격상 화살표는 board 5·10·15·20·25·30·35 — 정확히 5보드 간격이다. 전부 +/// 거의 같은 깊이에 있어 원근 왜곡도 작으므로 화면상 간격도 균일하게 남는다 +/// (실측 최대/최소 간격비 1.12). 잡음이 한 개라도 끼면 그쪽 간격만 크게 벌어져 +/// 이 비율이 급등한다 — 실측에서 프레임 가장자리 오검출 1개가 끼자 2.65, +/// 뭉친 잡음 무리는 25까지 올라갔다. +bool _evenlySpaced(List laterals, {double maxGapRatio = 2.2}) { + if (laterals.length < 3) return false; + final sorted = [...laterals]..sort(); + var minGap = double.infinity; + var maxGap = 0.0; + for (var i = 1; i < sorted.length; i++) { + final gap = sorted[i] - sorted[i - 1]; + if (gap <= 0) return false; // 같은 자리 중복 + if (gap < minGap) minGap = gap; + if (gap > maxGap) maxGap = gap; + } + return maxGap <= minGap * maxGapRatio; } /// 점들이 조준 화살표 배치(셰브론)인지 검증한다. diff --git a/test/features/analysis/domain/services/arrow_detector_realframe_probe_test.dart b/test/features/analysis/domain/services/arrow_detector_realframe_probe_test.dart new file mode 100644 index 0000000..9d78c4c --- /dev/null +++ b/test/features/analysis/domain/services/arrow_detector_realframe_probe_test.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +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:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; + +/// 실촬영 프레임에 대한 화살표 검출기 탐침 — 정식 테스트가 아니라 조사용이다. +/// 이미지가 없으면 조용히 통과한다(CI/타 머신에서 깨지지 않게). +const _dir = '/Users/parkhyunryeol/.claude/jobs/c9e42aa7/tmp'; + +void main() { + test('실촬영 첫 프레임 3방향 화살표 검출', () { + for (final name in ['lm_land', 'lm_cw', 'lm_ccw', 'lm_bg']) { + final file = File('$_dir/$name.jpg'); + if (!file.existsSync()) { + // ignore: avoid_print + print('$name: 파일 없음 — 건너뜀'); + continue; + } + final frame = img.decodeImage(file.readAsBytesSync())!; + final arrows = detectArrows(frame); + // ignore: avoid_print + print('$name (${frame.width}x${frame.height}): 검출 ${arrows.length}개 ' + '${arrows.map((a) => '(${a.nx.toStringAsFixed(3)},${a.ny.toStringAsFixed(3)})').join(' ')}'); + final line = arrowLineFromDetections(arrows); + // ignore: avoid_print + print(' 선: ${line == null ? '실패' : '성립 uM=${line.uM}'}'); + } + }); +} diff --git a/test/features/analysis/domain/services/arrow_detector_test.dart b/test/features/analysis/domain/services/arrow_detector_test.dart index 307fa81..862424c 100644 --- a/test/features/analysis/domain/services/arrow_detector_test.dart +++ b/test/features/analysis/domain/services/arrow_detector_test.dart @@ -104,6 +104,46 @@ void main() { expect(detectArrows(_landscapeFrame(_measured.take(3).toList())), isEmpty); }); + test('크기가 다른 잡음 블롭이 섞여도 화살표 7개만 골라낸다', () { + // 실촬영 첫 프레임 회귀 — 필터를 통과한 블롭 20개 중 화살표 7개(변 5~6)와 + // 잡음 13개(변 8~18)가 섞이자, 전체 크기 중앙값이 8로 잡혀 ±비율 창이 둘 + // 다 삼켰고 14개 → 개수 초과로 **전부** 버려졌다(검출 0개). 아래 잡음 + // 좌표/크기는 그 프레임에서 실제로 살아남던 것들이다. + final im = _landscapeFrame(_measured); + for (final n in <(double, double, int)>[ + (466, 441, 8), + (445, 438, 8), + (455, 433, 8), + (370, 404, 14), + (370, 362, 14), + (371, 381, 11), + (476, 452, 12), + ]) { + _stamp(im, n.$1, n.$2, side: n.$3); + } + + final found = detectArrows(im); + expect(found.length, _measured.length); + for (final m in _measured) { + final target = FramePoint(nx: m.$1 / 960, ny: m.$2 / 540); + expect( + found.any((p) => + (p.nx - target.nx).abs() < 1 / 960 * 2 && + (p.ny - target.ny).abs() < 1 / 540 * 2), + isTrue, + reason: '화살표 $m 를 못 찾음', + ); + } + }); + + test('바깥 화살표가 빠진 5개 무리는 채택하지 않는다', () { + // 구속 계산이 "최대분리 쌍 = board 5·35(둘 다 12ft)"를 가정하므로, 끝 + // 화살표가 빠진 무리를 받으면 기준 거리가 조용히 틀어진다. + final inner = _measured.sublist(1, _measured.length - 1); + expect(inner.length, 5); + expect(detectArrows(_landscapeFrame(inner)), isEmpty); + }); + test('탐색 영역 밖(핀덱 쪽) 표식은 무시된다', () { // 깊이축 nx를 0.35 미만(=336px 미만)으로 옮기면 후보에서 빠진다. final tooFar = _measured.map((m) => (m.$1 - 400, m.$2)).toList();