commit b9f9eb8b3a28e2c43f945b280c6a658af307b6a4 Author: feie9456 Date: Sun Aug 10 10:01:43 2025 +0800 init 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