# Qwen-Image-Layered로 포스터 자동 레이어 분해 (7/10): 웹 인터페이스 구현
## 인터페이스 디자인 목표
사용자가 기술적 지식 없이도 쉽게 사용할 수 있는 UI를 만든다.
**핵심 원칙**:
1. **단순성** - 3번의 클릭으로 완료
2. **피드백** - 모든 단계에서 진행 상황 표시
3. **시각성** - 결과를 즉시 미리보기
4. **접근성** - 모바일에서도 사용 가능
## 전체 플로우
```
[1단계] 이미지 드래그 앤 드롭
↓
[2단계] 레이어 수 선택 (AI 추천 옵션)
↓
[3단계] 처리 중... (진행률 표시)
↓
[4단계] 결과 확인 및 다운로드
```
## HTML 구조
`frontend/index.html`:
```html
Poster Layer Decomposer - AI로 포스터 분해
1. 포스터 이미지 업로드
✕
2. 레이어 수 설정
레이어 개수
3 레이어 (단순)
5 레이어 (권장)
7 레이어 (복잡)
10 레이어 (매우 복잡)
해상도
640px (빠름)
1024px (균형)
2048px (최고 품질)
🤖 AI 추천 레이어 수 분석
🚀 레이어 분해 시작
3. AI 처리 중...
⏱ 예상 소요 시간: 30초 ~ 2분
✅ 분해 완료!
품질 점수:
-
📥 모든 레이어 다운로드 (ZIP)
🔄 새로운 이미지 처리
```
## CSS 스타일
`frontend/style.css`:
```css
:root {
--primary: #6366f1;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--gray-50: #f9fafb;
--gray-200: #e5e7eb;
--gray-700: #374151;
--gray-900: #111827;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
color: white;
margin-bottom: 40px;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}
.subtitle {
font-size: 1.1rem;
opacity: 0.9;
}
.section {
display: none;
}
.section.active {
display: block;
}
.card {
background: white;
border-radius: 16px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.card h2 {
color: var(--gray-900);
margin-bottom: 20px;
font-size: 1.5rem;
}
/* Upload Zone */
.upload-zone {
border: 3px dashed var(--gray-200);
border-radius: 12px;
padding: 60px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
}
.upload-zone:hover,
.upload-zone.drag-over {
border-color: var(--primary);
background: var(--gray-50);
}
.upload-icon {
fill: var(--gray-700);
margin-bottom: 20px;
}
.upload-text {
font-size: 1.1rem;
color: var(--gray-700);
margin-bottom: 10px;
}
.upload-hint {
color: var(--gray-700);
opacity: 0.7;
font-size: 0.9rem;
}
/* Preview */
.preview-container {
position: relative;
margin-top: 20px;
}
.preview-container img {
width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 8px;
}
.btn-icon {
position: absolute;
top: 10px;
right: 10px;
background: white;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Config */
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.config-item label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: var(--gray-700);
}
.input {
width: 100%;
padding: 12px;
border: 2px solid var(--gray-200);
border-radius: 8px;
font-size: 1rem;
}
.ai-suggestion {
background: var(--gray-50);
padding: 15px;
border-radius: 8px;
margin: 20px 0;
}
/* Progress */
.progress-container {
margin: 30px 0;
}
.progress-bar {
width: 100%;
height: 24px;
background: var(--gray-200);
border-radius: 12px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--success));
transition: width 0.3s;
width: 0;
}
.progress-text {
text-align: center;
margin-top: 10px;
color: var(--gray-700);
}
.log-container {
margin-top: 30px;
}
.log-output {
background: var(--gray-900);
color: #0f0;
padding: 15px;
border-radius: 8px;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
max-height: 200px;
overflow-y: auto;
}
/* Layer Grid */
.quality-badge {
background: var(--success);
color: white;
padding: 10px 20px;
border-radius: 8px;
display: inline-block;
margin-bottom: 20px;
}
.layer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.layer-card {
border: 2px solid var(--gray-200);
border-radius: 12px;
padding: 15px;
text-align: center;
}
.layer-card img {
width: 100%;
height: 150px;
object-fit: contain;
margin-bottom: 10px;
background:
repeating-linear-gradient(45deg, #f0f0f0, #f0f0f0 10px, #e0e0e0 10px, #e0e0e0 20px);
}
.layer-card h4 {
margin-bottom: 5px;
color: var(--gray-900);
}
.layer-card p {
font-size: 0.9rem;
color: var(--gray-700);
margin-bottom: 10px;
}
/* Buttons */
.btn-primary,
.btn-secondary,
.btn-success {
padding: 14px 28px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #4f46e5;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: var(--gray-200);
color: var(--gray-700);
}
.btn-success {
background: var(--success);
color: white;
}
.actions {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.hint {
color: var(--gray-700);
opacity: 0.8;
font-size: 0.9rem;
margin-top: 10px;
}
.footer {
text-align: center;
color: white;
margin-top: 40px;
opacity: 0.8;
}
```
## JavaScript 로직
`frontend/app.js`:
```javascript
class PosterDecomposer {
constructor() {
this.apiBase = 'http://localhost:8000';
this.fileId = null;
this.jobId = null;
this.ws = null;
this.init();
}
init() {
// 파일 업로드
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', async (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
await this.handleFileUpload(file);
});
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
await this.handleFileUpload(file);
});
// AI 추천
document.getElementById('ai-suggest-btn').addEventListener('click',
() => this.suggestLayerCount()
);
// 분해 시작
document.getElementById('start-decompose-btn').addEventListener('click',
() => this.startDecompose()
);
// 재시작
document.getElementById('restart-btn').addEventListener('click',
() => location.reload()
);
}
async handleFileUpload(file) {
if (!file || !file.type.startsWith('image/')) {
alert('이미지 파일만 업로드 가능합니다');
return;
}
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch(`${this.apiBase}/api/upload`, {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('업로드 실패');
const data = await response.json();
this.fileId = data.file_id;
// 미리보기 표시
const reader = new FileReader();
reader.onload = (e) => {
document.getElementById('preview-image').src = e.target.result;
document.getElementById('preview-container').style.display = 'block';
document.getElementById('dropZone').style.display = 'none';
};
reader.readAsDataURL(file);
// 다음 섹션 활성화
this.showSection('config');
document.getElementById('start-decompose-btn').disabled = false;
} catch (error) {
console.error(error);
alert('업로드 실패: ' + error.message);
}
}
async suggestLayerCount() {
const btn = document.getElementById('ai-suggest-btn');
btn.disabled = true;
btn.textContent = '🤖 AI 분석 중...';
try {
const fileInput = document.getElementById('fileInput');
const formData = new FormData();
formData.append('file', fileInput.files[0]);
const response = await fetch(`${this.apiBase}/api/analyze/suggest-layers`, {
method: 'POST',
body: formData
});
const data = await response.json();
// 추천 표시
document.getElementById('suggested-layers').textContent = data.recommended_layers;
document.getElementById('suggestion-reason').textContent = data.reason;
document.getElementById('ai-suggestion').style.display = 'block';
// 자동 선택
document.getElementById('num-layers').value = data.recommended_layers;
} catch (error) {
console.error(error);
} finally {
btn.disabled = false;
btn.textContent = '🤖 AI 추천 레이어 수 분석';
}
}
async startDecompose() {
this.showSection('processing');
const numLayers = parseInt(document.getElementById('num-layers').value);
const resolution = parseInt(document.getElementById('resolution').value);
try {
// 작업 생성
const response = await fetch(`${this.apiBase}/api/decompose`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
file_id: this.fileId,
num_layers: numLayers,
resolution: resolution
})
});
const data = await response.json();
this.jobId = data.job_id;
// WebSocket 연결
this.connectWebSocket();
} catch (error) {
console.error(error);
alert('처리 실패: ' + error.message);
}
}
connectWebSocket() {
this.ws = new WebSocket(`ws://localhost:8000/ws/status/${this.jobId}`);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// 진행률 업데이트
document.getElementById('progress-fill').style.width = `${data.progress}%`;
document.getElementById('progress-percent').textContent = `${data.progress}%`;
document.getElementById('progress-message').textContent = data.message;
// 로그 추가
this.appendLog(`[${new Date().toLocaleTimeString()}] ${data.message}`);
// 완료 시
if (data.status === 'completed') {
this.showResults();
} else if (data.status === 'failed') {
alert('처리 실패');
}
};
}
async showResults() {
this.showSection('results');
// 결과 조회
const response = await fetch(`${this.apiBase}/api/status/${this.jobId}`);
const data = await response.json();
// 품질 점수
document.getElementById('quality-score').textContent = `${data.quality_score || 95}/100`;
// 레이어 그리드
const grid = document.getElementById('layer-grid');
grid.innerHTML = '';
data.layers.forEach((layer) => {
const card = document.createElement('div');
card.className = 'layer-card';
card.innerHTML = `
Layer ${layer.index}
${layer.description || '레이어 ' + layer.index}
${layer.size_kb} KB
다운로드
`;
grid.appendChild(card);
});
// ZIP 다운로드
document.getElementById('download-all-btn').onclick = () => {
window.location.href = `${this.apiBase}/api/download/${this.jobId}`;
};
}
downloadLayer(url, filename) {
const a = document.createElement('a');
a.href = this.apiBase + url;
a.download = filename;
a.click();
}
showSection(section) {
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.getElementById(`${section}-section`).classList.add('active');
}
appendLog(message) {
const log = document.getElementById('log-output');
log.textContent += message + '\n';
log.scrollTop = log.scrollHeight;
}
}
// 앱 시작
const app = new PosterDecomposer();
```
## 모바일 반응형
```css
@media (max-width: 768px) {
.header h1 {
font-size: 1.8rem;
}
.card {
padding: 20px;
}
.config-grid {
grid-template-columns: 1fr;
}
.layer-grid {
grid-template-columns: 1fr;
}
.actions {
flex-direction: column;
}
.actions button {
width: 100%;
}
}
```
## 다음 단계
v8에서는 **후처리 및 최적화**를 다룬다:
- Alpha matting 품질 향상
- 레이어 경계선 smoothing
- GPU 메모리 최적화
- 배치 처리
UI가 완성되었으니, 품질과 성능을 극대화하자.
---
**이전 글**: [Vertex AI 통합 (6/10)](./qwen-image-layered-v6.md)
**다음 글**: [후처리 및 최적화 (8/10)](./qwen-image-layered-v8.md)