Files
ros_flexiv/infer_holes_realtime.py
orisys 4ad53f4e97 first
2026-08-21 14:51:57 +08:00

480 lines
19 KiB
Python

"""工件螺纹孔二阶段视频推理:YOLO / Faster R-CNN 定位 + HoughCircles。"""
from __future__ import annotations
import threading
import time
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
import cv2
import numpy as np
from PIL import Image, ImageTk
from detectors import guess_backend, load_detector
from hole_detect import HoleStabilizer, detect_holes, load_config
from infer_realtime import (
list_realsense,
open_source,
)
ROOT = Path(__file__).resolve().parent
def default_model_path(backend: str = "yolo") -> str:
if backend == "faster_rcnn":
candidates = [
ROOT / "runs" / "faster_rcnn" / "gongjian_frcnn" / "weights" / "best.pth",
ROOT / "runs" / "faster_rcnn" / "gongjian_frcnn" / "weights" / "last.pth",
]
for path in candidates:
if path.exists():
return str(path)
return str(ROOT / "runs" / "faster_rcnn" / "gongjian_frcnn" / "weights" / "best.pth")
candidates = [
ROOT / "ultralytics" / "runs" / "detect" / "runs" / "yolov8_gongjian" / "weights" / "best.pt",
ROOT / "ultralytics" / "runs" / "detect" / "runs" / "yolov8_gongjian" / "weights" / "last.pt",
ROOT / "ultralytics" / "runs" / "detect" / "runs" / "yolov8_exp" / "weights" / "best.pt",
ROOT / "yolov8n.pt",
]
for path in candidates:
if path.exists():
return str(path)
return str(ROOT / "yolov8n.pt")
def default_config_path() -> str:
path = ROOT / "hole_detect_config.yaml"
return str(path)
class HoleInferApp(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title("工件螺纹孔二阶段检测")
self.geometry("1180x860")
self.minsize(980, 720)
self.model = None
self.cfg: dict = {}
self.cam = None
self.running = False
self.opening = False
self.infer_enabled = False
self.photo: ImageTk.PhotoImage | None = None
self.latest: np.ndarray | None = None
self.last_result: dict | None = None
self.stabilizer = HoleStabilizer()
self.fps = 0.0
self.lock = threading.Lock()
self.cam_lock = threading.Lock()
self.fail_reads = 0
self.rs_devices = list_realsense()
self.var_backend = tk.StringVar(value="yolo")
self.var_model = tk.StringVar(value=default_model_path("yolo"))
self.var_num_classes = tk.IntVar(value=2)
self.var_config = tk.StringVar(value=default_config_path())
self.var_source = tk.StringVar(value="realsense" if self.rs_devices else "webcam")
self.var_rs = tk.StringVar(value=self.rs_devices[0][0] if self.rs_devices else "")
self.var_camera = tk.IntVar(value=1)
self.var_width = tk.IntVar(value=1280)
self.var_height = tk.IntVar(value=720)
self.var_device = tk.StringVar(value="0")
self.var_status = tk.StringVar(value="就绪:打开摄像头 → 开始推理")
self._build()
self.protocol("WM_DELETE_WINDOW", self.on_close)
self.after(33, self.refresh_view)
self.reload_config(silent=True)
def _build(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
view = ttk.Frame(self, padding=8)
view.grid(row=0, column=0, sticky="nsew")
view.columnconfigure(0, weight=1)
view.rowconfigure(0, weight=1)
self.canvas = tk.Label(view, bg="#111", fg="#eee", text="检测画面", width=80, height=20)
self.canvas.grid(row=0, column=0, sticky="nsew")
ttk.Label(view, textvariable=self.var_status).grid(row=1, column=0, sticky="w", pady=(8, 0))
panel = ttk.LabelFrame(self, text="二阶段检测参数", padding=10)
panel.grid(row=1, column=0, sticky="ew", padx=8, pady=(0, 8))
for c in range(8):
panel.columnconfigure(c, weight=1)
ttk.Label(panel, text="算法").grid(row=0, column=0, sticky="w")
ttk.Combobox(
panel,
textvariable=self.var_backend,
values=["yolo", "faster_rcnn", "auto"],
width=12,
state="readonly",
).grid(row=0, column=1, sticky="w")
ttk.Label(panel, text="RCNN类数(+背景)").grid(row=0, column=2, sticky="w")
ttk.Entry(panel, textvariable=self.var_num_classes, width=6).grid(row=0, column=3, sticky="w")
self.var_backend.trace_add("write", lambda *_: self.on_backend_change())
ttk.Label(panel, text="检测权重").grid(row=1, column=0, sticky="w", pady=(8, 0))
ttk.Entry(panel, textvariable=self.var_model).grid(row=1, column=1, columnspan=5, sticky="ew", padx=4, pady=(8, 0))
ttk.Button(panel, text="浏览…", command=self.browse_model).grid(row=1, column=6, sticky="ew", pady=(8, 0))
ttk.Button(panel, text="自动查找", command=self.autofill_model).grid(row=1, column=7, sticky="ew", padx=(4, 0), pady=(8, 0))
ttk.Label(panel, text="检测配置").grid(row=2, column=0, sticky="w", pady=(8, 0))
ttk.Entry(panel, textvariable=self.var_config).grid(row=2, column=1, columnspan=5, sticky="ew", padx=4, pady=(8, 0))
ttk.Button(panel, text="浏览…", command=self.browse_config).grid(row=2, column=6, sticky="ew", pady=(8, 0))
ttk.Button(panel, text="重新加载", command=self.reload_config).grid(row=2, column=7, sticky="ew", padx=(4, 0), pady=(8, 0))
ttk.Label(panel, text="视频源").grid(row=3, column=0, sticky="w", pady=(8, 0))
src = ttk.Frame(panel)
src.grid(row=3, column=1, columnspan=3, sticky="w", pady=(8, 0))
ttk.Radiobutton(src, text="RealSense RGB", variable=self.var_source, value="realsense").pack(side="left")
ttk.Radiobutton(src, text="普通摄像头", variable=self.var_source, value="webcam").pack(side="left", padx=(10, 0))
ttk.Label(panel, text="设备").grid(row=3, column=4, sticky="w", pady=(8, 0))
ttk.Combobox(panel, textvariable=self.var_device, values=["0", "cpu"], width=8, state="readonly").grid(
row=3, column=5, sticky="w", pady=(8, 0)
)
ttk.Label(panel, text="RealSense").grid(row=4, column=0, sticky="w", pady=(8, 0))
self.cmb_rs = ttk.Combobox(
panel,
textvariable=self.var_rs,
values=[x[0] for x in self.rs_devices],
state="readonly",
)
self.cmb_rs.grid(row=4, column=1, columnspan=3, sticky="ew", pady=(8, 0))
ttk.Button(panel, text="刷新设备", command=self.refresh_devices).grid(row=4, column=4, sticky="w", pady=(8, 0))
ttk.Label(panel, text="摄像头编号").grid(row=4, column=5, sticky="w", pady=(8, 0))
ttk.Spinbox(panel, from_=0, to=8, textvariable=self.var_camera, width=6).grid(
row=4, column=6, sticky="w", pady=(8, 0)
)
ttk.Label(panel, text="分辨率").grid(row=5, column=0, sticky="w", pady=(8, 0))
ttk.Entry(panel, textvariable=self.var_width, width=8).grid(row=5, column=1, sticky="w", pady=(8, 0))
ttk.Label(panel, text="x").grid(row=5, column=2, pady=(8, 0))
ttk.Entry(panel, textvariable=self.var_height, width=8).grid(row=5, column=3, sticky="w", pady=(8, 0))
btns = ttk.Frame(panel)
btns.grid(row=6, column=0, columnspan=8, sticky="ew", pady=(12, 0))
self.btn_open = ttk.Button(btns, text="打开摄像头", command=self.open_camera)
self.btn_open.pack(side="left")
self.btn_infer = ttk.Button(btns, text="开始推理", command=self.start_infer, state="disabled")
self.btn_infer.pack(side="left", padx=8)
self.btn_stop = ttk.Button(btns, text="停止", command=self.stop, state="disabled")
self.btn_stop.pack(side="left")
ttk.Button(btns, text="保存当前帧", command=self.save_frame).pack(side="left", padx=8)
def refresh_devices(self) -> None:
self.rs_devices = list_realsense()
labels = [x[0] for x in self.rs_devices]
self.cmb_rs["values"] = labels
if labels:
self.var_rs.set(labels[0])
self.var_status.set(f"找到 {len(labels)} 台 RealSense")
else:
self.var_rs.set("")
self.var_status.set("未检测到 RealSense")
def current_rs_serial(self) -> str | None:
label = self.var_rs.get()
for text, serial in self.rs_devices:
if text == label:
return serial
return None
def on_backend_change(self) -> None:
backend = self.var_backend.get()
if backend == "auto":
return
self.var_model.set(default_model_path(backend))
def browse_model(self) -> None:
initial = self.var_model.get()
initial_dir = str(Path(initial).parent) if initial else str(ROOT)
path = filedialog.askopenfilename(
title="选择检测权重",
initialdir=initial_dir,
filetypes=[("PyTorch 权重", "*.pt *.pth"), ("所有文件", "*.*")],
)
if path:
self.var_model.set(path)
if self.var_backend.get() == "auto":
self.var_status.set(f"auto 将按文件识别为: {guess_backend(path)}")
def autofill_model(self) -> None:
backend = self.var_backend.get()
if backend == "auto":
backend = "yolo"
path = default_model_path(backend)
self.var_model.set(path)
self.var_status.set(f"已填入权重: {path}")
def browse_config(self) -> None:
initial = self.var_config.get()
initial_dir = str(Path(initial).parent) if initial else str(ROOT)
path = filedialog.askopenfilename(
title="选择检测配置",
initialdir=initial_dir,
filetypes=[("YAML", "*.yaml;*.yml"), ("所有文件", "*.*")],
)
if path:
self.var_config.set(path)
self.reload_config()
def reload_config(self, silent: bool = False) -> None:
try:
path = Path(self.var_config.get().strip())
if not path.is_absolute():
path = ROOT / path
self.cfg = load_config(path)
self.var_config.set(str(path))
self.stabilizer = HoleStabilizer(self.cfg)
if not silent:
self.var_status.set(f"已加载配置: {path.name}")
except Exception as exc:
self.cfg = {}
self.stabilizer = HoleStabilizer()
if not silent:
messagebox.showerror("配置", str(exc))
self.var_status.set(f"配置加载失败: {exc}")
def release_cam(self) -> None:
with self.cam_lock:
if self.cam is not None:
try:
self.cam.release()
except Exception:
pass
self.cam = None
def open_camera(self) -> None:
if self.running or self.opening:
return
self.opening = True
self.btn_open.config(state="disabled")
self.var_status.set("正在打开摄像头…")
self.update_idletasks()
source = self.var_source.get()
width = int(self.var_width.get())
height = int(self.var_height.get())
serial = self.current_rs_serial()
cam_index = int(self.var_camera.get())
def worker() -> None:
cam = None
err = ""
try:
self.release_cam()
if source == "realsense":
devices = list_realsense()
if not devices:
raise RuntimeError("没有 RealSense,可改选普通摄像头编号 1")
cam = open_source("realsense", width, height, serial or devices[0][1], cam_index)
else:
cam = open_source("webcam", width, height, None, cam_index)
ok, frame = cam.read()
if not ok or frame is None:
raise RuntimeError("摄像头打开后读不到画面")
except Exception as exc:
if cam is not None:
try:
cam.release()
except Exception:
pass
cam = None
err = str(exc)
self.after(0, lambda: self._open_done(cam, err))
threading.Thread(target=worker, daemon=True).start()
def _open_done(self, cam, err: str) -> None:
self.opening = False
if err or cam is None:
self.btn_open.config(state="normal")
self.btn_infer.config(state="disabled")
self.btn_stop.config(state="disabled")
self.var_status.set(err or "打开失败")
messagebox.showerror("摄像头", err or "打开失败")
return
with self.cam_lock:
self.cam = cam
self.running = True
self.infer_enabled = False
self.fail_reads = 0
self.btn_open.config(state="disabled")
self.btn_infer.config(state="normal")
self.btn_stop.config(state="normal")
self.var_status.set("摄像头已打开。点「开始推理」加载权重并检测螺纹孔")
threading.Thread(target=self.loop_capture, daemon=True).start()
def start_infer(self) -> None:
if not self.running or self.cam is None:
messagebox.showwarning("推理", "请先打开摄像头")
return
model_path = Path(self.var_model.get().strip())
if not model_path.is_absolute():
model_path = ROOT / model_path
if not model_path.exists():
messagebox.showerror("权重", f"找不到权重:\n{model_path}")
return
self.reload_config(silent=True)
if not self.cfg:
messagebox.showerror("配置", "请先加载有效的 hole_detect_config.yaml")
return
self.btn_infer.config(state="disabled")
self.var_status.set(f"正在加载模型: {model_path.name} …")
warmup = None
with self.lock:
if self.latest is not None:
warmup = self.latest.copy()
def load() -> None:
try:
backend = self.var_backend.get()
model = load_detector(
backend=backend,
weights=model_path,
device=self.var_device.get(),
num_classes=int(self.var_num_classes.get()),
conf=float(self.cfg.get("conf", 0.25)),
)
if warmup is not None:
detect_holes(warmup, model, self.cfg, device=self.var_device.get())
except Exception as exc:
self.after(0, lambda e=str(exc): self._infer_load_failed(e))
return
self.model = model
self.infer_enabled = True
self.stabilizer.reset()
used = getattr(model, "backend", backend)
self.after(0, lambda: self.var_status.set(f"推理中 backend={used} 模型={model_path.name}"))
threading.Thread(target=load, daemon=True).start()
def _infer_load_failed(self, msg: str) -> None:
self.infer_enabled = False
self.model = None
self.btn_infer.config(state="normal")
self.var_status.set(msg)
messagebox.showerror("模型加载失败", msg)
def stop(self) -> None:
self.running = False
self.infer_enabled = False
self.var_status.set("正在停止…")
def loop_capture(self) -> None:
t0, n = time.perf_counter(), 0
while self.running:
with self.cam_lock:
cam = self.cam
if cam is None:
break
ok, frame = cam.read()
if not ok or frame is None:
self.fail_reads += 1
if self.fail_reads >= 30:
self.after(0, lambda: self.var_status.set("连续读帧失败,请停止后重开"))
self.fail_reads = 0
time.sleep(0.02)
continue
self.fail_reads = 0
show = frame
result = None
if self.infer_enabled and self.model is not None:
raw = detect_holes(frame, self.model, self.cfg, device=self.var_device.get())
result = self.stabilizer.update(frame, raw)
show = result["draw_img"]
self.last_result = result
n += 1
if n >= 8:
now = time.perf_counter()
self.fps = n / max(1e-6, now - t0)
t0, n = now, 0
overlay = show.copy()
if result is None:
mode = "PREVIEW"
color = (0, 200, 255)
info = ""
elif result["status"]:
mode = "OK"
color = (0, 255, 0)
pts = result["hole_points"]
info = f" holes={pts}"
else:
mode = "FAIL"
color = (0, 0, 255)
info = f" {result['msg']}"
cv2.putText(
overlay,
f"{mode} FPS {self.fps:.1f}{info}",
(16, overlay.shape[0] - 20),
cv2.FONT_HERSHEY_SIMPLEX,
0.7,
color,
2,
)
with self.lock:
self.latest = overlay
self.release_cam()
self.model = None
self.infer_enabled = False
self.after(0, self._stopped_ui)
def _stopped_ui(self) -> None:
self.btn_open.config(state="normal")
self.btn_infer.config(state="disabled")
self.btn_stop.config(state="disabled")
self.var_status.set("已停止")
def refresh_view(self) -> None:
frame = None
with self.lock:
if self.latest is not None:
frame = self.latest.copy()
if frame is not None:
try:
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
h, w = rgb.shape[:2]
max_w = max(int(self.canvas.winfo_width()), 640)
max_h = max(int(self.canvas.winfo_height()), 360)
scale = min(max_w / w, max_h / h, 1.0)
size = (max(1, int(w * scale)), max(1, int(h * scale)))
img = Image.fromarray(rgb).resize(size, Image.Resampling.BILINEAR)
self.photo = ImageTk.PhotoImage(img)
self.canvas.config(image=self.photo, text="")
except Exception as exc:
self.var_status.set(f"画面刷新失败: {exc}")
self.after(33, self.refresh_view)
def save_frame(self) -> None:
with self.lock:
frame = None if self.latest is None else self.latest.copy()
if frame is None:
messagebox.showwarning("保存", "当前没有可保存的画面")
return
out_dir = ROOT / "runs" / "predict_holes"
out_dir.mkdir(parents=True, exist_ok=True)
out = out_dir / f"hole_{int(time.time())}.jpg"
cv2.imwrite(str(out), frame)
self.var_status.set(f"已保存: {out}")
def on_close(self) -> None:
self.running = False
self.infer_enabled = False
self.release_cam()
self.destroy()
if __name__ == "__main__":
HoleInferApp().mainloop()