init
This commit is contained in:
commit
b9f9eb8b3a
285
.gitignore
vendored
Normal file
285
.gitignore
vendored
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Flask
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Node.js / npm / pnpm / yarn
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
public
|
||||||
|
|
||||||
|
# Vue.js build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# Vite build outputs
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Rollup build output
|
||||||
|
.rollup.cache/
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
Icon
|
||||||
|
._*
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
Thumbs.db
|
||||||
|
Thumbs.db:encryptable
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
Desktop.ini
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
*~
|
||||||
|
.fuse_hidden*
|
||||||
|
.directory
|
||||||
|
.Trash-*
|
||||||
|
.nfs*
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
# Generated images and session data
|
||||||
|
data/sessions/*/
|
||||||
|
*.png
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.gif
|
||||||
|
|
||||||
|
# Configuration files with sensitive data
|
||||||
|
config.local.py
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
temp/
|
||||||
|
tmp/
|
||||||
190
DEPLOYMENT.md
Normal file
190
DEPLOYMENT.md
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
# 部署指南
|
||||||
|
|
||||||
|
## 📋 部署前准备
|
||||||
|
|
||||||
|
### 系统要求
|
||||||
|
- Ubuntu 20.04+ / CentOS 8+ / Debian 11+
|
||||||
|
- Python 3.8+
|
||||||
|
- Node.js 16+ 或 Bun
|
||||||
|
- Nginx
|
||||||
|
- Supervisor 或 systemd
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install python3 python3-venv python3-pip nginx supervisor
|
||||||
|
|
||||||
|
# 安装 Bun (推荐)
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
|
||||||
|
# 或者安装 Node.js
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 部署步骤
|
||||||
|
|
||||||
|
### 1. 克隆项目
|
||||||
|
```bash
|
||||||
|
cd /var/www
|
||||||
|
sudo git clone <your-repo-url> ball-tracking-server
|
||||||
|
cd ball-tracking-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 修改配置文件路径
|
||||||
|
编辑以下文件,将路径替换为实际路径:
|
||||||
|
- `deploy.sh` - 第6行 PROJECT_ROOT
|
||||||
|
- `supervisor.conf` - command, directory, stdout_logfile, environment 中的路径
|
||||||
|
- `gunicorn.conf.py` - 如果需要修改用户
|
||||||
|
- `ball-tracking-server.service` - WorkingDirectory, Environment, ExecStart 中的路径
|
||||||
|
- `nginx.conf` - root, server_name 等配置
|
||||||
|
|
||||||
|
### 3. 运行部署脚本
|
||||||
|
```bash
|
||||||
|
# 给脚本执行权限
|
||||||
|
chmod +x deploy.sh start.sh stop.sh
|
||||||
|
|
||||||
|
# 运行部署
|
||||||
|
sudo ./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 配置 Nginx
|
||||||
|
```bash
|
||||||
|
# 复制 Nginx 配置
|
||||||
|
sudo cp nginx.conf /etc/nginx/sites-available/ball-tracking-server
|
||||||
|
|
||||||
|
# 启用站点
|
||||||
|
sudo ln -s /etc/nginx/sites-available/ball-tracking-server /etc/nginx/sites-enabled/
|
||||||
|
|
||||||
|
# 测试配置
|
||||||
|
sudo nginx -t
|
||||||
|
|
||||||
|
# 重启 Nginx
|
||||||
|
sudo systemctl restart nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 服务管理
|
||||||
|
|
||||||
|
### 使用 Supervisor(推荐)
|
||||||
|
```bash
|
||||||
|
# 查看状态
|
||||||
|
sudo supervisorctl status ball-tracking-server
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
sudo supervisorctl start ball-tracking-server
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
sudo supervisorctl stop ball-tracking-server
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
sudo supervisorctl restart ball-tracking-server
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
sudo supervisorctl tail -f ball-tracking-server
|
||||||
|
|
||||||
|
# 或者使用脚本
|
||||||
|
./start.sh # 启动
|
||||||
|
./stop.sh # 停止
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 systemd(备选方案)
|
||||||
|
```bash
|
||||||
|
# 复制服务文件
|
||||||
|
sudo cp ball-tracking-server.service /etc/systemd/system/
|
||||||
|
|
||||||
|
# 重载服务配置
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
# 启用开机自启
|
||||||
|
sudo systemctl enable ball-tracking-server
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
sudo systemctl start ball-tracking-server
|
||||||
|
|
||||||
|
# 查看状态
|
||||||
|
sudo systemctl status ball-tracking-server
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
sudo journalctl -u ball-tracking-server -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 日志文件位置
|
||||||
|
- Supervisor 日志: `logs/supervisor.log`
|
||||||
|
- Gunicorn 访问日志: `logs/gunicorn_access.log`
|
||||||
|
- Gunicorn 错误日志: `logs/gunicorn_error.log`
|
||||||
|
- Nginx 访问日志: `/var/log/nginx/ball-tracking-server_access.log`
|
||||||
|
- Nginx 错误日志: `/var/log/nginx/ball-tracking-server_error.log`
|
||||||
|
|
||||||
|
## 🔍 故障排查
|
||||||
|
|
||||||
|
### 检查端口占用
|
||||||
|
```bash
|
||||||
|
sudo netstat -tulpn | grep :5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 检查进程
|
||||||
|
```bash
|
||||||
|
ps aux | grep gunicorn
|
||||||
|
ps aux | grep supervisord
|
||||||
|
```
|
||||||
|
|
||||||
|
### 检查防火墙
|
||||||
|
```bash
|
||||||
|
sudo ufw status
|
||||||
|
sudo ufw allow 80
|
||||||
|
sudo ufw allow 443
|
||||||
|
```
|
||||||
|
|
||||||
|
### 权限问题
|
||||||
|
```bash
|
||||||
|
sudo chown -R www-data:www-data /var/www/ball-tracking-server
|
||||||
|
sudo chmod -R 755 /var/www/ball-tracking-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 更新部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 简单更新(自动构建前端)
|
||||||
|
sudo ./deploy.sh
|
||||||
|
|
||||||
|
# 手动更新
|
||||||
|
git pull origin main
|
||||||
|
cd app/web && bun run build && cd ../..
|
||||||
|
sudo supervisorctl restart ball-tracking-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 域名和 HTTPS
|
||||||
|
|
||||||
|
### 配置域名
|
||||||
|
1. 修改 `nginx.conf` 中的 `server_name`
|
||||||
|
2. 重启 Nginx
|
||||||
|
|
||||||
|
### 启用 HTTPS (Let's Encrypt)
|
||||||
|
```bash
|
||||||
|
# 安装 Certbot
|
||||||
|
sudo apt install certbot python3-certbot-nginx
|
||||||
|
|
||||||
|
# 获取证书
|
||||||
|
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
|
||||||
|
|
||||||
|
# 自动续期
|
||||||
|
sudo crontab -e
|
||||||
|
# 添加: 0 12 * * * /usr/bin/certbot renew --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 监控建议
|
||||||
|
|
||||||
|
- 使用 `htop` 或 `glances` 监控系统资源
|
||||||
|
- 设置日志轮转避免磁盘空间不足
|
||||||
|
- 配置监控告警(如 Zabbix、Prometheus)
|
||||||
|
- 定期备份数据目录
|
||||||
|
|
||||||
|
## 🚨 安全建议
|
||||||
|
|
||||||
|
- 关闭不必要的端口
|
||||||
|
- 启用防火墙
|
||||||
|
- 定期更新系统和依赖
|
||||||
|
- 使用 HTTPS
|
||||||
|
- 设置强密码和 SSH 密钥登录
|
||||||
53
app/__init__.py
Normal file
53
app/__init__.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from flask import Flask, send_from_directory, send_file
|
||||||
|
from flask_cors import CORS
|
||||||
|
from .config import Config
|
||||||
|
from .logging_config import configure_logging
|
||||||
|
from .routes import bp as api_bp
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_class: type[Config] = Config) -> Flask:
|
||||||
|
configure_logging()
|
||||||
|
|
||||||
|
# 设置静态文件目录
|
||||||
|
static_folder = os.path.join(os.path.dirname(__file__), 'web', 'dist')
|
||||||
|
app = Flask(__name__, static_folder=static_folder, static_url_path='')
|
||||||
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS(app, resources={r"/api/*": {"origins": app.config.get("CORS_ORIGINS", "*")}})
|
||||||
|
|
||||||
|
# Blueprints
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
|
||||||
|
# 静态文件路由
|
||||||
|
@app.route('/')
|
||||||
|
def serve_index():
|
||||||
|
"""服务主页面"""
|
||||||
|
return send_file(os.path.join(app.static_folder, 'index.html'))
|
||||||
|
|
||||||
|
@app.route('/<path:path>')
|
||||||
|
def serve_static(path):
|
||||||
|
"""服务其他静态文件"""
|
||||||
|
try:
|
||||||
|
return send_from_directory(app.static_folder, path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# 如果文件不存在,返回主页面(用于SPA路由)
|
||||||
|
return send_file(os.path.join(app.static_folder, 'index.html'))
|
||||||
|
|
||||||
|
# Error handlers -> JSON
|
||||||
|
@app.errorhandler(413)
|
||||||
|
def too_large(_):
|
||||||
|
return {"success": False, "error": "上传文件过大"}, 413
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(_):
|
||||||
|
return {"success": False, "error": "未找到"}, 404
|
||||||
|
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
def all_errors(e):
|
||||||
|
logging.getLogger(__name__).exception("Unhandled error")
|
||||||
|
return {"success": False, "error": str(e)}, 500
|
||||||
|
|
||||||
|
return app
|
||||||
279
app/analysis.py
Normal file
279
app/analysis.py
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
import logging
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg') # headless backend
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from scipy.optimize import curve_fit
|
||||||
|
from scipy.signal import savgol_filter as _savgol_filter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def load_data(file_path_or_buffer):
|
||||||
|
"""加载CSV文件中的单摆数据 (path or file-like)"""
|
||||||
|
try:
|
||||||
|
data = pd.read_csv(file_path_or_buffer)
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"读取数据文件时出错: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fit_circle(x, y):
|
||||||
|
"""
|
||||||
|
通过轨迹点拟合圆,找出圆心和半径
|
||||||
|
返回: x_c, y_c, r
|
||||||
|
"""
|
||||||
|
A = np.column_stack([x, y, np.ones(len(x))])
|
||||||
|
b = x**2 + y**2
|
||||||
|
try:
|
||||||
|
c = np.linalg.lstsq(A, b, rcond=None)[0]
|
||||||
|
x_c = c[0] / 2
|
||||||
|
y_c = c[1] / 2
|
||||||
|
r = np.sqrt(c[2] + x_c**2 + y_c**2)
|
||||||
|
return x_c, y_c, r
|
||||||
|
except np.linalg.LinAlgError:
|
||||||
|
logger.warning("拟合圆时出现线性代数错误,使用备用方法")
|
||||||
|
x_c = np.mean(x)
|
||||||
|
y_c = np.min(y) - np.sqrt(np.var(x))
|
||||||
|
distances = np.sqrt((x - x_c)**2 + (y - y_c)**2)
|
||||||
|
r = np.mean(distances)
|
||||||
|
return x_c, y_c, r
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_angle_from_pendulum_length(data, pendulum_length_m):
|
||||||
|
"""
|
||||||
|
从位置数据和给定的摆长计算单摆角度
|
||||||
|
返回: t, theta, L(米), pivot(x_c,y_c)
|
||||||
|
"""
|
||||||
|
t = data['time'].values
|
||||||
|
x = data['x'].values # 像素坐标
|
||||||
|
y = data['y'].values # 像素坐标
|
||||||
|
|
||||||
|
# 拟合圆找到支点和半径(像素单位)
|
||||||
|
x_c, y_c, r_pixels = fit_circle(x, y)
|
||||||
|
|
||||||
|
# 根据给定的摆长计算像素到米的转换系数
|
||||||
|
mm_per_pixel = (pendulum_length_m * 1000) / r_pixels # 毫米每像素
|
||||||
|
|
||||||
|
# 转换为毫米坐标
|
||||||
|
x_mm = x * mm_per_pixel
|
||||||
|
y_mm = y * mm_per_pixel
|
||||||
|
x_c_mm = x_c * mm_per_pixel
|
||||||
|
y_c_mm = y_c * mm_per_pixel
|
||||||
|
|
||||||
|
# 计算角度
|
||||||
|
theta = np.arctan2(x_mm - x_c_mm, y_mm - y_c_mm)
|
||||||
|
t = t - t[0] # 时间归零
|
||||||
|
|
||||||
|
logger.info(f"给定摆长: {pendulum_length_m:.4f} 米")
|
||||||
|
logger.info(f"拟合得到的支点坐标: ({x_c:.2f}, {y_c:.2f}) 像素")
|
||||||
|
logger.info(f"转换后的支点坐标: ({x_c_mm:.2f}, {y_c_mm:.2f}) 毫米")
|
||||||
|
logger.info(f"计算得到的像素转换系数: {mm_per_pixel:.4f} mm/pixel")
|
||||||
|
|
||||||
|
return t, theta, pendulum_length_m, (x_c_mm, y_c_mm)
|
||||||
|
|
||||||
|
|
||||||
|
def damped_oscillator(t, theta0, gamma, omega, phi):
|
||||||
|
"""阻尼简谐振动模型"""
|
||||||
|
return theta0 * np.exp(-gamma * t) * np.cos(omega * t + phi)
|
||||||
|
|
||||||
|
|
||||||
|
def fit_pendulum_model(t, theta):
|
||||||
|
"""拟合阻尼简谐振动模型到实验数据 -> (popt, pcov)"""
|
||||||
|
theta0_guess = np.max(np.abs(theta))
|
||||||
|
gamma_guess = 0.1
|
||||||
|
indices = np.where(np.diff(np.signbit(theta)))[0]
|
||||||
|
if len(indices) >= 2:
|
||||||
|
T = 2 * (t[indices[1]] - t[indices[0]])
|
||||||
|
else:
|
||||||
|
T = (t[-1] - t[0]) / 2
|
||||||
|
omega_guess = 2 * np.pi / T
|
||||||
|
phi_guess = 0
|
||||||
|
|
||||||
|
p0 = [theta0_guess, gamma_guess, omega_guess, phi_guess]
|
||||||
|
bounds = ([0, 0, 0, -np.pi], [np.inf, np.inf, np.inf, np.pi])
|
||||||
|
|
||||||
|
try:
|
||||||
|
popt, pcov = curve_fit(
|
||||||
|
damped_oscillator, t, theta, p0=p0, bounds=bounds, maxfev=10000
|
||||||
|
)
|
||||||
|
return popt, pcov
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"拟合过程中出错: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def identify_periods(t, theta):
|
||||||
|
"""识别单摆的每个周期并计算每个周期的最大摆角"""
|
||||||
|
zero_crossings = []
|
||||||
|
for i in range(1, len(theta)):
|
||||||
|
if theta[i-1] > 0 and theta[i] <= 0:
|
||||||
|
t_interp = t[i-1] + (t[i] - t[i-1]) * (0 - theta[i-1]) / (theta[i] - theta[i-1])
|
||||||
|
zero_crossings.append((i, t_interp))
|
||||||
|
|
||||||
|
periods = []
|
||||||
|
for i in range(len(zero_crossings) - 1):
|
||||||
|
start_idx, start_t = zero_crossings[i]
|
||||||
|
end_idx, end_t = zero_crossings[i+1]
|
||||||
|
period_indices = range(start_idx, end_idx)
|
||||||
|
period_t = t[period_indices]
|
||||||
|
period_theta = theta[period_indices]
|
||||||
|
period_duration = end_t - start_t
|
||||||
|
max_amp = np.max(np.abs(period_theta))
|
||||||
|
periods.append({
|
||||||
|
'start_t': start_t,
|
||||||
|
'end_t': end_t,
|
||||||
|
'duration': period_duration,
|
||||||
|
'max_amplitude': max_amp,
|
||||||
|
'indices': period_indices
|
||||||
|
})
|
||||||
|
return periods
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_large_angle_g(L, periods, gamma):
|
||||||
|
"""计算同时考虑大摆角效应和阻尼效应的重力加速度"""
|
||||||
|
g_values, weights = [], []
|
||||||
|
for period in periods:
|
||||||
|
T = period['duration']
|
||||||
|
theta_max = period['max_amplitude']
|
||||||
|
g_approx = (4 * np.pi**2 * L) / T**2
|
||||||
|
omega0_approx = np.sqrt(g_approx / L)
|
||||||
|
angle_correction = 1 + theta_max**2/16 + 11*theta_max**4/3072
|
||||||
|
damping_factor = 1 + gamma**2/(8 * omega0_approx**2)
|
||||||
|
combined_correction = angle_correction * damping_factor
|
||||||
|
g = (4 * np.pi**2 * L) / (T**2 / combined_correction**2)
|
||||||
|
weight = 1.0 / max(0.01, theta_max)
|
||||||
|
g_values.append(g)
|
||||||
|
weights.append(weight)
|
||||||
|
|
||||||
|
if g_values:
|
||||||
|
g_corrected = np.average(g_values, weights=weights)
|
||||||
|
deviations = [abs(g - g_corrected) / g_corrected * 100 for g in g_values]
|
||||||
|
max_dev = max(deviations) if deviations else 0
|
||||||
|
logger.info("\n同时考虑大摆角和阻尼效应的重力加速度计算:")
|
||||||
|
logger.info(f"阻尼系数 γ = {gamma:.4f} s⁻¹; 周期数量: {len(periods)}; 最大偏差: {max_dev:.2f}%")
|
||||||
|
return g_corrected
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_physical_parameters(L, popt):
|
||||||
|
"""
|
||||||
|
从拟合参数计算物理参数
|
||||||
|
返回: g, b_over_m, g_no_damping
|
||||||
|
"""
|
||||||
|
theta0, gamma, omega, phi = popt
|
||||||
|
g = L * (omega**2 + gamma**2)
|
||||||
|
b_over_m = 2 * gamma
|
||||||
|
omega0 = np.sqrt(omega**2 + gamma**2)
|
||||||
|
g_no_damping = L * omega0**2
|
||||||
|
return g, b_over_m, g_no_damping
|
||||||
|
|
||||||
|
|
||||||
|
plt.rcParams['font.sans-serif'] = ['PingFang HK']
|
||||||
|
|
||||||
|
|
||||||
|
def visualize_results(t, theta, theta_smooth, theta_fit, fitted_params, data=None, pivot=None, mm_per_pixel=None, output_path='pendulum_fit_analysis.png'):
|
||||||
|
"""可视化实验数据和拟合结果"""
|
||||||
|
fig = plt.figure(figsize=(12, 18))
|
||||||
|
try:
|
||||||
|
ax1 = fig.add_subplot(3, 1, 1)
|
||||||
|
ax1.plot(t, theta, 'bo', alpha=0.3, markersize=2, label='原始数据')
|
||||||
|
ax1.plot(t, theta_smooth, 'g-', label='平滑后数据')
|
||||||
|
ax1.plot(t, theta_fit, 'r-', label='拟合曲线')
|
||||||
|
ax1.set_xlabel('时间 t (s)')
|
||||||
|
ax1.set_ylabel('摆角 θ (rad)')
|
||||||
|
ax1.set_title('单摆摆角随时间的变化')
|
||||||
|
ax1.legend(); ax1.grid(True)
|
||||||
|
|
||||||
|
ax2 = fig.add_subplot(3, 1, 2)
|
||||||
|
residuals = theta_smooth - theta_fit
|
||||||
|
ax2.plot(t, residuals, 'k.')
|
||||||
|
ax2.axhline(y=0, color='r', linestyle='-')
|
||||||
|
ax2.set_xlabel('时间 t (s)'); ax2.set_ylabel('残差 (rad)'); ax2.set_title('拟合残差'); ax2.grid(True)
|
||||||
|
|
||||||
|
if data is not None and pivot is not None and mm_per_pixel is not None:
|
||||||
|
ax3 = fig.add_subplot(3, 1, 3)
|
||||||
|
# 原始像素坐标
|
||||||
|
x_pixels = data['x'].values; y_pixels = data['y'].values
|
||||||
|
# 转换为毫米
|
||||||
|
x_mm = x_pixels * mm_per_pixel; y_mm = y_pixels * mm_per_pixel
|
||||||
|
x_c_mm, y_c_mm = pivot
|
||||||
|
ax3.plot(x_mm, y_mm, 'b.', markersize=2, label='轨迹点')
|
||||||
|
ax3.plot(x_c_mm, y_c_mm, 'ro', markersize=8, label='拟合支点')
|
||||||
|
theta_circle = np.linspace(0, 2*np.pi, 100)
|
||||||
|
r_mm = np.sqrt(np.mean((x_mm - x_c_mm)**2 + (y_mm - y_c_mm)**2))
|
||||||
|
x_circle = x_c_mm + r_mm * np.cos(theta_circle)
|
||||||
|
y_circle = y_c_mm + r_mm * np.sin(theta_circle)
|
||||||
|
ax3.plot(x_circle, y_circle, 'r-', label=f'拟合圆 (r={r_mm:.2f}mm)')
|
||||||
|
ax3.set_xlabel('x (mm)'); ax3.set_ylabel('y (mm)'); ax3.set_title('单摆轨迹和拟合圆')
|
||||||
|
ax3.legend(); ax3.axis('equal'); ax3.grid(True)
|
||||||
|
fig.savefig(output_path, dpi=300)
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def visualize_physical_parameters(L, popt, g, b_over_m, g_no_damping, g_corrected=None, output_path='pendulum_parameters_summary.png'):
|
||||||
|
"""创建一个展示所有物理参数的可视化页面"""
|
||||||
|
theta0, gamma, omega, phi = popt
|
||||||
|
T = 2 * np.pi / omega
|
||||||
|
f = 1 / T
|
||||||
|
decay_time = 1 / gamma
|
||||||
|
Q = omega / (2 * gamma)
|
||||||
|
|
||||||
|
fig = plt.figure(figsize=(10, 8))
|
||||||
|
try:
|
||||||
|
gs = plt.GridSpec(2, 2, height_ratios=[2, 1])
|
||||||
|
ax1 = plt.subplot(gs[0, :]); ax1.axis('off')
|
||||||
|
param_names = [
|
||||||
|
"摆长 L\n(拟合得到的单摆长度)",
|
||||||
|
"初始振幅 θ₀\n(摆角的最大值)",
|
||||||
|
"阻尼系数 γ\n(振幅衰减速率)",
|
||||||
|
"相对阻尼系数 b/m\n(摩擦力与质量的比值)",
|
||||||
|
"角频率 ω\n(单位时间内角度变化)",
|
||||||
|
"周期 T\n(完成一次完整摆动的时间)",
|
||||||
|
"频率 f\n(每秒摆动次数)",
|
||||||
|
"衰减时间 T_d\n(振幅衰减至1/e所需时间)",
|
||||||
|
"品质因数 Q\n(振动系统的品质指标)",
|
||||||
|
]
|
||||||
|
param_values = [
|
||||||
|
f"{L*100:.1f} cm",
|
||||||
|
f"{theta0:.4g} rad",
|
||||||
|
f"{gamma:.4g} s^-1",
|
||||||
|
f"{b_over_m:.4g} s^-1",
|
||||||
|
f"{omega:.4g} rad/s",
|
||||||
|
f"{T:.4g} s",
|
||||||
|
f"{f:.4g} Hz",
|
||||||
|
f"{decay_time:.4g} s",
|
||||||
|
f"{Q:.4g}",
|
||||||
|
]
|
||||||
|
table_data = list(zip(param_names, param_values))
|
||||||
|
table = ax1.table(cellText=table_data, colLabels=["参数", "数值"], loc='center', cellLoc='center', colWidths=[0.6, 0.3])
|
||||||
|
table.auto_set_font_size(False); table.set_fontsize(10); table.scale(1, 2.2)
|
||||||
|
for (i, j), cell in table.get_celld().items():
|
||||||
|
cell.set_linewidth(1)
|
||||||
|
if i == 0:
|
||||||
|
cell.set_facecolor('#E6E6FA'); cell.set_text_props(weight='bold')
|
||||||
|
else:
|
||||||
|
cell.set_facecolor('#F8F8FF')
|
||||||
|
ax1.set_title('单摆物理参数', fontsize=16, pad=20)
|
||||||
|
|
||||||
|
ax2 = plt.subplot(gs[1, :])
|
||||||
|
g_methods = ["拟合模型", "无阻尼修正", "大摆角+阻尼修正" if g_corrected else "无大摆角修正"]
|
||||||
|
g_values = [g, g_no_damping, g_corrected if g_corrected else 0]
|
||||||
|
g_standard = 9.8
|
||||||
|
g_errors = [(gv - g_standard) / g_standard * 100 for gv in g_values]
|
||||||
|
bars = ax2.bar(g_methods, g_values, width=0.4)
|
||||||
|
ax2.axhline(y=g_standard, color='k', linestyle='--', label='标准值 (9.8 m/s²)')
|
||||||
|
for i, bar in enumerate(bars):
|
||||||
|
height = bar.get_height(); error = g_errors[i]
|
||||||
|
ax2.text(bar.get_x() + bar.get_width()/2, height, f'{height:.3f}\n({error:+.1f}%)', ha='center', va='bottom', fontsize=9)
|
||||||
|
ax2.set_ylabel('重力加速度 (m/s²)'); ax2.grid(axis='y', linestyle='--', alpha=0.7); ax2.legend()
|
||||||
|
fig.tight_layout(); fig.savefig(output_path, dpi=300)
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
# Helper: expose the same savgol name expected by your original server code
|
||||||
|
savgol_filter = _savgol_filter
|
||||||
26
app/config.py
Normal file
26
app/config.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
BASE_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
# Where session folders and generated images live
|
||||||
|
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||||
|
SESSIONS_DIR = os.path.join(DATA_DIR, "sessions")
|
||||||
|
|
||||||
|
# Ensure directories exist at import time
|
||||||
|
os.makedirs(SESSIONS_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# Security & limits
|
||||||
|
MAX_CONTENT_LENGTH = 25 * 1024 * 1024 # 25 MB upload cap
|
||||||
|
|
||||||
|
# Cross-origin
|
||||||
|
CORS_ORIGINS = "*" # tighten in prod
|
||||||
|
|
||||||
|
# Housekeeping
|
||||||
|
SESSION_TTL = timedelta(hours=6) # delete session dirs older than this
|
||||||
|
|
||||||
|
# Flask
|
||||||
|
JSON_SORT_KEYS = False
|
||||||
|
PROPAGATE_EXCEPTIONS = False
|
||||||
17
app/logging_config.py
Normal file
17
app/logging_config.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: int = logging.INFO) -> None:
|
||||||
|
"""Configure root logger once per process."""
|
||||||
|
if getattr(configure_logging, "_configured", False):
|
||||||
|
return
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
fmt = (
|
||||||
|
"%(asctime)s | %(levelname)s | %(name)s | %(message)s"
|
||||||
|
)
|
||||||
|
handler.setFormatter(logging.Formatter(fmt))
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(level)
|
||||||
|
root.addHandler(handler)
|
||||||
|
configure_logging._configured = True
|
||||||
248
app/routes.py
Normal file
248
app/routes.py
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from flask import Blueprint, current_app, jsonify, request, send_from_directory
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .analysis import (
|
||||||
|
load_data,
|
||||||
|
calculate_angle_from_pendulum_length,
|
||||||
|
fit_pendulum_model,
|
||||||
|
damped_oscillator,
|
||||||
|
calculate_physical_parameters,
|
||||||
|
identify_periods,
|
||||||
|
visualize_results,
|
||||||
|
visualize_physical_parameters,
|
||||||
|
calculate_large_angle_g,
|
||||||
|
savgol_filter,
|
||||||
|
fit_circle,
|
||||||
|
)
|
||||||
|
from .storage import LocalSessionStorage
|
||||||
|
|
||||||
|
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_error(message: str, status: int = 400):
|
||||||
|
return jsonify({"success": False, "error": message}), status
|
||||||
|
|
||||||
|
|
||||||
|
@bp.before_app_request
|
||||||
|
def _housekeeping():
|
||||||
|
# opportunistic TTL cleanup each request
|
||||||
|
LocalSessionStorage().cleanup_expired()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/health", methods=["GET"])
|
||||||
|
def health():
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/analyze", methods=["POST"])
|
||||||
|
def analyze():
|
||||||
|
if 'csv_file' not in request.files:
|
||||||
|
return _json_error('没有上传CSV文件', 400)
|
||||||
|
|
||||||
|
file = request.files['csv_file']
|
||||||
|
if not file.filename:
|
||||||
|
return _json_error('空文件名', 400)
|
||||||
|
|
||||||
|
# Basic content type & size checks are handled by Flask + MAX_CONTENT_LENGTH
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
|
||||||
|
# Read into memory buffer; Pandas can ingest file-like
|
||||||
|
buf = io.BytesIO(file.read())
|
||||||
|
buf.seek(0)
|
||||||
|
data = load_data(buf)
|
||||||
|
if data is None:
|
||||||
|
return _json_error('CSV文件格式错误或无法读取', 400)
|
||||||
|
|
||||||
|
# Column normalization and pendulum length extraction
|
||||||
|
cols = set(data.columns)
|
||||||
|
pendulum_length = None
|
||||||
|
|
||||||
|
if {'time', 'x', 'y', 'pendulum_length'}.issubset(cols):
|
||||||
|
# 新格式:包含摆长信息
|
||||||
|
pendulum_length = data['pendulum_length'].iloc[0] # 获取摆长
|
||||||
|
log.info(f"从CSV中读取摆长: {pendulum_length:.4f} 米")
|
||||||
|
elif {'time', 'x/mm', 'y/mm'}.issubset(cols):
|
||||||
|
# 兼容旧格式:已经是毫米单位,通过拟合圆推断摆长
|
||||||
|
log.warning("使用旧数据格式,将通过拟合圆推断摆长")
|
||||||
|
data = data.rename(columns={'x/mm': 'x', 'y/mm': 'y'})
|
||||||
|
# 临时计算摆长用于兼容
|
||||||
|
x_temp = data['x'].values
|
||||||
|
y_temp = data['y'].values
|
||||||
|
_, _, r_mm = fit_circle(x_temp, y_temp)
|
||||||
|
pendulum_length = r_mm / 1000.0 # 转换为米
|
||||||
|
elif {'time', 'x', 'y'}.issubset(cols):
|
||||||
|
return _json_error('缺少摆长信息,请在前端输入摆长参数', 400)
|
||||||
|
else:
|
||||||
|
return _json_error('数据格式不兼容,需要time、x、y列以及pendulum_length信息', 400)
|
||||||
|
|
||||||
|
if pendulum_length is None or pendulum_length <= 0:
|
||||||
|
return _json_error('摆长参数无效', 400)
|
||||||
|
|
||||||
|
# Compute core analysis
|
||||||
|
t, theta, L, pivot = calculate_angle_from_pendulum_length(data, pendulum_length)
|
||||||
|
|
||||||
|
# 计算像素到毫米的转换系数(用于可视化)
|
||||||
|
x_pixels = data['x'].values
|
||||||
|
y_pixels = data['y'].values
|
||||||
|
_, _, r_pixels = fit_circle(x_pixels, y_pixels)
|
||||||
|
mm_per_pixel = (pendulum_length * 1000) / r_pixels
|
||||||
|
|
||||||
|
# Smooth safely
|
||||||
|
window_length = min(11, len(theta) - 1)
|
||||||
|
if window_length < 3:
|
||||||
|
return _json_error('数据点过少,无法进行分析', 400)
|
||||||
|
if window_length % 2 == 0:
|
||||||
|
window_length -= 1
|
||||||
|
theta_smooth = savgol_filter(theta, window_length=window_length, polyorder=2)
|
||||||
|
|
||||||
|
# Choose analysis range
|
||||||
|
if len(theta) <= 200:
|
||||||
|
start_idx = 10
|
||||||
|
end_idx = max(len(theta) - 10, start_idx + 5)
|
||||||
|
else:
|
||||||
|
start_idx = min(100, len(theta) // 10)
|
||||||
|
end_idx = min(20000, len(theta) - 10)
|
||||||
|
|
||||||
|
t_analyze = t[start_idx:end_idx]
|
||||||
|
theta_analyze = theta_smooth[start_idx:end_idx]
|
||||||
|
|
||||||
|
popt, pcov = fit_pendulum_model(t_analyze, theta_analyze)
|
||||||
|
if popt is None:
|
||||||
|
return _json_error('拟合模型失败', 400)
|
||||||
|
|
||||||
|
theta_fit = damped_oscillator(t_analyze, *popt)
|
||||||
|
|
||||||
|
# Allocate session & write images
|
||||||
|
storage = LocalSessionStorage()
|
||||||
|
session_id = storage.new_session()
|
||||||
|
|
||||||
|
# Figure 1
|
||||||
|
fig1_path = os.path.join(session_id, 'pendulum_fit_analysis.png')
|
||||||
|
visualize_results(t_analyze, theta[start_idx:end_idx], theta_analyze, theta_fit, popt, data, pivot, mm_per_pixel, output_path=os.path.join(storage.base_dir, fig1_path))
|
||||||
|
|
||||||
|
# Physical params
|
||||||
|
g, b_over_m, g_no_damping = calculate_physical_parameters(L, popt)
|
||||||
|
|
||||||
|
# Periods + corrected g
|
||||||
|
periods = identify_periods(t_analyze, theta_analyze)
|
||||||
|
theta0, gamma, omega, phi = popt
|
||||||
|
g_corrected = calculate_large_angle_g(L, periods, gamma)
|
||||||
|
|
||||||
|
# Figure 2
|
||||||
|
fig2_path = os.path.join(session_id, 'pendulum_parameters_summary.png')
|
||||||
|
visualize_physical_parameters(L, popt, g, b_over_m, g_no_damping, g_corrected, output_path=os.path.join(storage.base_dir, fig2_path))
|
||||||
|
|
||||||
|
# Figure 3 (period analysis)
|
||||||
|
if periods:
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
fig = plt.figure(figsize=(10, 6))
|
||||||
|
try:
|
||||||
|
amplitudes = [p['max_amplitude'] for p in periods]
|
||||||
|
durations = [p['duration'] for p in periods]
|
||||||
|
|
||||||
|
def filter_period_outliers(amplitudes, durations, threshold=0.008):
|
||||||
|
duration_mean = np.mean(durations)
|
||||||
|
out_a, out_d = [], []
|
||||||
|
for amp, dur in zip(amplitudes, durations):
|
||||||
|
deviation = abs(dur - duration_mean) / duration_mean
|
||||||
|
if deviation <= threshold:
|
||||||
|
out_a.append(amp); out_d.append(dur)
|
||||||
|
return out_a, out_d
|
||||||
|
|
||||||
|
plt.subplot(2, 1, 1)
|
||||||
|
f_amp, f_dur = filter_period_outliers(amplitudes, durations)
|
||||||
|
plt.plot(f_amp, f_dur, 'bo-')
|
||||||
|
plt.xlabel('最大摆角 (rad)'); plt.ylabel('周期 T (s)'); plt.title('周期与最大摆角的关系'); plt.grid(True)
|
||||||
|
theta_theory = np.linspace(0, max(amplitudes) * 1.1, 100)
|
||||||
|
T0 = 2 * np.pi * np.sqrt(L / (g_corrected or g))
|
||||||
|
T_angle_only = T0 * (1 + theta_theory**2/16 + 11*theta_theory**4/3072)
|
||||||
|
plt.plot(theta_theory, T_angle_only, 'r-', label='大摆角修正'); plt.legend()
|
||||||
|
|
||||||
|
plt.subplot(2, 1, 2)
|
||||||
|
g_values_angle = []
|
||||||
|
for p in periods:
|
||||||
|
theta_max = p['max_amplitude']
|
||||||
|
T = p['duration']
|
||||||
|
angle_correction = 1 + theta_max**2/16 + 11*theta_max**4/3072
|
||||||
|
g_period = (4 * np.pi**2 * L) / (T**2 * angle_correction**2)
|
||||||
|
g_values_angle.append(g_period)
|
||||||
|
|
||||||
|
g_values_combined = []
|
||||||
|
for p in periods:
|
||||||
|
theta_max = p['max_amplitude']
|
||||||
|
T = p['duration']
|
||||||
|
g_approx = (4 * np.pi**2 * L) / T**2
|
||||||
|
omega0_approx = np.sqrt(g_approx / L)
|
||||||
|
angle_correction = 1 + theta_max**2/16 + 11*theta_max**4/3072
|
||||||
|
damping_factor = 1 + gamma**2/(8 * omega0_approx**2)
|
||||||
|
combined_correction = angle_correction * damping_factor
|
||||||
|
g_period = (4 * np.pi**2 * L) / (T**2 / combined_correction**2)
|
||||||
|
g_values_combined.append(g_period)
|
||||||
|
|
||||||
|
def filter_outliers(amplitudes, g_values, threshold=0.01):
|
||||||
|
g_mean = np.mean(g_values)
|
||||||
|
out_a, out_g = [], []
|
||||||
|
for amp, gv in zip(amplitudes, g_values):
|
||||||
|
deviation = abs(gv - g_mean) / g_mean
|
||||||
|
if deviation <= threshold:
|
||||||
|
out_a.append(amp); out_g.append(gv)
|
||||||
|
return out_a, out_g
|
||||||
|
|
||||||
|
fa1, fg1 = filter_outliers(amplitudes, g_values_angle)
|
||||||
|
fa2, fg2 = filter_outliers(amplitudes, g_values_combined)
|
||||||
|
|
||||||
|
plt.plot(fa1, fg1, 'ro-', label='仅大摆角修正')
|
||||||
|
plt.plot(fa2, fg2, 'go-', label='大摆角+阻尼修正')
|
||||||
|
plt.axhline(y=9.8, color='k', linestyle='--', label='标准重力加速度 9.8 m/s²')
|
||||||
|
plt.xlabel('最大摆角 (rad)'); plt.ylabel('计算的重力加速度 (m/s²)'); plt.title('各个周期计算的重力加速度 (不同修正方法)')
|
||||||
|
plt.grid(True); plt.legend();
|
||||||
|
fig3_full = os.path.join(storage.base_dir, session_id, 'pendulum_period_analysis.png')
|
||||||
|
fig.tight_layout(); fig.savefig(fig3_full, dpi=300)
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
# Response payload
|
||||||
|
analysis_data = {
|
||||||
|
'pendulum_length_m': round(float(L), 6),
|
||||||
|
'gravity_acceleration_m_s2': round(float(g_corrected or g), 6),
|
||||||
|
'damping_coefficient_s_inv': round(float(gamma), 6),
|
||||||
|
'period_s': round(float(2*np.pi/omega), 6),
|
||||||
|
'angle_max_rad': round(float(popt[0]), 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
base = "/api/images"
|
||||||
|
images = [
|
||||||
|
f"{base}/{session_id}/pendulum_fit_analysis.png",
|
||||||
|
f"{base}/{session_id}/pendulum_parameters_summary.png",
|
||||||
|
f"{base}/{session_id}/pendulum_period_analysis.png",
|
||||||
|
]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'session_id': session_id,
|
||||||
|
'images': images,
|
||||||
|
'analysis_data': analysis_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/images/<session_id>/<path:filename>', methods=['GET'])
|
||||||
|
def get_image(session_id: str, filename: str):
|
||||||
|
# serve safely from session dir
|
||||||
|
storage = LocalSessionStorage()
|
||||||
|
directory = storage._session_path(session_id)
|
||||||
|
return send_from_directory(directory, filename)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/sessions/<session_id>', methods=['DELETE'])
|
||||||
|
def delete_session(session_id: str):
|
||||||
|
storage = LocalSessionStorage()
|
||||||
|
ok = storage.delete_session(session_id)
|
||||||
|
return jsonify({'success': ok})
|
||||||
74
app/storage.py
Normal file
74
app/storage.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalSessionStorage:
|
||||||
|
"""Per-request session folder storage with TTL cleanup."""
|
||||||
|
|
||||||
|
def __init__(self, base_dir: str | None = None):
|
||||||
|
self.base_dir = base_dir or Config.SESSIONS_DIR
|
||||||
|
os.makedirs(self.base_dir, exist_ok=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
def _session_path(self, session_id: str) -> str:
|
||||||
|
# defensive join to prevent path traversal
|
||||||
|
candidate = os.path.realpath(os.path.join(self.base_dir, session_id))
|
||||||
|
if not candidate.startswith(os.path.realpath(self.base_dir)):
|
||||||
|
raise ValueError("Invalid session path")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
def new_session(self) -> str:
|
||||||
|
sid = str(uuid.uuid4())
|
||||||
|
path = self._session_path(sid)
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
return sid
|
||||||
|
|
||||||
|
def save_bytes(self, session_id: str, filename: str, content: bytes) -> str:
|
||||||
|
path = self._session_path(session_id)
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
safe_name = os.path.basename(filename)
|
||||||
|
full = os.path.join(path, safe_name)
|
||||||
|
with open(full, 'wb') as f:
|
||||||
|
f.write(content)
|
||||||
|
return full
|
||||||
|
|
||||||
|
def list_files(self, session_id: str) -> List[str]:
|
||||||
|
path = self._session_path(session_id)
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
return []
|
||||||
|
return [os.path.join(path, p) for p in os.listdir(path) if os.path.isfile(os.path.join(path, p))]
|
||||||
|
|
||||||
|
def delete_session(self, session_id: str) -> bool:
|
||||||
|
path = self._session_path(session_id)
|
||||||
|
if os.path.isdir(path):
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def cleanup_expired(self) -> int:
|
||||||
|
"""Remove sessions older than TTL; returns count removed."""
|
||||||
|
removed = 0
|
||||||
|
ttl = Config.SESSION_TTL
|
||||||
|
now = self._now_utc()
|
||||||
|
for sid in os.listdir(self.base_dir):
|
||||||
|
spath = self._session_path(sid)
|
||||||
|
try:
|
||||||
|
mtime = datetime.fromtimestamp(os.path.getmtime(spath), tz=timezone.utc)
|
||||||
|
if now - mtime > ttl:
|
||||||
|
shutil.rmtree(spath, ignore_errors=True)
|
||||||
|
removed += 1
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"cleanup skip {sid}: {e}")
|
||||||
|
if removed:
|
||||||
|
log.info(f"Session cleanup removed {removed} dirs")
|
||||||
|
return removed
|
||||||
5
app/web/README.md
Normal file
5
app/web/README.md
Normal 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).
|
||||||
228
app/web/bun.lock
Normal file
228
app/web/bun.lock
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "pendulum_analyze",
|
||||||
|
"dependencies": {
|
||||||
|
"@techstark/opencv-js": "^4.10.0-release.1",
|
||||||
|
"gsap": "^3.13.0",
|
||||||
|
"vite-plugin-singlefile": "^2.2.0",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vue-tsc": "^2.2.8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/parser@7.27.2", "", { "dependencies": { "@babel/types": "^7.27.1" }, "bin": "./bin/babel-parser.js" }, "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.40.2", "", { "os": "android", "cpu": "arm" }, "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.40.2", "", { "os": "android", "cpu": "arm64" }, "sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.40.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.40.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.40.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.40.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.40.2", "", { "os": "linux", "cpu": "arm" }, "sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.40.2", "", { "os": "linux", "cpu": "arm" }, "sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.40.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.40.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.40.2", "", { "os": "linux", "cpu": "none" }, "sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.40.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.40.2", "", { "os": "linux", "cpu": "none" }, "sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.40.2", "", { "os": "linux", "cpu": "none" }, "sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.40.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.40.2", "", { "os": "linux", "cpu": "x64" }, "sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.40.2", "", { "os": "linux", "cpu": "x64" }, "sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.40.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.40.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.40.2", "", { "os": "win32", "cpu": "x64" }, "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA=="],
|
||||||
|
|
||||||
|
"@techstark/opencv-js": ["@techstark/opencv-js@4.10.0-release.1", "", {}, "sha512-S4XELidRiQeA0q1s9VQLo540wCxUo24r1O4C+LqZ6llX+sPCXvZCPv3Ice8dEIr0uavyZ8YZeKXSBdDgMXSXjw=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||||
|
|
||||||
|
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
|
||||||
|
|
||||||
|
"@volar/language-core": ["@volar/language-core@2.4.14", "", { "dependencies": { "@volar/source-map": "2.4.14" } }, "sha512-X6beusV0DvuVseaOEy7GoagS4rYHgDHnTrdOj5jeUb49fW5ceQyP9Ej5rBhqgz2wJggl+2fDbbojq1XKaxDi6w=="],
|
||||||
|
|
||||||
|
"@volar/source-map": ["@volar/source-map@2.4.14", "", {}, "sha512-5TeKKMh7Sfxo8021cJfmBzcjfY1SsXsPMMjMvjY7ivesdnybqqS+GxGAoXHAOUawQTwtdUxgP65Im+dEmvWtYQ=="],
|
||||||
|
|
||||||
|
"@volar/typescript": ["@volar/typescript@2.4.14", "", { "dependencies": { "@volar/language-core": "2.4.14", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-p8Z6f/bZM3/HyCdRNFZOEEzts51uV8WHeN8Tnfnm2EBv6FDB2TQLzfVx7aJvnl8ofKAOnS64B2O8bImBFaauRw=="],
|
||||||
|
|
||||||
|
"@vue/compiler-core": ["@vue/compiler-core@3.5.14", "", { "dependencies": { "@babel/parser": "^7.27.2", "@vue/shared": "3.5.14", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-k7qMHMbKvoCXIxPhquKQVw3Twid3Kg4s7+oYURxLGRd56LiuHJVrvFKI4fm2AM3c8apqODPfVJGoh8nePbXMRA=="],
|
||||||
|
|
||||||
|
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.14", "", { "dependencies": { "@vue/compiler-core": "3.5.14", "@vue/shared": "3.5.14" } }, "sha512-1aOCSqxGOea5I80U2hQJvXYpPm/aXo95xL/m/mMhgyPUsKe9jhjwWpziNAw7tYRnbz1I61rd9Mld4W9KmmRoug=="],
|
||||||
|
|
||||||
|
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.14", "", { "dependencies": { "@babel/parser": "^7.27.2", "@vue/compiler-core": "3.5.14", "@vue/compiler-dom": "3.5.14", "@vue/compiler-ssr": "3.5.14", "@vue/shared": "3.5.14", "estree-walker": "^2.0.2", "magic-string": "^0.30.17", "postcss": "^8.5.3", "source-map-js": "^1.2.1" } }, "sha512-9T6m/9mMr81Lj58JpzsiSIjBgv2LiVoWjIVa7kuXHICUi8LiDSIotMpPRXYJsXKqyARrzjT24NAwttrMnMaCXA=="],
|
||||||
|
|
||||||
|
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.14", "", { "dependencies": { "@vue/compiler-dom": "3.5.14", "@vue/shared": "3.5.14" } }, "sha512-Y0G7PcBxr1yllnHuS/NxNCSPWnRGH4Ogrp0tsLA5QemDZuJLs99YjAKQ7KqkHE0vCg4QTKlQzXLKCMF7WPSl7Q=="],
|
||||||
|
|
||||||
|
"@vue/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="],
|
||||||
|
|
||||||
|
"@vue/language-core": ["@vue/language-core@2.2.10", "", { "dependencies": { "@volar/language-core": "~2.4.11", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^1.0.3", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-+yNoYx6XIKuAO8Mqh1vGytu8jkFEOH5C8iOv3i8Z/65A7x9iAOXA97Q+PqZ3nlm2lxf5rOJuIGI/wDtx/riNYw=="],
|
||||||
|
|
||||||
|
"@vue/reactivity": ["@vue/reactivity@3.5.14", "", { "dependencies": { "@vue/shared": "3.5.14" } }, "sha512-7cK1Hp343Fu/SUCCO52vCabjvsYu7ZkOqyYu7bXV9P2yyfjUMUXHZafEbq244sP7gf+EZEz+77QixBTuEqkQQw=="],
|
||||||
|
|
||||||
|
"@vue/runtime-core": ["@vue/runtime-core@3.5.14", "", { "dependencies": { "@vue/reactivity": "3.5.14", "@vue/shared": "3.5.14" } }, "sha512-w9JWEANwHXNgieAhxPpEpJa+0V5G0hz3NmjAZwlOebtfKyp2hKxKF0+qSh0Xs6/PhfGihuSdqMprMVcQU/E6ag=="],
|
||||||
|
|
||||||
|
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.14", "", { "dependencies": { "@vue/reactivity": "3.5.14", "@vue/runtime-core": "3.5.14", "@vue/shared": "3.5.14", "csstype": "^3.1.3" } }, "sha512-lCfR++IakeI35TVR80QgOelsUIdcKjd65rWAMfdSlCYnaEY5t3hYwru7vvcWaqmrK+LpI7ZDDYiGU5V3xjMacw=="],
|
||||||
|
|
||||||
|
"@vue/server-renderer": ["@vue/server-renderer@3.5.14", "", { "dependencies": { "@vue/compiler-ssr": "3.5.14", "@vue/shared": "3.5.14" }, "peerDependencies": { "vue": "3.5.14" } }, "sha512-Rf/ISLqokIvcySIYnv3tNWq40PLpNLDLSJwwVWzG6MNtyIhfbcrAxo5ZL9nARJhqjZyWWa40oRb2IDuejeuv6w=="],
|
||||||
|
|
||||||
|
"@vue/shared": ["@vue/shared@3.5.14", "", {}, "sha512-oXTwNxVfc9EtP1zzXAlSlgARLXNC84frFYkS0HHz0h3E4WZSP9sywqjqzGCP9Y34M8ipNmd380pVgmMuwELDyQ=="],
|
||||||
|
|
||||||
|
"@vue/tsconfig": ["@vue/tsconfig@0.7.0", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg=="],
|
||||||
|
|
||||||
|
"alien-signals": ["alien-signals@1.0.13", "", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||||
|
|
||||||
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
|
"de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
|
||||||
|
|
||||||
|
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
|
||||||
|
|
||||||
|
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.4.4", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg=="],
|
||||||
|
|
||||||
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"gsap": ["gsap@3.13.0", "", {}, "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw=="],
|
||||||
|
|
||||||
|
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||||
|
|
||||||
|
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||||
|
|
||||||
|
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||||
|
|
||||||
|
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.40.2", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.40.2", "@rollup/rollup-android-arm64": "4.40.2", "@rollup/rollup-darwin-arm64": "4.40.2", "@rollup/rollup-darwin-x64": "4.40.2", "@rollup/rollup-freebsd-arm64": "4.40.2", "@rollup/rollup-freebsd-x64": "4.40.2", "@rollup/rollup-linux-arm-gnueabihf": "4.40.2", "@rollup/rollup-linux-arm-musleabihf": "4.40.2", "@rollup/rollup-linux-arm64-gnu": "4.40.2", "@rollup/rollup-linux-arm64-musl": "4.40.2", "@rollup/rollup-linux-loongarch64-gnu": "4.40.2", "@rollup/rollup-linux-powerpc64le-gnu": "4.40.2", "@rollup/rollup-linux-riscv64-gnu": "4.40.2", "@rollup/rollup-linux-riscv64-musl": "4.40.2", "@rollup/rollup-linux-s390x-gnu": "4.40.2", "@rollup/rollup-linux-x64-gnu": "4.40.2", "@rollup/rollup-linux-x64-musl": "4.40.2", "@rollup/rollup-win32-arm64-msvc": "4.40.2", "@rollup/rollup-win32-ia32-msvc": "4.40.2", "@rollup/rollup-win32-x64-msvc": "4.40.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.13", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw=="],
|
||||||
|
|
||||||
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||||
|
|
||||||
|
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
|
||||||
|
|
||||||
|
"vite-plugin-singlefile": ["vite-plugin-singlefile@2.2.0", "", { "dependencies": { "micromatch": "^4.0.8" }, "peerDependencies": { "rollup": "^4.35.0", "vite": "^5.4.11 || ^6.0.0" } }, "sha512-Ik1wXmJaGzeQtUeIV7JprDUqqy6DlLzXAY27Blei5peE4c9VJF+Kp9xWDJeuX0RJUZmFbIAuw1/RAh06A+Ql7w=="],
|
||||||
|
|
||||||
|
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||||
|
|
||||||
|
"vue": ["vue@3.5.14", "", { "dependencies": { "@vue/compiler-dom": "3.5.14", "@vue/compiler-sfc": "3.5.14", "@vue/runtime-dom": "3.5.14", "@vue/server-renderer": "3.5.14", "@vue/shared": "3.5.14" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-LbOm50/vZFG6Mhy6KscQYXZMQ0LMCC/y40HDJPPvGFQ+i/lUH+PJHR6C3assgOQiXdl6tAfsXHbXYVBZZu65ew=="],
|
||||||
|
|
||||||
|
"vue-tsc": ["vue-tsc@2.2.10", "", { "dependencies": { "@volar/typescript": "~2.4.11", "@vue/language-core": "2.2.10" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-jWZ1xSaNbabEV3whpIDMbjVSVawjAyW+x1n3JeGQo7S0uv2n9F/JMgWW90tGWNFRKya4YwKMZgCtr0vRAM7DeQ=="],
|
||||||
|
|
||||||
|
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/web/index.html
Normal file
14
app/web/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!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>单摆在线跟踪与分析</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
app/web/package.json
Normal file
23
app/web/package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "pendulum_analyze",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@techstark/opencv-js": "^4.10.0-release.1",
|
||||||
|
"gsap": "^3.13.0",
|
||||||
|
"vue": "^3.5.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vue-tsc": "^2.2.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
604
app/web/src/App.vue
Normal file
604
app/web/src/App.vue
Normal file
@ -0,0 +1,604 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home" :class="{ 'tracker-open': stage === 'tracker' }">
|
||||||
|
<!-- 全局隐藏文件选择(两端都可用) -->
|
||||||
|
<input ref="fileInput" type="file" accept="video/*" class="sr-only" @change="onVideoFileChange" />
|
||||||
|
<input ref="csvInput" type="file" accept=".csv,text/csv" class="sr-only" @change="onCsvFileChange" />
|
||||||
|
|
||||||
|
<!-- ===== HERO / LANDING ===== -->
|
||||||
|
<section v-if="stage === 'hero'" ref="hero" class="hero" role="region" aria-label="欢迎">
|
||||||
|
<canvas ref="fx" class="fx" aria-hidden="true"></canvas>
|
||||||
|
<div class="hero-inner">
|
||||||
|
<h1 ref="logo" class="logo"><span class="accent">Pendulum</span> Lab</h1>
|
||||||
|
<p ref="subtitle" class="subtitle">实时追踪 · 物理建模 · 一键分析</p>
|
||||||
|
<div ref="cta" class="cta">
|
||||||
|
<button class="btn primary" @click="openCamera">使用摄像头</button>
|
||||||
|
<button class="btn secondary" @click="pickFile">选择视频</button>
|
||||||
|
<button class="btn ghost" @click="pickCSV">导入 CSV</button>
|
||||||
|
</div>
|
||||||
|
<div class="note">为横屏移动端优化 · 建议将手机横置</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== TRACKER STAGE ===== -->
|
||||||
|
<section v-if="stage === 'tracker'" class="stage" role="region" aria-label="实时追踪">
|
||||||
|
<video ref="video" muted playsinline class="video-el" style="height:0"></video>
|
||||||
|
<canvas ref="canvas" class="preview-canvas"></canvas>
|
||||||
|
|
||||||
|
<!-- 性能面板 -->
|
||||||
|
<div class="performance">
|
||||||
|
<canvas height="64" width="192" ref="frameTimeCanvas"></canvas>
|
||||||
|
<div class="stats" v-if="pInfo.frameTime.length">
|
||||||
|
<div class="stat-item"><span class="stat-label">平均</span><span class="stat-value">{{ pInfo.avgFrameTime
|
||||||
|
}}ms</span></div>
|
||||||
|
<div class="stat-item"><span class="stat-label">最小</span><span class="stat-value">{{ pInfo.minFrameTime
|
||||||
|
}}ms</span></div>
|
||||||
|
<div class="stat-item"><span class="stat-label">最大</span><span class="stat-value">{{ pInfo.maxFrameTime
|
||||||
|
}}ms</span></div>
|
||||||
|
<div class="stat-item"><span class="stat-label">FPS</span><span class="stat-value">{{ pInfo.fps }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 位置图表 -->
|
||||||
|
<div class="position-charts">
|
||||||
|
<div class="chart"><canvas height="64" width="192" ref="xPosCanvas"></canvas>
|
||||||
|
<div class="chart-label">X坐标</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart"><canvas height="64" width="192" ref="yPosCanvas"></canvas>
|
||||||
|
<div class="chart-label">Y坐标</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右上录制/导出 -->
|
||||||
|
<div class="record-controls">
|
||||||
|
<button @click="toggleRecording" :class="{ 'recording': isRecording }">{{ isRecording ? '停止记录' : '开始记录'
|
||||||
|
}}</button>
|
||||||
|
<button @click="exportCSV" :disabled="!recordData.length">导出 CSV</button>
|
||||||
|
<button class="btn ghost" @click="backToHero">退出</button>
|
||||||
|
<span class="record-info" v-if="isRecording">已记录 <span class="mono" aria-live="polite">{{ recordData.length
|
||||||
|
}}</span> 帧</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分析抽屉 -->
|
||||||
|
<PendulumAnalysis v-if="showAnalysis && recordData.length" :data="recordData" :mmPerPixel="mmPerPixel"
|
||||||
|
@close="showAnalysis = false" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, reactive, watch, onMounted, onUnmounted, nextTick, useTemplateRef } from 'vue';
|
||||||
|
import gsap from 'gsap';
|
||||||
|
import cv from '@techstark/opencv-js';
|
||||||
|
import PendulumAnalysis from './components/PendulumAnalysis.vue';
|
||||||
|
import { YellowBallTracker, type Position, type TrackerParams } from './utils/yellowBallTracker';
|
||||||
|
import { drawPositionChart, drawPerformanceChart, MAX_POSITION_HISTORY, POSITION_CHART_PADDING, FIXED_MIN_Y, FIXED_MAX_Y } from './utils/chartUtils';
|
||||||
|
|
||||||
|
// ===== Config =====
|
||||||
|
const apiBase = (import.meta as any).env?.VITE_API_BASE || '';
|
||||||
|
const mmPerPixel = 0.166; // 如果你的 x/y 已是毫米,改成 1
|
||||||
|
|
||||||
|
// ===== Stage (hero <-> tracker) =====
|
||||||
|
const stage = ref<'hero' | 'tracker'>('hero');
|
||||||
|
async function openCamera() {
|
||||||
|
stage.value = 'tracker';
|
||||||
|
await nextTick();
|
||||||
|
startCamera();
|
||||||
|
}
|
||||||
|
function pickFile() { (fileInput.value as HTMLInputElement)?.click(); }
|
||||||
|
function pickCSV() { (csvInput.value as HTMLInputElement)?.click(); }
|
||||||
|
function backToHero() { stopStream(); resetTracking(); stage.value = 'hero'; }
|
||||||
|
|
||||||
|
// ===== HERO anim + particles =====
|
||||||
|
const hero = useTemplateRef('hero');
|
||||||
|
const fx = useTemplateRef('fx');
|
||||||
|
|
||||||
|
let rafId = 0;
|
||||||
|
function startFx() {
|
||||||
|
if (!fx.value) return;
|
||||||
|
const DPR = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
const ctx = fx.value.getContext('2d')!;
|
||||||
|
const resize = () => { if (!fx.value) return; fx.value!.width = fx.value!.clientWidth * DPR; fx.value!.height = fx.value!.clientHeight * DPR; };
|
||||||
|
resize();
|
||||||
|
const dots = Array.from({ length: 40 }, () => ({ x: Math.random() * fx.value!.width, y: Math.random() * fx.value!.height, r: (1 + Math.random() * 2) * DPR, vx: (Math.random() - 0.5) * 0.25 * DPR, vy: (Math.random() - 0.5) * 0.25 * DPR }));
|
||||||
|
const loop = () => {
|
||||||
|
if (!fx.value) return;
|
||||||
|
const w = fx.value!.width, h = fx.value!.height; ctx.clearRect(0, 0, w, h); ctx.globalCompositeOperation = 'lighter';
|
||||||
|
for (const d of dots) { d.x += d.vx; d.y += d.vy; if (d.x < 0 || d.x > w) d.vx *= -1; if (d.y < 0 || d.y > h) d.vy *= -1; const g = ctx.createRadialGradient(d.x, d.y, 0, d.x, d.y, d.r * 4); g.addColorStop(0, 'rgba(0,255,170,0.35)'); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(d.x, d.y, d.r * 4, 0, Math.PI * 2); ctx.fill(); }
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', resize, { passive: true });
|
||||||
|
loop();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });
|
||||||
|
tl.from(hero.value, { autoAlpha: 0, duration: 0.4 })
|
||||||
|
.from('.logo', { y: 20, autoAlpha: 0, duration: 0.6 })
|
||||||
|
.from('.subtitle', { y: 16, autoAlpha: 0, duration: 0.5 }, '-=0.25')
|
||||||
|
.from('.cta', { y: 12, autoAlpha: 0, duration: 0.45 }, '-=0.25');
|
||||||
|
startFx();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => cancelAnimationFrame(rafId));
|
||||||
|
|
||||||
|
// ===== Tracking / OpenCV =====
|
||||||
|
const video = ref<HTMLVideoElement | null>(null);
|
||||||
|
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const xPosCanvas = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const yPosCanvas = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const frameTimeCanvas = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const csvInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
let stream: MediaStream | null = null;
|
||||||
|
let frameCount = 0; let lastPosition: Position | null = null; let ctx: CanvasRenderingContext2D | null = null;
|
||||||
|
|
||||||
|
const cvReady = ref(false); const cvStarted = ref(false);
|
||||||
|
cv.onRuntimeInitialized = () => (cvReady.value = true);
|
||||||
|
|
||||||
|
const isRecording = ref(false);
|
||||||
|
const recordData = reactive<{ frame: number; time: number; x: number; y: number }[]>([]);
|
||||||
|
const showAnalysis = ref(false);
|
||||||
|
|
||||||
|
const positionData = reactive({ xHistory: [] as number[], yHistory: [] as number[], xRange: { min: 0, max: 100 }, yRange: { min: 0, max: 100 } });
|
||||||
|
const params: TrackerParams = reactive({ lowerYellow: new cv.Scalar(20, 100, 100, 0), upperYellow: new cv.Scalar(40, 255, 255, 255), blurSize: 7, morphKernel: 5, minContourArea: 20, useRoi: true, roiScaleX: 0.25, roiScaleY: 0.35 });
|
||||||
|
let tracker: YellowBallTracker | null = null;
|
||||||
|
|
||||||
|
const pInfo = reactive({ lastUpdateTime: 0, frameTime: [] as number[], avgFrameTime: 0, minFrameTime: 0, maxFrameTime: 0, fps: 0, fpsUpdateTime: 0, frameCountForFps: 0 });
|
||||||
|
|
||||||
|
watch(() => [positionData.xHistory, positionData.yHistory], ([xH, yH]) => {
|
||||||
|
if (!xH.length || !yH.length) return;
|
||||||
|
// X
|
||||||
|
const xMin = Math.min(...xH), xMax = Math.max(...xH); const xPad = (xMax - xMin) * 0.1 || POSITION_CHART_PADDING; positionData.xRange = { min: xMin - xPad, max: xMax + xPad };
|
||||||
|
drawPositionChart(xPosCanvas.value!, xH, positionData.xRange, 'X位置');
|
||||||
|
// Y
|
||||||
|
const yMin = Math.min(...yH), yMax = Math.max(...yH); const yPad = (yMax - yMin) * 0.1 || POSITION_CHART_PADDING; positionData.yRange = { min: yMin - yPad, max: yMax + yPad };
|
||||||
|
drawPositionChart(yPosCanvas.value!, yH, positionData.yRange, 'Y位置');
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
watch(() => pInfo.frameTime, (v) => {
|
||||||
|
if (!v.length) return;
|
||||||
|
const min = Math.min(...v), max = Math.max(...v), avg = v.reduce((a, b) => a + b, 0) / v.length;
|
||||||
|
pInfo.minFrameTime = Math.round(min * 100) / 100; pInfo.maxFrameTime = Math.round(max * 100) / 100; pInfo.avgFrameTime = Math.round(avg * 100) / 100;
|
||||||
|
drawPerformanceChart(frameTimeCanvas.value!, v, FIXED_MIN_Y, FIXED_MAX_Y);
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
function processFrame() {
|
||||||
|
if (!video.value || !canvas.value || !cvReady.value || video.value.readyState < 2) { requestAnimationFrame(processFrame); return; }
|
||||||
|
if (video.value.ended) return;
|
||||||
|
pInfo.lastUpdateTime = performance.now(); cvStarted.value = true;
|
||||||
|
if (!ctx) ctx = canvas.value.getContext('2d', { willReadFrequently: true })!;
|
||||||
|
|
||||||
|
// auto-resize canvas to video
|
||||||
|
if (canvas.value.width !== video.value.videoWidth || canvas.value.height !== video.value.videoHeight) {
|
||||||
|
canvas.value.width = video.value.videoWidth; canvas.value.height = video.value.videoHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(video.value, 0, 0, canvas.value.width, canvas.value.height);
|
||||||
|
const currentFrameTime = video.value.currentTime;
|
||||||
|
const imgData = ctx.getImageData(0, 0, canvas.value.width, canvas.value.height);
|
||||||
|
|
||||||
|
if (!tracker) { tracker = new YellowBallTracker(params); tracker.initMats(canvas.value.width, canvas.value.height); }
|
||||||
|
else if (canvas.value.width !== tracker.srcMat?.cols || canvas.value.height !== tracker.srcMat?.rows) {
|
||||||
|
tracker.initMats(canvas.value.width, canvas.value.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = tracker.detectYellowBall(imgData, lastPosition); lastPosition = center;
|
||||||
|
|
||||||
|
if (center) {
|
||||||
|
positionData.xHistory.push(center.x); positionData.yHistory.push(center.y);
|
||||||
|
if (positionData.xHistory.length > MAX_POSITION_HISTORY) positionData.xHistory.shift();
|
||||||
|
if (positionData.yHistory.length > MAX_POSITION_HISTORY) positionData.yHistory.shift();
|
||||||
|
if (isRecording.value) {
|
||||||
|
if (!showAnalysis.value) {
|
||||||
|
recordData.push({ frame: frameCount, time: currentFrameTime, x: center.x, y: center.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示处理输出
|
||||||
|
tracker.getOutputCanvas(canvas.value);
|
||||||
|
|
||||||
|
// 性能统计
|
||||||
|
const now = performance.now(); const ft = now - pInfo.lastUpdateTime; if (pInfo.frameTime.length >= 128) pInfo.frameTime.shift(); pInfo.frameTime.push(ft);
|
||||||
|
frameCount++; pInfo.frameCountForFps++;
|
||||||
|
if (now - pInfo.fpsUpdateTime >= 1000) { pInfo.fps = Math.round(pInfo.frameCountForFps * 1000 / (now - pInfo.fpsUpdateTime)); pInfo.frameCountForFps = 0; pInfo.fpsUpdateTime = now; }
|
||||||
|
|
||||||
|
// 下一帧
|
||||||
|
// @ts-ignore
|
||||||
|
if (video.value.requestVideoFrameCallback) video.value.requestVideoFrameCallback(processFrame); else requestAnimationFrame(processFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCamera() {
|
||||||
|
try {
|
||||||
|
stopStream(); resetTracking();
|
||||||
|
const constraints: MediaStreamConstraints = { video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 60, max: 120 }, facingMode: { ideal: 'environment' } }, audio: false } as any;
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
if (video.value) {
|
||||||
|
video.value.srcObject = stream; await video.value.play().catch(() => { }); // @ts-ignore
|
||||||
|
if (video.value.requestVideoFrameCallback) video.value.requestVideoFrameCallback(processFrame); else requestAnimationFrame(processFrame);
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('摄像头启动失败', e); alert('无法访问摄像头,请检查权限设置'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStream() { if (!stream) return; stream.getTracks().forEach(t => t.stop()); stream = null; }
|
||||||
|
|
||||||
|
async function onVideoFileChange(e: Event) {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
resetTracking(); stopStream();
|
||||||
|
// 先切到追踪页,保证 video/canvas 已挂载
|
||||||
|
if (stage.value !== 'tracker') { stage.value = 'tracker'; await nextTick(); }
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
if (!video.value) { await nextTick(); }
|
||||||
|
if (!video.value) { console.warn('video 元素未就绪'); return; }
|
||||||
|
|
||||||
|
video.value.srcObject = null; video.value.src = url; video.value.muted = true; video.value.playsInline = true;
|
||||||
|
await video.value.play().catch(() => { });
|
||||||
|
// @ts-ignore
|
||||||
|
if (video.value.requestVideoFrameCallback) video.value.requestVideoFrameCallback(processFrame); else requestAnimationFrame(processFrame);
|
||||||
|
|
||||||
|
// 自动开始记录;视频播完后自动弹出分析
|
||||||
|
isRecording.value = true; recordData.splice(0);
|
||||||
|
const onEnded = () => {
|
||||||
|
if (isRecording.value) {
|
||||||
|
isRecording.value = false;
|
||||||
|
healthCheck().finally(() => { showAnalysis.value = true; });
|
||||||
|
}
|
||||||
|
video.value?.removeEventListener('ended', onEnded);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
video.value.addEventListener('ended', onEnded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRecording() {
|
||||||
|
isRecording.value = !isRecording.value;
|
||||||
|
if (isRecording.value) { recordData.splice(0, recordData.length); showAnalysis.value = false; }
|
||||||
|
else { // 结束 -> 打开分析
|
||||||
|
healthCheck().finally(() => { showAnalysis.value = true; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCSV() { if (!recordData.length) return; let csv = 'frame,time,x,y\n'; for (const r of recordData) { csv += `${r.frame},${r.time},${r.x},${r.y}\n`; } const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `pendulum_positions_${new Date().toISOString().replace(/[:.]/g, '-')}.csv`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 100); }
|
||||||
|
|
||||||
|
function resetTracking() { frameCount = 0; lastPosition = null; positionData.xHistory = []; positionData.yHistory = []; recordData.splice(0); isRecording.value = false; pInfo.fps = 0; pInfo.fpsUpdateTime = performance.now(); pInfo.frameCountForFps = 0; pInfo.frameTime = []; ctx = null; }
|
||||||
|
|
||||||
|
async function onCsvFileChange(e: Event) {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 读取CSV文件内容
|
||||||
|
const text = await file.text();
|
||||||
|
const lines = text.trim().split('\n');
|
||||||
|
|
||||||
|
if (lines.length < 2) {
|
||||||
|
alert('CSV文件格式不正确,需要包含标题行和数据行');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析标题行,找出各列的索引
|
||||||
|
const headers = lines[0].toLowerCase().split(',').map(h => h.trim());
|
||||||
|
const frameIndex = headers.findIndex(h => h.includes('frame'));
|
||||||
|
const timeIndex = headers.findIndex(h => h.includes('time'));
|
||||||
|
const xIndex = headers.findIndex(h => h.includes('x'));
|
||||||
|
const yIndex = headers.findIndex(h => h.includes('y'));
|
||||||
|
|
||||||
|
// 验证必要的列是否存在
|
||||||
|
if (timeIndex === -1 || xIndex === -1 || yIndex === -1) {
|
||||||
|
alert('CSV文件必须包含time、x、y列');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析数据行
|
||||||
|
const csvData: { frame: number; time: number; x: number; y: number }[] = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const row = lines[i].split(',').map(cell => cell.trim());
|
||||||
|
if (row.length < Math.max(timeIndex, xIndex, yIndex) + 1) continue;
|
||||||
|
|
||||||
|
const frame = frameIndex >= 0 ? parseFloat(row[frameIndex]) || i - 1 : i - 1;
|
||||||
|
const time = parseFloat(row[timeIndex]);
|
||||||
|
const x = parseFloat(row[xIndex]);
|
||||||
|
const y = parseFloat(row[yIndex]);
|
||||||
|
|
||||||
|
// 跳过无效数据
|
||||||
|
if (isNaN(time) || isNaN(x) || isNaN(y)) continue;
|
||||||
|
|
||||||
|
csvData.push({ frame, time, x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (csvData.length === 0) {
|
||||||
|
alert('CSV文件中没有找到有效的数据行');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空当前的记录数据,将CSV数据设置为记录数据
|
||||||
|
recordData.splice(0, recordData.length, ...csvData);
|
||||||
|
|
||||||
|
// 切换到追踪页面(如果当前不在)
|
||||||
|
if (stage.value !== 'tracker') {
|
||||||
|
stage.value = 'tracker';
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接打开分析抽屉
|
||||||
|
showAnalysis.value = true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('CSV文件解析错误:', error);
|
||||||
|
alert('CSV文件解析失败,请检查文件格式');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function healthCheck() { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 3000); try { const resp = await fetch(`${apiBase.replace(/\/+$/, '')}/api/health`, { signal: ctrl.signal }); return resp.ok; } catch { return false; } finally { clearTimeout(timer); } }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home {
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: #0b0f12;
|
||||||
|
color: #e8f8f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'Times New Roman', Times, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fx {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-inner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: clamp(28px, 6vw, 58px);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo .accent {
|
||||||
|
color: #00ffaa;
|
||||||
|
text-shadow: 0 0 22px rgba(0, 255, 170, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
opacity: .9;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
font-size: clamp(14px, 2.4vw, 18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid rgba(255, 255, 255, .18);
|
||||||
|
background: rgba(255, 255, 255, .06);
|
||||||
|
color: #e8f8f4;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .2s
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: rgba(255, 255, 255, .12);
|
||||||
|
border-color: rgba(255, 255, 255, .28)
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: linear-gradient(180deg, rgba(0, 255, 170, .25), rgba(0, 255, 170, .15));
|
||||||
|
border-color: rgba(0, 255, 170, .35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.secondary {
|
||||||
|
background: rgba(255, 255, 255, .08);
|
||||||
|
border-color: rgba(255, 255, 255, .25)
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.ghost {
|
||||||
|
background: transparent
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin-top: 14px;
|
||||||
|
opacity: .75;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(1px, 1px, 1px, 1px);
|
||||||
|
white-space: nowrap
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
outline: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-el {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(0, 0, 0, .6);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, .2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance canvas {
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
background: rgba(0, 0, 0, .4);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255, 255, 255, .7)
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #00ffaa
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-charts {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(0, 0, 0, .6);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, .2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255, 255, 255, .7);
|
||||||
|
margin-top: 2px
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
background: rgba(0, 0, 0, .6);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls select,
|
||||||
|
.record-controls button {
|
||||||
|
background: rgba(0, 0, 0, .6);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .3);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls select:hover {
|
||||||
|
background: rgba(0, 0, 0, .8);
|
||||||
|
border-color: rgba(255, 255, 255, .5)
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, .6);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .1)
|
||||||
|
}
|
||||||
|
|
||||||
|
button.recording {
|
||||||
|
background: rgba(255, 50, 50, .8) !important;
|
||||||
|
animation: pulse 2s infinite
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-info {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: rgba(255, 255, 255, .7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-info .mono {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 5ch;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-feature-settings: 'tnum';
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: .7
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
app/web/src/assets/vue.svg
Normal file
1
app/web/src/assets/vue.svg
Normal 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 |
779
app/web/src/components/PendulumAnalysis.vue
Normal file
779
app/web/src/components/PendulumAnalysis.vue
Normal file
@ -0,0 +1,779 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pendulum-analysis" role="dialog" aria-modal="true">
|
||||||
|
<div class="safe-area">
|
||||||
|
<header class="header">
|
||||||
|
<h2 class="title">单摆分析</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button
|
||||||
|
class="btn ghost"
|
||||||
|
@click="onRetry"
|
||||||
|
:disabled="loading"
|
||||||
|
v-show="!showLengthInput"
|
||||||
|
>
|
||||||
|
{{ loading ? '分析中…' : '重新分析' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn danger" @click="closeAnalysis" aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 状态条 -->
|
||||||
|
<div class="status-bar" v-if="loading || error || lastUpdated">
|
||||||
|
<div class="loading" v-if="loading">
|
||||||
|
<div class="spinner" aria-hidden="true"></div>
|
||||||
|
<span>正在分析数据,请稍候…</span>
|
||||||
|
</div>
|
||||||
|
<div class="error" v-else-if="error">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
<button class="btn small" @click="onRetry">重试</button>
|
||||||
|
</div>
|
||||||
|
<div class="meta" v-else-if="lastUpdated">
|
||||||
|
<span>上次更新:{{ lastUpdated }}</span>
|
||||||
|
<span v-if="sessionId">会话:{{ sessionId.slice(0, 8) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<!-- 摆长输入界面 -->
|
||||||
|
<section v-if="showLengthInput" class="length-input-section">
|
||||||
|
<h3>请输入摆长参数</h3>
|
||||||
|
<div class="input-form">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="pendulum-length">摆长 (米):</label>
|
||||||
|
<input
|
||||||
|
id="pendulum-length"
|
||||||
|
v-model.number="pendulumLength"
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
min="0.1"
|
||||||
|
max="10"
|
||||||
|
class="length-input"
|
||||||
|
placeholder="请输入摆长,如 1.0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="input-actions">
|
||||||
|
<button class="btn primary" @click="startAnalysis" :disabled="!isValidLength">
|
||||||
|
开始分析
|
||||||
|
</button>
|
||||||
|
<button class="btn ghost" @click="closeAnalysis">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-tips">
|
||||||
|
<p>提示:</p>
|
||||||
|
<ul>
|
||||||
|
<li>摆长是从悬挂点到小球中心的距离</li>
|
||||||
|
<li>请确保输入的摆长单位为米</li>
|
||||||
|
<li>典型的实验室单摆摆长范围:0.5-2.0米</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 分析结果界面(原有内容) -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- 左侧:图片栅格(横屏优先) -->
|
||||||
|
<section class="results" aria-label="分析图像">
|
||||||
|
<!-- 骨架屏 -->
|
||||||
|
<div v-if="loading && !results.length" class="skeleton-grid">
|
||||||
|
<div class="skeleton-card" v-for="i in 3" :key="'skel-' + i"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="results.length" class="image-grid">
|
||||||
|
<figure v-for="(image, index) in results" :key="index" class="result-item" @click="openLightbox(index)">
|
||||||
|
<img :src="image.url" :alt="image.title" class="result-image" loading="eager" decoding="async" />
|
||||||
|
<figcaption class="result-title">{{ image.title }}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-else class="empty">
|
||||||
|
上传数据后将显示分析图像
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 右侧:参数信息(粘性面板) -->
|
||||||
|
<aside class="analysis-info" v-if="analysisInfo">
|
||||||
|
<h3>分析结果</h3>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item" v-for="row in formattedInfo" :key="row.key">
|
||||||
|
<div class="info-label">{{ row.label }}</div>
|
||||||
|
<div class="info-value">{{ row.value }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tips">
|
||||||
|
<p>提示:</p>
|
||||||
|
<ul>
|
||||||
|
<li>点击左侧图像可全屏查看。</li>
|
||||||
|
<li>如网络抖动,可点“重新分析”。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片灯箱 -->
|
||||||
|
<div v-if="lightbox.open" class="lightbox" @click.self="closeLightbox">
|
||||||
|
<button class="btn danger close-lightbox" @click="closeLightbox" aria-label="关闭预览">×</button>
|
||||||
|
<div class="lightbox-inner">
|
||||||
|
<img :src="results[lightbox.index].url" :alt="results[lightbox.index].title" />
|
||||||
|
<div class="lightbox-caption">{{ results[lightbox.index].title }}</div>
|
||||||
|
</div>
|
||||||
|
<button v-if="results.length > 1" class="nav prev" @click.stop="navLightbox(-1)" aria-label="上一张">‹</button>
|
||||||
|
<button v-if="results.length > 1" class="nav next" @click.stop="navLightbox(1)" aria-label="下一张">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||||
|
|
||||||
|
type RawPoint = { frame: number; time: number; x: number; y: number };
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
data: RawPoint[],
|
||||||
|
/** 可选:后端基础地址,如 'http://localhost:5000';默认同源 */
|
||||||
|
apiBase?: string,
|
||||||
|
/** 可选:组件打开时自动分析 */
|
||||||
|
autoAnalyze?: boolean
|
||||||
|
}>(), {
|
||||||
|
apiBase: '',
|
||||||
|
autoAnalyze: false
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const results = ref<{ url: string; title: string }[]>([]);
|
||||||
|
const analysisInfo = ref<Record<string, any> | null>(null);
|
||||||
|
const sessionId = ref<string | null>(null);
|
||||||
|
const lastUpdated = ref<string | null>(null);
|
||||||
|
const pendulumLength = ref<number>(1.0); // 摆长,单位:米
|
||||||
|
const showLengthInput = ref(true); // 显示摆长输入界面
|
||||||
|
|
||||||
|
let controller: AbortController | null = null;
|
||||||
|
|
||||||
|
const closeAnalysis = () => emit('close');
|
||||||
|
const onRetry = () => analyzeData(true);
|
||||||
|
|
||||||
|
/** 验证摆长输入是否有效 */
|
||||||
|
const isValidLength = computed(() => {
|
||||||
|
return pendulumLength.value > 0.1 && pendulumLength.value <= 10;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 开始分析 */
|
||||||
|
const startAnalysis = () => {
|
||||||
|
if (!isValidLength.value) return;
|
||||||
|
showLengthInput.value = false;
|
||||||
|
analyzeData(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 将相对 URL 前缀化为 {apiBase}+path,保证跨域部署可用 */
|
||||||
|
const withBase = (url: string) => {
|
||||||
|
if (!url) return url;
|
||||||
|
try {
|
||||||
|
// 已是绝对 URL
|
||||||
|
new URL(url);
|
||||||
|
return url;
|
||||||
|
} catch {
|
||||||
|
const base = (props.apiBase || '').replace(/\/+$/, '');
|
||||||
|
return base + url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 新后端字段映射与格式化 */
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
pendulum_length_m: '摆长',
|
||||||
|
gravity_acceleration_m_s2: '重力加速度',
|
||||||
|
damping_coefficient_s_inv: '阻尼系数 γ',
|
||||||
|
period_s: '周期',
|
||||||
|
angle_max_rad: '最大摆角'
|
||||||
|
};
|
||||||
|
const unitMap: Record<string, string> = {
|
||||||
|
pendulum_length_m: ' m',
|
||||||
|
gravity_acceleration_m_s2: ' m/s²',
|
||||||
|
damping_coefficient_s_inv: ' s⁻¹',
|
||||||
|
period_s: ' s',
|
||||||
|
angle_max_rad: ' rad'
|
||||||
|
};
|
||||||
|
const formatNumber = (v: any) => typeof v === 'number' ? (Math.abs(v) < 1e-3 ? v.toExponential(3) : v.toFixed(4)) : v;
|
||||||
|
|
||||||
|
const formattedInfo = computed(() => {
|
||||||
|
if (!analysisInfo.value) return [];
|
||||||
|
return Object.entries(analysisInfo.value).map(([key, val]) => ({
|
||||||
|
key,
|
||||||
|
label: labelMap[key] || key,
|
||||||
|
value: `${formatNumber(val)}${unitMap[key] || ''}`
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 生成 CSV(列满足后端:time, x, y, pendulum_length) */
|
||||||
|
const buildCsv = () => {
|
||||||
|
const lines = ['time,x,y,pendulum_length']; // 添加摆长列
|
||||||
|
for (const r of props.data) {
|
||||||
|
lines.push(`${r.time},${r.x},${r.y},${pendulumLength.value}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 图片标题 */
|
||||||
|
const getImageTitle = (index: number) => {
|
||||||
|
const titles = [
|
||||||
|
'单摆拟合分析',
|
||||||
|
'物理参数摘要',
|
||||||
|
'周期与摆角关系',
|
||||||
|
];
|
||||||
|
return titles[index] || `图像 ${index + 1}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 主流程:发送到 {apiBase}/api/analyze */
|
||||||
|
const analyzeData = async (force = false) => {
|
||||||
|
if (!props.data?.length) return;
|
||||||
|
if (loading.value && !force) return;
|
||||||
|
|
||||||
|
// 取消上一次请求
|
||||||
|
if (controller) controller.abort();
|
||||||
|
controller = new AbortController();
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
if (force) results.value = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const csv = buildCsv();
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('csv_file', new Blob([csv], { type: 'text/csv' }), 'pendulum_data.csv');
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => controller?.abort(), 60_000);
|
||||||
|
const endpoint = `${(props.apiBase || '').replace(/\/+$/, '')}/api/analyze`;
|
||||||
|
|
||||||
|
const resp = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
signal: controller.signal
|
||||||
|
}).finally(() => clearTimeout(timeout));
|
||||||
|
|
||||||
|
// 解析错误
|
||||||
|
if (!resp.ok) {
|
||||||
|
let msg = `服务器错误: ${resp.status}`;
|
||||||
|
try {
|
||||||
|
const j = await resp.json();
|
||||||
|
if (j?.error) msg = j.error;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await resp.json();
|
||||||
|
|
||||||
|
if (json.success === false) {
|
||||||
|
throw new Error(json.error || '分析失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结果
|
||||||
|
sessionId.value = json.session_id || null;
|
||||||
|
|
||||||
|
if (Array.isArray(json.images)) {
|
||||||
|
results.value = json.images.map((p: string, i: number) => ({
|
||||||
|
url: withBase(p),
|
||||||
|
title: getImageTitle(i)
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
results.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
analysisInfo.value = json.analysis_data || null;
|
||||||
|
lastUpdated.value = new Date().toLocaleString();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === 'AbortError') {
|
||||||
|
error.value = '请求已取消';
|
||||||
|
} else if (
|
||||||
|
e?.message?.includes('Failed to fetch') ||
|
||||||
|
e?.message?.includes('NetworkError') ||
|
||||||
|
e?.message?.includes('Network request failed')
|
||||||
|
) {
|
||||||
|
error.value = '网络异常,请检查连接后重试';
|
||||||
|
} else {
|
||||||
|
error.value = e?.message || '分析过程中发生错误';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.autoAnalyze && props.data?.length) {
|
||||||
|
// 如果设置了自动分析,跳过摆长输入界面
|
||||||
|
showLengthInput.value = false;
|
||||||
|
analyzeData(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
console.log('PendulumAnalysis component unmounted');
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 灯箱逻辑 */
|
||||||
|
const lightbox = ref({ open: false, index: 0 });
|
||||||
|
const openLightbox = (i: number) => {
|
||||||
|
lightbox.value.open = true;
|
||||||
|
lightbox.value.index = i;
|
||||||
|
nextTick(() => {
|
||||||
|
// 禁止页面滚动(移动端体验更好)
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const closeLightbox = () => {
|
||||||
|
lightbox.value.open = false;
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
const navLightbox = (step: number) => {
|
||||||
|
if (!results.value.length) return;
|
||||||
|
const n = results.value.length;
|
||||||
|
lightbox.value.index = (lightbox.value.index + step + n) % n;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ========== 基础布局(横屏优先) ========== */
|
||||||
|
.pendulum-analysis {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: radial-gradient(1200px 800px at 70% -10%, rgba(0, 255, 170, 0.08), transparent 60%),
|
||||||
|
#0b0f12;
|
||||||
|
color: #e8f8f4;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.safe-area {
|
||||||
|
padding: calc(12px + env(safe-area-inset-top)) 16px calc(16px + env(safe-area-inset-bottom));
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: #00ffaa;
|
||||||
|
text-shadow: 0 0 12px rgba(0, 255, 170, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #e8f8f4;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .05s ease, background .2s ease, border-color .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border-color: rgba(255, 255, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.small {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.ghost {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger {
|
||||||
|
border-color: rgba(255, 90, 90, 0.45);
|
||||||
|
color: #ffdede;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger:hover {
|
||||||
|
background: rgba(255, 90, 90, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态条 */
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error,
|
||||||
|
.meta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid rgba(0, 255, 170, 0.35);
|
||||||
|
border-top-color: #00ffaa;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff7a7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主内容区:横屏优先双列 */
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 1fr;
|
||||||
|
/* 图像区域更大 */
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px),
|
||||||
|
(orientation: portrait) {
|
||||||
|
.content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 摆长输入界面 */
|
||||||
|
.length-input-section {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.length-input-section h3 {
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
color: #00ffaa;
|
||||||
|
font-size: 20px;
|
||||||
|
text-shadow: 0 0 12px rgba(0, 255, 170, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-form {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group label {
|
||||||
|
color: #d6efe8;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.length-input {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #e8f8f4;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.length-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00ffaa;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.length-input::placeholder {
|
||||||
|
color: rgba(232, 248, 244, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: linear-gradient(135deg, #00ffaa, #00cc88);
|
||||||
|
border-color: #00ffaa;
|
||||||
|
color: #0b0f12;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:hover {
|
||||||
|
background: linear-gradient(135deg, #00cc88, #00aa77);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(232, 248, 244, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-tips {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
text-align: left;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-tips ul {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧:图像区 */
|
||||||
|
.results {
|
||||||
|
min-height: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.image-grid {
|
||||||
|
grid-auto-rows: minmax(200px, 36vh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
|
||||||
|
cursor: zoom-in;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:hover {
|
||||||
|
border-color: rgba(0, 255, 170, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: #cfeee6;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 骨架屏 */
|
||||||
|
.skeleton-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
height: min(36vh, 320px);
|
||||||
|
border-radius: 14px;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 25%, rgba(255, 255, 255, 0.12) 37%, rgba(255, 255, 255, 0.06) 63%) 0 0 / 400% 100%,
|
||||||
|
rgba(255, 255, 255, 0.04);
|
||||||
|
animation: shimmer 1.6s infinite;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 100% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
opacity: 0.7;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧:参数面板(粘性) */
|
||||||
|
.analysis-info {
|
||||||
|
position: sticky;
|
||||||
|
top: 8px;
|
||||||
|
align-self: start;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0.2));
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-info h3 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #00ffaa;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
color: #d6efe8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #00ffaa;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul{
|
||||||
|
margin: 2px 0 0 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 灯箱 */
|
||||||
|
.lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
background: rgba(0, 0, 0, 0.86);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: env(safe-area-inset-top) 20px env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-inner {
|
||||||
|
width: 95vw;
|
||||||
|
height: 86vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-inner img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-caption {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #cfeee6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-lightbox {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav.prev {
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav.next {
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
4
app/web/src/main.css
Normal file
4
app/web/src/main.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
body{
|
||||||
|
margin: 0;
|
||||||
|
background-color: #1F1F1F;
|
||||||
|
}
|
||||||
4
app/web/src/main.ts
Normal file
4
app/web/src/main.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './main.css'
|
||||||
|
createApp(App).mount('#app')
|
||||||
54
app/web/src/useCamera.ts
Normal file
54
app/web/src/useCamera.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export type StartOptions = {
|
||||||
|
preferRearCamera?: boolean;
|
||||||
|
width?: number; height?: number; fps?: number;
|
||||||
|
deviceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCamera() {
|
||||||
|
const stream = ref<MediaStream | null>(null);
|
||||||
|
const devices = ref<MediaDeviceInfo[]>([]);
|
||||||
|
const activeDeviceId = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function enumerate() {
|
||||||
|
try {
|
||||||
|
const list = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
devices.value = list.filter(d => d.kind === 'videoinput');
|
||||||
|
return devices.value;
|
||||||
|
} catch (e) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(opts: StartOptions = {}) {
|
||||||
|
await stop();
|
||||||
|
const preferRear = opts.preferRearCamera ?? true;
|
||||||
|
const constraints: MediaStreamConstraints = {
|
||||||
|
video: {
|
||||||
|
width: { ideal: opts.width ?? 1280 },
|
||||||
|
height: { ideal: opts.height ?? 720 },
|
||||||
|
frameRate: { ideal: opts.fps ?? 60, max: 120 },
|
||||||
|
...(opts.deviceId ? { deviceId: { exact: opts.deviceId } } : { facingMode: preferRear ? { ideal: 'environment' } : 'user' }),
|
||||||
|
},
|
||||||
|
audio: false
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const s = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
stream.value = s;
|
||||||
|
|
||||||
|
// remember deviceId if available
|
||||||
|
const track = s.getVideoTracks()[0];
|
||||||
|
// @ts-ignore
|
||||||
|
const settings = track.getSettings?.() || {};
|
||||||
|
activeDeviceId.value = settings.deviceId || null;
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
if (!stream.value) return;
|
||||||
|
for (const t of stream.value.getTracks()) t.stop();
|
||||||
|
stream.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stream, devices, activeDeviceId, enumerate, start, stop };
|
||||||
|
}
|
||||||
240
app/web/src/utils/chartUtils.ts
Normal file
240
app/web/src/utils/chartUtils.ts
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
export interface ChartRange {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerformanceInfo {
|
||||||
|
lastUpdateTime: number;
|
||||||
|
frameTime: number[];
|
||||||
|
avgFrameTime: number;
|
||||||
|
minFrameTime: number;
|
||||||
|
maxFrameTime: number;
|
||||||
|
fps: number;
|
||||||
|
fpsUpdateTime: number;
|
||||||
|
frameCountForFps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 固定的y轴范围常量
|
||||||
|
export const FIXED_MIN_Y = 0; // 最小帧时间 (ms)
|
||||||
|
export const FIXED_MAX_Y = 100; // 最大帧时间 (ms),可根据实际情况调整
|
||||||
|
|
||||||
|
// 位置图表的常量
|
||||||
|
export const MAX_POSITION_HISTORY = 128; // 最多保存的历史点数
|
||||||
|
export const POSITION_CHART_PADDING = 20; // 图表边距,防止数值贴边
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制性能图表
|
||||||
|
*/
|
||||||
|
export function drawPerformanceChart(
|
||||||
|
canvas: HTMLCanvasElement | null,
|
||||||
|
data: number[],
|
||||||
|
minY = FIXED_MIN_Y,
|
||||||
|
maxY = FIXED_MAX_Y
|
||||||
|
) {
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// 画布尺寸
|
||||||
|
const width = canvas.width;
|
||||||
|
const height = canvas.height;
|
||||||
|
|
||||||
|
// 清除画布
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// 绘制背景
|
||||||
|
ctx.fillStyle = '#1a1a2e';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// 绘制网格线
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
|
||||||
|
// 水平网格线
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const y = (i / 4) * height;
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 垂直网格线
|
||||||
|
for (let i = 0; i <= 8; i++) {
|
||||||
|
const x = (i / 8) * width;
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 绘制y轴刻度值
|
||||||
|
ctx.font = '8px Arial';
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const y = height - (i / 4) * height;
|
||||||
|
const value = Math.round(minY + (i / 4) * (maxY - minY));
|
||||||
|
ctx.fillText(`${value}ms`, 2, y - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length < 2) return;
|
||||||
|
|
||||||
|
// 绘制曲线阴影
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||||
|
gradient.addColorStop(0, 'rgba(0, 255, 170, 0.5)');
|
||||||
|
gradient.addColorStop(1, 'rgba(0, 255, 170, 0.05)');
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const x = (i / (data.length - 1)) * width;
|
||||||
|
// 使用固定的Y轴范围进行归一化
|
||||||
|
const normalizedValue = Math.min(1, Math.max(0, (data[i] - minY) / (maxY - minY)));
|
||||||
|
const y = height - normalizedValue * height;
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成区域填充路径
|
||||||
|
ctx.lineTo(width, height);
|
||||||
|
ctx.lineTo(0, height);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 绘制原始数据曲线
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const x = (i / (data.length - 1)) * width;
|
||||||
|
// 使用固定的Y轴范围进行归一化
|
||||||
|
const normalizedValue = Math.min(1, Math.max(0, (data[i] - minY) / (maxY - minY)));
|
||||||
|
const y = height - normalizedValue * height;
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = '#00ffaa';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 绘制平均线
|
||||||
|
if (data.length > 0) {
|
||||||
|
const avg = data.reduce((a, b) => a + b, 0) / data.length;
|
||||||
|
const normalizedAvg = Math.min(1, Math.max(0, (avg - minY) / (maxY - minY)));
|
||||||
|
const avgY = height - normalizedAvg * height;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.setLineDash([3, 3]);
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
|
||||||
|
ctx.moveTo(0, avgY);
|
||||||
|
ctx.lineTo(width, avgY);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绘制位置图表
|
||||||
|
*/
|
||||||
|
export function drawPositionChart(
|
||||||
|
canvas: HTMLCanvasElement | null,
|
||||||
|
data: number[],
|
||||||
|
range: ChartRange,
|
||||||
|
label: string
|
||||||
|
) {
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// 画布尺寸
|
||||||
|
const width = canvas.width;
|
||||||
|
const height = canvas.height;
|
||||||
|
|
||||||
|
// 清除画布
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// 绘制背景
|
||||||
|
ctx.fillStyle = '#1a1a2e';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// 绘制网格线
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
|
||||||
|
// 水平网格线
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const y = (i / 4) * height;
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 垂直网格线
|
||||||
|
for (let i = 0; i <= 8; i++) {
|
||||||
|
const x = (i / 8) * width;
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 绘制y轴刻度值
|
||||||
|
ctx.font = '8px Arial';
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const y = height - (i / 4) * height;
|
||||||
|
const value = Math.round(range.min + (i / 4) * (range.max - range.min));
|
||||||
|
ctx.fillText(`${value}`, 2, y - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length < 2) return;
|
||||||
|
|
||||||
|
// 绘制曲线阴影
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||||
|
gradient.addColorStop(0, label === 'X位置' ? 'rgba(255, 100, 100, 0.5)' : 'rgba(100, 100, 255, 0.5)');
|
||||||
|
gradient.addColorStop(1, label === 'X位置' ? 'rgba(255, 100, 100, 0.05)' : 'rgba(100, 100, 255, 0.05)');
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const x = (i / (data.length - 1)) * width;
|
||||||
|
// 归一化数据点
|
||||||
|
const normalizedValue = Math.min(1, Math.max(0, (data[i] - range.min) / (range.max - range.min)));
|
||||||
|
const y = height - normalizedValue * height;
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成区域填充路径
|
||||||
|
ctx.lineTo(width, height);
|
||||||
|
ctx.lineTo(0, height);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 绘制原始数据曲线
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const x = (i / (data.length - 1)) * width;
|
||||||
|
// 归一化数据点
|
||||||
|
const normalizedValue = Math.min(1, Math.max(0, (data[i] - range.min) / (range.max - range.min)));
|
||||||
|
const y = height - normalizedValue * height;
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = label === 'X位置' ? '#ff6464' : '#6464ff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
220
app/web/src/utils/yellowBallTracker.ts
Normal file
220
app/web/src/utils/yellowBallTracker.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import cv from '@techstark/opencv-js'
|
||||||
|
|
||||||
|
export interface TrackerParams {
|
||||||
|
lowerYellow: any;
|
||||||
|
upperYellow: any;
|
||||||
|
blurSize: number;
|
||||||
|
morphKernel: number;
|
||||||
|
minContourArea: number;
|
||||||
|
useRoi: boolean;
|
||||||
|
roiScaleX: number;
|
||||||
|
roiScaleY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YellowBallTracker {
|
||||||
|
public srcMat: cv.Mat | null = null
|
||||||
|
private hsvMat: cv.Mat | null = null
|
||||||
|
private blurredMat: cv.Mat | null = null
|
||||||
|
private maskMat: cv.Mat | null = null
|
||||||
|
private hierarchy: cv.Mat | null = null
|
||||||
|
private contours: cv.MatVector | null = null
|
||||||
|
private morphKernelMat: cv.Mat | null = null
|
||||||
|
private lowerBoundMat: cv.Mat | null = null
|
||||||
|
private upperBoundMat: cv.Mat | null = null
|
||||||
|
private params: TrackerParams
|
||||||
|
|
||||||
|
constructor(params: TrackerParams) {
|
||||||
|
this.params = params
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateKernel() {
|
||||||
|
if (this.morphKernelMat) this.morphKernelMat.delete()
|
||||||
|
this.morphKernelMat = cv.Mat.ones(this.params.morphKernel, this.params.morphKernel, cv.CV_8U)
|
||||||
|
}
|
||||||
|
|
||||||
|
public initMats(w: number, h: number) {
|
||||||
|
this.cleanupMats()
|
||||||
|
|
||||||
|
this.srcMat = new cv.Mat(h, w, cv.CV_8UC4)
|
||||||
|
this.hsvMat = new cv.Mat()
|
||||||
|
this.blurredMat = new cv.Mat()
|
||||||
|
this.maskMat = new cv.Mat()
|
||||||
|
this.hierarchy = new cv.Mat()
|
||||||
|
this.contours = new cv.MatVector()
|
||||||
|
this.updateKernel()
|
||||||
|
// 创建阈值 Mat
|
||||||
|
this.lowerBoundMat = new cv.Mat(h, w, cv.CV_8UC3, this.params.lowerYellow)
|
||||||
|
this.upperBoundMat = new cv.Mat(h, w, cv.CV_8UC3, this.params.upperYellow)
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateROI(lastPos: Position): { x: number, y: number, width: number, height: number } {
|
||||||
|
const width = this.srcMat!.cols * this.params.roiScaleX
|
||||||
|
const height = this.srcMat!.rows * this.params.roiScaleY
|
||||||
|
const x = Math.max(0, Math.min(this.srcMat!.cols - width, lastPos.x - width / 2))
|
||||||
|
const y = Math.max(0, Math.min(this.srcMat!.rows - height, lastPos.y - height / 2))
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.round(x),
|
||||||
|
y: Math.round(y),
|
||||||
|
width: Math.round(width),
|
||||||
|
height: Math.round(height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawROIDebugInfo(roi: { x: number, y: number, width: number, height: number }) {
|
||||||
|
cv.rectangle(this.srcMat!,
|
||||||
|
new cv.Point(roi.x, roi.y),
|
||||||
|
new cv.Point(roi.x + roi.width, roi.y + roi.height),
|
||||||
|
new cv.Scalar(255, 0, 0, 255), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private processImage(inputMat: cv.Mat, lowerBound: cv.Mat, upperBound: cv.Mat) {
|
||||||
|
// 颜色空间转换和模糊处理
|
||||||
|
cv.cvtColor(inputMat, this.hsvMat!, cv.COLOR_RGBA2RGB)
|
||||||
|
cv.cvtColor(this.hsvMat!, this.hsvMat!, cv.COLOR_RGB2HSV)
|
||||||
|
cv.blur(this.hsvMat!, this.blurredMat!, new cv.Size(this.params.blurSize, this.params.blurSize))
|
||||||
|
|
||||||
|
// 颜色范围过滤
|
||||||
|
cv.inRange(this.blurredMat!, lowerBound, upperBound, this.maskMat!)
|
||||||
|
|
||||||
|
// 形态学操作
|
||||||
|
cv.morphologyEx(this.maskMat!, this.maskMat!, cv.MORPH_OPEN, this.morphKernelMat!)
|
||||||
|
cv.morphologyEx(this.maskMat!, this.maskMat!, cv.MORPH_CLOSE, this.morphKernelMat!)
|
||||||
|
cv.dilate(this.maskMat!, this.maskMat!, this.morphKernelMat!)
|
||||||
|
}
|
||||||
|
|
||||||
|
private findBestContour(offsetX: number = 0, offsetY: number = 0): Position | null {
|
||||||
|
// 查找轮廓
|
||||||
|
this.contours!.delete()
|
||||||
|
this.contours = new cv.MatVector()
|
||||||
|
cv.findContours(this.maskMat!, this.contours, this.hierarchy!, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
// 找到最大面积的轮廓
|
||||||
|
let maxArea = 0
|
||||||
|
let best: Position | null = null
|
||||||
|
|
||||||
|
for (let i = 0; i < this.contours.size(); i++) {
|
||||||
|
const cnt = this.contours.get(i)
|
||||||
|
const area = cv.contourArea(cnt)
|
||||||
|
|
||||||
|
if (area > this.params.minContourArea && area > maxArea) {
|
||||||
|
maxArea = area
|
||||||
|
const circle = cv.minEnclosingCircle(cnt)
|
||||||
|
best = {
|
||||||
|
x: Math.round(circle.center.x + offsetX),
|
||||||
|
y: Math.round(circle.center.y + offsetY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cnt.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawDetectionResult(position: Position) {
|
||||||
|
cv.circle(this.srcMat!, new cv.Point(position.x, position.y), 10, new cv.Scalar(0, 255, 0, 255), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
public detectYellowBall(imgData: ImageData, lastPos?: Position | null): Position | null {
|
||||||
|
if (!this.srcMat || !this.hsvMat || !this.blurredMat || !this.maskMat || !this.hierarchy ||
|
||||||
|
!this.contours || !this.morphKernelMat || !this.lowerBoundMat || !this.upperBoundMat) {
|
||||||
|
console.error('Mats not initialized')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set image data
|
||||||
|
this.srcMat.data.set(imgData.data)
|
||||||
|
|
||||||
|
let best: Position | null = null
|
||||||
|
let srcRoiMat = new cv.Mat()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试ROI检测
|
||||||
|
if (this.params.useRoi && lastPos) {
|
||||||
|
const roi = this.calculateROI(lastPos)
|
||||||
|
this.drawROIDebugInfo(roi)
|
||||||
|
|
||||||
|
// 提取ROI区域
|
||||||
|
const roiRect = new cv.Rect(roi.x, roi.y, roi.width, roi.height)
|
||||||
|
srcRoiMat = this.srcMat.roi(roiRect)
|
||||||
|
|
||||||
|
// 为ROI区域创建合适大小的阈值Mat
|
||||||
|
const roiLowerBound = new cv.Mat(roi.height, roi.width, cv.CV_8UC3, this.params.lowerYellow)
|
||||||
|
const roiUpperBound = new cv.Mat(roi.height, roi.width, cv.CV_8UC3, this.params.upperYellow)
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.processImage(srcRoiMat, roiLowerBound, roiUpperBound)
|
||||||
|
best = this.findBestContour(roi.x, roi.y)
|
||||||
|
} finally {
|
||||||
|
roiLowerBound.delete()
|
||||||
|
roiUpperBound.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果ROI中没找到或者没启用ROI,就在全图搜索
|
||||||
|
if (!best) {
|
||||||
|
if (this.params.useRoi && lastPos) {
|
||||||
|
console.log("ROI失败,全域搜索")
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processImage(this.srcMat, this.lowerBoundMat, this.upperBoundMat)
|
||||||
|
best = this.findBestContour()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在原图上绘制找到的点
|
||||||
|
if (best) {
|
||||||
|
this.drawDetectionResult(best)
|
||||||
|
}
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
// 清理资源
|
||||||
|
srcRoiMat.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
public getOutputCanvas(targetCanvas: HTMLCanvasElement) {
|
||||||
|
if (!this.srcMat) return null
|
||||||
|
cv.imshow(targetCanvas, this.srcMat)
|
||||||
|
return targetCanvas
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateParams(params: Partial<TrackerParams>) {
|
||||||
|
this.params = { ...this.params, ...params }
|
||||||
|
|
||||||
|
// 如果更新了morphKernel,需要重新创建kernel
|
||||||
|
if ('morphKernel' in params) {
|
||||||
|
this.updateKernel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public cleanupMats() {
|
||||||
|
// 清理资源
|
||||||
|
if (this.srcMat) this.srcMat.delete()
|
||||||
|
if (this.hsvMat) this.hsvMat.delete()
|
||||||
|
if (this.blurredMat) this.blurredMat.delete()
|
||||||
|
if (this.maskMat) this.maskMat.delete()
|
||||||
|
if (this.hierarchy) this.hierarchy.delete()
|
||||||
|
if (this.contours) this.contours.delete()
|
||||||
|
if (this.morphKernelMat) this.morphKernelMat.delete()
|
||||||
|
if (this.lowerBoundMat) this.lowerBoundMat.delete()
|
||||||
|
if (this.upperBoundMat) this.upperBoundMat.delete()
|
||||||
|
|
||||||
|
// 重置引用
|
||||||
|
this.srcMat = null
|
||||||
|
this.hsvMat = null
|
||||||
|
this.blurredMat = null
|
||||||
|
this.maskMat = null
|
||||||
|
this.hierarchy = null
|
||||||
|
this.contours = null
|
||||||
|
this.morphKernelMat = null
|
||||||
|
this.lowerBoundMat = null
|
||||||
|
this.upperBoundMat = null
|
||||||
|
}
|
||||||
|
}
|
||||||
1
app/web/src/vite-env.d.ts
vendored
Normal file
1
app/web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
15
app/web/tsconfig.app.json
Normal file
15
app/web/tsconfig.app.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
7
app/web/tsconfig.json
Normal file
7
app/web/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
app/web/tsconfig.node.json
Normal file
25
app/web/tsconfig.node.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
10
app/web/vite.config.ts
Normal file
10
app/web/vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server:{
|
||||||
|
host:'0.0.0.0'
|
||||||
|
}
|
||||||
|
})
|
||||||
85
deploy.sh
Executable file
85
deploy.sh
Executable file
@ -0,0 +1,85 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 部署脚本
|
||||||
|
# 使用方法: ./deploy.sh
|
||||||
|
|
||||||
|
set -e # 遇到错误立即退出
|
||||||
|
|
||||||
|
PROJECT_ROOT="/path/to/your/ball-tracking/server-neo" # 请替换为实际路径
|
||||||
|
PROJECT_NAME="ball-tracking-server"
|
||||||
|
USER="www-data" # 或者你的用户名
|
||||||
|
PYTHON_VERSION="python3"
|
||||||
|
|
||||||
|
echo "🚀 开始部署 $PROJECT_NAME..."
|
||||||
|
|
||||||
|
# 1. 进入项目目录
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
# 2. 更新代码 (如果使用 git)
|
||||||
|
if [ -d ".git" ]; then
|
||||||
|
echo "📥 更新代码..."
|
||||||
|
git pull origin main # 或者你的主分支名
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. 创建虚拟环境 (如果不存在)
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
echo "🐍 创建 Python 虚拟环境..."
|
||||||
|
$PYTHON_VERSION -m venv venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. 激活虚拟环境并安装依赖
|
||||||
|
echo "📦 安装 Python 依赖..."
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install gunicorn supervisor # 生产环境依赖
|
||||||
|
|
||||||
|
# 5. 构建前端
|
||||||
|
echo "🏗️ 构建前端..."
|
||||||
|
cd app/web
|
||||||
|
if command -v bun &> /dev/null; then
|
||||||
|
echo "使用 Bun 构建..."
|
||||||
|
bun install
|
||||||
|
bun run build
|
||||||
|
elif command -v npm &> /dev/null; then
|
||||||
|
echo "使用 npm 构建..."
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
else
|
||||||
|
echo "❌ 错误: 未找到 bun 或 npm"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. 返回项目根目录
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
# 7. 创建必要的目录
|
||||||
|
echo "📁 创建必要目录..."
|
||||||
|
mkdir -p logs
|
||||||
|
mkdir -p data/sessions
|
||||||
|
|
||||||
|
# 8. 设置权限
|
||||||
|
echo "🔐 设置文件权限..."
|
||||||
|
chown -R $USER:$USER .
|
||||||
|
chmod +x deploy.sh
|
||||||
|
chmod +x start.sh
|
||||||
|
chmod +x stop.sh
|
||||||
|
|
||||||
|
# 9. 复制配置文件到系统目录 (需要 sudo)
|
||||||
|
echo "⚙️ 配置系统服务..."
|
||||||
|
if [ -f "/etc/supervisor/conf.d/$PROJECT_NAME.conf" ]; then
|
||||||
|
sudo cp supervisor.conf /etc/supervisor/conf.d/$PROJECT_NAME.conf
|
||||||
|
sudo supervisorctl reread
|
||||||
|
sudo supervisorctl update
|
||||||
|
sudo supervisorctl restart $PROJECT_NAME
|
||||||
|
else
|
||||||
|
sudo cp supervisor.conf /etc/supervisor/conf.d/$PROJECT_NAME.conf
|
||||||
|
sudo supervisorctl reread
|
||||||
|
sudo supervisorctl update
|
||||||
|
sudo supervisorctl start $PROJECT_NAME
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ 部署完成!"
|
||||||
|
echo "📝 查看日志: sudo supervisorctl tail -f $PROJECT_NAME"
|
||||||
|
echo "🔄 重启服务: sudo supervisorctl restart $PROJECT_NAME"
|
||||||
|
echo "⏹️ 停止服务: sudo supervisorctl stop $PROJECT_NAME"
|
||||||
53
gunicorn.conf.py
Normal file
53
gunicorn.conf.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# Gunicorn 配置文件
|
||||||
|
import os
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
|
# 服务器绑定
|
||||||
|
bind = "0.0.0.0:5000"
|
||||||
|
backlog = 2048
|
||||||
|
|
||||||
|
# 工作进程
|
||||||
|
workers = multiprocessing.cpu_count() * 2 + 1 # 推荐的工作进程数
|
||||||
|
worker_class = "sync"
|
||||||
|
worker_connections = 1000
|
||||||
|
timeout = 30
|
||||||
|
keepalive = 2
|
||||||
|
max_requests = 1000
|
||||||
|
max_requests_jitter = 50
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
accesslog = "logs/gunicorn_access.log"
|
||||||
|
errorlog = "logs/gunicorn_error.log"
|
||||||
|
loglevel = "info"
|
||||||
|
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s'
|
||||||
|
|
||||||
|
# 进程命名
|
||||||
|
proc_name = "ball-tracking-server"
|
||||||
|
|
||||||
|
# 安全
|
||||||
|
user = os.getenv('GUNICORN_USER', 'www-data')
|
||||||
|
group = os.getenv('GUNICORN_GROUP', 'www-data')
|
||||||
|
tmp_upload_dir = None
|
||||||
|
|
||||||
|
# 预加载应用
|
||||||
|
preload_app = True
|
||||||
|
|
||||||
|
# SSL (如果需要)
|
||||||
|
# keyfile = "/path/to/ssl/private.key"
|
||||||
|
# certfile = "/path/to/ssl/certificate.crt"
|
||||||
|
|
||||||
|
# 开发环境配置 (生产环境请注释掉)
|
||||||
|
# reload = True
|
||||||
|
# reload_extra_files = ["app/"]
|
||||||
|
|
||||||
|
def when_ready(server):
|
||||||
|
server.log.info("Gunicorn server is ready. Listening on: %s", server.address)
|
||||||
|
|
||||||
|
def worker_int(worker):
|
||||||
|
worker.log.info("worker received INT or QUIT signal")
|
||||||
|
|
||||||
|
def pre_fork(server, worker):
|
||||||
|
server.log.info("Worker spawned (pid: %s)", worker.pid)
|
||||||
|
|
||||||
|
def post_fork(server, worker):
|
||||||
|
server.log.info("Worker spawned (pid: %s)", worker.pid)
|
||||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
Flask>=3.0.0
|
||||||
|
flask-cors>=4.0.0
|
||||||
|
numpy>=1.26.0
|
||||||
|
pandas>=2.2.0
|
||||||
|
matplotlib>=3.8.0
|
||||||
|
scipy>=1.12.0
|
||||||
|
gunicorn>=21.2.0
|
||||||
|
supervisor>=4.2.5
|
||||||
6
run.py
Normal file
6
run.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from app import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=50000, debug=True)
|
||||||
21
start.sh
Executable file
21
start.sh
Executable file
@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 启动服务
|
||||||
|
|
||||||
|
PROJECT_NAME="ball-tracking-server"
|
||||||
|
|
||||||
|
echo "🚀 启动 $PROJECT_NAME..."
|
||||||
|
|
||||||
|
# 检查 supervisor 是否运行
|
||||||
|
if ! pgrep -x "supervisord" > /dev/null; then
|
||||||
|
echo "启动 supervisord..."
|
||||||
|
sudo systemctl start supervisor
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
sudo supervisorctl start $PROJECT_NAME
|
||||||
|
|
||||||
|
# 检查状态
|
||||||
|
sudo supervisorctl status $PROJECT_NAME
|
||||||
|
|
||||||
|
echo "✅ 服务启动完成!"
|
||||||
|
echo "📝 查看日志: sudo supervisorctl tail -f $PROJECT_NAME"
|
||||||
14
stop.sh
Executable file
14
stop.sh
Executable file
@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 停止服务
|
||||||
|
|
||||||
|
PROJECT_NAME="ball-tracking-server"
|
||||||
|
|
||||||
|
echo "⏹️ 停止 $PROJECT_NAME..."
|
||||||
|
|
||||||
|
# 停止应用
|
||||||
|
sudo supervisorctl stop $PROJECT_NAME
|
||||||
|
|
||||||
|
# 检查状态
|
||||||
|
sudo supervisorctl status $PROJECT_NAME
|
||||||
|
|
||||||
|
echo "✅ 服务已停止!"
|
||||||
29
supervisor.conf
Normal file
29
supervisor.conf
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
[program:ball-tracking-server]
|
||||||
|
; Supervisor 配置文件
|
||||||
|
; 复制到 /etc/supervisor/conf.d/ 目录
|
||||||
|
|
||||||
|
; 基本配置
|
||||||
|
command=/path/to/your/ball-tracking/server-neo/venv/bin/gunicorn --config gunicorn.conf.py wsgi:app
|
||||||
|
directory=/path/to/your/ball-tracking/server-neo
|
||||||
|
user=www-data
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startretries=3
|
||||||
|
redirect_stderr=true
|
||||||
|
|
||||||
|
; 日志配置
|
||||||
|
stdout_logfile=/path/to/your/ball-tracking/server-neo/logs/supervisor.log
|
||||||
|
stdout_logfile_maxbytes=50MB
|
||||||
|
stdout_logfile_backups=5
|
||||||
|
|
||||||
|
; 环境变量
|
||||||
|
environment=PATH="/path/to/your/ball-tracking/server-neo/venv/bin",FLASK_ENV="production"
|
||||||
|
|
||||||
|
; 进程管理
|
||||||
|
killasgroup=true
|
||||||
|
stopasgroup=true
|
||||||
|
stopsignal=TERM
|
||||||
|
|
||||||
|
; 其他配置
|
||||||
|
priority=999
|
||||||
|
numprocs=1
|
||||||
Loading…
x
Reference in New Issue
Block a user