import cv2 import numpy as np from flask import Flask, render_template, request, jsonify, send_file from flask_socketio import SocketIO, emit import io import base64 import time import threading import logging import os import json import sqlite3 from datetime import datetime import zipfile import tempfile from cap_trigger import ImageClient # --- 配置 --- SAVE_PATH_LEFT = "./static/received/left" SAVE_PATH_RIGHT = "./static/received/right" FLASK_HOST = "0.0.0.0" FLASK_PORT = 5000 MAX_LIVE_FRAMES = 2 # 保留最新的几帧用于实时显示 DATABASE_PATH = "received_images.db" # SQLite 数据库文件路径 # --- 配置 --- # 设置日志 logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # --- 数据库初始化 --- def init_db(): """初始化 SQLite 数据库和表""" conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() # 创建表,存储图片信息 cursor.execute(''' CREATE TABLE IF NOT EXISTS images ( id INTEGER PRIMARY KEY AUTOINCREMENT, left_filename TEXT NOT NULL, right_filename TEXT NOT NULL, timestamp REAL NOT NULL, metadata TEXT, comment TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') conn.commit() conn.close() logger.info(f"Database {DATABASE_PATH} initialized.") # --- 全局变量 --- # 用于存储最新的左右帧,用于实时显示 latest_left_frame = None latest_right_frame = None latest_timestamp = None frame_lock = threading.Lock() # 保护全局帧变量 # --- Flask & SocketIO 应用 --- app = Flask(__name__) # 为生产环境配置 SECRET_KEY app.config['SECRET_KEY'] = 'your-secret-key-change-this' # 配置异步模式,如果需要异步处理可以调整 socketio = SocketIO(app, cors_allowed_origins="*") # 允许所有来源,生产环境请具体配置 # 初始化图像客户端 image_client = ImageClient("tcp://127.0.0.1:54321", client_id="local") # 初始化数据库 init_db() # --- Flask 路由 --- @app.route('/') def index(): """主页,加载实时图像页面""" return render_template('index.html') @app.route('/list') # 新增路由用于显示图片列表 def list_images(): """加载图片列表页面""" return render_template('list_images.html') @app.route('/api/images', methods=['GET']) def get_images_api(): """API: 获取图片列表 (JSON 格式)""" conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() # 按时间倒序排列 cursor.execute("SELECT id, left_filename, right_filename, timestamp, metadata, comment, created_at FROM images ORDER BY timestamp DESC") rows = cursor.fetchall() conn.close() images = [] for row in rows: images.append({ "id": row[0], "left_filename": row[1], "right_filename": row[2], "timestamp": row[3], "metadata": row[4], "comment": row[5] or "", # 如果没有comment则显示空字符串 "created_at": row[6] }) return jsonify(images) @app.route('/api/images', methods=['DELETE']) def delete_image_api(): """API: 删除单张图片记录及其文件""" image_id = request.json.get('id') if not image_id: return jsonify({"error": "Image ID is required"}), 400 conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() # 查询文件名 cursor.execute("SELECT left_filename, right_filename FROM images WHERE id = ?", (image_id,)) row = cursor.fetchone() if not row: conn.close() return jsonify({"error": "Image not found"}), 404 left_filename, right_filename = row # 删除数据库记录 cursor.execute("DELETE FROM images WHERE id = ?", (image_id,)) conn.commit() conn.close() # 删除对应的文件 left_path = os.path.join(SAVE_PATH_LEFT, left_filename) right_path = os.path.join(SAVE_PATH_RIGHT, right_filename) try: if os.path.exists(left_path): os.remove(left_path) logger.info(f"Deleted file: {left_path}") if os.path.exists(right_path): os.remove(right_path) logger.info(f"Deleted file: {right_path}") except OSError as e: logger.error(f"Error deleting files: {e}") # 即使删除文件失败,数据库记录也已删除,返回成功 pass return jsonify({"message": f"Image {image_id} deleted successfully"}) @app.route('/api/images/export', methods=['POST']) def export_images_api(): """API: 打包导出选中的图片""" selected_ids = request.json.get('ids', []) if not selected_ids: return jsonify({"error": "No image IDs selected"}), 400 conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() placeholders = ','.join('?' * len(selected_ids)) cursor.execute(f"SELECT left_filename, right_filename FROM images WHERE id IN ({placeholders})", selected_ids) rows = cursor.fetchall() conn.close() if not rows: return jsonify({"error": "No matching images found"}), 404 # 创建临时 ZIP 文件 temp_zip_fd, temp_zip_path = tempfile.mkstemp(suffix='.zip') os.close(temp_zip_fd) # 关闭文件描述符,让 zipfile 模块管理 try: with zipfile.ZipFile(temp_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: for left_fn, right_fn in rows: left_path = os.path.join(SAVE_PATH_LEFT, left_fn) right_path = os.path.join(SAVE_PATH_RIGHT, right_fn) if os.path.exists(left_path): zipf.write(left_path, os.path.join('left', left_fn)) if os.path.exists(right_path): zipf.write(right_path, os.path.join('right', right_fn)) logger.info(f"Exported {len(rows)} image pairs to {temp_zip_path}") # 返回 ZIP 文件给客户端 return send_file(temp_zip_path, as_attachment=True, download_name='exported_images.zip') except Exception as e: logger.error(f"Error creating export ZIP: {e}") if os.path.exists(temp_zip_path): os.remove(temp_zip_path) return jsonify({"error": str(e)}), 500 @app.route('/upload', methods=['POST']) def upload_images(): """接收左右摄像头图片,保存并推送更新""" try: # 从 multipart/form-data 中获取文件 left_file = request.files.get('left_image') right_file = request.files.get('right_image') metadata_str = request.form.get('metadata') # 如果需要处理元数据 comment = request.form.get('comment', '') # 获取comment字段 if not left_file or not right_file: logger.warning("Received request without required image files.") return jsonify({"error": "Missing left_image or right_image"}), 400 # 读取图片数据 left_img_bytes = left_file.read() right_img_bytes = right_file.read() # 解码图片用于后续处理 (如显示、保存) nparr_left = np.frombuffer(left_img_bytes, np.uint8) nparr_right = np.frombuffer(right_img_bytes, np.uint8) img_left = cv2.imdecode(nparr_left, cv2.IMREAD_COLOR) img_right = cv2.imdecode(nparr_right, cv2.IMREAD_COLOR) if img_left is None or img_right is None: logger.error("Failed to decode received images.") return jsonify({"error": "Could not decode images"}), 400 # 解析元数据 (如果提供) metadata = {} if metadata_str: try: metadata = json.loads(metadata_str) except json.JSONDecodeError as e: logger.warning(f"Could not parse metadata: {e}") timestamp_str = str(metadata.get("timestamp", str(int(time.time())))) timestamp_str_safe = timestamp_str.replace(".", "_") # 避免文件名中的点号问题 # 生成文件名 left_filename = f"left_{timestamp_str_safe}.jpg" right_filename = f"right_{timestamp_str_safe}.jpg" # 保存图片到本地 left_path = os.path.join(SAVE_PATH_LEFT, left_filename) right_path = os.path.join(SAVE_PATH_RIGHT, right_filename) # 确保目录存在 os.makedirs(SAVE_PATH_LEFT, exist_ok=True) os.makedirs(SAVE_PATH_RIGHT, exist_ok=True) cv2.imwrite(left_path, img_left) cv2.imwrite(right_path, img_right) logger.info(f"Saved images: {left_path}, {right_path}") # 将图片信息写入数据库 conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() cursor.execute(''' INSERT INTO images (left_filename, right_filename, timestamp, metadata, comment) VALUES (?, ?, ?, ?, ?) ''', (left_filename, right_filename, float(timestamp_str), json.dumps(metadata), comment)) conn.commit() image_id = cursor.lastrowid # 获取新插入记录的 ID conn.close() logger.info(f"Recorded image pair (ID: {image_id}) in database.") # 将 OpenCV 图像编码为 base64 字符串,用于 WebSocket 传输 _, left_encoded = cv2.imencode('.jpg', img_left) _, right_encoded = cv2.imencode('.jpg', img_right) left_b64 = base64.b64encode(left_encoded).decode('utf-8') right_b64 = base64.b64encode(right_encoded).decode('utf-8') # 更新用于实时显示的全局变量 (如果需要) with frame_lock: global latest_left_frame, latest_right_frame, latest_timestamp latest_left_frame = img_left latest_right_frame = img_right latest_timestamp = timestamp_str socketio.emit('update_image', { 'left_image': left_b64, 'right_image': right_b64, 'timestamp': timestamp_str }) return jsonify({"message": "Images received, saved, and pushed via WebSocket", "timestamp": timestamp_str, "id": image_id}) except Exception as e: logger.error(f"Error processing upload: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/images/comment', methods=['PUT']) def update_image_comment(): """API: 更新图片的comment""" data = request.json image_id = data.get('id') comment = data.get('comment', '') if not image_id: return jsonify({"error": "Image ID is required"}), 400 conn = sqlite3.connect(DATABASE_PATH) cursor = conn.cursor() # 更新comment字段 cursor.execute("UPDATE images SET comment = ? WHERE id = ?", (comment, image_id)) conn.commit() conn.close() return jsonify({"message": f"Comment for image {image_id} updated successfully"}) @app.route('/status') def status(): with frame_lock: has_frames = latest_left_frame is not None and latest_right_frame is not None timestamp = latest_timestamp if has_frames else "N/A" return jsonify({"has_frames": has_frames, "latest_timestamp": timestamp}) # --- SocketIO 事件处理程序 --- @socketio.event def connect(): """客户端连接事件""" logger.info("Client connected") @socketio.event def disconnect(): """客户端断开连接事件""" logger.info("Client disconnected") @socketio.event def capture_button(data): """ SocketIO 事件处理器,当客户端发送 'button_pressed' 事件时触发 """ try: image_client.request_sync() logger.info("Request sent to server.") except Exception as e: logger.error(f"Error sending request: {e}") if __name__ == '__main__': logger.info(f"Starting Flask-SocketIO server on {FLASK_HOST}:{FLASK_PORT}") socketio.run(app, host=FLASK_HOST, port=FLASK_PORT, debug=False, allow_unsafe_werkzeug=True)