From 35d05d47014706687cfb76d5cb0bdd8b2135673c Mon Sep 17 00:00:00 2001 From: root Date: Thu, 12 Feb 2026 12:23:31 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E7=89=88=E5=BA=94=E7=94=A8=E5=92=8C=E5=BF=83=E8=B7=B3=E7=9B=91?= =?UTF-8?q?=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: - src/desktop.py: PyQt6 桌面应用,支持 Droidcam 摄像头 - 实时视频预览 + 绿色扫描框叠加 - 空格键快速拍照识别 - 批量记录管理和 Excel 导出 - heartbeat.py: 服务心跳监控,自动重启 Streamlit - requirements-desktop.txt: 桌面版专用依赖 Web 版优化: - src/app.py: 自定义摄像头组件,扫描框叠加到视频流 Co-Authored-By: Claude Opus 4.5 --- heartbeat.py | 53 ++++++ requirements-desktop.txt | 8 + src/app.py | 251 ++++++++++++++++++++++----- src/desktop.py | 354 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 620 insertions(+), 46 deletions(-) create mode 100644 heartbeat.py create mode 100644 requirements-desktop.txt create mode 100644 src/desktop.py diff --git a/heartbeat.py b/heartbeat.py new file mode 100644 index 0000000..1373da2 --- /dev/null +++ b/heartbeat.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""心跳程序 - 保持服务活跃""" +import sys +import time +import subprocess +import requests +from datetime import datetime + +# 禁用输出缓冲 +sys.stdout.reconfigure(line_buffering=True) + + +def log(msg): + print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] {msg}", flush=True) + + +def check_streamlit(): + """检查 Streamlit 服务""" + try: + r = requests.get("http://localhost:8501", timeout=5) + return r.status_code == 200 + except: + return False + + +def restart_streamlit(): + """重启 Streamlit""" + subprocess.run(["pkill", "-f", "streamlit run"], capture_output=True) + time.sleep(2) + subprocess.Popen( + ["streamlit", "run", "src/app.py", "--server.port", "8501", "--server.address", "0.0.0.0"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + print(f"[{datetime.now():%H:%M:%S}] Streamlit 已重启") + + +def main(): + log("心跳程序启动") + + while True: + if not check_streamlit(): + log("Streamlit 无响应,正在重启...") + restart_streamlit() + time.sleep(10) + else: + log("✓ 服务正常") + + time.sleep(60) # 每分钟检查一次 + + +if __name__ == "__main__": + main() diff --git a/requirements-desktop.txt b/requirements-desktop.txt new file mode 100644 index 0000000..4931fca --- /dev/null +++ b/requirements-desktop.txt @@ -0,0 +1,8 @@ +# 桌面版依赖(本地电脑安装) +paddleocr>=2.6,<3 +paddlepaddle>=2.5,<3 +pandas +openpyxl +pydantic +PyQt6 +opencv-python diff --git a/src/app.py b/src/app.py index 63eeee5..c6e2dd1 100644 --- a/src/app.py +++ b/src/app.py @@ -1,14 +1,30 @@ import os import tempfile +import base64 import pandas as pd import streamlit as st +import streamlit.components.v1 as components from paddleocr import PaddleOCR from processor import extract_info, save_to_excel os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True" -st.set_page_config(page_title="信封信息提取系统", page_icon="📮", layout="wide") -st.title("📮 信封信息提取系统") +st.set_page_config( + page_title="信封信息提取系统", + page_icon="📮", + layout="centered", + initial_sidebar_state="collapsed", +) + +st.markdown(""" + +""", unsafe_allow_html=True) + +st.title("📮 信封信息提取") @st.cache_resource @@ -19,10 +35,10 @@ def load_ocr(): ocr = load_ocr() -def process_image(image_file): - """处理单张图片""" +def process_image(image_data): + """处理图片数据""" with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp: - tmp.write(image_file.getvalue()) + tmp.write(image_data) tmp_path = tmp.name try: @@ -37,52 +53,195 @@ def process_image(image_file): os.unlink(tmp_path) -# 文件上传 -uploaded_files = st.file_uploader( - "上传信封图片(支持批量)", - type=["jpg", "jpeg", "png", "bmp"], - accept_multiple_files=True, -) +# 自定义摄像头组件,带叠加扫描框 +CAMERA_COMPONENT = """ +
+ -if uploaded_files: - all_records = [] + +
+ +
+
+
+
- progress = st.progress(0) - status = st.empty() + +
邮编
+
地址
+
联系人
+
电话
+
- for i, file in enumerate(uploaded_files): - status.text(f"正在处理: {file.name}") - record, raw_texts = process_image(file) - record["文件名"] = file.name - all_records.append(record) - progress.progress((i + 1) / len(uploaded_files)) + +
+ ↑ 编号在此处 ↑ +
- status.text("处理完成!") + - # 显示结果表格 - df = pd.DataFrame(all_records) - cols = ["文件名", "编号", "邮编", "地址", "联系人/单位名", "电话"] - df = df.reindex(columns=cols) +

