From daf2e4daa3281a7f174fed5f0bf1e9070d3d2696 Mon Sep 17 00:00:00 2001 From: bluepulsar Date: Tue, 14 Apr 2026 19:56:43 +0800 Subject: [PATCH] add demo script --- demo_wuxi.py | 961 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 961 insertions(+) create mode 100644 demo_wuxi.py diff --git a/demo_wuxi.py b/demo_wuxi.py new file mode 100644 index 0000000..bde8dfc --- /dev/null +++ b/demo_wuxi.py @@ -0,0 +1,961 @@ +import os +os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" +# Force pyqtgraph to bind to PyQt5 when multiple Qt bindings are installed. +os.environ.setdefault("PYQTGRAPH_QT_LIB", "PyQt5") +import cv2 +import orisys +import numpy as np +import time +import argparse +from datetime import datetime +import pyqtgraph as pg +from PyQt5.QtCore import Qt, QSize, QEvent +from PyQt5.QtGui import QImage, QFont, QPixmap, QKeySequence, QCursor +from PyQt5.QtWidgets import QLabel, QHBoxLayout, QVBoxLayout, QWidget, QSizePolicy, QShortcut, QApplication +from pyqtgraph.Qt import QtGui + + +# Final desired appearance on the mirrored 1872x1560 (6:5) display. +# The desktop is first shown on a 1920x1080 (16:9) screen, then stretched by +# the OS onto the 6:5 mirror. We therefore pre-distort the source layout so +# the mirrored output looks correct. +TARGET_SINGLE_PANEL_W = 936 +TARGET_ASPECT_W = TARGET_SINGLE_PANEL_W * 2 +TARGET_VIDEO_FRAME_H = 1053 +TARGET_BOTTOM_PANEL_H = 507 +TARGET_ASPECT_H_TOTAL = TARGET_VIDEO_FRAME_H + TARGET_BOTTOM_PANEL_H +TARGET_MIRROR_W = 1872 +TARGET_MIRROR_H = 1560 + +SINGLE_PANEL_W = TARGET_SINGLE_PANEL_W +ASPECT_W = TARGET_ASPECT_W +BOTTOM_PANEL_H = TARGET_BOTTOM_PANEL_H +VIDEO_FRAME_W = TARGET_ASPECT_W +VIDEO_FRAME_H = TARGET_VIDEO_FRAME_H +ASPECT_H_TOTAL = TARGET_ASPECT_H_TOTAL +UI_SCALE = 1.0 +ARROW_STRETCH = 507 +PLOT_STRETCH = 429 +VIDEO_LOOP_PATH = "./叠动机械爪_成片.m4v" +npoints = 120 # 图表数据长度 +dispaly_angle = -90 # 箭头图显示角度 +touch_threshold = 5 # 触碰反应阈值 +frame_to_recover = 10 # 触碰后等待回复帧数 + +# Opneing Hour for keep reading image +open_hour = 9 +close_hour = 17 +manual_open_enable = True # 控制是否可以手动切换营业状态 (按键G) +is_open = True + +# Match arrow panel / Qt style (not pure black) +PLOT_THEME_BG = "#0B1016" +PLOT_THEME_FG = "#A0A0A0" + +# OS title bar cannot be tinted to #0B1016; frameless removes it (use Alt+drag to move). +USE_FRAMELESS_WINDOW = False + + +def scale_ui_px(px, floor=1): + return max(floor, int(round(px * UI_SCALE))) + + +def compute_source_layout(screen_w, screen_h): + """Pre-distort the app on the source screen so the mirrored monitor is correct.""" + target_video_ratio = TARGET_VIDEO_FRAME_H / float(TARGET_ASPECT_H_TOTAL) + target_bottom_ratio = TARGET_BOTTOM_PANEL_H / float(TARGET_ASPECT_H_TOTAL) + + return { + "window_w": max(320, int(screen_w)), + "window_h": max(240, int(screen_h)), + "video_w": max(320, int(screen_w)), + "video_h": max(1, int(round(screen_h * target_video_ratio))), + "bottom_h": max(1, int(round(screen_h * target_bottom_ratio))), + "single_panel_w": max(1, int(round(screen_w / 2.0))), + } + + +class CombinedTactileWindow(QWidget): + """ + Resizable window that keeps width:height = ASPECT_W : ASPECT_H_TOTAL. + Dragging the width or height edge updates the other dimension accordingly. + """ + + RESIZE_MARGIN = 8 + + def __init__(self): + super().__init__() + self._aspect_hw = ASPECT_H_TOTAL / float(ASPECT_W) + self._resize_guard = False + self.user_closed = False + self._drag_anchor = None + self._frameless_resize_edge = None + self._frameless_resize_start_global = None + self._frameless_resize_start_geom = None + + def _min_client_size(self): + w = max(320, self.minimumSizeHint().width()) + h = int(round(w * self._aspect_hw)) + return w, h + + def _hit_test_resize(self, global_pos): + if not USE_FRAMELESS_WINDOW: + return None + p = self.mapFromGlobal(global_pos) + w, h = self.width(), self.height() + m = self.RESIZE_MARGIN + left = p.x() <= m + right = p.x() >= w - m + top = p.y() <= m + bottom = p.y() >= h - m + if top and left: + return "tl" + if top and right: + return "tr" + if bottom and left: + return "bl" + if bottom and right: + return "br" + if top: + return "t" + if bottom: + return "b" + if left: + return "l" + if right: + return "r" + return None + + def _cursor_for_edge(self, edge): + return { + "tl": Qt.SizeFDiagCursor, + "br": Qt.SizeFDiagCursor, + "tr": Qt.SizeBDiagCursor, + "bl": Qt.SizeBDiagCursor, + "l": Qt.SizeHorCursor, + "r": Qt.SizeHorCursor, + "t": Qt.SizeVerCursor, + "b": Qt.SizeVerCursor, + }.get(edge, Qt.ArrowCursor) + + def _apply_frameless_resize(self, dg): + edge = self._frameless_resize_edge + g0 = self._frameless_resize_start_geom + min_w, min_h = self._min_client_size() + dx, dy = dg.x(), dg.y() + x0, y0, w0, h0 = g0.x(), g0.y(), g0.width(), g0.height() + + if edge == "br": + nw = max(min_w, w0 + dx) + nh = int(round(nw * self._aspect_hw)) + self.setGeometry(x0, y0, nw, nh) + elif edge == "r": + nw = max(min_w, w0 + dx) + nh = int(round(nw * self._aspect_hw)) + self.setGeometry(x0, y0, nw, nh) + elif edge == "b": + nh = max(min_h, h0 + dy) + nw = int(round(nh / self._aspect_hw)) + self.setGeometry(x0, y0, nw, nh) + elif edge == "l": + nw = max(min_w, w0 - dx) + nh = int(round(nw * self._aspect_hw)) + nx = g0.right() - nw + self.setGeometry(nx, y0, nw, nh) + elif edge == "t": + nh = max(min_h, h0 - dy) + nw = int(round(nh / self._aspect_hw)) + ny = g0.bottom() - nh + self.setGeometry(x0, ny, nw, nh) + elif edge == "tr": + nw = max(min_w, w0 + dx) + nh = int(round(nw * self._aspect_hw)) + ny = g0.bottom() - nh + self.setGeometry(x0, ny, nw, nh) + elif edge == "bl": + nw = max(min_w, w0 - dx) + nh = int(round(nw * self._aspect_hw)) + nx = g0.right() - nw + self.setGeometry(nx, y0, nw, nh) + elif edge == "tl": + nw = max(min_w, w0 - dx) + nh = int(round(nw * self._aspect_hw)) + nx = g0.right() - nw + ny = g0.bottom() - nh + self.setGeometry(nx, ny, nw, nh) + + def install_drag_filters(self): + """Alt+left-drag to move when frameless; grabs go to this widget.""" + for w in self.findChildren(QWidget): + if w is not self: + w.installEventFilter(self) + + def eventFilter(self, watched, event): + if not USE_FRAMELESS_WINDOW: + return super().eventFilter(watched, event) + et = event.type() + if et == QEvent.MouseButtonPress and event.button() == Qt.LeftButton: + edge = self._hit_test_resize(event.globalPos()) + if edge is not None: + self._frameless_resize_edge = edge + self._frameless_resize_start_global = event.globalPos() + self._frameless_resize_start_geom = self.geometry() + self.grabMouse() + return True + if QApplication.keyboardModifiers() & Qt.AltModifier: + self._drag_anchor = event.globalPos() - self.frameGeometry().topLeft() + self.grabMouse() + return True + if et == QEvent.MouseMove and not event.buttons(): + edge = self._hit_test_resize(event.globalPos()) + watched.setCursor(QCursor(self._cursor_for_edge(edge))) + return super().eventFilter(watched, event) + + def mouseMoveEvent(self, event): + if USE_FRAMELESS_WINDOW and self._frameless_resize_edge and event.buttons() & Qt.LeftButton: + dg = event.globalPos() - self._frameless_resize_start_global + self._apply_frameless_resize(dg) + event.accept() + return + if ( + USE_FRAMELESS_WINDOW + and self._drag_anchor is not None + and event.buttons() & Qt.LeftButton + ): + self.move(event.globalPos() - self._drag_anchor) + event.accept() + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + if USE_FRAMELESS_WINDOW and self._frameless_resize_edge and event.button() == Qt.LeftButton: + self.releaseMouse() + self._frameless_resize_edge = None + self._frameless_resize_start_global = None + self._frameless_resize_start_geom = None + event.accept() + return + if USE_FRAMELESS_WINDOW and self._drag_anchor is not None and event.button() == Qt.LeftButton: + self.releaseMouse() + self._drag_anchor = None + event.accept() + return + super().mouseReleaseEvent(event) + + def closeEvent(self, event): + self.user_closed = True + event.accept() + + def minimumSizeHint(self): + w = 320 + return QSize(w, int(round(w * self._aspect_hw))) + + def resizeEvent(self, event): + if self._resize_guard: + super().resizeEvent(event) + return + super().resizeEvent(event) + w, h = self.width(), self.height() + if w < 8 or h < 8: + return + old = event.oldSize() + self._resize_guard = True + try: + if old.isValid() and old.width() > 0 and old.height() > 0: + dw = abs(w - old.width()) + dh = abs(h - old.height()) + if dw >= dh: + new_w, new_h = w, int(round(w * self._aspect_hw)) + else: + new_w, new_h = int(round(h / self._aspect_hw)), h + else: + new_w, new_h = w, int(round(w * self._aspect_hw)) + if new_w != w or new_h != h: + self.resize(new_w, new_h) + finally: + self._resize_guard = False + + +def compute_arrow_canvas_side(panel_widget): + """ + Pixel size of the square arrow canvas from the window client area and the + fixed horizontal split ARROW_STRETCH : PLOT_STRETCH (matches QHBoxLayout stretch). + """ + w = panel_widget.width() + h = panel_widget.height() + if w < 16 or h < 16: + return max(32, ASPECT_H_TOTAL) + w_arrow = int(round(w * ARROW_STRETCH / float(ARROW_STRETCH + PLOT_STRETCH))) + # Keep the rendered arrow image square inside the left panel. + return max(32, min(h, w_arrow)) + + +def draw_arrows_wuxi( + frame, + optical_flow, + fps=True, + threshold=5, + grid_spacing=40, + arrow_scale=1.0, + margin=0.8, + colormap=cv2.COLORMAP_PARULA, + below_threshold_color=(0, 0, 255), + edge_margin=15, + target_size=936, +): + """ + Draw colored optical flow arrows on the image by magnitude (JET colormap). + Resizes frame and flow to target_size x target_size; scales flow vectors and + grid_spacing / margins / threshold so arrow density matches the original resolution. + """ + image = frame.copy() + if len(image.shape) == 2 or image.shape[2] == 1: + image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) + + h0, w0 = image.shape[:2] + if h0 != target_size or w0 != target_size: + sx = target_size / float(w0) + sy = target_size / float(h0) + image = cv2.resize(image, (target_size, target_size), interpolation=cv2.INTER_LINEAR) + u = cv2.resize(optical_flow[:, :, 0], (target_size, target_size), interpolation=cv2.INTER_LINEAR) * sx + v = cv2.resize(optical_flow[:, :, 1], (target_size, target_size), interpolation=cv2.INTER_LINEAR) * sy + optical_flow = np.stack([u, v], axis=2) + scale = max(sx, sy) + grid_spacing = max(4, int(round(grid_spacing * scale))) + edge_margin = max(1, int(round(edge_margin * scale))) + threshold = float(threshold) * scale + + h, w = image.shape[:2] + # Arrow grid in pixel coordinates (assume frame and flow same size) + rr = np.arange(grid_spacing // 2, h, grid_spacing) + cc = np.arange(grid_spacing // 2, w, grid_spacing) + rr, cc = np.meshgrid(rr, cc, indexing="ij") + rr, cc = rr.reshape(-1), cc.reshape(-1) + + # Displacement at each grid point (flow stored as half-displacement) + disp = 2.0 * optical_flow[rr, cc, :] + mag = np.linalg.norm(disp, axis=1) + dx = disp[:, 0] / arrow_scale + dy = disp[:, 1] / arrow_scale + length = np.sqrt(dx * dx + dy * dy) + # Cap arrow length at grid_spacing + scale = np.where(length > grid_spacing*margin, grid_spacing / np.maximum(length, 1e-9), 1.0)*margin + dx, dy = dx * scale, dy * scale + end_x = (cc + dx).astype(np.int32) + end_y = (rr + dy).astype(np.int32) + + valid = ( + (cc >= edge_margin) & (cc < w - edge_margin) & (rr >= edge_margin) & (rr < h - edge_margin) + & (end_x >= 0) & (end_x < w) & (end_y >= 0) & (end_y < h) + ) + mag_range = mag.max() - mag.min() + norm_mag = ((mag - mag.min()) / mag_range * 255).astype(np.uint8) if mag_range > 0 else np.zeros_like(mag, dtype=np.uint8) + norm_mag[mag < threshold] = 0 + colors = cv2.applyColorMap(norm_mag.reshape(-1, 1), colormap) + + for i in np.where(valid)[0]: + pt1, pt2 = (int(cc[i]), int(rr[i])), (int(end_x[i]), int(end_y[i])) + color = tuple(int(c) for c in colors[i, 0]) + if mag[i] < threshold: + cv2.circle(image, pt1, radius=1, color=below_threshold_color, thickness=-1) + else: + # thickness = max(1, int(2 * mag[i] / mag.max())) if mag.max() > 0 else 2 + thickness = 1 + cv2.arrowedLine(image, pt1, pt2, color, thickness=thickness, tipLength=0.2) + return image + + +def set_arrow_label_bgr(label, bgr_image): + """Show a BGR OpenCV image in a QLabel using source-screen pre-distortion.""" + rgb = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB) + rgb = np.ascontiguousarray(rgb) + h, w, ch = rgb.shape + qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy() + pm = QPixmap.fromImage(qimg) + lw, lh = label.width(), label.height() + if lw > 0 and lh > 0 and (pm.width() != lw or pm.height() != lh): + pm = pm.scaled(lw, lh, Qt.IgnoreAspectRatio, Qt.SmoothTransformation) + label.setPixmap(pm) + +def set_video_label_bgr(label, bgr_image): + """Show loop video in the top frame using the fitted display size.""" + rgb = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2RGB) + rgb = np.ascontiguousarray(rgb) + h, w, ch = rgb.shape + qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy() + pm = QPixmap.fromImage(qimg) + lw, lh = label.width(), label.height() + if lw > 0 and lh > 0 and (pm.width() != lw or pm.height() != lh): + pm = pm.scaled(lw, lh, Qt.IgnoreAspectRatio, Qt.FastTransformation) + label.setPixmap(pm) + + +def read_loop_video_frame_realtime(video_cap, video_clock): + """Read loop-video frame paced to wall clock; skip frames when main loop is slow.""" + if video_cap is None or not video_cap.isOpened(): + return False, None + + now = time.perf_counter() + fps = max(1.0, float(video_clock.get("fps", 30.0))) + frame_dt = 1.0 / fps + next_ts = video_clock.get("next_ts") + if next_ts is None: + next_ts = now + + frames_due = 1 + if now > next_ts + frame_dt: + frames_due += int((now - next_ts) / frame_dt) + # Cap skip burst to avoid long catch-up loops after pauses. + frames_due = min(frames_due, int(max(2.0, fps))) + + for _ in range(frames_due - 1): + if not video_cap.grab(): + video_cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + if not video_cap.grab(): + return False, None + + vret, vframe = video_cap.read() + if not vret: + video_cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + vret, vframe = video_cap.read() + if not vret: + return False, None + + next_ts = next_ts + frames_due * frame_dt + if next_ts < now - 2.0 * frame_dt: + next_ts = now + video_clock["next_ts"] = next_ts + return True, vframe + + +def make_gradient_brush(color_hex, max_alpha=150): + """ + 创建一个从上到下渐变的笔刷 (兼容 PyQt5 和 PyQt6) + """ + c_top = QtGui.QColor(color_hex) + c_top.setAlpha(max_alpha) # 顶部半透明 + + c_bot = QtGui.QColor(color_hex) + c_bot.setAlpha(0) # 底部全透明 + + # 创建线性渐变:(0,0)到(0,1) 代表从控件顶部到底部 + grad = QtGui.QLinearGradient(0, 0, 0, 1) + + if hasattr(QtGui.QGradient, 'CoordinateMode'): + grad.setCoordinateMode(QtGui.QGradient.CoordinateMode.ObjectBoundingMode) + else: + grad.setCoordinateMode(QtGui.QGradient.ObjectBoundingMode) + # ============================================== + + grad.setColorAt(0, c_top) # 0.0 (顶端) 颜色 + grad.setColorAt(1, c_bot) # 1.0 (底端) 颜色 + + return QtGui.QBrush(grad) + +def create_plot(plot_container): + pg.setConfigOption("background", PLOT_THEME_BG) + pg.setConfigOption("foreground", PLOT_THEME_FG) + pg.setConfigOptions(antialias=True) # 抗锯齿 + theme_color = '#00E5FF' # '#00E5FF' + line_pen = pg.mkPen(color=theme_color, width=2) + grad_brush = make_gradient_brush(theme_color, max_alpha=120) + + axis_font = QFont("Segoe UI", scale_ui_px(11, floor=8)) + p1 = plot_container.addPlot() + p1.setLabel('left', 'N') + p1.getAxis('left').label.setFont(axis_font) + p1.getAxis('left').label.setRotation(dispaly_angle) + p1.showGrid(x=True, y=True, alpha=0.3) + plot_container.nextRow() + p2 = plot_container.addPlot() + p2.setLabel('left', 'X') + p2.getAxis('left').label.setFont(axis_font) + p2.getAxis('left').label.setRotation(dispaly_angle) + p2.showGrid(x=True, y=True, alpha=0.3) + plot_container.nextRow() + p3 = plot_container.addPlot() + p3.setLabel('left', 'Y') + + # p3.setLabel('bottom', 'Time', units='s') + p3.getAxis('left').label.setFont(axis_font) + p3.getAxis('left').label.setRotation(dispaly_angle) + p3.getAxis('bottom').label.setFont(axis_font) + p3.showGrid(x=True, y=True, alpha=0.3) + _plot_bg = pg.mkColor(PLOT_THEME_BG) + for p in (p1, p2, p3): + p.getAxis("bottom").setStyle(showValues=False) + p.getAxis("left").setStyle(showValues=False) + # Keep title as "N" / "X" / "Y" — no PyQtGraph "(x0.001)" suffix on small ranges. + p.getAxis("left").enableAutoSIPrefix(False) + # PlotItem.setBackground exists only in newer pyqtgraph; ViewBox works on older installs. + p.vb.setBackgroundColor(_plot_bg) + plot_af = p1.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush) + plot_tfx = p2.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush) + plot_tfy = p3.plot(pen=line_pen, fillLevel=0, fillBrush=grad_brush) + return p1, p2, p3, plot_af, plot_tfx, plot_tfy + +def _plot_y_range(y): + """Avoid degenerate Y span; explicit x/y in setData so older PyQtGraph never plots time as y.""" + mn = float(np.min(y)) + mx = float(np.max(y)) + if not (np.isfinite(mn) and np.isfinite(mx)): + return 0.0, 1.0 + if mx - mn <= 1e-15: + mid = 0.5 * (mn + mx) + return mid - 0.5, mid + 0.5 + pad = (mx - mn) * 0.05 + return mn - pad, mx + pad + + +def update(plot_af, plot_tfx, plot_tfy, data, p1, p2, p3): + # global plot_af, plot_tfx, plot_tfy, data, p1, p2, p3 + plot_af.setData(data[:, 0, 0], data[:, 1, 0]) + p1.setYRange(*_plot_y_range(data[:, 1, 0])) + plot_tfx.setData(data[:, 0, 1], data[:, 1, 1]) + p2.setYRange(*_plot_y_range(data[:, 1, 1])) + plot_tfy.setData(data[:, 0, 2], data[:, 1, 2]) + p3.setYRange(*_plot_y_range(data[:, 1, 2])) + + +def build_sensor_panel(parent_layout): + panel = QWidget() + panel_layout = QHBoxLayout(panel) + panel_layout.setContentsMargins(0, 0, 0, 0) + panel_layout.setSpacing(0) + + # Ignored: do not let pixmap/content drive QLabel size — layout stretch sets arrow vs plot widths. + _policy_layout = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) + + arrow_label = QLabel() + arrow_label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + arrow_label.setStyleSheet( + "background-color: #0B1016; margin: 0; padding: 0; border: none;" + ) + arrow_label.setSizePolicy(_policy_layout) + arrow_label.setMinimumSize(0, 0) + arrow_label.setScaledContents(False) + panel_layout.addWidget(arrow_label, stretch=ARROW_STRETCH) + + plot_widget = pg.GraphicsLayoutWidget() + plot_widget.setBackground(PLOT_THEME_BG) + plot_widget.setSizePolicy(_policy_layout) + plot_widget.setMinimumSize(0, 0) + panel_layout.addWidget(plot_widget, stretch=PLOT_STRETCH) + + disconnected_label = QLabel("已断开") + disconnected_label.setAlignment(Qt.AlignCenter) + disconnected_label.setStyleSheet( + f"background-color: #0B1016; color: #FF4D4D; font-size: {scale_ui_px(54, floor=24)}px; " + "font-weight: bold; letter-spacing: 2px; margin: 0; padding: 0; border: none;" + ) + disconnected_label.hide() + panel_layout.addWidget(disconnected_label, stretch=SINGLE_PANEL_W) + + parent_layout.addWidget(panel, stretch=1) + + p1, p2, p3, plot_af, plot_tfx, plot_tfy = create_plot(plot_widget) + data = np.zeros([npoints, 2, 3]) + for i in range(3): + data[:, 0, i] = np.linspace(0.0, npoints / 30.0, npoints) + + return { + "panel": panel, + "arrow_label": arrow_label, + "plot_widget": plot_widget, + "disconnected_label": disconnected_label, + "p1": p1, + "p2": p2, + "p3": p3, + "plot_af": plot_af, + "plot_tfx": plot_tfx, + "plot_tfy": plot_tfy, + "data": data, + } + + +# 实时图表:单窗口,两套并排(左 sensor1,右 sensor2) +pg.setConfigOption("background", PLOT_THEME_BG) +pg.setConfigOption("foreground", PLOT_THEME_FG) +app = pg.mkQApp("Wuxi Demo") +screen = app.primaryScreen() +screen_geometry = screen.availableGeometry() if screen is not None else None +screen_w = screen_geometry.width() if screen_geometry is not None else 1920 +screen_h = screen_geometry.height() if screen_geometry is not None else 1080 +source_layout = compute_source_layout(screen_w, screen_h) +ASPECT_W = source_layout["window_w"] +ASPECT_H_TOTAL = source_layout["window_h"] +VIDEO_FRAME_W = source_layout["video_w"] +VIDEO_FRAME_H = source_layout["video_h"] +BOTTOM_PANEL_H = source_layout["bottom_h"] +SINGLE_PANEL_W = source_layout["single_panel_w"] +UI_SCALE = max(0.1, screen_h / float(TARGET_ASPECT_H_TOTAL)) + +combined_win = CombinedTactileWindow() +combined_win.setWindowTitle("Wuxi tactile — dual flow + forces") +main_layout = QVBoxLayout(combined_win) +main_layout.setContentsMargins(0, 0, 0, 0) +main_layout.setSpacing(0) + +video_label = QLabel() +video_label.setAlignment(Qt.AlignCenter) +video_label.setStyleSheet("background-color: #000000; margin: 0; padding: 0; border: none;") +video_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) +video_label.setMinimumSize(0, 0) +main_layout.addWidget(video_label, stretch=VIDEO_FRAME_H) + +bottom_container = QWidget() +bottom_layout = QHBoxLayout(bottom_container) +bottom_layout.setContentsMargins(0, 0, 0, 0) +bottom_layout.setSpacing(0) +main_layout.addWidget(bottom_container, stretch=BOTTOM_PANEL_H) + +panel1_ui = build_sensor_panel(bottom_layout) +panel2_ui = build_sensor_panel(bottom_layout) + +if USE_FRAMELESS_WINDOW: + combined_win.setWindowFlags(Qt.Window | Qt.FramelessWindowHint) +# Client area matches theme (no bright border flash on frameless) +combined_win.setStyleSheet(f"background-color: {PLOT_THEME_BG};") +combined_win.resize(ASPECT_W, ASPECT_H_TOTAL) +combined_win.install_drag_filters() +combined_win.showFullScreen() +closed_hour_overlay = QLabel(combined_win) +closed_hour_overlay.setAlignment(Qt.AlignCenter) +closed_hour_overlay.setStyleSheet( + f"background-color: {PLOT_THEME_BG}; color: #A0A0A0; " + f"font-size: {scale_ui_px(72, floor=32)}px; font-weight: bold; " + "margin: 0; padding: 0; border: none;" +) +closed_hour_overlay.setText("Closed hour\n非营业时间") +closed_hour_overlay.hide() +start_time = None # Will be set when sensor starts +fps = 30.0 + + +def outside_opening_hours(now): + return now.hour < open_hour or now.hour >= close_hour + + + +def main(): + global start_time + + parser = argparse.ArgumentParser() + parser.add_argument("--video1","-v1", type=str, default="0", help="视频文件路径") + parser.add_argument("--video2","-v2", type=str, default="0", help="视频文件路径") + args = parser.parse_args() + + session_start = datetime.now() + print( + f"[session] START {session_start:%Y-%m-%d %H:%M:%S} (long-run / crash log marker)", + flush=True, + ) + if USE_FRAMELESS_WINDOW: + print( + "[window] Frameless: resize = drag window edges/corners; move = Alt+left-drag; quit = Q.", + flush=True, + ) + + sensor1 = None + sensor2 = None + video_cap = None + try: + input1 = int(args.video1) + input2 = int(args.video2) + is_camera = True + except: + is_camera = False + try: + if is_camera: + sensor1 = orisys.Sensor( + input1, + config_name="./config/ddjx01_wuxi.json", + cal_path="./config/dd01a_0306.npy", + isstitch=True, + cuda=False, + verbose=False, + ) + + sensor2 = orisys.Sensor( + input2, + config_name="./config/ddjx01_wuxi.json", + cal_path="./config/dd01a_0306.npy", + isstitch=True, + cuda=False, + verbose=False, + ) + + else: + sensor1 = orisys.Sensor( + args.video1, + config_name="./config/ddjx01_wuxi.json", + cal_path="./config/dd01a_0306.npy", + isstitch=True, + cuda=False, + verbose=False, + ) + sensor2 = orisys.Sensor( + args.video2, + config_name="./config/ddjx01_wuxi.json", + cal_path="./config/dd01a_0306.npy", + isstitch=True, + cuda=False, + verbose=False, + ) + + def _shortcut_reset_tracker(): + sensor1.reset() + sensor2.reset() + print("传感器已重置 (快捷键 R)", flush=True) + + def _shortcut_quit(): + combined_win.close() + def _shortcut_toggle_open(): + global is_open + is_open = not is_open + print(f"营业状态: {'营业' if is_open else '非营业'}", flush=True) + + _sc_r = QShortcut(QKeySequence(Qt.Key_R), combined_win) + _sc_r.activated.connect(_shortcut_reset_tracker) + if manual_open_enable: + _sc_g = QShortcut(QKeySequence(Qt.Key_G), combined_win) + _sc_g.activated.connect(_shortcut_toggle_open) + _sc_q = QShortcut(QKeySequence(Qt.Key_Q), combined_win) + _sc_q.activated.connect(_shortcut_quit) + + video_cap = cv2.VideoCapture(VIDEO_LOOP_PATH) + video_clock = {"fps": 30.0, "next_ts": None} + if not video_cap.isOpened(): + print(f"[video] Failed to open loop video: {VIDEO_LOOP_PATH}", flush=True) + else: + src_fps = float(video_cap.get(cv2.CAP_PROP_FPS) or 0.0) + if np.isfinite(src_fps) and src_fps >= 1.0: + video_clock["fps"] = src_fps + + start_time = time.time() + is_save = False + frame_count = 0 + writer = None + sensor1_active = True + sensor2_active = True + # Local to main(): assignments inside the loop make this local; must exist + # before the first iteration that skips the closed branch. + start_open = False + while True: + now = datetime.now() + if outside_opening_hours(now) or not is_open: + start_open = False + closed_hour_overlay.setGeometry(0, 0, combined_win.width(), combined_win.height()) + closed_hour_overlay.raise_() + closed_hour_overlay.show() + app.processEvents() + if combined_win.user_closed or not combined_win.isVisible(): + print("\n窗口已关闭,退出程序") + if writer is not None: + writer.release() + writer = None + break + time.sleep(1) + continue + + if not start_open: + start_open = True + sensor1.reset() + sensor2.reset() + print(f"传感器已重置: {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True) + closed_hour_overlay.hide() + + if video_cap is not None and video_cap.isOpened(): + vret, vframe = read_loop_video_frame_realtime(video_cap, video_clock) + if vret: + if vframe.shape[1] != VIDEO_FRAME_W or vframe.shape[0] != VIDEO_FRAME_H: + vframe = cv2.resize(vframe, (VIDEO_FRAME_W, VIDEO_FRAME_H), interpolation=cv2.INTER_LINEAR) + set_video_label_bgr(video_label, vframe) + + if sensor1_active: + img1_valid = sensor1.get_img() + if not img1_valid: + panel1_ui["arrow_label"].hide() + panel1_ui["plot_widget"].hide() + panel1_ui["disconnected_label"].show() + sensor1_active = False + print(f"Sensor1 disconnected. Showing DISCONNECTED screen. {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True) + else: + sensor1.compute_deformation(check_motion=True, tail_frames=frame_to_recover, threshold=touch_threshold) + _, fn, fx, fy, flow = sensor1.read_info( + sensor1.info.FPS, + sensor1.info.FNORMAL, + sensor1.info.FSHEARX, + sensor1.info.FSHEARY, + sensor1.info.VRAW, + ) + _bg_bgr = (22, 16, 11) + _img = sensor1.img + _h, _w = _img.shape[:2] + black_img = np.empty((_h, _w, 3), dtype=_img.dtype) + black_img[:, :, 0] = _bg_bgr[0] + black_img[:, :, 1] = _bg_bgr[1] + black_img[:, :, 2] = _bg_bgr[2] + lw, lh = panel1_ui["arrow_label"].width(), panel1_ui["arrow_label"].height() + if lw > 16 and lh > 16: + side = max(32, min(lw, lh)) + else: + side = compute_arrow_canvas_side(panel1_ui["panel"]) + arrows = draw_arrows_wuxi( + black_img, + flow, + threshold=2, + grid_spacing=20, + arrow_scale=0.5, + below_threshold_color=(160, 160, 160), + target_size=int(side), + ) + set_arrow_label_bgr(panel1_ui["arrow_label"], arrows) + data1 = panel1_ui["data"] + for i in range(3): + data1[:-1, 0, i] = data1[1:, 0, i] + data1[:-1, 1, i] = data1[1:, 1, i] + data1[-1, 0, :] = frame_count / 15.0 + data1[-1, 1, 0] = fn + data1[-1, 1, 1] = abs(fx) + data1[-1, 1, 2] = abs(fy) + update( + panel1_ui["plot_af"], + panel1_ui["plot_tfx"], + panel1_ui["plot_tfy"], + data1, + panel1_ui["p1"], + panel1_ui["p2"], + panel1_ui["p3"], + ) + + if sensor2_active: + img2_valid = sensor2.get_img() + if not img2_valid: + panel2_ui["arrow_label"].hide() + panel2_ui["plot_widget"].hide() + panel2_ui["disconnected_label"].show() + sensor2_active = False + print(f"Sensor2 disconnected. Showing DISCONNECTED screen. {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True) + else: + sensor2.compute_deformation(check_motion=True, tail_frames=frame_to_recover, threshold=touch_threshold) + _, fn2, fx2, fy2, flow2 = sensor2.read_info( + sensor2.info.FPS, + sensor2.info.FNORMAL, + sensor2.info.FSHEARX, + sensor2.info.FSHEARY, + sensor2.info.VRAW, + ) + _bg_bgr2 = (22, 16, 11) + _img2 = sensor2.img + _h2, _w2 = _img2.shape[:2] + black_img2 = np.empty((_h2, _w2, 3), dtype=_img2.dtype) + black_img2[:, :, 0] = _bg_bgr2[0] + black_img2[:, :, 1] = _bg_bgr2[1] + black_img2[:, :, 2] = _bg_bgr2[2] + lw2, lh2 = panel2_ui["arrow_label"].width(), panel2_ui["arrow_label"].height() + if lw2 > 16 and lh2 > 16: + side2 = max(32, min(lw2, lh2)) + else: + side2 = compute_arrow_canvas_side(panel2_ui["panel"]) + arrows2 = draw_arrows_wuxi( + black_img2, + flow2, + threshold=2, + grid_spacing=20, + arrow_scale=0.5, + below_threshold_color=(160, 160, 160), + target_size=int(side2), + ) + set_arrow_label_bgr(panel2_ui["arrow_label"], arrows2) + data2 = panel2_ui["data"] + for i in range(3): + data2[:-1, 0, i] = data2[1:, 0, i] + data2[:-1, 1, i] = data2[1:, 1, i] + data2[-1, 0, :] = frame_count / 15.0 + data2[-1, 1, 0] = fn2 + data2[-1, 1, 1] = abs(fx2) + data2[-1, 1, 2] = abs(fy2) + update( + panel2_ui["plot_af"], + panel2_ui["plot_tfx"], + panel2_ui["plot_tfy"], + data2, + panel2_ui["p1"], + panel2_ui["p2"], + panel2_ui["p3"], + ) + + app.processEvents() + now = datetime.now() + if now.hour == 9 and now.minute == 0 and now.second == 0: + if sensor1_active: + sensor1.reset() + if sensor2_active: + sensor2.reset() + print( + f"[session] 传感器已重置: {now:%Y-%m-%d %H:%M:%S} ", + flush=True, + ) + + if not sensor1_active and not sensor2_active: + while not combined_win.user_closed and combined_win.isVisible(): + app.processEvents() + time.sleep(0.05) + break + + frame_count += 1 + app.processEvents() + + if combined_win.user_closed or not combined_win.isVisible(): + print("\n窗口已关闭,退出程序") + if writer is not None: + writer.release() + writer = None + break + + # Convert combined window to image (arrows + curves) + pixmap = combined_win.grab() + # Convert QPixmap to QImage + qimg = pixmap.toImage() + # Convert QImage to numpy array (RGB format) + qimg = qimg.convertToFormat(QImage.Format_RGB888) + width = qimg.width() + height = qimg.height() + # Convert QImage to numpy array + ptr = qimg.bits() + ptr.setsize(height * width * 3) + arr = np.array(ptr, dtype=np.uint8).reshape((height, width, 3)) + + except BaseException as exc: + session_abnormal = datetime.now() + print( + f"[session] ABNORMAL at {session_abnormal:%Y-%m-%d %H:%M:%S}: " + f"{type(exc).__name__}: {exc}", + flush=True, + ) + raise + finally: + session_end = datetime.now() + elapsed = session_end - session_start + print( + f"[session] END {session_end:%Y-%m-%d %H:%M:%S} " + f"(elapsed {elapsed})", + flush=True, + ) + if sensor1 is not None: + try: + sensor1.disconnect() + except Exception as disconnect_err: + print(f"[session] disconnect error: {disconnect_err} {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True) + if 'sensor2' in locals() and sensor2 is not None: + try: + sensor2.disconnect() + except Exception as disconnect_err: + print(f"[session] disconnect error (sensor2): {disconnect_err} {datetime.now():%Y-%m-%d %H:%M:%S}", flush=True) + if video_cap is not None: + video_cap.release() + + +if __name__ == '__main__': + main()