This commit is contained in:
2025-12-24 14:34:41 +08:00
commit f16048247c
16 changed files with 2579 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>wh-hpe-ui</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1433
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "wh-hpe-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.24"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

22
src/App.vue Normal file
View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import Pose from "./components/Pose.vue";
</script>
<template>
<Pose />
</template>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
}
</style>

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

915
src/components/Pose.vue Normal file
View File

@ -0,0 +1,915 @@
<template>
<div class="pose-estimation-app">
<h1>人体姿态估计</h1>
<!-- 文件上传区域 -->
<div class="upload-section">
<input
type="file"
id="fileInput"
ref="fileInput"
@change="handleFileUpload"
accept="image/jpeg,image/png"
hidden
/>
<div
class="upload-area"
:class="{ 'drag-over': isDragOver }"
@click="triggerFileInput"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<div class="upload-content">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2ZM18 20H6V4H13V9H18V20ZM8 15.01L9.41 16.42L11 14.84V18H13V14.84L14.59 16.42L16 15.01L12.01 11L8 15.01Z" fill="currentColor"/>
</svg>
<p>点击或拖拽图片到这里</p>
<p class="file-types">支持 JPEG PNG 格式</p>
<p class="file-types">最大 10MB</p>
</div>
</div>
</div>
<!-- 后端地址配置 -->
<div class="config-section">
<div class="config-item">
<label for="backendUrl">后端地址:</label>
<input
type="text"
id="backendUrl"
v-model="backendUrl"
placeholder="http://192.168.2.184:8245"
/>
<!-- <button @click="testConnection" :disabled="isTesting">测试连接</button>-->
</div>
</div>
<!-- 加载状态 -->
<div v-if="isLoading" class="loading">
<div class="spinner"></div>
<p>正在分析图片...</p>
</div>
<!-- 连接测试状态 -->
<div v-if="connectionStatus" class="connection-status" :class="connectionStatus.type">
{{ connectionStatus.message }}
</div>
<!-- 错误信息 -->
<div v-if="error" class="error-message">
<p>{{ error }}</p>
</div>
<!-- 结果显示区域 -->
<div v-if="poseData && originalImage" class="result-section">
<div class="result-header">
<h3>检测结果</h3>
<div class="stats">
<span>检测到 {{ poseData.bbox.length }} 个人体</span>
<span v-if="averageConfidence > 0">平均置信度: {{ averageConfidence.toFixed(2) }}</span>
</div>
</div>
<div class="canvas-container">
<div class="canvas-wrapper">
<canvas ref="canvas" :width="canvasWidth" :height="canvasHeight"></canvas>
</div>
<div class="controls">
<div class="control-group">
<label>显示选项:</label>
<div class="checkbox-group">
<label>
<input type="checkbox" v-model="showBoundingBox" />
边界框
</label>
<label>
<input type="checkbox" v-model="showSkeleton" />
骨骼
</label>
<label>
<input type="checkbox" v-model="showKeypoints" />
关键点
</label>
</div>
</div>
<div class="control-group">
<label>置信度阈值: {{ confidenceThreshold.toFixed(2) }}</label>
<input
type="range"
v-model.number="confidenceThreshold"
min="0"
max="1"
step="0.05"
/>
</div>
<div class="color-palette">
<div
v-for="(color, index) in personColors"
:key="index"
class="color-item"
:style="{ backgroundColor: color }"
@click="selectColorIndex = selectColorIndex === index ? null : index"
:class="{ selected: selectColorIndex === index }"
>
<span>人物 {{ index + 1 }}</span>
</div>
</div>
<button @click="downloadResult" class="download-btn">下载结果图片</button>
</div>
</div>
<!-- 详细信息 -->
<div v-if="poseData" class="details-section">
<h4>检测详情:</h4>
<div class="details-grid">
<div class="detail-item">
<span class="label">图片尺寸:</span>
<span class="value">{{ imageWidth }} × {{ imageHeight }} 像素</span>
</div>
<div class="detail-item">
<span class="label">参考尺寸:</span>
<span class="value">{{ poseData.reference_size[0] }} × {{ poseData.reference_size[1] }} 像素</span>
</div>
<div class="detail-item">
<span class="label">帧索引:</span>
<span class="value">{{ poseData.frame_index }}</span>
</div>
</div>
<!-- 人体详情 -->
<div v-for="(person, index) in poseData.bbox" :key="index" class="person-detail">
<h5>人物 {{ index + 1 }}</h5>
<div class="person-info">
<div class="info-item">
<span>边界框: </span>
<code>[{{ person[0].toFixed(1) }}, {{ person[1].toFixed(1) }}, {{ person[2].toFixed(1) }}, {{ person[3].toFixed(1) }}]</code>
</div>
<div v-if="poseData.bbox_confidence" class="info-item">
<span>置信度: </span>
<span :class="{
'confidence-high': poseData.bbox_confidence[index] > 0.7,
'confidence-medium': poseData.bbox_confidence[index] > 0.3 && poseData.bbox_confidence[index] <= 0.7,
'confidence-low': poseData.bbox_confidence[index] <= 0.3
}">
{{ (poseData.bbox_confidence[index] * 100).toFixed(1) }}%
</span>
</div>
<div class="info-item">
<span>关键点数量: </span>
<span>{{ poseData.keypoints[index]?.length || 0 }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
// 定义类型
interface PoseDetectionInfo {
frame_index: number
reference_size: [number, number]
bbox: number[][]
bbox_confidence: number[]
keypoints: number[][][]
keypoints_confidence: number[][]
}
// 响应式数据
const fileInput = ref<HTMLInputElement | null>(null)
const canvas = ref<HTMLCanvasElement | null>(null)
const isDragOver = ref(false)
const isLoading = ref(false)
const isTesting = ref(false)
const error = ref('')
const connectionStatus = ref<{type: 'success' | 'error', message: string} | null>(null)
const poseData = ref<PoseDetectionInfo | null>(null)
const originalImage = ref<HTMLImageElement | null>(null)
const imageWidth = ref(0)
const imageHeight = ref(0)
const averageConfidence = ref(0)
const backendUrl = ref('http://192.168.2.184:8245')
const canvasWidth = ref(800)
const canvasHeight = ref(600)
const showBoundingBox = ref(true)
const showSkeleton = ref(true)
const showKeypoints = ref(true)
const confidenceThreshold = ref(0.3)
const selectColorIndex = ref<number | null>(null)
const personColors = ref(['#FF5252', '#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4'])
// 骨骼连接关系COCO-WholeBody格式
const skeletonConnections = [
[0, 1], [0, 2], [1, 3], [2, 4], [5, 6], [5, 7], [6, 8], [7, 9], [8, 10],
[11, 12], [5, 11], [6, 12], [11, 13], [13, 15], [12, 14], [14, 16],
[17, 18], [18, 19], [19, 20], [20, 21], [22, 23], [23, 24], [24, 25], [25, 26],
[27, 28], [28, 29], [29, 30], [31, 32], [32, 33], [33, 34], [34, 35],
[36, 37], [37, 38], [38, 39], [39, 40], [41, 42], [42, 43], [43, 44], [44, 45],
[46, 47], [47, 48], [48, 49], [49, 50], [51, 52], [52, 53], [53, 54]
]
// 观察置信度阈值变化
watch(confidenceThreshold, () => {
if (originalImage.value && poseData.value) {
drawPoseResults(originalImage.value)
}
})
// 观察显示选项变化
watch([showBoundingBox, showSkeleton, showKeypoints], () => {
if (originalImage.value && poseData.value) {
drawPoseResults(originalImage.value)
}
})
// 触发文件选择
const triggerFileInput = () => {
fileInput.value?.click()
}
// 处理文件上传
const handleFileUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files.length > 0) {
await processImage(target.files[0])
}
}
// 处理拖拽
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
isDragOver.value = true
}
const handleDragLeave = () => {
isDragOver.value = false
}
const handleDrop = async (e: DragEvent) => {
e.preventDefault()
isDragOver.value = false
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
const file = e.dataTransfer.files[0]
await processImage(file)
}
}
// 测试后端连接
const testConnection = async () => {
isTesting.value = true
connectionStatus.value = null
error.value = ''
try {
const response = await fetch(backendUrl.value + '/hpe', {
method: 'OPTIONS' // 先发送OPTIONS请求检查CORS
})
if (response.ok) {
connectionStatus.value = {
type: 'success',
message: '后端连接成功!'
}
} else {
connectionStatus.value = {
type: 'error',
message: '后端连接失败,状态码:' + response.status
}
}
} catch (err) {
connectionStatus.value = {
type: 'error',
message: '无法连接到后端:' + (err as Error).message
}
} finally {
isTesting.value = false
}
}
// 处理图片分析
const processImage = async (file: File) => {
// 验证文件类型
if (!['image/jpeg', 'image/jpg', 'image/png'].includes(file.type)) {
error.value = '只支持 JPEG 和 PNG 格式的图片'
return
}
// 验证文件大小
if (file.size > 10 * 1024 * 1024) {
error.value = '图片大小不能超过 10MB'
return
}
isLoading.value = true
error.value = ''
poseData.value = null
connectionStatus.value = null
try {
// 创建图片预览
const img = new Image()
const imgUrl = URL.createObjectURL(file)
img.src = imgUrl
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
})
originalImage.value = img
imageWidth.value = img.width
imageHeight.value = img.height
// 计算画布尺寸,保持宽高比
const maxWidth = 800
const maxHeight = 600
const aspectRatio = img.width / img.height
if (aspectRatio > 1) {
canvasWidth.value = Math.min(img.width, maxWidth)
canvasHeight.value = canvasWidth.value / aspectRatio
} else {
canvasHeight.value = Math.min(img.height, maxHeight)
canvasWidth.value = canvasHeight.value * aspectRatio
}
// 准备图片数据
const arrayBuffer = await file.arrayBuffer()
// 发送请求到后端
const response = await fetch(`${backendUrl.value}/hpe`, {
method: 'POST',
headers: {
'Content-Type': file.type // 设置正确的Content-Type
},
body: arrayBuffer
})
if (!response.ok) {
if (response.status === 415) {
throw new Error('不支持的图片格式请使用JPEG或PNG格式')
} else if (response.status === 413) {
throw new Error('图片过大请使用小于10MB的图片')
} else if (response.status === 503) {
throw new Error('服务器繁忙,请稍后重试')
} else if (response.status === 408) {
throw new Error('请求超时,请重试')
} else {
const errorText = await response.text()
throw new Error(`请求失败: ${response.status} ${response.statusText} - ${errorText}`)
}
}
// 检查是否有检测结果
const contentLength = response.headers.get('content-length')
if (response.status === 200 && contentLength !== '0' && parseInt(contentLength || '0') > 0) {
poseData.value = await response.json()
// 计算平均置信度
if (poseData.value.bbox_confidence && poseData.value.bbox_confidence.length > 0) {
averageConfidence.value = poseData.value.bbox_confidence.reduce((a, b) => a + b, 0) / poseData.value.bbox_confidence.length
}
// 绘制结果
await nextTick()
drawPoseResults(img)
} else {
error.value = '未检测到人体姿态'
}
} catch (err) {
console.error('分析图片时出错:', err)
error.value = err instanceof Error ? err.message : '分析图片时出错'
} finally {
isLoading.value = false
}
}
// 绘制姿态结果
const drawPoseResults = (img: HTMLImageElement) => {
if (!canvas.value || !poseData.value) return
const ctx = canvas.value.getContext('2d')
if (!ctx) return
// 清空画布
ctx.clearRect(0, 0, canvasWidth.value, canvasHeight.value)
// 绘制缩放后的图片
ctx.drawImage(img, 0, 0, canvasWidth.value, canvasHeight.value)
// 计算缩放比例
const scaleX = canvasWidth.value / img.width
const scaleY = canvasHeight.value / img.height
poseData.value.bbox.forEach((bbox, personIndex) => {
const color = personColors.value[personIndex % personColors.value.length]
// 绘制边界框
if (showBoundingBox.value) {
ctx.strokeStyle = color!
ctx.lineWidth = 2
const scaledBbox = [
bbox[0]! * scaleX,
bbox[1]! * scaleY,
(bbox[2]! - bbox[0]!) * scaleX,
(bbox[3]! - bbox[1]!) * scaleY
]
ctx.strokeRect(scaledBbox[0], scaledBbox[1], scaledBbox[2], scaledBbox[3])
// 绘制边界框标签
ctx.fillStyle = color!
ctx.font = '12px Arial'
ctx.fillText(`Person ${personIndex + 1}`, scaledBbox[0]! + 5, scaledBbox[1]! - 5)
}
// 绘制关键点和骨骼
if (personIndex < poseData.value!.keypoints.length) {
const keypoints = poseData.value!.keypoints[personIndex]
const confidences = poseData.value!.keypoints_confidence?.[personIndex] || []
// 绘制骨骼连接
if (showSkeleton.value) {
ctx.strokeStyle = color
ctx.lineWidth = 2
skeletonConnections.forEach(([start, end]) => {
if (start < keypoints.length && end < keypoints.length) {
const startPoint = keypoints[start]
const endPoint = keypoints[end]
// 检查关键点置信度
const startConf = confidences[start] || 1
const endConf = confidences[end] || 1
if (startConf > confidenceThreshold.value && endConf > confidenceThreshold.value) {
ctx.beginPath()
ctx.moveTo(startPoint[0] * scaleX, startPoint[1] * scaleY)
ctx.lineTo(endPoint[0] * scaleX, endPoint[1] * scaleY)
ctx.stroke()
}
}
})
}
// 绘制关键点
if (showKeypoints.value) {
keypoints.forEach((point, index) => {
const confidence = confidences[index] || 1
if (confidence > confidenceThreshold.value) {
ctx.fillStyle = color
ctx.beginPath()
ctx.arc(point[0] * scaleX, point[1] * scaleY, 1, 0, Math.PI * 2)
ctx.fill()
}
})
}
}
})
}
// 下载结果图片
const downloadResult = () => {
if (!canvas.value) return
const link = document.createElement('a')
link.download = 'pose-detection-result.png'
link.href = canvas.value.toDataURL('image/png')
link.click()
}
onMounted(() => {
// 初始化时测试后端连接
// testConnection()
})
</script>
<style scoped>
.pose-estimation-app {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
h1 {
text-align: center;
color: #2c3e50;
margin-bottom: 30px;
font-weight: 300;
}
.upload-section {
margin-bottom: 30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.upload-area {
border: 3px dashed rgba(255, 255, 255, 0.7);
border-radius: 10px;
padding: 60px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.upload-area:hover, .upload-area.drag-over {
border-color: #ffffff;
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.upload-content svg {
color: white;
margin-bottom: 20px;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
}
.upload-content p {
margin: 10px 0;
color: white;
font-size: 1.1em;
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.file-types {
font-size: 0.9em;
color: rgba(255, 255, 255, 0.9) !important;
}
.config-section {
background: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.config-item {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.config-item label {
font-weight: 500;
color: #2c3e50;
min-width: 80px;
}
.config-item input {
flex: 1;
min-width: 200px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 14px;
}
.config-item input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
.config-item button {
padding: 8px 20px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background 0.3s;
font-weight: 500;
}
.config-item button:hover:not(:disabled) {
background: #5a67d8;
}
.config-item button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 40px;
background: white;
border-radius: 10px;
margin: 20px 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-top: 4px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.connection-status {
padding: 12px 20px;
border-radius: 5px;
margin: 10px 0;
font-weight: 500;
}
.connection-status.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.connection-status.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.error-message {
background: #f8d7da;
color: #721c24;
padding: 15px 20px;
border-radius: 5px;
margin: 20px 0;
text-align: center;
border: 1px solid #f5c6cb;
}
.result-section {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-top: 20px;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.result-header h3 {
margin: 0;
color: #2c3e50;
}
.stats {
display: flex;
gap: 20px;
color: #666;
font-size: 0.9em;
}
.canvas-container {
display: grid;
grid-template-columns: 1fr 300px;
gap: 20px;
margin-bottom: 20px;
}
.canvas-wrapper {
border: 1px solid #eee;
border-radius: 5px;
overflow: hidden;
background: #f8f9fa;
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
canvas {
max-width: 100%;
height: auto;
display: block;
}
.controls {
display: flex;
flex-direction: column;
gap: 20px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.control-group label {
font-weight: 500;
color: #2c3e50;
font-size: 0.9em;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 5px;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 8px;
font-weight: normal;
cursor: pointer;
font-size: 0.9em;
color: #666;
}
.checkbox-group input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
.control-group input[type="range"] {
width: 100%;
margin-top: 5px;
}
.color-palette {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 5px;
}
.color-item {
padding: 8px;
border-radius: 5px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
text-align: center;
color: white;
font-size: 0.8em;
font-weight: bold;
text-shadow: 1px 1px 1px rgba(0,0,0,0.3);
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.color-item:hover {
transform: translateY(-2px);
box-shadow: 0 3px 6px rgba(0,0,0,0.2);
}
.color-item.selected {
transform: scale(1.05);
box-shadow: 0 0 0 3px rgba(0,0,0,0.2);
}
.download-btn {
padding: 12px 20px;
background: #4CAF50;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-weight: 500;
transition: background 0.3s;
margin-top: 10px;
}
.download-btn:hover {
background: #45a049;
}
.details-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.details-section h4 {
color: #2c3e50;
margin-bottom: 15px;
}
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.detail-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
background: #f8f9fa;
border-radius: 5px;
font-size: 0.9em;
}
.detail-item .label {
font-weight: 500;
color: #495057;
}
.detail-item .value {
color: #212529;
font-family: 'Courier New', monospace;
}
.person-detail {
background: #f8f9fa;
border-radius: 5px;
padding: 15px;
margin-bottom: 10px;
border-left: 4px solid #667eea;
}
.person-detail h5 {
margin: 0 0 10px 0;
color: #2c3e50;
}
.person-info {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 0.9em;
}
.info-item {
display: flex;
align-items: center;
gap: 10px;
}
.info-item span:first-child {
font-weight: 500;
color: #495057;
min-width: 80px;
}
.confidence-high {
color: #28a745;
font-weight: bold;
}
.confidence-medium {
color: #ffc107;
font-weight: bold;
}
.confidence-low {
color: #dc3545;
font-weight: bold;
}
@media (max-width: 768px) {
.canvas-container {
grid-template-columns: 1fr;
}
.result-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.config-item {
flex-direction: column;
align-items: flex-start;
}
.config-item input {
width: 100%;
}
}
</style>

5
src/main.ts Normal file
View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

79
src/style.css Normal file
View File

@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

16
tsconfig.app.json Normal file
View File

@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})