diff --git a/build_nuitka.py b/build_nuitka.py index 51adb6d..90f5cab 100644 --- a/build_nuitka.py +++ b/build_nuitka.py @@ -21,11 +21,11 @@ def install_nuitka(): def get_version() -> str: init_file = Path("src/orisys/__init__.py") if not init_file.exists(): - return "0.3.1" + return "0.3.2" for line in init_file.read_text(encoding="utf-8").splitlines(): if line.startswith("__version__"): return line.split("=", 1)[1].strip().strip('"').strip("'") - return "0.3.1" + return "0.3.2" def find_compiled_extension(output_dir: Path) -> Path: diff --git a/config/compound_eye.png b/config/compound_eye.png index 3b89059..0fa802b 100644 Binary files a/config/compound_eye.png and b/config/compound_eye.png differ diff --git a/config/ddjx01_wuxi.json b/config/ddjx01_wuxi.json new file mode 100644 index 0000000..0f0a874 --- /dev/null +++ b/config/ddjx01_wuxi.json @@ -0,0 +1,45 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1280, 720], + "fps": 15, + "units": 15, + "units_used": 9, + "resolution_stitch": [263, 263], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.04, + "max_aspect_ratio": 25, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 67, + "n": [3,3], + "row_tol": 0.6, + "box_size": [180, 180] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3, + "backend": "cpu" + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":1, + "div_threshold":0.02, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 10 + } +} + + diff --git a/demo_wuxi.py b/demo_wuxi.py new file mode 100644 index 0000000..04ad844 --- /dev/null +++ b/demo_wuxi.py @@ -0,0 +1,631 @@ +import os +os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" +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, QVBoxLayout, QWidget, QSizePolicy, QShortcut, QApplication +from pyqtgraph.Qt import QtGui + + +# Window aspect: width : total height = 936 : (936 + plot strip) +ASPECT_W = 936 +ASPECT_H_TOTAL = 936 + 624 +ARROW_STRETCH = 936 +PLOT_STRETCH = 624 +npoints = 120 # 图表数据长度 +dispaly_angle = -90 # 箭头图显示角度 +touch_threshold = 5 # 触碰反应阈值 +frame_to_recover = 10 # 触碰后等待回复帧数 + +# 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 = True + + +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(win): + """ + Pixel size of the square arrow canvas from the window client area and the + fixed vertical split ARROW_STRETCH : PLOT_STRETCH (matches QVBoxLayout stretch). + """ + w = win.width() + h = win.height() + if w < 16 or h < 16: + return max(32, ASPECT_W) + h_arrow = int(round(h * ARROW_STRETCH / float(ARROW_STRETCH + PLOT_STRETCH))) + # With aspect ASPECT_W:ASPECT_H_TOTAL, h_arrow should equal w; min() absorbs rounding. + return max(32, min(w, h_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 (RGB for Qt), centered with equal margins.""" + 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): + # Keep square aspect; center in label (equal L/R or T/B bars if rect not square) + pm = pm.scaled(lw, lh, Qt.KeepAspectRatio, Qt.SmoothTransformation) + label.setPixmap(pm) + + +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", 11) + 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])) + +# 实时图表:单窗口,上为光流箭头,下为曲线 +pg.setConfigOption("background", PLOT_THEME_BG) +pg.setConfigOption("foreground", PLOT_THEME_FG) +app = pg.mkQApp("Wuxi Demo") + +combined_win = CombinedTactileWindow() +combined_win.setWindowTitle("Wuxi tactile — flow + forces") +main_layout = QVBoxLayout(combined_win) +main_layout.setContentsMargins(0, 0, 0, 0) +main_layout.setSpacing(0) + +# Ignored: do not let pixmap/content drive QLabel size — layout stretch sets arrow vs plot heights. +_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) +main_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) +main_layout.addWidget(plot_widget, stretch=PLOT_STRETCH) + +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.show() + +p1, p2, p3, plot_af, plot_tfx, plot_tfy = create_plot(plot_widget) +data = np.zeros([npoints,2,3]) +start_time = None # Will be set when sensor starts +fps = 30.0 +for i in range(3): + data[:,0,i] = np.linspace(0.0, npoints / fps, npoints) + + + +def main(): + global start_time + + parser = argparse.ArgumentParser() + parser.add_argument("--video","-v", type=int, 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 + try: + sensor1 = orisys.Sensor( + args.video, + config_name="./config/ddjx01_wuxi.json", + cal_path="./config/dd01a_0306.npy", + isstitch=True, + cuda=False, + verbose=False, + ) + + def _shortcut_reset_tracker(): + sensor1.reset() + print("传感器已重置 (快捷键 R)", flush=True) + + def _shortcut_quit(): + combined_win.close() + + _sc_r = QShortcut(QKeySequence(Qt.Key_R), combined_win) + _sc_r.activated.connect(_shortcut_reset_tracker) + _sc_q = QShortcut(QKeySequence(Qt.Key_Q), combined_win) + _sc_q.activated.connect(_shortcut_quit) + + start_time = time.time() + is_save = False + frame_count = 0 + writer = None + while True: + sensor1.get_img() # 提取并拼接复眼图像 + + sensor1.compute_deformation(check_motion = True, tail_frames = frame_to_recover, threshold = touch_threshold) # 计算形变和分解 + # 提取信息: + # 向量场: VRAW, VNORMAL, VSHEAR: 原始,法向,切向 + # 帧率,时间戳: FPS, TIMESTAMP + # 变形量总和: FNORMAL , FSHEARX, FSHEARY : 法向,切向(X),切向(Y) + fps, fn, fx, fy, flow = sensor1.read_info( + sensor1.info.FPS, + sensor1.info.FNORMAL, + sensor1.info.FSHEARX, + sensor1.info.FSHEARY, + sensor1.info.VRAW, + ) + + # print(fps,fn,fx,fy) + # 显示向量场 — BGR background; img may be 2D (H,W) or 3D (H,W,C) + _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] + # Square side = min(label w,h) so pixmap matches centered KeepAspectRatio scaling + app.processEvents() + lw, lh = arrow_label.width(), arrow_label.height() + if lw > 16 and lh > 16: + side = max(32, min(lw, lh)) + else: + side = compute_arrow_canvas_side(combined_win) + 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(arrow_label, arrows) + + # 显示图表 - 更新时间轴 + current_time = time.time() - start_time + for i in range(3): + data[:-1, 0, i] = data[1:, 0, i] # shift time data left + data[:-1, 1, i] = data[1:, 1, i] # shift force data left + data[-1, 0, :] = frame_count / 15.0 # set current time for all plots + data[-1, 1, 0] = fn + data[-1, 1, 1] = abs(fx) + data[-1, 1, 2] = abs(fy) + update(plot_af, plot_tfx, plot_tfy, data, p1, p2, p3) # Update plot + 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)) + + key = cv2.waitKey(1) + if key & 0xFF == ord("q"): + if writer is not None: + writer.release() + writer = None + print("\n 停止程序") + break + if key & 0xFF == ord("r"): + # 重置光流追踪器(当追踪漂移时使用);焦点需在 OpenCV 窗口上 + sensor1.reset() + print("传感器已重置") + + 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}", flush=True) + + +if __name__ == '__main__': + main() diff --git a/doc/get_started.md b/doc/get_started.md index 5ef11c0..60916cb 100644 --- a/doc/get_started.md +++ b/doc/get_started.md @@ -1,5 +1,5 @@ 叠动科技软件使用手册 -版本: 0.3.1 +版本: 0.3.2 更新日期: 2026年3月6日 # 简介 @@ -57,9 +57,9 @@ orisys SDK 以 .whl 文件形式提供,您需要根据操作系统、Python ## 下载正确的 .whl 文件 从官方渠道获取 .whl 文件,文件名格式通常为: orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl -例如:orisys-0.3.1-cp310-cp310-win_amd64.whl 表示: +例如:orisys-0.3.2-cp310-cp310-win_amd64.whl 表示: -版本:0.3.1 +版本:0.3.2 Python 版本:3.10(cp310) 操作系统:Windows(win) 硬件架构:64位(amd64) @@ -71,7 +71,7 @@ Python 版本:3.10(cp310) ``` bash # 请将文件名替换为您实际下载的文件名 -pip install orisys-0.3.1-cp310-cp310-win_amd64.whl +pip install orisys-0.3.2-cp310-cp310-win_amd64.whl ``` ## 安装 CuPy(可选) @@ -108,7 +108,7 @@ python import orisys print(orisys.__version__) ``` -如果输出版本号(例如 0.3.1),则说明安装成功。 +如果输出版本号(例如 0.3.2),则说明安装成功。 # 使用方法 ## 运行示例程序 diff --git a/requirements.txt b/requirements.txt index 799b157..e95cff8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,5 @@ opencv_python>=4.10.0.84 scipy==1.13 setuptools>=45.2.0 av +numba diff --git a/setup.py b/setup.py index 6202818..b229e47 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ if configs_dir.exists(): setup( name="orisys", - version="0.3.1", + version="0.3.2", description="Orisys - Optical Tactile Sensor SDK", author="Your Name", package_dir={"": "src"}, diff --git a/src/orisys/__init__.py b/src/orisys/__init__.py index f30f816..829f606 100644 --- a/src/orisys/__init__.py +++ b/src/orisys/__init__.py @@ -5,7 +5,7 @@ A Python library for processing and analyzing optical tactile sensor data, including image stitching, optical flow tracking, and deformation decomposition. """ -__version__ = "0.3.1" +__version__ = "0.3.2" __author__ = "Orisys" from .sensor import Sensor diff --git a/src/orisys/sensor.py b/src/orisys/sensor.py index e06d1b7..3bb3382 100644 --- a/src/orisys/sensor.py +++ b/src/orisys/sensor.py @@ -119,6 +119,7 @@ class Sensor: shape = (self.__config_cam["resolution_stitch"][1], self.__config_cam["resolution_stitch"][0]) self.img_size_raw = None self.img_filtered = None + self.tail_frames_count = 0 # Optical Flow Data Variables (public) self.flow = np.zeros(shape + (2,), dtype=np.float32) @@ -268,7 +269,7 @@ class Sensor: # print(f"mag: {mag.max()}, {mag.min()}, {mag.mean()}") return mag.mean() > threshold - def compute_deformation(self, decompose=True, check_motion=True, threshold=6): + def compute_deformation(self, decompose=True, check_motion=False, threshold=6, tail_frames = 5): """ Compute optical flow and decompose into force components. @@ -282,9 +283,14 @@ class Sensor: # self.compute_hhd() if check_motion: if self.detect_motion(threshold=threshold): + self.tail_frames_count = 0 self.compute_hhd() return True - else: + else: + if self.tail_frames_count <= tail_frames: + self.tail_frames_count +=1 + self.compute_hhd() + return True return False else: if decompose: diff --git a/src/orisys/tracker.py b/src/orisys/tracker.py index 5a8a67a..fb8cc9b 100644 --- a/src/orisys/tracker.py +++ b/src/orisys/tracker.py @@ -85,11 +85,11 @@ class Tracker: def track(self, frame): """Main tracking function that dispatches to CUDA or CPU implementation""" if self.__algorithm == "raft": - return self.track_raft(frame) + return self.track_raft(frame.copy()) elif self.__algorithm == "dis": if frame.ndim == 3: frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) - return self.track_cpu(frame) + return self.track_cpu(frame.copy()) def track_cpu(self, frame): """CPU-based optical flow tracking"""