diff --git a/src/features/photoValidation/.gitkeep b/src/features/photoValidation/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/photoValidation/__tests__/validatePose.test.ts b/src/features/photoValidation/__tests__/validatePose.test.ts new file mode 100644 index 0000000..070d796 --- /dev/null +++ b/src/features/photoValidation/__tests__/validatePose.test.ts @@ -0,0 +1,43 @@ +import { validatePose } from '../validatePose'; +import type { PoseResult } from '../../pose'; + +describe('validatePose', () => { + it('필수 6개 joint(어깨/골반/발목)가 모두 신뢰도 0.5↑이면 통과한다', () => { + 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 result = validatePose(pose); + expect(result.score).toBeGreaterThanOrEqual(80); + expect(result.passed).toBe(true); + expect(result.reasons).toEqual([]); + }); + + it('어깨가 누락되면 실패하고 어깨 사유가 포함된다', () => { + const pose: PoseResult = { + left_hip_joint: { x: 120, y: 400, confidence: 0.85 }, + right_hip_joint: { x: 280, y: 400, confidence: 0.85 }, + }; + const result = validatePose(pose); + expect(result.passed).toBe(false); + expect(result.reasons).toContain('어깨가 보이지 않습니다'); + }); + + it('어깨 y 차이가 30px 초과하면 기울어짐 사유가 포함된다', () => { + const pose: PoseResult = { + left_shoulder_joint: { x: 100, y: 200, confidence: 0.9 }, + right_shoulder_joint: { x: 300, y: 250, 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 result = validatePose(pose); + expect(result.passed).toBe(false); + expect(result.reasons).toContain('몸이 기울어져 있습니다'); + }); +}); diff --git a/src/features/photoValidation/validatePose.ts b/src/features/photoValidation/validatePose.ts new file mode 100644 index 0000000..65d0ffb --- /dev/null +++ b/src/features/photoValidation/validatePose.ts @@ -0,0 +1,52 @@ +import type { PoseResult, Joint } from '../pose'; + +export interface ValidationResult { + score: number; + passed: boolean; + reasons: string[]; +} + +const REQUIRED_JOINTS: readonly Joint[] = [ + 'left_shoulder_joint', + 'right_shoulder_joint', + 'left_hip_joint', + 'right_hip_joint', + 'left_ankle_joint', + 'right_ankle_joint', +] as const; + +const MIN_CONFIDENCE = 0.5; +const MAX_SHOULDER_TILT_PX = 30; +const PASS_SCORE = 80; +const REASON_PENALTY = 25; + +function reasonForMissingJoint(joint: Joint): string { + if (joint.includes('shoulder')) return '어깨가 보이지 않습니다'; + if (joint.includes('hip')) return '골반이 보이지 않습니다'; + if (joint.includes('ankle')) return '발목이 보이지 않습니다'; + return '관절이 보이지 않습니다'; +} + +export function validatePose(pose: PoseResult): ValidationResult { + const reasons: string[] = []; + + for (const joint of REQUIRED_JOINTS) { + const kp = pose[joint]; + if (!kp || kp.confidence < MIN_CONFIDENCE) { + const msg = reasonForMissingJoint(joint); + if (!reasons.includes(msg)) reasons.push(msg); + } + } + + const ls = pose.left_shoulder_joint; + const rs = pose.right_shoulder_joint; + if (ls && rs) { + const yDiff = Math.abs(ls.y - rs.y); + if (yDiff > MAX_SHOULDER_TILT_PX) { + reasons.push('몸이 기울어져 있습니다'); + } + } + + const score = Math.max(0, 100 - reasons.length * REASON_PENALTY); + return { score, passed: score >= PASS_SCORE, reasons }; +}