- docs/spec.md: 정식 spec (브랜드·시장·기술 스택·일정·Day 0 검증 결과) - docs/v0-plan.md: W1~W4 13 task 구현 plan (TDD + manual test 절차) - docs/brainstorming-raw.md: 2026-05-23 brainstorming 원본 - CLAUDE.md: 하네스 운영 규약 (컨텍스트 3단·agentic 7 구성요소·박재오 Why 정합도) - README.md: 입구 + v0 상태 표 - .gitattributes: Windows ↔ macOS LF 통일 + pbxproj 바이너리 처리 - src/, modules/ 폴더 구조 (.gitkeep) 박재오 + AI agent 협업 표준 정립. 7월 착수 → 5/24 앞당김. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1469 lines
49 KiB
Markdown
1469 lines
49 KiB
Markdown
> [!takeaway] 비즈니스/방향성 관점에서의 핵심
|
||
> [[사업-Lapie-피팅앱]] design의 **v0 4~6주 구체 구현 계획**. 7월 착수 직전 (6월 말 [[프로젝트-우리카드-AI숏폼-공모전]] 마감 후) 재수정 가능. 박재오는 RN 신규 영역이므로 모든 task는 bite-sized + TDD 가능 부분은 TDD / UI·카메라·합성은 manual test 절차 명시. 핵심 차별화 = **Task 9 자동 스케일** = v0 W3 발목 잡힐 가능성 가장 큰 곳.
|
||
|
||
# 사업 — Lapie 피팅앱 v0 구현 계획
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** iOS 단독 RN 앱 v0 빌드 — 정면 전신 사진 등록 → 자동 마스킹(상/하/전신 3모드) → 카메라 라이브 합성(자동스케일+핀치) → 캡쳐·SNS 공유까지. 박재오 본인+아내+친구 5명이 사용 가능한 수준.
|
||
|
||
**Architecture:** Expo bare workflow 위 React Native + TypeScript. 카메라·세그멘테이션·합성은 react-native-vision-camera + react-native-skia로 처리. iOS Vision Framework 직접 호출 부분은 Swift 네이티브 모듈로 분리. 상태는 Zustand로 최소화.
|
||
|
||
**Tech Stack:** React Native (0.76+) / Expo (52+, bare) / TypeScript / react-native-vision-camera (v4) / react-native-skia (v1) / @shopify/react-native-skia / react-native-worklets-core / Vision Framework (Swift bridge) / Zustand / Jest (유닛) / Detox (선택, v1으로 미룸)
|
||
|
||
**전제 조건:**
|
||
- 박재오 가용시간: 주 15~20h. 7월 1주 착수 가정.
|
||
- macOS + Xcode 15+ 필요 (현재 박재오 메인은 Windows이므로 Xcode 접근 방안 별도 확인 — 5번 액션 참고).
|
||
- Apple Developer Program 가입 ($99/yr).
|
||
- 워크스페이스: `C:\Users\jaeoh\Desktop\workspace\lapie\` 신설 (또는 macOS 경로).
|
||
|
||
**중요:** 박재오 Windows 환경에서 RN iOS 빌드 불가. 다음 중 선택:
|
||
- (a) macOS 기기 확보 (중고 Mac mini M1 ~50만) — Day 0 결정 항목
|
||
- (b) Expo EAS Build 클라우드 빌드 ($29/월) — Mac 없이 가능, 단 iOS 네이티브 모듈 디버깅 어려움
|
||
- (c) 처음부터 RN 포기, **Flutter or 웹 PWA** 재검토 → design 재논의 필요
|
||
|
||
→ **이 plan은 (a) 또는 (b) 가정**. 7월 착수 전 결정 필요.
|
||
|
||
---
|
||
|
||
## Pre-Tasks: Day 0 차단성 액션 (착수 전, 비코딩)
|
||
|
||
### Task 0.1: 도메인·앱스토어·인스타·상표 검증
|
||
|
||
**Files:** 없음 (외부 검색)
|
||
|
||
- [ ] **Step 1: 도메인 가용성 확인**
|
||
- `lapie.app` / `lapie.kr` / `lapie.io` Namecheap·Cafe24에서 검색
|
||
- 결과 기록: [[사업-Lapie-피팅앱]] "Day 0 차단성 액션" 표에 ✅/❌ 표시
|
||
- 모두 사용 불가 시 대안: Wittu / Mirree / Geola 순으로 재검증
|
||
|
||
- [ ] **Step 2: 앱스토어 동명 앱 검색**
|
||
- App Store (iOS) + Google Play (Android) 검색: "Lapie", "라피"
|
||
- 동명 패션·피팅 앱 존재 시 → 네이밍 재검토
|
||
|
||
- [ ] **Step 3: 인스타 핸들 확보**
|
||
- `@lapie` / `@lapie_app` / `@lapie_official` 검색
|
||
- 가능한 핸들 1개 즉시 선점 등록 (이메일·비밀번호만 필요, 정식 운영은 W4 직전)
|
||
|
||
- [ ] **Step 4: 상표 검색 (KIPRIS + USPTO)**
|
||
- KIPRIS (http://www.kipris.or.kr) "Lapie" 검색 — 의류·앱 류
|
||
- USPTO (https://tmsearch.uspto.gov) "Lapie" 검색
|
||
- 선출원 발견 시 → 네이밍 재검토
|
||
|
||
- [ ] **Step 5: 결과를 wiki에 반영**
|
||
- [[사업-Lapie-피팅앱]] "Day 0 차단성 액션" 표에 검증 결과 갱신
|
||
- 충돌 발견 시 본 plan 머리의 brand name을 새 후보로 변경
|
||
|
||
### Task 0.2: 개발 환경 결정
|
||
|
||
- [ ] **Step 1: macOS 접근 방안 결정**
|
||
- (a) 중고 Mac mini M1 구매 (~50만)
|
||
- (b) EAS Build 클라우드 ($29/월 = 7월~10월 약 12만)
|
||
- (c) Flutter or PWA 재논의 (design 변경 필요)
|
||
- 결정 → [[사업-Lapie-피팅앱]] 변경 이력에 한 줄 기록
|
||
|
||
- [ ] **Step 2: Apple Developer Program 가입**
|
||
- https://developer.apple.com/programs/ $99/yr
|
||
- 7월 착수 직전 가입 (그 전에 가입하면 1년 카운트가 일찍 시작됨)
|
||
|
||
---
|
||
|
||
## Workspace Setup
|
||
|
||
### Task 1: RN + Expo 프로젝트 초기화
|
||
|
||
**Files:**
|
||
- Create: `C:/Users/jaeoh/Desktop/workspace/lapie/` (또는 mac 경로)
|
||
- Create: `lapie/package.json` (Expo CLI가 생성)
|
||
- Create: `lapie/app.json`
|
||
- Create: `lapie/tsconfig.json`
|
||
|
||
- [ ] **Step 1: Expo 프로젝트 생성**
|
||
|
||
```bash
|
||
cd C:/Users/jaeoh/Desktop/workspace/
|
||
npx create-expo-app@latest lapie --template blank-typescript
|
||
cd lapie
|
||
```
|
||
|
||
Expected: `lapie/` 디렉터리에 `App.tsx`, `package.json`, `tsconfig.json` 생성.
|
||
|
||
- [ ] **Step 2: Bare workflow로 전환 (네이티브 모듈 필요)**
|
||
|
||
```bash
|
||
npx expo prebuild --platform ios
|
||
```
|
||
|
||
Expected: `lapie/ios/` 디렉터리 생성, CocoaPods install. (Windows에서는 prebuild 실패 가능 — mac 환경 또는 EAS에서 실행).
|
||
|
||
- [ ] **Step 3: 필수 의존성 설치**
|
||
|
||
```bash
|
||
npm install react-native-vision-camera @shopify/react-native-skia react-native-worklets-core zustand react-native-safe-area-context
|
||
npm install --save-dev jest @types/jest ts-jest
|
||
```
|
||
|
||
- [ ] **Step 4: 권한 설정 (`app.json`)**
|
||
|
||
```json
|
||
{
|
||
"expo": {
|
||
"name": "Lapie",
|
||
"slug": "lapie",
|
||
"ios": {
|
||
"bundleIdentifier": "com.lapie.app",
|
||
"infoPlist": {
|
||
"NSCameraUsageDescription": "옷을 카메라에 비춰 합성 미리보기에 사용합니다.",
|
||
"NSPhotoLibraryUsageDescription": "정면 사진을 등록하고 캡쳐를 저장하기 위해 사용합니다.",
|
||
"NSPhotoLibraryAddUsageDescription": "합성 결과 사진을 저장하기 위해 사용합니다."
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 첫 빌드 확인**
|
||
|
||
```bash
|
||
npx expo run:ios
|
||
```
|
||
|
||
Expected: iOS 시뮬레이터에 기본 화면 표시.
|
||
|
||
- [ ] **Step 6: Git 초기화 + 첫 커밋**
|
||
|
||
```bash
|
||
cd lapie
|
||
git init
|
||
git add .
|
||
git commit -m "chore: initial Expo + RN + TS setup with vision-camera/skia deps"
|
||
```
|
||
|
||
---
|
||
|
||
## W1 — 카메라 + 세그멘테이션 PoC
|
||
|
||
### Task 2: Vision Camera 카메라 화면 + 권한
|
||
|
||
**Files:**
|
||
- Create: `lapie/src/screens/CameraTestScreen.tsx`
|
||
- Modify: `lapie/App.tsx`
|
||
|
||
- [ ] **Step 1: 카메라 권한 hook 작성**
|
||
|
||
`lapie/src/screens/CameraTestScreen.tsx`:
|
||
```tsx
|
||
import { useEffect } from 'react';
|
||
import { View, Text, StyleSheet } from 'react-native';
|
||
import { Camera, useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
|
||
|
||
export function CameraTestScreen() {
|
||
const { hasPermission, requestPermission } = useCameraPermission();
|
||
const device = useCameraDevice('back');
|
||
|
||
useEffect(() => {
|
||
if (!hasPermission) requestPermission();
|
||
}, [hasPermission, requestPermission]);
|
||
|
||
if (!hasPermission) {
|
||
return <View style={styles.center}><Text>카메라 권한이 필요합니다</Text></View>;
|
||
}
|
||
if (!device) {
|
||
return <View style={styles.center}><Text>카메라 디바이스 없음</Text></View>;
|
||
}
|
||
|
||
return (
|
||
<Camera
|
||
style={StyleSheet.absoluteFill}
|
||
device={device}
|
||
isActive={true}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: App.tsx에서 호출**
|
||
|
||
`lapie/App.tsx`:
|
||
```tsx
|
||
import { CameraTestScreen } from './src/screens/CameraTestScreen';
|
||
export default function App() {
|
||
return <CameraTestScreen />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 실기기 빌드 + 검증** (시뮬레이터는 카메라 없음)
|
||
|
||
```bash
|
||
npx expo run:ios --device
|
||
```
|
||
|
||
Expected: 실기기에서 후방 카메라 실시간 프리뷰 표시.
|
||
|
||
- [ ] **Step 4: 커밋**
|
||
|
||
```bash
|
||
git add src/screens/CameraTestScreen.tsx App.tsx
|
||
git commit -m "feat: vision-camera live preview with permission flow"
|
||
```
|
||
|
||
### Task 3: iOS Vision Selfie Segmentation 네이티브 브릿지
|
||
|
||
**Files:**
|
||
- Create: `lapie/modules/segmentation/ios/SegmentationModule.swift`
|
||
- Create: `lapie/modules/segmentation/ios/SegmentationModule.m`
|
||
- Create: `lapie/src/features/segmentation/index.ts`
|
||
- Create: `lapie/src/features/segmentation/segmentImage.ts`
|
||
- Test: `lapie/src/features/segmentation/__tests__/segmentation.test.ts`
|
||
|
||
> Vision Framework의 `VNGeneratePersonSegmentationRequest`를 RN으로 노출. 입력: 이미지 URI, 출력: 마스크 PNG URI.
|
||
|
||
- [ ] **Step 1: Swift 모듈 작성**
|
||
|
||
`lapie/modules/segmentation/ios/SegmentationModule.swift`:
|
||
```swift
|
||
import Foundation
|
||
import Vision
|
||
import UIKit
|
||
|
||
@objc(SegmentationModule)
|
||
class SegmentationModule: NSObject {
|
||
|
||
@objc static func requiresMainQueueSetup() -> Bool { false }
|
||
|
||
@objc(segmentPerson:resolver:rejecter:)
|
||
func segmentPerson(_ imageUri: String,
|
||
resolver: @escaping RCTPromiseResolveBlock,
|
||
rejecter: @escaping RCTPromiseRejectBlock) {
|
||
guard let url = URL(string: imageUri),
|
||
let imageData = try? Data(contentsOf: url),
|
||
let uiImage = UIImage(data: imageData),
|
||
let cgImage = uiImage.cgImage else {
|
||
rejecter("E_IMG", "Image load failed", nil)
|
||
return
|
||
}
|
||
|
||
let request = VNGeneratePersonSegmentationRequest()
|
||
request.qualityLevel = .accurate
|
||
request.outputPixelFormat = kCVPixelFormatType_OneComponent8
|
||
|
||
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
|
||
do {
|
||
try handler.perform([request])
|
||
guard let result = request.results?.first else {
|
||
rejecter("E_NO_RESULT", "No segmentation result", nil); return
|
||
}
|
||
// PixelBuffer → PNG 파일로 저장
|
||
let maskUri = try saveMaskPNG(result.pixelBuffer, originalSize: uiImage.size)
|
||
resolver(maskUri)
|
||
} catch {
|
||
rejecter("E_SEG", error.localizedDescription, error)
|
||
}
|
||
}
|
||
|
||
private func saveMaskPNG(_ pixelBuffer: CVPixelBuffer, originalSize: CGSize) throws -> String {
|
||
let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
|
||
let scaleX = originalSize.width / ciImage.extent.width
|
||
let scaleY = originalSize.height / ciImage.extent.height
|
||
let scaled = ciImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY))
|
||
let context = CIContext()
|
||
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else {
|
||
throw NSError(domain: "Lapie", code: 1)
|
||
}
|
||
let uiImage = UIImage(cgImage: cgImage)
|
||
let pngData = uiImage.pngData()!
|
||
let path = NSTemporaryDirectory() + "mask_\(UUID().uuidString).png"
|
||
try pngData.write(to: URL(fileURLWithPath: path))
|
||
return "file://" + path
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Objective-C 브릿지**
|
||
|
||
`lapie/modules/segmentation/ios/SegmentationModule.m`:
|
||
```objc
|
||
#import <React/RCTBridgeModule.h>
|
||
|
||
@interface RCT_EXTERN_MODULE(SegmentationModule, NSObject)
|
||
RCT_EXTERN_METHOD(segmentPerson:(NSString *)imageUri
|
||
resolver:(RCTPromiseResolveBlock)resolve
|
||
rejecter:(RCTPromiseRejectBlock)reject)
|
||
@end
|
||
```
|
||
|
||
- [ ] **Step 3: TS 래퍼**
|
||
|
||
`lapie/src/features/segmentation/index.ts`:
|
||
```ts
|
||
import { NativeModules } from 'react-native';
|
||
|
||
const { SegmentationModule } = NativeModules;
|
||
|
||
export async function segmentPerson(imageUri: string): Promise<string> {
|
||
if (!SegmentationModule?.segmentPerson) {
|
||
throw new Error('SegmentationModule not linked');
|
||
}
|
||
return SegmentationModule.segmentPerson(imageUri);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 유닛 테스트 (모킹)**
|
||
|
||
`lapie/src/features/segmentation/__tests__/segmentation.test.ts`:
|
||
```ts
|
||
import { segmentPerson } from '..';
|
||
|
||
jest.mock('react-native', () => ({
|
||
NativeModules: {
|
||
SegmentationModule: {
|
||
segmentPerson: jest.fn(async (uri: string) => `file://mask_for_${uri}.png`),
|
||
},
|
||
},
|
||
}));
|
||
|
||
describe('segmentPerson', () => {
|
||
it('returns mask URI for input image', async () => {
|
||
const result = await segmentPerson('file://input.jpg');
|
||
expect(result).toBe('file://mask_for_file://input.jpg.png');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: 테스트 실행 (실패 → 통과)**
|
||
|
||
```bash
|
||
npx jest src/features/segmentation
|
||
```
|
||
|
||
Expected: 1 passed (모킹된 호출 검증).
|
||
|
||
- [ ] **Step 6: 실기기 manual test**
|
||
- Photo Library에서 정면사진 1장 선택 → `segmentPerson(uri)` 호출
|
||
- 반환된 mask URI를 `<Image>`로 표시
|
||
- 시각 검증: 사람 영역이 흰색, 배경이 검은색인 마스크 확인
|
||
|
||
- [ ] **Step 7: CocoaPods 설치 + 커밋**
|
||
|
||
```bash
|
||
cd ios && pod install && cd ..
|
||
git add modules/segmentation src/features/segmentation
|
||
git commit -m "feat: iOS Vision person segmentation native bridge"
|
||
```
|
||
|
||
---
|
||
|
||
## W2 — 정면사진 등록 + Pose 키포인트 + 마스크 분할
|
||
|
||
### Task 4: iOS Vision Pose Detection 네이티브 브릿지
|
||
|
||
**Files:**
|
||
- Create: `lapie/modules/pose/ios/PoseModule.swift`
|
||
- Create: `lapie/modules/pose/ios/PoseModule.m`
|
||
- Create: `lapie/src/features/pose/index.ts`
|
||
- Test: `lapie/src/features/pose/__tests__/pose.test.ts`
|
||
|
||
- [ ] **Step 1: Swift 모듈 작성 (17개 keypoint 반환)**
|
||
|
||
`lapie/modules/pose/ios/PoseModule.swift`:
|
||
```swift
|
||
import Foundation
|
||
import Vision
|
||
import UIKit
|
||
|
||
@objc(PoseModule)
|
||
class PoseModule: NSObject {
|
||
|
||
@objc static func requiresMainQueueSetup() -> Bool { false }
|
||
|
||
@objc(detectPose:resolver:rejecter:)
|
||
func detectPose(_ imageUri: String,
|
||
resolver: @escaping RCTPromiseResolveBlock,
|
||
rejecter: @escaping RCTPromiseRejectBlock) {
|
||
guard let url = URL(string: imageUri),
|
||
let data = try? Data(contentsOf: url),
|
||
let uiImage = UIImage(data: data),
|
||
let cgImage = uiImage.cgImage else {
|
||
rejecter("E_IMG", "Image load failed", nil); return
|
||
}
|
||
|
||
let request = VNDetectHumanBodyPoseRequest()
|
||
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
|
||
do {
|
||
try handler.perform([request])
|
||
guard let observation = request.results?.first else {
|
||
rejecter("E_NO_POSE", "No pose detected", nil); return
|
||
}
|
||
let points = try observation.recognizedPoints(.all)
|
||
let imageW = CGFloat(cgImage.width)
|
||
let imageH = CGFloat(cgImage.height)
|
||
var result: [String: [String: Any]] = [:]
|
||
for (jointName, point) in points where point.confidence > 0.3 {
|
||
// Vision 좌표: 좌하단 원점, normalized (0~1). UIKit으로 변환.
|
||
result[jointName.rawValue.rawValue] = [
|
||
"x": point.location.x * imageW,
|
||
"y": (1 - point.location.y) * imageH,
|
||
"confidence": point.confidence,
|
||
]
|
||
}
|
||
resolver(result)
|
||
} catch {
|
||
rejecter("E_POSE", error.localizedDescription, error)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Obj-C 브릿지**
|
||
|
||
`lapie/modules/pose/ios/PoseModule.m`:
|
||
```objc
|
||
#import <React/RCTBridgeModule.h>
|
||
|
||
@interface RCT_EXTERN_MODULE(PoseModule, NSObject)
|
||
RCT_EXTERN_METHOD(detectPose:(NSString *)imageUri
|
||
resolver:(RCTPromiseResolveBlock)resolve
|
||
rejecter:(RCTPromiseRejectBlock)reject)
|
||
@end
|
||
```
|
||
|
||
- [ ] **Step 3: TS 래퍼 + 타입**
|
||
|
||
`lapie/src/features/pose/index.ts`:
|
||
```ts
|
||
import { NativeModules } from 'react-native';
|
||
|
||
const { PoseModule } = NativeModules;
|
||
|
||
export interface Keypoint {
|
||
x: number;
|
||
y: number;
|
||
confidence: number;
|
||
}
|
||
|
||
export type Joint =
|
||
| 'left_shoulder_joint' | 'right_shoulder_joint'
|
||
| 'left_hip_joint' | 'right_hip_joint'
|
||
| 'left_ankle_joint' | 'right_ankle_joint'
|
||
| 'neck_1_joint' | 'root';
|
||
|
||
export type PoseResult = Partial<Record<Joint, Keypoint>>;
|
||
|
||
export async function detectPose(imageUri: string): Promise<PoseResult> {
|
||
if (!PoseModule?.detectPose) throw new Error('PoseModule not linked');
|
||
return PoseModule.detectPose(imageUri);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 유닛 테스트 (모킹)**
|
||
|
||
`lapie/src/features/pose/__tests__/pose.test.ts`:
|
||
```ts
|
||
import { detectPose } from '..';
|
||
|
||
jest.mock('react-native', () => ({
|
||
NativeModules: {
|
||
PoseModule: {
|
||
detectPose: jest.fn(async () => ({
|
||
left_shoulder_joint: { x: 100, y: 200, confidence: 0.9 },
|
||
right_shoulder_joint: { x: 300, y: 200, confidence: 0.9 },
|
||
})),
|
||
},
|
||
},
|
||
}));
|
||
|
||
describe('detectPose', () => {
|
||
it('returns keypoint map', async () => {
|
||
const result = await detectPose('file://photo.jpg');
|
||
expect(result.left_shoulder_joint?.x).toBe(100);
|
||
expect(result.right_shoulder_joint?.x).toBe(300);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: 실기기 manual test**
|
||
- 정면사진 1장 입력 → 어깨·골반·발목 keypoint가 사진 위 정확한 위치에 표시되는지 시각 검증
|
||
- 임시 화면 `PoseDebugScreen.tsx`에 keypoint를 점으로 overlay
|
||
|
||
- [ ] **Step 6: 커밋**
|
||
|
||
```bash
|
||
git add modules/pose src/features/pose
|
||
git commit -m "feat: iOS Vision human body pose native bridge (17 keypoints)"
|
||
```
|
||
|
||
### Task 5: 정면사진 등록 화면 + 자세 가이드 오버레이
|
||
|
||
**Files:**
|
||
- Create: `lapie/src/screens/PhotoCaptureScreen.tsx`
|
||
- Create: `lapie/src/features/photoValidation/validatePose.ts`
|
||
- Test: `lapie/src/features/photoValidation/__tests__/validatePose.test.ts`
|
||
|
||
- [ ] **Step 1: 자세 검증 함수 (TDD)**
|
||
|
||
`lapie/src/features/photoValidation/__tests__/validatePose.test.ts`:
|
||
```ts
|
||
import { validatePose } from '../validatePose';
|
||
|
||
describe('validatePose', () => {
|
||
it('passes when all required joints present with high confidence', () => {
|
||
const pose = {
|
||
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);
|
||
});
|
||
|
||
it('fails when shoulders missing', () => {
|
||
const pose = {
|
||
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('fails when body tilted (shoulders y diff > 30px)', () => {
|
||
const pose = {
|
||
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('몸이 기울어져 있습니다');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 실행 → 실패 확인**
|
||
|
||
```bash
|
||
npx jest src/features/photoValidation
|
||
```
|
||
|
||
Expected: FAIL — validatePose not defined.
|
||
|
||
- [ ] **Step 3: 구현**
|
||
|
||
`lapie/src/features/photoValidation/validatePose.ts`:
|
||
```ts
|
||
import type { PoseResult } from '../pose';
|
||
|
||
export interface ValidationResult {
|
||
score: number;
|
||
passed: boolean;
|
||
reasons: string[];
|
||
}
|
||
|
||
const REQUIRED = [
|
||
'left_shoulder_joint', 'right_shoulder_joint',
|
||
'left_hip_joint', 'right_hip_joint',
|
||
'left_ankle_joint', 'right_ankle_joint',
|
||
] as const;
|
||
|
||
export function validatePose(pose: PoseResult): ValidationResult {
|
||
const reasons: string[] = [];
|
||
|
||
for (const joint of REQUIRED) {
|
||
if (!pose[joint] || pose[joint]!.confidence < 0.5) {
|
||
const label = joint.includes('shoulder') ? '어깨'
|
||
: joint.includes('hip') ? '골반'
|
||
: '발목';
|
||
const msg = `${label}이 보이지 않습니다`;
|
||
if (!reasons.includes(msg)) reasons.push(msg);
|
||
}
|
||
}
|
||
|
||
if (pose.left_shoulder_joint && pose.right_shoulder_joint) {
|
||
const yDiff = Math.abs(pose.left_shoulder_joint.y - pose.right_shoulder_joint.y);
|
||
if (yDiff > 30) reasons.push('몸이 기울어져 있습니다');
|
||
}
|
||
|
||
const score = Math.max(0, 100 - reasons.length * 25);
|
||
return { score, passed: score >= 80, reasons };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 테스트 통과 확인**
|
||
|
||
```bash
|
||
npx jest src/features/photoValidation
|
||
```
|
||
|
||
Expected: 3 passed.
|
||
|
||
- [ ] **Step 5: 사진 등록 화면 (UI)**
|
||
|
||
`lapie/src/screens/PhotoCaptureScreen.tsx`:
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { View, Text, Button, Image, StyleSheet } from 'react-native';
|
||
import * as ImagePicker from 'expo-image-picker';
|
||
import { detectPose } from '../features/pose';
|
||
import { validatePose, ValidationResult } from '../features/photoValidation/validatePose';
|
||
|
||
export function PhotoCaptureScreen({ onPhotoReady }: { onPhotoReady: (uri: string) => void }) {
|
||
const [photoUri, setPhotoUri] = useState<string | null>(null);
|
||
const [validation, setValidation] = useState<ValidationResult | null>(null);
|
||
|
||
async function pickAndValidate() {
|
||
const result = await ImagePicker.launchImageLibraryAsync({
|
||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||
allowsEditing: false,
|
||
quality: 0.8,
|
||
});
|
||
if (result.canceled) return;
|
||
const uri = result.assets[0].uri;
|
||
setPhotoUri(uri);
|
||
const pose = await detectPose(uri);
|
||
const v = validatePose(pose);
|
||
setValidation(v);
|
||
}
|
||
|
||
return (
|
||
<View style={styles.container}>
|
||
<Text style={styles.title}>정면 전신 사진</Text>
|
||
<Text style={styles.guide}>
|
||
• 어깨·골반·발목이 모두 보이도록{'\n'}
|
||
• 정면을 응시{'\n'}
|
||
• 단색 배경 권장
|
||
</Text>
|
||
<Button title="갤러리에서 선택" onPress={pickAndValidate} />
|
||
{photoUri && <Image source={{ uri: photoUri }} style={styles.preview} />}
|
||
{validation && (
|
||
<View style={styles.result}>
|
||
<Text>점수: {validation.score} / 100</Text>
|
||
{validation.reasons.map(r => <Text key={r} style={styles.warn}>⚠ {r}</Text>)}
|
||
{validation.passed && (
|
||
<Button title="다음" onPress={() => onPhotoReady(photoUri!)} />
|
||
)}
|
||
</View>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: { flex: 1, padding: 20, paddingTop: 60 },
|
||
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 12 },
|
||
guide: { fontSize: 14, color: '#666', marginBottom: 20 },
|
||
preview: { width: 200, height: 400, alignSelf: 'center', marginVertical: 20 },
|
||
result: { marginTop: 12 },
|
||
warn: { color: '#c00', marginVertical: 4 },
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: 의존성 추가 + 커밋**
|
||
|
||
```bash
|
||
npx expo install expo-image-picker
|
||
git add src/features/photoValidation src/screens/PhotoCaptureScreen.tsx package.json
|
||
git commit -m "feat: photo capture screen with pose-based validation"
|
||
```
|
||
|
||
### Task 6: 3 모드 마스크 자동 분할
|
||
|
||
**Files:**
|
||
- Create: `lapie/src/features/maskSplit/splitMask.ts`
|
||
- Test: `lapie/src/features/maskSplit/__tests__/splitMask.test.ts`
|
||
|
||
> Selfie Segmentation 마스크(전체 실루엣) + Pose keypoint(어깨·골반·발목)로 상의/하의/전신 3 마스크를 잘라낸다. Skia로 처리.
|
||
|
||
- [ ] **Step 1: 분할 로직 테스트 (TDD)**
|
||
|
||
`lapie/src/features/maskSplit/__tests__/splitMask.test.ts`:
|
||
```ts
|
||
import { computeSplitRegions } from '../splitMask';
|
||
|
||
describe('computeSplitRegions', () => {
|
||
const pose = {
|
||
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 },
|
||
};
|
||
|
||
it('top region spans shoulders to hips', () => {
|
||
const r = computeSplitRegions(pose, { width: 400, height: 800 });
|
||
expect(r.top.yStart).toBe(180); // 어깨 - 20 (목까지)
|
||
expect(r.top.yEnd).toBe(400); // 골반 y
|
||
});
|
||
|
||
it('bottom region spans hips to ankles', () => {
|
||
const r = computeSplitRegions(pose, { width: 400, height: 800 });
|
||
expect(r.bottom.yStart).toBe(400);
|
||
expect(r.bottom.yEnd).toBe(720); // 발목 + 20 (발끝까지)
|
||
});
|
||
|
||
it('full region spans entire body bbox', () => {
|
||
const r = computeSplitRegions(pose, { width: 400, height: 800 });
|
||
expect(r.full.yStart).toBe(120); // 머리 위 (어깨 - 80)
|
||
expect(r.full.yEnd).toBe(720);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 구현**
|
||
|
||
`lapie/src/features/maskSplit/splitMask.ts`:
|
||
```ts
|
||
import type { PoseResult } from '../pose';
|
||
|
||
export interface Region {
|
||
xStart: number;
|
||
yStart: number;
|
||
xEnd: number;
|
||
yEnd: number;
|
||
}
|
||
|
||
export interface SplitRegions {
|
||
top: Region;
|
||
bottom: Region;
|
||
full: Region;
|
||
}
|
||
|
||
export function computeSplitRegions(
|
||
pose: PoseResult,
|
||
imageSize: { width: number; height: number }
|
||
): SplitRegions {
|
||
const ls = pose.left_shoulder_joint!;
|
||
const rs = pose.right_shoulder_joint!;
|
||
const lh = pose.left_hip_joint!;
|
||
const rh = pose.right_hip_joint!;
|
||
const la = pose.left_ankle_joint!;
|
||
const ra = 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) - 20;
|
||
const xMax = Math.max(ls.x, rs.x, lh.x, rh.x, la.x, ra.x) + 20;
|
||
|
||
return {
|
||
top: { xStart: xMin, yStart: shoulderY - 20, xEnd: xMax, yEnd: hipY },
|
||
bottom: { xStart: xMin, yStart: hipY, xEnd: xMax, yEnd: ankleY + 20 },
|
||
full: { xStart: xMin, yStart: shoulderY - 80, xEnd: xMax, yEnd: ankleY + 20 },
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 테스트 통과 확인**
|
||
|
||
```bash
|
||
npx jest src/features/maskSplit
|
||
```
|
||
|
||
Expected: 3 passed.
|
||
|
||
- [ ] **Step 4: 커밋**
|
||
|
||
```bash
|
||
git add src/features/maskSplit
|
||
git commit -m "feat: split full mask into top/bottom/full regions using pose keypoints"
|
||
```
|
||
|
||
---
|
||
|
||
## W3 — Skia 합성 + 자동 스케일 (핵심 차별화)
|
||
|
||
### Task 7: Skia 합성 컴포넌트 (마스크 영역에 카메라 라이브 채우기)
|
||
|
||
**Files:**
|
||
- Create: `lapie/src/features/composite/CompositeView.tsx`
|
||
- Create: `lapie/src/features/composite/shaders.ts`
|
||
|
||
> 정면사진 위에 마스크 영역만큼 카메라 라이브 프레임을 잘라 채운다. Skia `ImageShader` + `Mask` 사용.
|
||
|
||
- [ ] **Step 1: 셰이더 정의**
|
||
|
||
`lapie/src/features/composite/shaders.ts`:
|
||
```ts
|
||
import { Skia } from '@shopify/react-native-skia';
|
||
|
||
// 마스크가 흰색(>0.5)인 픽셀만 카메라 프레임으로 채우고,
|
||
// 나머지는 원본 사진 픽셀 유지
|
||
export const compositeShader = Skia.RuntimeEffect.Make(`
|
||
uniform shader baseImage; // 정면사진
|
||
uniform shader cameraImage; // 카메라 라이브 프레임 (스케일·이동 적용)
|
||
uniform shader maskImage; // 분할 영역 마스크 (흰=합성영역)
|
||
|
||
half4 main(float2 xy) {
|
||
half4 base = baseImage.eval(xy);
|
||
half4 mask = maskImage.eval(xy);
|
||
if (mask.r > 0.5) {
|
||
half4 cam = cameraImage.eval(xy);
|
||
return cam;
|
||
}
|
||
return base;
|
||
}
|
||
`)!;
|
||
```
|
||
|
||
- [ ] **Step 2: 합성 컴포넌트**
|
||
|
||
`lapie/src/features/composite/CompositeView.tsx`:
|
||
```tsx
|
||
import { Canvas, Image as SkImage, useImage, Fill, Shader } from '@shopify/react-native-skia';
|
||
import { useMemo } from 'react';
|
||
import { compositeShader } from './shaders';
|
||
|
||
interface Props {
|
||
baseImageUri: string;
|
||
maskImageUri: string;
|
||
cameraFrameUri: string | null;
|
||
cameraTransform: { scale: number; tx: number; ty: number };
|
||
width: number;
|
||
height: number;
|
||
}
|
||
|
||
export function CompositeView({ baseImageUri, maskImageUri, cameraFrameUri, cameraTransform, width, height }: Props) {
|
||
const base = useImage(baseImageUri);
|
||
const mask = useImage(maskImageUri);
|
||
const cam = useImage(cameraFrameUri);
|
||
|
||
if (!base || !mask) return null;
|
||
|
||
const uniforms = useMemo(() => ({}), []);
|
||
|
||
return (
|
||
<Canvas style={{ width, height }}>
|
||
<Fill>
|
||
<Shader source={compositeShader} uniforms={uniforms}>
|
||
{/* Skia shader children = uniform shaders, 순서 = 셰이더 정의 순서 */}
|
||
<SkImage image={base} x={0} y={0} width={width} height={height} />
|
||
{cam ? (
|
||
<SkImage
|
||
image={cam}
|
||
x={cameraTransform.tx}
|
||
y={cameraTransform.ty}
|
||
width={width * cameraTransform.scale}
|
||
height={height * cameraTransform.scale}
|
||
/>
|
||
) : (
|
||
<SkImage image={base} x={0} y={0} width={width} height={height} />
|
||
)}
|
||
<SkImage image={mask} x={0} y={0} width={width} height={height} />
|
||
</Shader>
|
||
</Fill>
|
||
</Canvas>
|
||
);
|
||
}
|
||
```
|
||
|
||
> ⚠ Skia Shader child 전달 방식은 SDK 버전마다 다름. v1.x에서는 `useShader` hook + `Shader` 사용. 착수 시점에 `@shopify/react-native-skia` 문서 재확인.
|
||
|
||
- [ ] **Step 3: 카메라 frame을 jpeg URI로 추출하는 헬퍼 (Vision Camera Frame Processor)**
|
||
|
||
`lapie/src/features/composite/useCameraFrameUri.ts`:
|
||
```ts
|
||
import { useRef, useEffect, useState } from 'react';
|
||
import { Camera, useFrameProcessor } from 'react-native-vision-camera';
|
||
|
||
// 단순화: 매 N 프레임마다 takeSnapshot → temp URI
|
||
export function useCameraSnapshotUri(cameraRef: React.RefObject<Camera>, intervalMs = 100) {
|
||
const [uri, setUri] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
const t = setInterval(async () => {
|
||
if (!cameraRef.current) return;
|
||
try {
|
||
const snap = await cameraRef.current.takeSnapshot({ quality: 50 });
|
||
setUri('file://' + snap.path);
|
||
} catch {}
|
||
}, intervalMs);
|
||
return () => clearInterval(t);
|
||
}, [cameraRef, intervalMs]);
|
||
|
||
return uri;
|
||
}
|
||
```
|
||
|
||
> takeSnapshot은 10fps 정도. 30fps 합성이 필요하면 Frame Processor + Skia 텍스처 직접 전달로 업그레이드 (v0 W5 버퍼 또는 v1).
|
||
|
||
- [ ] **Step 4: 실기기 manual test**
|
||
- 정면사진 + 마스크 + 카메라 라이브 prop으로 `CompositeView` 렌더
|
||
- 마스크 영역에 카메라 프레임이 채워지는지 시각 확인
|
||
- 프레임 끊김 / FPS / 정렬 정확도 메모
|
||
|
||
- [ ] **Step 5: 커밋**
|
||
|
||
```bash
|
||
git add src/features/composite
|
||
git commit -m "feat: Skia composite view masking camera live into base photo"
|
||
```
|
||
|
||
### Task 8: 자동 스케일 추정 (하이브리드 — 핵심 차별화)
|
||
|
||
**Files:**
|
||
- Create: `lapie/src/features/autoScale/calculateScale.ts`
|
||
- Create: `lapie/src/features/autoScale/detectClothBounds.ts`
|
||
- Test: `lapie/src/features/autoScale/__tests__/calculateScale.test.ts`
|
||
|
||
- [ ] **Step 1: 스케일 계산 테스트 (TDD)**
|
||
|
||
`lapie/src/features/autoScale/__tests__/calculateScale.test.ts`:
|
||
```ts
|
||
import { calculateScale } from '../calculateScale';
|
||
|
||
describe('calculateScale', () => {
|
||
it('returns ratio of photo shoulder width to camera cloth width', () => {
|
||
const result = calculateScale({
|
||
photoShoulderPx: 200,
|
||
cameraClothWidthPx: 400,
|
||
});
|
||
expect(result.scale).toBe(0.5); // 사진 어깨 200px / 카메라 옷 400px = 옷을 절반으로 축소
|
||
expect(result.confidence).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('returns scale=1 when camera cloth width is 0 (unknown)', () => {
|
||
const result = calculateScale({
|
||
photoShoulderPx: 200,
|
||
cameraClothWidthPx: 0,
|
||
});
|
||
expect(result.scale).toBe(1);
|
||
expect(result.confidence).toBe(0);
|
||
});
|
||
|
||
it('clamps scale to [0.3, 3.0]', () => {
|
||
expect(calculateScale({ photoShoulderPx: 200, cameraClothWidthPx: 10 }).scale).toBe(3.0);
|
||
expect(calculateScale({ photoShoulderPx: 10, cameraClothWidthPx: 200 }).scale).toBe(0.3);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 구현**
|
||
|
||
`lapie/src/features/autoScale/calculateScale.ts`:
|
||
```ts
|
||
export interface ScaleInput {
|
||
photoShoulderPx: number;
|
||
cameraClothWidthPx: number;
|
||
}
|
||
|
||
export interface ScaleResult {
|
||
scale: number;
|
||
confidence: number; // 0~1, 옷 검출 신뢰도
|
||
}
|
||
|
||
const MIN_SCALE = 0.3;
|
||
const MAX_SCALE = 3.0;
|
||
|
||
export function calculateScale({ photoShoulderPx, cameraClothWidthPx }: ScaleInput): ScaleResult {
|
||
if (cameraClothWidthPx <= 0) {
|
||
return { scale: 1, confidence: 0 };
|
||
}
|
||
const raw = photoShoulderPx / cameraClothWidthPx;
|
||
const scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, raw));
|
||
const confidence = scale === raw ? 1 : 0.5; // 클램핑 됐으면 신뢰도 낮춤
|
||
return { scale, confidence };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 테스트 통과**
|
||
|
||
```bash
|
||
npx jest src/features/autoScale/calculateScale
|
||
```
|
||
|
||
Expected: 3 passed.
|
||
|
||
- [ ] **Step 4: 옷 경계 검출 (단순화: 카메라 프레임에서 person mask 제외 영역의 bbox)**
|
||
|
||
`lapie/src/features/autoScale/detectClothBounds.ts`:
|
||
```ts
|
||
import { segmentPerson } from '../segmentation';
|
||
|
||
// 카메라 프레임 1장에서 사람이 아닌 영역(=손에 들린 옷)의 가로 너비 추정
|
||
// 단순화: 사람 mask의 외부에서 색 변화 큰 연속 픽셀의 가로 너비
|
||
// v0에서는 사용자가 카메라 중앙에 옷을 들도록 안내 → 중앙 가로선 색 prof 분석
|
||
export async function detectClothWidthPx(cameraFrameUri: string): Promise<number> {
|
||
// Step 1: 사람 mask 받기
|
||
const maskUri = await segmentPerson(cameraFrameUri);
|
||
// Step 2: 마스크가 검은(=배경=옷일 가능성) 영역 중 중앙 가로선의 연속 가로 너비
|
||
// → 실제 픽셀 분석은 네이티브 모듈에서 (TODO Task 8.5 별도 분리)
|
||
// v0 폴백: 화면 가로의 50%를 기본값으로 (사용자 핀치로 보정)
|
||
return 0; // 0 = 자동 검출 실패, 핀치만으로 보정
|
||
}
|
||
```
|
||
|
||
> ⚠ 옷 윤곽 검출은 v0 W3 가장 어려운 부분. **v0 1차 출시는 자동 검출 폴백(scale=1) + 핀치만으로 운영**, 정확도는 W5~6 버퍼·v1에 보강. 본 단계의 핵심은 calculateScale 순수 함수의 TDD 검증.
|
||
|
||
- [ ] **Step 5: 핀치 줌 hook**
|
||
|
||
`lapie/src/features/autoScale/usePinchScale.ts`:
|
||
```ts
|
||
import { useState, useCallback } from 'react';
|
||
import { Gesture } from 'react-native-gesture-handler';
|
||
|
||
export function usePinchScale(initialScale = 1) {
|
||
const [scale, setScale] = useState(initialScale);
|
||
const [tx, setTx] = useState(0);
|
||
const [ty, setTy] = useState(0);
|
||
|
||
const pinch = Gesture.Pinch().onUpdate((e) => {
|
||
setScale(s => Math.max(0.3, Math.min(3.0, s * e.scale)));
|
||
});
|
||
|
||
const pan = Gesture.Pan().onUpdate((e) => {
|
||
setTx(x => x + e.translationX);
|
||
setTy(y => y + e.translationY);
|
||
});
|
||
|
||
const composed = Gesture.Simultaneous(pinch, pan);
|
||
|
||
const reset = useCallback(() => {
|
||
setScale(initialScale); setTx(0); setTy(0);
|
||
}, [initialScale]);
|
||
|
||
return { scale, tx, ty, gesture: composed, reset };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: 의존성 + 커밋**
|
||
|
||
```bash
|
||
npx expo install react-native-gesture-handler
|
||
git add src/features/autoScale package.json
|
||
git commit -m "feat: hybrid auto-scale (calculation + pinch zoom override)"
|
||
```
|
||
|
||
---
|
||
|
||
## W4 — 캡쳐·공유 + 온보딩 + 폴리싱
|
||
|
||
### Task 9: 라이브 피팅 화면 (모든 요소 통합)
|
||
|
||
**Files:**
|
||
- Create: `lapie/src/screens/LiveFittingScreen.tsx`
|
||
- Create: `lapie/src/store/useAppStore.ts`
|
||
|
||
- [ ] **Step 1: Zustand store**
|
||
|
||
`lapie/src/store/useAppStore.ts`:
|
||
```ts
|
||
import { create } from 'zustand';
|
||
import type { PoseResult } from '../features/pose';
|
||
|
||
export type Mode = 'top' | 'bottom' | 'full';
|
||
|
||
interface AppState {
|
||
photoUri: string | null;
|
||
maskUri: string | null;
|
||
pose: PoseResult | null;
|
||
mode: Mode;
|
||
setPhoto: (uri: string) => void;
|
||
setMask: (uri: string) => void;
|
||
setPose: (p: PoseResult) => void;
|
||
setMode: (m: Mode) => void;
|
||
}
|
||
|
||
export const useAppStore = create<AppState>((set) => ({
|
||
photoUri: null,
|
||
maskUri: null,
|
||
pose: null,
|
||
mode: 'top',
|
||
setPhoto: (uri) => set({ photoUri: uri }),
|
||
setMask: (uri) => set({ maskUri: uri }),
|
||
setPose: (p) => set({ pose: p }),
|
||
setMode: (m) => set({ mode: m }),
|
||
}));
|
||
```
|
||
|
||
- [ ] **Step 2: 라이브 피팅 화면 UI**
|
||
|
||
`lapie/src/screens/LiveFittingScreen.tsx`:
|
||
```tsx
|
||
import { useRef } from 'react';
|
||
import { View, Text, TouchableOpacity, StyleSheet, Dimensions } from 'react-native';
|
||
import { Camera, useCameraDevice } from 'react-native-vision-camera';
|
||
import { GestureDetector } from 'react-native-gesture-handler';
|
||
import { useAppStore, Mode } from '../store/useAppStore';
|
||
import { CompositeView } from '../features/composite/CompositeView';
|
||
import { useCameraSnapshotUri } from '../features/composite/useCameraFrameUri';
|
||
import { usePinchScale } from '../features/autoScale/usePinchScale';
|
||
|
||
const { width, height } = Dimensions.get('window');
|
||
|
||
export function LiveFittingScreen({ onCapture }: { onCapture: (uri: string) => void }) {
|
||
const { photoUri, maskUri, mode, setMode } = useAppStore();
|
||
const cameraRef = useRef<Camera>(null);
|
||
const device = useCameraDevice('back');
|
||
const cameraUri = useCameraSnapshotUri(cameraRef, 100);
|
||
const { scale, tx, ty, gesture, reset } = usePinchScale(1);
|
||
|
||
if (!photoUri || !maskUri || !device) return <Text>준비 중...</Text>;
|
||
|
||
return (
|
||
<View style={styles.container}>
|
||
<Camera ref={cameraRef} device={device} isActive={true} photo={true} style={styles.hiddenCam} />
|
||
<GestureDetector gesture={gesture}>
|
||
<View style={{ width, height: height * 0.8 }}>
|
||
<CompositeView
|
||
baseImageUri={photoUri}
|
||
maskImageUri={maskUri} // mode 별 마스크는 Task 6 결과로 미리 생성
|
||
cameraFrameUri={cameraUri}
|
||
cameraTransform={{ scale, tx, ty }}
|
||
width={width}
|
||
height={height * 0.8}
|
||
/>
|
||
</View>
|
||
</GestureDetector>
|
||
|
||
<View style={styles.controls}>
|
||
{(['top', 'bottom', 'full'] as Mode[]).map(m => (
|
||
<TouchableOpacity key={m} onPress={() => { setMode(m); reset(); }}
|
||
style={[styles.modeBtn, mode === m && styles.modeBtnActive]}>
|
||
<Text>{m === 'top' ? '상의' : m === 'bottom' ? '하의' : '전신'}</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
<TouchableOpacity style={styles.captureBtn}
|
||
onPress={async () => {
|
||
const snap = await cameraRef.current?.takePhoto();
|
||
if (snap) onCapture('file://' + snap.path);
|
||
}}>
|
||
<Text style={{ color: '#fff' }}>캡쳐</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: { flex: 1, backgroundColor: '#000' },
|
||
hiddenCam: { width: 1, height: 1, opacity: 0 },
|
||
controls: { flexDirection: 'row', justifyContent: 'space-around', padding: 20 },
|
||
modeBtn: { padding: 12, backgroundColor: '#333', borderRadius: 8 },
|
||
modeBtnActive: { backgroundColor: '#fff' },
|
||
captureBtn: { padding: 12, backgroundColor: '#e74c3c', borderRadius: 24, paddingHorizontal: 24 },
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 모드 변경 시 마스크 재생성 hook**
|
||
|
||
`lapie/src/features/maskSplit/useModeMask.ts`:
|
||
```ts
|
||
import { useEffect } from 'react';
|
||
import { useAppStore } from '../../store/useAppStore';
|
||
import { computeSplitRegions } from './splitMask';
|
||
import { generateRegionMask } from './generateRegionMask'; // 다음 Step에서 추가
|
||
|
||
export function useModeMask() {
|
||
const { photoUri, pose, mode, setMask } = useAppStore();
|
||
useEffect(() => {
|
||
if (!photoUri || !pose) return;
|
||
(async () => {
|
||
const regions = computeSplitRegions(pose, { width: 1080, height: 1920 });
|
||
const region = regions[mode];
|
||
const regionMaskUri = await generateRegionMask(photoUri, region);
|
||
setMask(regionMaskUri);
|
||
})();
|
||
}, [photoUri, pose, mode, setMask]);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 영역 마스크 생성 (Skia로 사각형 마스크 PNG 생성)**
|
||
|
||
`lapie/src/features/maskSplit/generateRegionMask.ts`:
|
||
```ts
|
||
import { Skia, ImageFormat } from '@shopify/react-native-skia';
|
||
import * as FileSystem from 'expo-file-system';
|
||
import type { Region } from './splitMask';
|
||
import { segmentPerson } from '../segmentation';
|
||
|
||
// person mask와 region을 AND → 해당 영역의 person 실루엣만 흰색
|
||
export async function generateRegionMask(photoUri: string, region: Region): Promise<string> {
|
||
const personMaskUri = await segmentPerson(photoUri);
|
||
const personMask = await Skia.Data.fromURI(personMaskUri).then(d => Skia.Image.MakeImageFromEncoded(d));
|
||
if (!personMask) throw new Error('person mask load failed');
|
||
|
||
const w = personMask.width();
|
||
const h = personMask.height();
|
||
const surface = Skia.Surface.MakeOffscreen(w, h)!;
|
||
const canvas = surface.getCanvas();
|
||
|
||
// 검은 배경
|
||
canvas.drawColor(Skia.Color('black'));
|
||
// region 안에서만 person mask 그리기 (clip)
|
||
canvas.save();
|
||
canvas.clipRect({ x: region.xStart, y: region.yStart, width: region.xEnd - region.xStart, height: region.yEnd - region.yStart }, 0, true);
|
||
canvas.drawImage(personMask, 0, 0);
|
||
canvas.restore();
|
||
|
||
const snap = surface.makeImageSnapshot();
|
||
const bytes = snap.encodeToBytes(ImageFormat.PNG);
|
||
const path = FileSystem.cacheDirectory + `region_mask_${Date.now()}.png`;
|
||
await FileSystem.writeAsStringAsync(path, Buffer.from(bytes).toString('base64'), { encoding: FileSystem.EncodingType.Base64 });
|
||
return path;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 의존성 추가 + 커밋**
|
||
|
||
```bash
|
||
npx expo install expo-file-system
|
||
npm install buffer
|
||
git add src/screens/LiveFittingScreen.tsx src/store src/features/maskSplit
|
||
git commit -m "feat: live fitting screen with mode switching and capture"
|
||
```
|
||
|
||
### Task 10: 캡쳐 결과 + 공유 화면
|
||
|
||
**Files:**
|
||
- Create: `lapie/src/screens/CaptureResultScreen.tsx`
|
||
|
||
- [ ] **Step 1: 결과 + 공유 UI**
|
||
|
||
`lapie/src/screens/CaptureResultScreen.tsx`:
|
||
```tsx
|
||
import { View, Image, Button, StyleSheet, Share, Alert } from 'react-native';
|
||
import * as MediaLibrary from 'expo-media-library';
|
||
|
||
export function CaptureResultScreen({ imageUri, onRetry }: { imageUri: string; onRetry: () => void }) {
|
||
async function saveToGallery() {
|
||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||
if (status !== 'granted') { Alert.alert('권한 필요'); return; }
|
||
await MediaLibrary.saveToLibraryAsync(imageUri);
|
||
Alert.alert('저장 완료');
|
||
}
|
||
async function shareImage() {
|
||
await Share.share({ url: imageUri, message: 'Lapie로 입어봤어요!' });
|
||
}
|
||
return (
|
||
<View style={styles.container}>
|
||
<Image source={{ uri: imageUri }} style={styles.preview} />
|
||
<View style={styles.buttons}>
|
||
<Button title="다시 찍기" onPress={onRetry} />
|
||
<Button title="갤러리 저장" onPress={saveToGallery} />
|
||
<Button title="공유" onPress={shareImage} />
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: { flex: 1, padding: 20, paddingTop: 60 },
|
||
preview: { flex: 1, resizeMode: 'contain' },
|
||
buttons: { flexDirection: 'row', justifyContent: 'space-around', paddingVertical: 20 },
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 의존성 추가 + 커밋**
|
||
|
||
```bash
|
||
npx expo install expo-media-library
|
||
git add src/screens/CaptureResultScreen.tsx
|
||
git commit -m "feat: capture result screen with save and share"
|
||
```
|
||
|
||
### Task 11: 네비게이션 + 온보딩 + App 통합
|
||
|
||
**Files:**
|
||
- Create: `lapie/src/navigation/AppNavigator.tsx`
|
||
- Create: `lapie/src/screens/OnboardingScreen.tsx`
|
||
- Modify: `lapie/App.tsx`
|
||
|
||
- [ ] **Step 1: 온보딩 3장**
|
||
|
||
`lapie/src/screens/OnboardingScreen.tsx`:
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { View, Text, Button, StyleSheet, Image } from 'react-native';
|
||
|
||
const SLIDES = [
|
||
{ title: '내 사진 한 장으로', body: '전신 정면 사진을 등록하면 자동으로 옷 영역을 인식합니다.' },
|
||
{ title: '옷을 카메라에', body: '카메라 앞에 옷을 들면 사진 속 내가 그 옷을 입은 듯 보입니다.' },
|
||
{ title: '캡쳐 후 공유', body: '결과를 친구에게 보내고 의견을 들어보세요.' },
|
||
];
|
||
|
||
export function OnboardingScreen({ onDone }: { onDone: () => void }) {
|
||
const [i, setI] = useState(0);
|
||
const slide = SLIDES[i];
|
||
return (
|
||
<View style={styles.container}>
|
||
<Text style={styles.title}>{slide.title}</Text>
|
||
<Text style={styles.body}>{slide.body}</Text>
|
||
<Button
|
||
title={i < SLIDES.length - 1 ? '다음' : '시작'}
|
||
onPress={() => i < SLIDES.length - 1 ? setI(i + 1) : onDone()}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: { flex: 1, justifyContent: 'center', padding: 40 },
|
||
title: { fontSize: 28, fontWeight: 'bold', marginBottom: 12 },
|
||
body: { fontSize: 16, color: '#555', marginBottom: 40 },
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 간단 네비게이션 (라이브러리 없이 state)**
|
||
|
||
`lapie/App.tsx`:
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||
import { OnboardingScreen } from './src/screens/OnboardingScreen';
|
||
import { PhotoCaptureScreen } from './src/screens/PhotoCaptureScreen';
|
||
import { LiveFittingScreen } from './src/screens/LiveFittingScreen';
|
||
import { CaptureResultScreen } from './src/screens/CaptureResultScreen';
|
||
import { useAppStore } from './src/store/useAppStore';
|
||
import { detectPose } from './src/features/pose';
|
||
|
||
type Screen = 'onboarding' | 'photo' | 'live' | 'result';
|
||
|
||
export default function App() {
|
||
const [screen, setScreen] = useState<Screen>('onboarding');
|
||
const [capturedUri, setCapturedUri] = useState<string>('');
|
||
const setPhoto = useAppStore(s => s.setPhoto);
|
||
const setPose = useAppStore(s => s.setPose);
|
||
|
||
return (
|
||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||
{screen === 'onboarding' && <OnboardingScreen onDone={() => setScreen('photo')} />}
|
||
{screen === 'photo' && (
|
||
<PhotoCaptureScreen onPhotoReady={async (uri) => {
|
||
setPhoto(uri);
|
||
const pose = await detectPose(uri);
|
||
setPose(pose);
|
||
setScreen('live');
|
||
}} />
|
||
)}
|
||
{screen === 'live' && (
|
||
<LiveFittingScreen onCapture={(uri) => { setCapturedUri(uri); setScreen('result'); }} />
|
||
)}
|
||
{screen === 'result' && (
|
||
<CaptureResultScreen imageUri={capturedUri} onRetry={() => setScreen('live')} />
|
||
)}
|
||
</GestureHandlerRootView>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 전체 동작 manual test**
|
||
- 앱 실행 → 온보딩 3장 → 사진 등록 → 자세 검증 → 라이브 피팅 → 모드 변경 → 캡쳐 → 저장·공유
|
||
- 박재오 본인 실사용 1회 완주
|
||
|
||
- [ ] **Step 4: 커밋**
|
||
|
||
```bash
|
||
git add App.tsx src/screens/OnboardingScreen.tsx
|
||
git commit -m "feat: integrate all screens with simple state-based navigation"
|
||
```
|
||
|
||
---
|
||
|
||
## W5~6 (버퍼) — 자동 스케일 보강 + 마케팅 콘텐츠
|
||
|
||
### Task 12: 자동 스케일 정확도 보강
|
||
|
||
**Files:** `lapie/src/features/autoScale/detectClothBounds.ts` 보강
|
||
|
||
- [ ] **Step 1: 옷 윤곽 검출 네이티브 모듈 (iOS Vision Saliency)**
|
||
- `VNGenerateAttentionBasedSaliencyImageRequest` 또는 `VNGenerateForegroundInstanceMaskRequest` 사용
|
||
- 카메라 프레임에서 손에 들린 옷의 saliency mask → bbox 추출
|
||
- calculateScale에 cameraClothWidthPx 전달
|
||
|
||
- [ ] **Step 2: 실기기 정확도 측정**
|
||
- 옷 5종 × 거리 3종 (20cm / 50cm / 100cm) = 15케이스
|
||
- 자동 스케일 후 핀치 보정량 측정 → 평균 보정 비율 < 20% 목표
|
||
- 미달 시 보정 알고리즘 재설계
|
||
|
||
- [ ] **Step 3: 커밋**
|
||
|
||
```bash
|
||
git add src/features/autoScale
|
||
git commit -m "feat: cloth bounds detection via Vision Saliency"
|
||
```
|
||
|
||
### Task 13: 마케팅 콘텐츠 5편
|
||
|
||
**Files:** 코드 없음 (인스타 / 쇼츠)
|
||
|
||
- [ ] **Step 1: Lapie 인스타 계정 정식 운영 개시**
|
||
- Task 0.1 Step 3에서 선점한 핸들로 프로필 설정
|
||
- 첫 게시물: "왜 Lapie를 만들었나" (박재오 D-3 Why 외면 분기 1줄)
|
||
|
||
- [ ] **Step 2: 사용 영상 5편 촬영·편집·업로드**
|
||
- 박재오 본인 + 아내 사용 영상 각 2편
|
||
- 친구 1명 사용 영상 1편
|
||
- 인스타 릴스 + 유튜브 쇼츠 동시 업로드
|
||
|
||
- [ ] **Step 3: [[사업-hedgy75-인스타]] 채널에 빌더 일지 카드뉴스 1건**
|
||
- "1인 개발자가 4주 만에 만든 피팅 앱 — 박재오 빌드 일지" 컨셉
|
||
|
||
- [ ] **Step 4: 매직 넘버 추적 시작**
|
||
- [[정체성-Why-탐색]] PMF 매직넘버 #7 원칙 적용
|
||
- 선행 지표: 주간 콘텐츠 발행 수 + 영상 조회수
|
||
- 후행 지표: 다운로드 수 + DAU
|
||
|
||
- [ ] **Step 5: 1차 데이터 수집 (2주)**
|
||
- 2주 후 [[사업-Lapie-피팅앱]] 변경 이력에 KPI 1줄 기록
|
||
- 반응 약하면 v1 보류 + Why 검증대 재평가
|
||
|
||
---
|
||
|
||
## Self-Review (작성 후 점검)
|
||
|
||
### Spec coverage 체크
|
||
- ✅ "정면 전신 사진 등록 + 자동 마스킹" → Task 3 + 5 + 6
|
||
- ✅ "상의/하의/전신 3모드" → Task 6 (computeSplitRegions) + Task 9 (모드 UI)
|
||
- ✅ "카메라 실행 + 합성" → Task 2 + 7 + 9
|
||
- ✅ "자동 스케일 (하이브리드)" → Task 8 + 12
|
||
- ✅ "캡쳐 + SNS 공유" → Task 10
|
||
- ✅ "자세 가이드 오버레이" → Task 5
|
||
- ✅ "온보딩 3장" → Task 11
|
||
- ✅ "W1~W4 일정 + W5~6 버퍼" → 일정 헤더와 task 라벨에 매핑
|
||
|
||
### Placeholder scan
|
||
- ⚠ Task 8 Step 4 `detectClothWidthPx`는 v0 1차 출시에서는 `return 0` 폴백으로 명시. Task 12에서 정식 구현. 의도된 점진적 개선이므로 placeholder 아님.
|
||
- ⚠ Task 7 Step 2 Skia Shader children 패턴은 SDK 버전 의존 → 착수 시 문서 재확인 명시. 명시적 책임 이관.
|
||
- 나머지는 모든 코드 단계에 실제 코드 포함. ✅
|
||
|
||
### Type consistency
|
||
- `PoseResult` (Task 4) → Task 5 (validatePose) → Task 6 (computeSplitRegions) 모두 동일 타입 사용. ✅
|
||
- `Region` (Task 6) → Task 9 (generateRegionMask) 동일. ✅
|
||
- `ScaleResult` / `ScaleInput` (Task 8) 일관. ✅
|
||
- Store `Mode = 'top' | 'bottom' | 'full'` (Task 9) → `computeSplitRegions` 반환 키와 일치. ✅
|
||
|
||
### 박재오 환경 특이점 (이미 plan에 명시)
|
||
- Windows 환경 → mac 접근 방안 Pre-Task 0.2에서 결정
|
||
- git이 wiki에는 없지만 코드 워크스페이스(`lapie/`)는 git init
|
||
- 7월 착수 직전 본 plan 재검토 — 사용자 명시: "다음에 수정하면서 디벨롭"
|
||
|
||
---
|
||
|
||
## 관련 링크 / 교차참조
|
||
|
||
- [[사업-Lapie-피팅앱]] — design 본 문서 (양방향)
|
||
- [[정체성-Why-탐색]] — Why 검증대, PMF 매직넘버 원칙 (Task 13)
|
||
- [[사업-hedgy75-인스타]] — Task 13 마케팅 시너지
|
||
- [[자산-인프라-NAS-GPU]] — v1 클라우드 저장 (이 plan 외)
|
||
- [[기술-스택-역량]] — RN + iOS Swift 신규 학습 영역
|
||
|
||
## 출처
|
||
|
||
- `raw/2026-05-23-Lapie-피팅앱-브리핑-원본.md` — 본 plan의 design 출발점
|
||
- 본 plan 자체는 superpowers:writing-plans 스킬 결과 (2026-05-23)
|
||
|
||
## 변경 이력
|
||
|
||
- 2026-05-23: plan 신설. Pre-Task 2 + W1~W4 본 Task 9 + W5~6 버퍼 Task 2 = 총 13 task. 핵심 알고리즘(자동스케일·마스크분할·자세검증)은 TDD 단위 테스트 포함. UI·카메라·합성은 manual test 절차 명시. Windows 환경에서 RN iOS 빌드 불가 이슈 Pre-Task 0.2에 명시. 7월 착수 직전 재검토 전제.
|