feat(maskSplit): computeSplitRegions TDD (v0-plan Task 6)

src/features/maskSplit/splitMask.ts:
- Region / SplitRegions / ImageSize 타입
- 상의(어깨-20 ~ 골반) / 하의(골반 ~ 발목+20) / 전신(어깨-80 ~ 발목+20)
- x 범위: keypoint bbox ±20px
- requireJoint helper: validatePose 통과 후 호출 invariant — 누락 시 explicit throw

src/features/maskSplit/__tests__/splitMask.test.ts:
- 4 케이스 TDD: top / bottom / full y 좌표 + 공통 x 범위

검증:
- npx jest src/features/maskSplit: 4 passed
- npx tsc --noEmit: 무에러

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 15:54:17 +09:00
parent 6f2998ef5f
commit 8ec53d95f0
3 changed files with 95 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
import { computeSplitRegions } from '../splitMask';
import type { PoseResult } from '../../pose';
describe('computeSplitRegions', () => {
const pose: PoseResult = {
left_shoulder_joint: { x: 100, y: 200, confidence: 0.9 },
right_shoulder_joint: { x: 300, y: 200, confidence: 0.9 },
left_hip_joint: { x: 120, y: 400, confidence: 0.85 },
right_hip_joint: { x: 280, y: 400, confidence: 0.85 },
left_ankle_joint: { x: 130, y: 700, confidence: 0.7 },
right_ankle_joint: { x: 270, y: 700, confidence: 0.7 },
};
const imageSize = { width: 400, height: 800 };
it('top region은 어깨-20부터 골반까지(목~허리)', () => {
const r = computeSplitRegions(pose, imageSize);
expect(r.top.yStart).toBe(180);
expect(r.top.yEnd).toBe(400);
});
it('bottom region은 골반부터 발목+20까지(허리~발끝)', () => {
const r = computeSplitRegions(pose, imageSize);
expect(r.bottom.yStart).toBe(400);
expect(r.bottom.yEnd).toBe(720);
});
it('full region은 어깨-80부터 발목+20까지(머리위~발끝)', () => {
const r = computeSplitRegions(pose, imageSize);
expect(r.full.yStart).toBe(120);
expect(r.full.yEnd).toBe(720);
});
it('모든 region은 x 범위가 keypoint bbox ±20에 맞춰진다', () => {
const r = computeSplitRegions(pose, imageSize);
// keypoint x 최소 = 100 (left_shoulder), 최대 = 300 (right_shoulder)
expect(r.top.xStart).toBe(80);
expect(r.top.xEnd).toBe(320);
expect(r.bottom.xStart).toBe(80);
expect(r.full.xEnd).toBe(320);
});
});

View File

@@ -0,0 +1,54 @@
import type { PoseResult, Joint, Keypoint } from '../pose';
export interface Region {
xStart: number;
yStart: number;
xEnd: number;
yEnd: number;
}
export interface SplitRegions {
top: Region;
bottom: Region;
full: Region;
}
export interface ImageSize {
width: number;
height: number;
}
const X_PADDING = 20;
const TOP_HEAD_MARGIN = 20;
const FULL_HEAD_MARGIN = 80;
const FOOT_MARGIN = 20;
function requireJoint(pose: PoseResult, joint: Joint): Keypoint {
const kp = pose[joint];
if (!kp) throw new Error(`computeSplitRegions: ${joint} keypoint 누락 — validatePose 통과 후 호출 필요`);
return kp;
}
export function computeSplitRegions(
pose: PoseResult,
_imageSize: ImageSize,
): SplitRegions {
const ls = requireJoint(pose, 'left_shoulder_joint');
const rs = requireJoint(pose, 'right_shoulder_joint');
const lh = requireJoint(pose, 'left_hip_joint');
const rh = requireJoint(pose, 'right_hip_joint');
const la = requireJoint(pose, 'left_ankle_joint');
const ra = requireJoint(pose, 'right_ankle_joint');
const shoulderY = Math.min(ls.y, rs.y);
const hipY = Math.max(lh.y, rh.y);
const ankleY = Math.max(la.y, ra.y);
const xMin = Math.min(ls.x, rs.x, lh.x, rh.x, la.x, ra.x) - X_PADDING;
const xMax = Math.max(ls.x, rs.x, lh.x, rh.x, la.x, ra.x) + X_PADDING;
return {
top: { xStart: xMin, yStart: shoulderY - TOP_HEAD_MARGIN, xEnd: xMax, yEnd: hipY },
bottom: { xStart: xMin, yStart: hipY, xEnd: xMax, yEnd: ankleY + FOOT_MARGIN },
full: { xStart: xMin, yStart: shoulderY - FULL_HEAD_MARGIN, xEnd: xMax, yEnd: ankleY + FOOT_MARGIN },
};
}