From b9f9eb8b3a28e2c43f945b280c6a658af307b6a4 Mon Sep 17 00:00:00 2001 From: feie9456 Date: Sun, 10 Aug 2025 10:01:43 +0800 Subject: [PATCH] init --- .gitignore | 285 +++++++ DEPLOYMENT.md | 190 +++++ app/__init__.py | 53 ++ app/analysis.py | 279 +++++++ app/config.py | 26 + app/logging_config.py | 17 + app/routes.py | 248 +++++++ app/storage.py | 74 ++ app/web/README.md | 5 + app/web/bun.lock | 228 ++++++ app/web/index.html | 14 + app/web/package.json | 23 + app/web/src/App.vue | 604 +++++++++++++++ app/web/src/assets/vue.svg | 1 + app/web/src/components/PendulumAnalysis.vue | 779 ++++++++++++++++++++ app/web/src/main.css | 4 + app/web/src/main.ts | 4 + app/web/src/useCamera.ts | 54 ++ app/web/src/utils/chartUtils.ts | 240 ++++++ app/web/src/utils/yellowBallTracker.ts | 220 ++++++ app/web/src/vite-env.d.ts | 1 + app/web/tsconfig.app.json | 15 + app/web/tsconfig.json | 7 + app/web/tsconfig.node.json | 25 + app/web/vite.config.ts | 10 + deploy.sh | 85 +++ gunicorn.conf.py | 53 ++ requirements.txt | 8 + run.py | 6 + start.sh | 21 + stop.sh | 14 + supervisor.conf | 29 + wsgi.py | 3 + 33 files changed, 3625 insertions(+) create mode 100644 .gitignore create mode 100644 DEPLOYMENT.md create mode 100644 app/__init__.py create mode 100644 app/analysis.py create mode 100644 app/config.py create mode 100644 app/logging_config.py create mode 100644 app/routes.py create mode 100644 app/storage.py create mode 100644 app/web/README.md create mode 100644 app/web/bun.lock create mode 100644 app/web/index.html create mode 100644 app/web/package.json create mode 100644 app/web/src/App.vue create mode 100644 app/web/src/assets/vue.svg create mode 100644 app/web/src/components/PendulumAnalysis.vue create mode 100644 app/web/src/main.css create mode 100644 app/web/src/main.ts create mode 100644 app/web/src/useCamera.ts create mode 100644 app/web/src/utils/chartUtils.ts create mode 100644 app/web/src/utils/yellowBallTracker.ts create mode 100644 app/web/src/vite-env.d.ts create mode 100644 app/web/tsconfig.app.json create mode 100644 app/web/tsconfig.json create mode 100644 app/web/tsconfig.node.json create mode 100644 app/web/vite.config.ts create mode 100755 deploy.sh create mode 100644 gunicorn.conf.py create mode 100644 requirements.txt create mode 100644 run.py create mode 100755 start.sh create mode 100755 stop.sh create mode 100644 supervisor.conf create mode 100644 wsgi.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52b0596 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..6a61f44 --- /dev/null +++ b/DEPLOYMENT.md @@ -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 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 密钥登录 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..1c7936d --- /dev/null +++ b/app/__init__.py @@ -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('/') + 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 \ No newline at end of file diff --git a/app/analysis.py b/app/analysis.py new file mode 100644 index 0000000..018cc85 --- /dev/null +++ b/app/analysis.py @@ -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 \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..1ab129e --- /dev/null +++ b/app/config.py @@ -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 \ No newline at end of file diff --git a/app/logging_config.py b/app/logging_config.py new file mode 100644 index 0000000..9a600c3 --- /dev/null +++ b/app/logging_config.py @@ -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 \ No newline at end of file diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..dbc054e --- /dev/null +++ b/app/routes.py @@ -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//', 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/', methods=['DELETE']) +def delete_session(session_id: str): + storage = LocalSessionStorage() + ok = storage.delete_session(session_id) + return jsonify({'success': ok}) \ No newline at end of file diff --git a/app/storage.py b/app/storage.py new file mode 100644 index 0000000..f0128aa --- /dev/null +++ b/app/storage.py @@ -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 diff --git a/app/web/README.md b/app/web/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/app/web/README.md @@ -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 ` + + diff --git a/app/web/package.json b/app/web/package.json new file mode 100644 index 0000000..6f19fae --- /dev/null +++ b/app/web/package.json @@ -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" + } +} diff --git a/app/web/src/App.vue b/app/web/src/App.vue new file mode 100644 index 0000000..d177fc2 --- /dev/null +++ b/app/web/src/App.vue @@ -0,0 +1,604 @@ + + + + + diff --git a/app/web/src/assets/vue.svg b/app/web/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/app/web/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/web/src/components/PendulumAnalysis.vue b/app/web/src/components/PendulumAnalysis.vue new file mode 100644 index 0000000..78d03ef --- /dev/null +++ b/app/web/src/components/PendulumAnalysis.vue @@ -0,0 +1,779 @@ + + + + + \ No newline at end of file diff --git a/app/web/src/main.css b/app/web/src/main.css new file mode 100644 index 0000000..c95c7e5 --- /dev/null +++ b/app/web/src/main.css @@ -0,0 +1,4 @@ +body{ + margin: 0; + background-color: #1F1F1F; +} diff --git a/app/web/src/main.ts b/app/web/src/main.ts new file mode 100644 index 0000000..8b24c5f --- /dev/null +++ b/app/web/src/main.ts @@ -0,0 +1,4 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './main.css' +createApp(App).mount('#app') diff --git a/app/web/src/useCamera.ts b/app/web/src/useCamera.ts new file mode 100644 index 0000000..4315328 --- /dev/null +++ b/app/web/src/useCamera.ts @@ -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(null); + const devices = ref([]); + const activeDeviceId = ref(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 }; +} \ No newline at end of file diff --git a/app/web/src/utils/chartUtils.ts b/app/web/src/utils/chartUtils.ts new file mode 100644 index 0000000..2eab13c --- /dev/null +++ b/app/web/src/utils/chartUtils.ts @@ -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(); +} diff --git a/app/web/src/utils/yellowBallTracker.ts b/app/web/src/utils/yellowBallTracker.ts new file mode 100644 index 0000000..127d576 --- /dev/null +++ b/app/web/src/utils/yellowBallTracker.ts @@ -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) { + 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 + } +} diff --git a/app/web/src/vite-env.d.ts b/app/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/app/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/app/web/tsconfig.app.json b/app/web/tsconfig.app.json new file mode 100644 index 0000000..3dbbc45 --- /dev/null +++ b/app/web/tsconfig.app.json @@ -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"] +} diff --git a/app/web/tsconfig.json b/app/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/app/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/app/web/tsconfig.node.json b/app/web/tsconfig.node.json new file mode 100644 index 0000000..9728af2 --- /dev/null +++ b/app/web/tsconfig.node.json @@ -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"] +} diff --git a/app/web/vite.config.ts b/app/web/vite.config.ts new file mode 100644 index 0000000..43ad375 --- /dev/null +++ b/app/web/vite.config.ts @@ -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' + } +}) diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..41a092d --- /dev/null +++ b/deploy.sh @@ -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" diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..82b3a88 --- /dev/null +++ b/gunicorn.conf.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c436d69 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..c55038e --- /dev/null +++ b/run.py @@ -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) diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..13875e0 --- /dev/null +++ b/start.sh @@ -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" diff --git a/stop.sh b/stop.sh new file mode 100755 index 0000000..f04f124 --- /dev/null +++ b/stop.sh @@ -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 "✅ 服务已停止!" diff --git a/supervisor.conf b/supervisor.conf new file mode 100644 index 0000000..ef23e11 --- /dev/null +++ b/supervisor.conf @@ -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 diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..7368d5a --- /dev/null +++ b/wsgi.py @@ -0,0 +1,3 @@ +from app import create_app + +app = create_app() \ No newline at end of file