feat: 添加桌面版应用和心跳监控
新增功能: - src/desktop.py: PyQt6 桌面应用,支持 Droidcam 摄像头 - 实时视频预览 + 绿色扫描框叠加 - 空格键快速拍照识别 - 批量记录管理和 Excel 导出 - heartbeat.py: 服务心跳监控,自动重启 Streamlit - requirements-desktop.txt: 桌面版专用依赖 Web 版优化: - src/app.py: 自定义摄像头组件,扫描框叠加到视频流 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
53
heartbeat.py
Normal file
53
heartbeat.py
Normal file
@@ -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()
|
||||
8
requirements-desktop.txt
Normal file
8
requirements-desktop.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
# 桌面版依赖(本地电脑安装)
|
||||
paddleocr>=2.6,<3
|
||||
paddlepaddle>=2.5,<3
|
||||
pandas
|
||||
openpyxl
|
||||
pydantic
|
||||
PyQt6
|
||||
opencv-python
|
||||
251
src/app.py
251
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("""
|
||||
<style>
|
||||
.stApp { max-width: 100%; }
|
||||
.stButton>button { width: 100%; height: 3em; font-size: 1.2em; }
|
||||
.stDownloadButton>button { width: 100%; height: 3em; }
|
||||
</style>
|
||||
""", 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 = """
|
||||
<div id="camera-container" style="position:relative; width:100%; max-width:500px; margin:0 auto;">
|
||||
<video id="video" autoplay playsinline style="width:100%; border-radius:10px; background:#000;"></video>
|
||||
|
||||
if uploaded_files:
|
||||
all_records = []
|
||||
<!-- 扫描框叠加层 -->
|
||||
<div id="overlay" style="
|
||||
position: absolute;
|
||||
top: 8%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 88%;
|
||||
height: 70%;
|
||||
border: 3px solid #00ff00;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
">
|
||||
<!-- 四角 -->
|
||||
<div style="position:absolute; top:-3px; left:-3px; width:20px; height:20px; border-top:4px solid #00ff00; border-left:4px solid #00ff00;"></div>
|
||||
<div style="position:absolute; top:-3px; right:-3px; width:20px; height:20px; border-top:4px solid #00ff00; border-right:4px solid #00ff00;"></div>
|
||||
<div style="position:absolute; bottom:-3px; left:-3px; width:20px; height:20px; border-bottom:4px solid #00ff00; border-left:4px solid #00ff00;"></div>
|
||||
<div style="position:absolute; bottom:-3px; right:-3px; width:20px; height:20px; border-bottom:4px solid #00ff00; border-right:4px solid #00ff00;"></div>
|
||||
|
||||
progress = st.progress(0)
|
||||
status = st.empty()
|
||||
<!-- 字段提示:邮编(左上)、地址(中间)、联系人+电话(底部) -->
|
||||
<div style="position:absolute; top:8px; left:10px; color:rgba(255,255,255,0.6); font-size:12px;">邮编</div>
|
||||
<div style="position:absolute; top:35%; left:10px; right:10px; color:rgba(255,255,255,0.6); font-size:12px; border-bottom:1px dashed rgba(255,255,255,0.3); padding-bottom:30%;">地址</div>
|
||||
<div style="position:absolute; bottom:8px; left:10px; color:rgba(255,255,255,0.6); font-size:12px;">联系人</div>
|
||||
<div style="position:absolute; bottom:8px; right:10px; color:rgba(255,255,255,0.6); font-size:12px;">电话</div>
|
||||
</div>
|
||||
|
||||
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))
|
||||
<!-- 编号提示在框外底部 -->
|
||||
<div style="position:absolute; bottom:18%; left:50%; transform:translateX(-50%); color:rgba(255,255,255,0.6); font-size:11px;">
|
||||
↑ 编号在此处 ↑
|
||||
</div>
|
||||
|
||||
status.text("处理完成!")
|
||||
<canvas id="canvas" style="display:none;"></canvas>
|
||||
|
||||
# 显示结果表格
|
||||
df = pd.DataFrame(all_records)
|
||||
cols = ["文件名", "编号", "邮编", "地址", "联系人/单位名", "电话"]
|
||||
df = df.reindex(columns=cols)
|
||||
<p id="hint" style="text-align:center; color:#666; margin:10px 0; font-size:14px;">
|
||||
📌 将信封背面对齐绿色框,编号对准底部
|
||||
</p>
|
||||
|
||||
st.subheader("📋 提取结果")
|
||||
st.dataframe(df, use_container_width=True)
|
||||
<button id="capture-btn" onclick="capturePhoto()" style="
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
font-size: 18px;
|
||||
background: #ff4b4b;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
">📷 拍照识别</button>
|
||||
</div>
|
||||
|
||||
# 下载按钮
|
||||
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)
|
||||
<script>
|
||||
const video = document.getElementById('video');
|
||||
const canvas = document.getElementById('canvas');
|
||||
const hint = document.getElementById('hint');
|
||||
|
||||
# 预览图片和识别详情
|
||||
with st.expander("🔍 查看识别详情"):
|
||||
cols = st.columns(min(3, len(uploaded_files)))
|
||||
for i, file in enumerate(uploaded_files):
|
||||
with cols[i % 3]:
|
||||
st.image(file, caption=file.name, use_container_width=True)
|
||||
st.json(all_records[i])
|
||||
// 启动后置摄像头
|
||||
async function startCamera() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
|
||||
});
|
||||
video.srcObject = stream;
|
||||
} catch (err) {
|
||||
hint.textContent = '❌ 无法访问摄像头: ' + err.message;
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function capturePhoto() {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext('2d').drawImage(video, 0, 0);
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||
|
||||
// 发送到 Streamlit
|
||||
window.parent.postMessage({
|
||||
type: 'streamlit:setComponentValue',
|
||||
value: dataUrl
|
||||
}, '*');
|
||||
|
||||
hint.textContent = '✅ 已拍照,正在识别...';
|
||||
document.getElementById('capture-btn').disabled = true;
|
||||
}
|
||||
|
||||
startCamera();
|
||||
</script>
|
||||
"""
|
||||
|
||||
# 初始化 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("👆 使用上方拍照或上传功能添加记录")
|
||||
|
||||
354
src/desktop.py
Normal file
354
src/desktop.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user