2025-10-26 10:03:07 +08:00
|
|
|
|
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,
|
2025-10-26 10:24:07 +08:00
|
|
|
|
comment TEXT,
|
2025-10-26 10:03:07 +08:00
|
|
|
|
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()
|
|
|
|
|
|
# 按时间倒序排列
|
2025-10-26 10:24:07 +08:00
|
|
|
|
cursor.execute("SELECT id, left_filename, right_filename, timestamp, metadata, comment, created_at FROM images ORDER BY timestamp DESC")
|
2025-10-26 10:03:07 +08:00
|
|
|
|
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],
|
2025-10-26 10:24:07 +08:00
|
|
|
|
"comment": row[5] or "", # 如果没有comment则显示空字符串
|
|
|
|
|
|
"created_at": row[6]
|
2025-10-26 10:03:07 +08:00
|
|
|
|
})
|
|
|
|
|
|
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') # 如果需要处理元数据
|
2025-10-26 10:24:07 +08:00
|
|
|
|
comment = request.form.get('comment', '') # 获取comment字段
|
2025-10-26 10:03:07 +08:00
|
|
|
|
|
|
|
|
|
|
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('''
|
2025-10-26 10:24:07 +08:00
|
|
|
|
INSERT INTO images (left_filename, right_filename, timestamp, metadata, comment)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, ?)
|
|
|
|
|
|
''', (left_filename, right_filename, float(timestamp_str), json.dumps(metadata), comment))
|
2025-10-26 10:03:07 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2025-10-26 10:24:07 +08:00
|
|
|
|
@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"})
|
|
|
|
|
|
|
2025-10-26 10:03:07 +08:00
|
|
|
|
@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)
|