+ 📌 将信封背面对齐绿色框,编号对准底部 +

- st.subheader("📋 提取结果") - st.dataframe(df, use_container_width=True) + +
- # 下载按钮 - output_path = tempfile.mktemp(suffix=".xlsx") - df.to_excel(output_path, index=False) - with open(output_path, "rb") as f: - st.download_button( - label="📥 下载 Excel", - data=f, - file_name="信封提取结果.xlsx", - mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ) - os.unlink(output_path) + +""" + +# 初始化 session state +if "records" not in st.session_state: + st.session_state.records = [] + +# 输入方式选择 +tab_camera, tab_upload = st.tabs(["📷 拍照扫描", "📁 上传图片"]) + +with tab_camera: + # 使用自定义摄像头组件 + photo_data = components.html(CAMERA_COMPONENT, height=550) + + # 检查是否有拍照数据 + if "captured_image" not in st.session_state: + st.session_state.captured_image = None + + # 文件上传作为备用(用于接收JS传来的数据) + uploaded_photo = st.file_uploader( + "或直接上传照片", + type=["jpg", "jpeg", "png"], + key="camera_upload", + label_visibility="collapsed" + ) + + if uploaded_photo: + with st.spinner("识别中..."): + record, raw_texts = process_image(uploaded_photo.getvalue()) + + st.success("✅ 识别完成!") + + col1, col2 = st.columns(2) + with col1: + st.image(uploaded_photo, caption="拍摄图片", use_container_width=True) + with col2: + st.metric("邮编", record.get("邮编", "-")) + st.metric("电话", record.get("电话", "-")) + st.metric("联系人", record.get("联系人/单位名", "-")) + + st.text_area("地址", record.get("地址", ""), disabled=True, height=68) + st.text_input("编号", record.get("编号", ""), disabled=True) + + if st.button("✅ 添加到列表", type="primary", key="add_camera"): + record["来源"] = "拍照" + st.session_state.records.append(record) + st.success(f"已添加!当前共 {len(st.session_state.records)} 条记录") + st.rerun() + +with tab_upload: + uploaded_files = st.file_uploader( + "选择图片文件", + type=["jpg", "jpeg", "png", "bmp"], + accept_multiple_files=True, + label_visibility="collapsed", + ) + + if uploaded_files: + if st.button("🚀 开始识别", type="primary"): + progress = st.progress(0) + + for i, file in enumerate(uploaded_files): + with st.spinner(f"处理 {file.name}..."): + record, _ = process_image(file.getvalue()) + record["来源"] = file.name + st.session_state.records.append(record) + progress.progress((i + 1) / len(uploaded_files)) + + st.success(f"完成!已添加 {len(uploaded_files)} 条记录") + st.rerun() + +# 显示已收集的记录 +st.divider() +st.subheader(f"📋 已收集 {len(st.session_state.records)} 条记录") + +if st.session_state.records: + df = pd.DataFrame(st.session_state.records) + cols = ["来源", "编号", "邮编", "地址", "联系人/单位名", "电话"] + df = df.reindex(columns=[c for c in cols if c in df.columns]) + + st.dataframe(df, use_container_width=True, hide_index=True) + + col1, col2 = st.columns(2) + + with col1: + output_path = tempfile.mktemp(suffix=".xlsx") + df.to_excel(output_path, index=False) + with open(output_path, "rb") as f: + st.download_button( + "📥 下载 Excel", + data=f, + file_name="信封提取结果.xlsx", + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + os.unlink(output_path) + + with col2: + if st.button("🗑️ 清空列表"): + st.session_state.records = [] + st.rerun() +else: + st.info("👆 使用上方拍照或上传功能添加记录") diff --git a/src/desktop.py b/src/desktop.py new file mode 100644 index 0000000..c1be899 --- /dev/null +++ b/src/desktop.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +""" +信封信息提取系统 - 桌面版 +使用 Droidcam 将手机作为摄像头,实时预览并识别信封信息 +""" +import os +import sys +import cv2 +import tempfile +import pandas as pd +from datetime import datetime +from pathlib import Path + +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QLabel, QTableWidget, QTableWidgetItem, QComboBox, + QFileDialog, QMessageBox, QGroupBox, QSplitter, QHeaderView, + QStatusBar, QProgressBar +) +from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal +from PyQt6.QtGui import QImage, QPixmap, QFont, QAction + +from paddleocr import PaddleOCR +from processor import extract_info + +os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True" + + +class OCRWorker(QThread): + """OCR 识别线程""" + finished = pyqtSignal(dict, list) + error = pyqtSignal(str) + + def __init__(self, ocr, image_path): + super().__init__() + self.ocr = ocr + self.image_path = image_path + + def run(self): + try: + result = self.ocr.ocr(self.image_path, cls=False) + ocr_texts = [] + if result and result[0]: + for line in result[0]: + if line and len(line) >= 2: + ocr_texts.append(line[1][0]) + record = extract_info(ocr_texts) + self.finished.emit(record, ocr_texts) + except Exception as e: + self.error.emit(str(e)) + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("📮 信封信息提取系统") + self.setMinimumSize(1200, 700) + + # 初始化 OCR + self.statusBar().showMessage("正在加载 OCR 模型...") + QApplication.processEvents() + self.ocr = PaddleOCR(use_textline_orientation=True, lang="ch", show_log=False) + self.statusBar().showMessage("OCR 模型加载完成") + + # 摄像头 + self.cap = None + self.timer = QTimer() + self.timer.timeout.connect(self.update_frame) + + # 数据 + self.records = [] + + self.init_ui() + self.load_cameras() + + def init_ui(self): + central = QWidget() + self.setCentralWidget(central) + layout = QHBoxLayout(central) + + # 左侧:摄像头预览 + left_panel = QGroupBox("📷 摄像头预览") + left_layout = QVBoxLayout(left_panel) + + # 摄像头选择 + cam_layout = QHBoxLayout() + cam_layout.addWidget(QLabel("摄像头:")) + self.cam_combo = QComboBox() + self.cam_combo.setMinimumWidth(200) + cam_layout.addWidget(self.cam_combo) + self.btn_refresh = QPushButton("🔄 刷新") + self.btn_refresh.clicked.connect(self.load_cameras) + cam_layout.addWidget(self.btn_refresh) + self.btn_connect = QPushButton("▶ 连接") + self.btn_connect.clicked.connect(self.toggle_camera) + cam_layout.addWidget(self.btn_connect) + cam_layout.addStretch() + left_layout.addLayout(cam_layout) + + # 视频画面 + self.video_label = QLabel() + self.video_label.setMinimumSize(640, 480) + self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.video_label.setStyleSheet("background-color: #1a1a1a; border: 2px solid #333; border-radius: 8px;") + self.video_label.setText("点击「连接」启动摄像头\n\n支持 Droidcam / Iriun 等虚拟摄像头") + left_layout.addWidget(self.video_label) + + # 拍照按钮 + self.btn_capture = QPushButton("📸 拍照识别 (空格键)") + self.btn_capture.setMinimumHeight(50) + self.btn_capture.setFont(QFont("", 14)) + self.btn_capture.setStyleSheet("background-color: #ff4b4b; color: white; border-radius: 8px;") + self.btn_capture.clicked.connect(self.capture_and_recognize) + self.btn_capture.setEnabled(False) + left_layout.addWidget(self.btn_capture) + + # 右侧:结果列表 + right_panel = QGroupBox(f"📋 已识别记录 (0)") + self.right_panel = right_panel + right_layout = QVBoxLayout(right_panel) + + # 表格 + self.table = QTableWidget() + self.table.setColumnCount(5) + self.table.setHorizontalHeaderLabels(["编号", "邮编", "地址", "联系人", "电话"]) + self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + right_layout.addWidget(self.table) + + # 操作按钮 + btn_layout = QHBoxLayout() + self.btn_delete = QPushButton("🗑 删除选中") + self.btn_delete.clicked.connect(self.delete_selected) + btn_layout.addWidget(self.btn_delete) + + self.btn_clear = QPushButton("🧹 清空全部") + self.btn_clear.clicked.connect(self.clear_all) + btn_layout.addWidget(self.btn_clear) + + self.btn_export = QPushButton("📥 导出 Excel") + self.btn_export.setStyleSheet("background-color: #4CAF50; color: white;") + self.btn_export.clicked.connect(self.export_excel) + btn_layout.addWidget(self.btn_export) + + right_layout.addLayout(btn_layout) + + # 分割器 + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.addWidget(left_panel) + splitter.addWidget(right_panel) + splitter.setSizes([600, 500]) + layout.addWidget(splitter) + + # 快捷键 + self.shortcut_capture = QAction(self) + self.shortcut_capture.setShortcut("Space") + self.shortcut_capture.triggered.connect(self.capture_and_recognize) + self.addAction(self.shortcut_capture) + + def load_cameras(self): + """扫描可用摄像头""" + self.cam_combo.clear() + for i in range(10): + cap = cv2.VideoCapture(i) + if cap.isOpened(): + ret, _ = cap.read() + if ret: + self.cam_combo.addItem(f"摄像头 {i}", i) + cap.release() + + if self.cam_combo.count() == 0: + self.cam_combo.addItem("未检测到摄像头", -1) + self.statusBar().showMessage("未检测到摄像头,请连接 Droidcam") + else: + self.statusBar().showMessage(f"检测到 {self.cam_combo.count()} 个摄像头") + + def toggle_camera(self): + """连接/断开摄像头""" + if self.cap is None: + cam_id = self.cam_combo.currentData() + if cam_id is None or cam_id < 0: + QMessageBox.warning(self, "错误", "请先选择有效的摄像头") + return + + self.cap = cv2.VideoCapture(cam_id) + self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) + self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) + + if self.cap.isOpened(): + self.timer.start(30) # ~33 FPS + self.btn_connect.setText("⏹ 断开") + self.btn_capture.setEnabled(True) + self.cam_combo.setEnabled(False) + self.statusBar().showMessage("摄像头已连接") + else: + self.cap = None + QMessageBox.warning(self, "错误", "无法打开摄像头") + else: + self.timer.stop() + self.cap.release() + self.cap = None + self.btn_connect.setText("▶ 连接") + self.btn_capture.setEnabled(False) + self.cam_combo.setEnabled(True) + self.video_label.setText("摄像头已断开") + self.statusBar().showMessage("摄像头已断开") + + def update_frame(self): + """更新视频帧""" + if self.cap is None: + return + + ret, frame = self.cap.read() + if ret: + # 绘制扫描框 + h, w = frame.shape[:2] + # 框的位置:上方 70%,编号在下方 + x1, y1 = int(w * 0.06), int(h * 0.08) + x2, y2 = int(w * 0.94), int(h * 0.78) + + # 绘制绿色边框 + cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) + + # 四角加粗 + corner_len = 25 + cv2.line(frame, (x1, y1), (x1 + corner_len, y1), (0, 255, 0), 4) + cv2.line(frame, (x1, y1), (x1, y1 + corner_len), (0, 255, 0), 4) + cv2.line(frame, (x2, y1), (x2 - corner_len, y1), (0, 255, 0), 4) + cv2.line(frame, (x2, y1), (x2, y1 + corner_len), (0, 255, 0), 4) + cv2.line(frame, (x1, y2), (x1 + corner_len, y2), (0, 255, 0), 4) + cv2.line(frame, (x1, y2), (x1, y2 - corner_len), (0, 255, 0), 4) + cv2.line(frame, (x2, y2), (x2 - corner_len, y2), (0, 255, 0), 4) + cv2.line(frame, (x2, y2), (x2, y2 - corner_len), (0, 255, 0), 4) + + # 提示文字 + cv2.putText(frame, "You Bian", (x1 + 10, y1 + 25), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1) + cv2.putText(frame, "Di Zhi", (x1 + 10, y1 + int((y2-y1)*0.4)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1) + cv2.putText(frame, "Lian Xi Ren", (x1 + 10, y2 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1) + cv2.putText(frame, "Dian Hua", (x2 - 80, y2 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1) + + # 编号提示 + cv2.putText(frame, "^ Bian Hao ^", (int(w*0.4), int(h*0.88)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) + + # 转换为 Qt 图像 + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + h, w, ch = rgb.shape + qimg = QImage(rgb.data, w, h, ch * w, QImage.Format.Format_RGB888) + scaled = qimg.scaled(self.video_label.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + self.video_label.setPixmap(QPixmap.fromImage(scaled)) + + def capture_and_recognize(self): + """拍照并识别""" + if self.cap is None: + return + + ret, frame = self.cap.read() + if not ret: + self.statusBar().showMessage("拍照失败") + return + + # 保存临时文件 + tmp_path = tempfile.mktemp(suffix=".jpg") + cv2.imwrite(tmp_path, frame) + + self.statusBar().showMessage("正在识别...") + self.btn_capture.setEnabled(False) + + # 启动 OCR 线程 + self.worker = OCRWorker(self.ocr, tmp_path) + self.worker.finished.connect(lambda r, t: self.on_ocr_finished(r, t, tmp_path)) + self.worker.error.connect(lambda e: self.on_ocr_error(e, tmp_path)) + self.worker.start() + + def on_ocr_finished(self, record, texts, tmp_path): + """OCR 完成""" + os.unlink(tmp_path) + self.btn_capture.setEnabled(True) + + # 添加到记录 + self.records.append(record) + self.update_table() + + self.statusBar().showMessage(f"识别完成: {record.get('联系人/单位名', '未知')}") + + def on_ocr_error(self, error, tmp_path): + """OCR 错误""" + os.unlink(tmp_path) + self.btn_capture.setEnabled(True) + self.statusBar().showMessage(f"识别失败: {error}") + + def update_table(self): + """更新表格""" + self.table.setRowCount(len(self.records)) + for i, r in enumerate(self.records): + self.table.setItem(i, 0, QTableWidgetItem(r.get("编号", ""))) + self.table.setItem(i, 1, QTableWidgetItem(r.get("邮编", ""))) + self.table.setItem(i, 2, QTableWidgetItem(r.get("地址", ""))) + self.table.setItem(i, 3, QTableWidgetItem(r.get("联系人/单位名", ""))) + self.table.setItem(i, 4, QTableWidgetItem(r.get("电话", ""))) + + self.right_panel.setTitle(f"📋 已识别记录 ({len(self.records)})") + + def delete_selected(self): + """删除选中行""" + rows = set(item.row() for item in self.table.selectedItems()) + for row in sorted(rows, reverse=True): + del self.records[row] + self.update_table() + + def clear_all(self): + """清空全部""" + if self.records: + reply = QMessageBox.question(self, "确认", f"确定清空全部 {len(self.records)} 条记录?") + if reply == QMessageBox.StandardButton.Yes: + self.records.clear() + self.update_table() + + def export_excel(self): + """导出 Excel""" + if not self.records: + QMessageBox.warning(self, "提示", "没有可导出的记录") + return + + default_name = f"信封提取_{datetime.now():%Y%m%d_%H%M%S}.xlsx" + path, _ = QFileDialog.getSaveFileName(self, "保存 Excel", default_name, "Excel Files (*.xlsx)") + + if path: + df = pd.DataFrame(self.records) + cols = ["编号", "邮编", "地址", "联系人/单位名", "电话"] + df = df.reindex(columns=cols) + df.to_excel(path, index=False) + self.statusBar().showMessage(f"已导出: {path}") + QMessageBox.information(self, "成功", f"已导出 {len(self.records)} 条记录到:\n{path}") + + def closeEvent(self, event): + """关闭窗口""" + if self.cap: + self.timer.stop() + self.cap.release() + event.accept() + + +def main(): + app = QApplication(sys.argv) + app.setStyle("Fusion") + + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main()