diff --git a/tools/graphviz_render/requirements.txt b/tools/graphviz_render/requirements.txt index 72e03b1c518ae7e4d7f2616a2bde70158d529d50..258e115aa747d690392467a08340086126d2e37d 100644 --- a/tools/graphviz_render/requirements.txt +++ b/tools/graphviz_render/requirements.txt @@ -2,3 +2,5 @@ Pillow==9.0.0 Pillow==7.0.0 Pillow==11.1.0 PyQt5==5.15.11 +python3-pyqt5.qtsvg + diff --git a/tools/graphviz_render/src/data_processing.py b/tools/graphviz_render/src/data_processing.py index 18443ddee336e9dc8a1eb8fc650f2651b60159c9..b36e820c332e80aed90200558d62b04d60a73e9c 100644 --- a/tools/graphviz_render/src/data_processing.py +++ b/tools/graphviz_render/src/data_processing.py @@ -28,7 +28,7 @@ from PyQt5.QtGui import QPixmap, QImage from PyQt5.QtCore import QThread, pyqtSignal class DataProcessing(QThread): - data_received = pyqtSignal(QPixmap) + data_received = pyqtSignal(QPixmap, bytes) def __init__(self): super().__init__() @@ -37,9 +37,10 @@ class DataProcessing(QThread): self._cond = threading.Condition(self._lock) # 数据变更条件变量 # 共享状态变量 - self._last_dot_data = '' # 最后接收的DOT数据 + self._last_dot_data = '' # 最后接收的DOT数据 self._running = True # 线程运行标志 self._original_image = None # 原始图像缓存 + self._svg_bytes = b'' # SVG字节数据缓存(用于高清光栅化) self._pending_update = False # 更新标记 def run(self): @@ -62,28 +63,38 @@ class DataProcessing(QThread): try: # 生成图像(耗时操作) - image = self._render_dot(current_data) + image, svg_bytes = self._render_dot(current_data) if image: self._original_image = image + self._svg_bytes = svg_bytes self._update_display() except Exception as e: print(f"Render error: {e}") - def _render_dot(self, dot_data: str) -> Image.Image: - """DOT数据渲染方法""" - proc = subprocess.Popen( + def _render_dot(self, dot_data: str): + """DOT数据渲染方法,同时生成 PNG(预览)和 SVG(高清光栅化用)""" + # 渲染 SVG(矢量格式,用于缩放后高清重绘) + proc_svg = subprocess.Popen( + ['dot', '-Tsvg'], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + svg_out, _ = proc_svg.communicate(dot_data.encode()) + + # 渲染 PNG(用于初始快速预览) + proc_png = subprocess.Popen( ['dot', '-Tpng'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) - stdout, stderr = proc.communicate(dot_data.encode()) + png_out, stderr = proc_png.communicate(dot_data.encode()) - if proc.returncode != 0: + if proc_png.returncode != 0: raise RuntimeError(f"Graphviz error: {stderr.decode()}") - return Image.open(BytesIO(stdout)) - + return Image.open(BytesIO(png_out)), svg_out def _update_display(self): """图像显示更新""" @@ -95,8 +106,8 @@ class DataProcessing(QThread): qimg = self._pil_to_qimage(self._original_image) pixmap = QPixmap.fromImage(qimg) - # 发射信号(跨线程安全) - self.data_received.emit(pixmap) + # 发射信号(跨线程安全),同时携带 SVG 数据供高清光栅化 + self.data_received.emit(pixmap, self._svg_bytes) except Exception as e: print(f"Display error: {e}") diff --git a/tools/graphviz_render/src/main.py b/tools/graphviz_render/src/main.py index d016feebd9bd0300dcd19b1c8870e737e4b81ced..da0b96c53ff2c99efdac5ea26c82ce59b6b87058 100755 --- a/tools/graphviz_render/src/main.py +++ b/tools/graphviz_render/src/main.py @@ -22,7 +22,6 @@ import os import sys -import signal from ui.viewer import GraphvizViewer from signal_handler import SignalHandler diff --git a/tools/graphviz_render/src/signal_handler.py b/tools/graphviz_render/src/signal_handler.py index b4f0e6fc27ec062cd7941b57e0685c64742caf3a..2135492b443bd5502f6b9f881a6041cf2d86b46c 100644 --- a/tools/graphviz_render/src/signal_handler.py +++ b/tools/graphviz_render/src/signal_handler.py @@ -19,7 +19,7 @@ # import signal -from PyQt5.QtCore import QObject, pyqtSignal, QTimer +from PyQt5.QtCore import QObject, pyqtSignal class SignalHandler(QObject): sig_interrupt = pyqtSignal() diff --git a/tools/graphviz_render/src/transform/__init__.py b/tools/graphviz_render/src/transform/__init__.py index 1cff8f6baa9c4b21eaf5d0980ff8984333a5510a..efd1feb6b89342543a4a09fa998892ed6a76cc9d 100644 --- a/tools/graphviz_render/src/transform/__init__.py +++ b/tools/graphviz_render/src/transform/__init__.py @@ -19,3 +19,4 @@ # from .zoomable import ZoomableGraphicsView +from .zoomable_svg import ZoomableSvgGraphicsView diff --git a/tools/graphviz_render/src/transform/zoomable.py b/tools/graphviz_render/src/transform/zoomable.py index 348c6f2f655d3851e864673ab8ecd9c8dc6b7b93..bc86607d28b00025bd8f18e997b16f9d12688eac 100644 --- a/tools/graphviz_render/src/transform/zoomable.py +++ b/tools/graphviz_render/src/transform/zoomable.py @@ -27,13 +27,13 @@ from PyQt5.QtWidgets import (QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, class ZoomableGraphicsView(QGraphicsView): def __init__(self, parent=None): super().__init__(parent) - + # 初始化视图设置 self._setup_view() self._setup_scene() self._setup_interaction() self._setup_indicators() - + # 初始化参数 self._zoom_factor = 1.0 self.min_zoom = 0.1 @@ -67,7 +67,7 @@ class ZoomableGraphicsView(QGraphicsView): self.scene = QGraphicsScene(self) self.scene.setSceneRect(-1e6, -1e6, 2e6, 2e6) # 超大场景范围 self.setScene(self.scene) - + self.pixmap_item = QGraphicsPixmapItem() self.pixmap_item.setTransformationMode(Qt.SmoothTransformation) self.pixmap_item.setShapeMode(QGraphicsPixmapItem.BoundingRectShape) @@ -92,14 +92,14 @@ class ZoomableGraphicsView(QGraphicsView): self.center_indicator.setVisible(False) self.scene.addItem(self.center_indicator) - def update_image(self, pixmap: QPixmap): - """更新图像并自适应视图""" + def update_image(self, pixmap: QPixmap, svg_bytes: bytes = b''): + """更新图像并自适应视图(svg_bytes 参数由方案B使用,此处忽略)""" self.pixmap_item.setPixmap(pixmap) self._center_pixmap(pixmap) if self.fisrt_refresh: self.fisrt_refresh = False self.fit_to_view() - + def _center_pixmap(self, pixmap: QPixmap): """居中放置图元""" self.pixmap_item.setPos(-pixmap.width()/2, -pixmap.height()/2) @@ -120,7 +120,7 @@ class ZoomableGraphicsView(QGraphicsView): zoom_in = event.angleDelta().y() > 0 factor = 1.25 if zoom_in else 0.8 new_zoom = self._zoom_factor * factor - + # 应用缩放限制 if self.min_zoom <= new_zoom <= self.max_zoom: self.scale(factor, factor) @@ -142,7 +142,7 @@ class ZoomableGraphicsView(QGraphicsView): new_zoom = self._zoom_factor * factor # 应用缩放限制 - if self.min_zoom <= new_zoom <= self.max_zoom: + if self.min_zoom <= new_zoom <= self.max_zoom: self.scale(factor, factor) self._zoom_factor = new_zoom return @@ -156,9 +156,9 @@ class ZoomableGraphicsView(QGraphicsView): self.dragging = True self.last_mouse_pos = event.pos() self.setCursor(Qt.ClosedHandCursor) - + super().mousePressEvent(event) - + def mouseDoubleClickEvent(self, event: QMouseEvent): """鼠标双击事件处理(增强版)""" if event.button() == Qt.RightButton: @@ -167,7 +167,7 @@ class ZoomableGraphicsView(QGraphicsView): return elif event.button() == Qt.LeftButton: event.accept() - factor = 2 + factor = 1.25 new_zoom = self._zoom_factor * factor # 应用缩放限制 @@ -183,13 +183,13 @@ class ZoomableGraphicsView(QGraphicsView): if self.dragging: delta = event.pos() - self.last_mouse_pos self.last_mouse_pos = event.pos() - + # 更新滚动条实现拖拽 self.horizontalScrollBar().setValue( self.horizontalScrollBar().value() - delta.x()) self.verticalScrollBar().setValue( self.verticalScrollBar().value() - delta.y()) - + super().mouseMoveEvent(event) def mouseReleaseEvent(self, event: QMouseEvent): @@ -206,10 +206,10 @@ class ZoomableGraphicsView(QGraphicsView): # 先执行自适应调整 self.fit_to_view() - + # 获取最终场景中心坐标 final_center = self.pixmap_item.sceneBoundingRect().center() - + # 创建组合动画 self._create_center_animation(final_center) @@ -218,14 +218,14 @@ class ZoomableGraphicsView(QGraphicsView): # 平移动画 anim_h = QPropertyAnimation(self.horizontalScrollBar(), b"value") anim_v = QPropertyAnimation(self.verticalScrollBar(), b"value") - + # 缩放动画 current_zoom = self._zoom_factor anim_zoom = QPropertyAnimation(self, b"zoom_factor") anim_zoom.setDuration(400) anim_zoom.setStartValue(current_zoom) anim_zoom.setEndValue(1.0) # 自适应后的标准缩放值 - + # 配置动画参数 for anim in [anim_h, anim_v]: anim.setDuration(400) @@ -234,19 +234,19 @@ class ZoomableGraphicsView(QGraphicsView): # 计算目标滚动值 view_center = self.mapToScene(self.viewport().rect().center()) delta = target_center - view_center - + # 设置动画参数 anim_h.setStartValue(self.horizontalScrollBar().value()) anim_h.setEndValue(self.horizontalScrollBar().value() + delta.x()) - + anim_v.setStartValue(self.verticalScrollBar().value()) anim_v.setEndValue(self.verticalScrollBar().value() + delta.y()) - + # 启动动画 anim_zoom.start() anim_h.start() anim_v.start() - + # 显示指示器 self.center_indicator.setPos(target_center) self.center_indicator.setVisible(True) diff --git a/tools/graphviz_render/src/transform/zoomable_svg.py b/tools/graphviz_render/src/transform/zoomable_svg.py new file mode 100644 index 0000000000000000000000000000000000000000..6da3129ba28a0b34bf2de625e4540c35acc7516d --- /dev/null +++ b/tools/graphviz_render/src/transform/zoomable_svg.py @@ -0,0 +1,326 @@ +# +# .============. +# // M A K E / \ +# // C++ DEV / \ +# // E A S Y / \/ \ +# ++ ----------. \/\ . +# \\ \ \ /\ / +# \\ \ \ / +# \\ \ \ / +# -============' +# +# Copyright (c) 2025 Hevake and contributors, all rights reserved. +# +# This file is part of cpp-tbox (https://github.com/cpp-main/cpp-tbox) +# Use of this source code is governed by MIT license that can be found +# in the LICENSE file in the root of the source tree. All contributing +# project authors may be found in the CONTRIBUTORS.md file in the root +# of the source tree. +# + +import threading + +from PyQt5.QtGui import QPixmap, QWheelEvent, QMouseEvent, QKeyEvent, QPainter, QImage +from PyQt5.QtCore import Qt, QPointF, QRectF, QPropertyAnimation, QTimer, QEasingCurve, QByteArray, pyqtSignal +from PyQt5.QtWidgets import QOpenGLWidget + +try: + from PyQt5.QtSvg import QSvgRenderer + _SVG_SUPPORT = True +except ImportError: + _SVG_SUPPORT = False + print("Warning: python3-pyqt5.qtsvg not installed. Zoom sharpening disabled. " + "Run: sudo apt install python3-pyqt5.qtsvg") + +from .zoomable import ZoomableGraphicsView + + +class ZoomableSvgGraphicsView(ZoomableGraphicsView): + """方案B:在方案A基础上增加 SVG 后台光栅化,缩放后保持高清。 + + 缩放/拖拽停止约 300ms 后,在后台线程将 SVG 光栅化为当前缩放级别对应的 + 高分辨率 PNG,并更新到显示区域,消除放大后的锯齿。 + """ + + # 用于从后台线程安全回调主线程:传递一个可调用对象,在主线程执行 + _call_in_main = pyqtSignal(object) + + def __init__(self, parent=None): + super().__init__(parent) + + # 连接跨线程回调信号(Qt.AutoConnection 在跨线程时自动转为 QueuedConnection) + self._call_in_main.connect(lambda f: f()) + + # SVG 高清缩放状态 + self._svg_bytes = b'' + # pixmap_item 在场景中的固定逻辑尺寸(由初始 PNG 决定,sharpen 时不变) + self._base_scene_w = 0.0 + self._base_scene_h = 0.0 + # 上次 sharpen 时的视图缩放比,用于跳过微小变化 + self._last_sharpened_scale = 0.0 + # 用户是否手动缩放过(防止 resizeEvent 重置缩放) + self._user_has_zoomed = False + # fit_to_view 时的绝对缩放比,用于动态计算缩放范围 + self._fit_scale = 1.0 + # 上次渲染时的视口中心(场景坐标),用于拖拽 skip 判断 + self._last_sharpened_vp_center = QPointF() + # 后台渲染代次,用于丢弃过期结果 + self._render_gen = 0 + # 防止并发渲染:上一次渲染未完成时跳过新请求,待完成后自动补发 + self._is_rendering = False + + self._sharpen_timer = QTimer(self) + self._sharpen_timer.setSingleShot(True) + self._sharpen_timer.timeout.connect(self._on_zoom_settled) + self._SHARPEN_DELAY_MS = 300 + + def update_image(self, pixmap: QPixmap, svg_bytes: bytes = b''): + """更新图像并自适应视图""" + if svg_bytes: + self._svg_bytes = svg_bytes + self._base_scene_w = 0.0 # 等 _center_pixmap 用新 PNG 尺寸重置 + self._base_scene_h = 0.0 + self._last_sharpened_scale = 0.0 + self._user_has_zoomed = False + self.pixmap_item.setPixmap(pixmap) + self._center_pixmap(pixmap) + if self.fisrt_refresh: + self.fisrt_refresh = False + self.fit_to_view() + if self._svg_bytes and _SVG_SUPPORT: + self._sharpen_timer.start(150) + + def _center_pixmap(self, pixmap: QPixmap): + """居中放置图元,并记录基准场景尺寸""" + # 每次新图数据到来时(_base_scene_w 已被重置为 0)重新记录基准尺寸 + if self._base_scene_w == 0.0: + self._base_scene_w = float(pixmap.width()) + self._base_scene_h = float(pixmap.height()) + self.pixmap_item.setScale(1.0) + self.pixmap_item.setPos(-self._base_scene_w / 2.0, -self._base_scene_h / 2.0) + self.scene.setSceneRect(-1e6, -1e6, 2e6, 2e6) + + def fit_to_view(self): + """自适应窗口显示""" + if self.pixmap_item.pixmap().isNull(): + return + if self._base_scene_w > 0: + rect = QRectF(-self._base_scene_w / 2.0, -self._base_scene_h / 2.0, + self._base_scene_w, self._base_scene_h) + else: + rect = self.pixmap_item.sceneBoundingRect() + self.fitInView(rect, Qt.KeepAspectRatio) + self._fit_scale = self.transform().m11() + self._update_zoom_limits() + self._zoom_factor = 1.0 + # 只在非手动缩放状态触发清晰化,避免 resizeEvent 干扰用户缩放 + if self._svg_bytes and _SVG_SUPPORT and not self._user_has_zoomed: + self._sharpen_timer.start(150) + + def _update_zoom_limits(self): + """根据图像在窗口中的比例动态计算缩放范围。 + + 不变量:最大绝对缩放 = fit_scale × max_zoom ≤ 10 屏幕像素/场景单位 + 大图 fit_scale 小 → max_zoom 大(可放大更多倍探索细节) + 小图 fit_scale 大 → max_zoom 小(放大空间本就够用) + 下限 min_zoom = 0.2:缩到 fit 的 20%,图像仍占窗口 20% 宽度。 + """ + if self._fit_scale <= 0: + return + self.min_zoom = 0.2 + raw_max = 10.0 / self._fit_scale + self.max_zoom = max(3.0, min(20.0, raw_max)) + + def wheelEvent(self, event: QWheelEvent): + zoom_in = event.angleDelta().y() > 0 + factor = 1.25 if zoom_in else 0.8 + new_zoom = self._zoom_factor * factor + if self.min_zoom <= new_zoom <= self.max_zoom: + self.scale(factor, factor) + self._zoom_factor = new_zoom + self._user_has_zoomed = True + if self._svg_bytes and _SVG_SUPPORT: + self._sharpen_timer.start(self._SHARPEN_DELAY_MS) + + def keyPressEvent(self, event: QKeyEvent): + new_zoom = 0.0 + factor = 0.0 + if event.modifiers() == Qt.ControlModifier: + if event.key() == Qt.Key_Left: + event.accept() + factor = 0.8 + new_zoom = self._zoom_factor * factor + elif event.key() == Qt.Key_Right: + event.accept() + factor = 1.25 + new_zoom = self._zoom_factor * factor + if self.min_zoom <= new_zoom <= self.max_zoom: + self.scale(factor, factor) + self._zoom_factor = new_zoom + self._user_has_zoomed = True + if self._svg_bytes and _SVG_SUPPORT: + self._sharpen_timer.start(self._SHARPEN_DELAY_MS) + return + super().keyPressEvent(event) + + def mouseDoubleClickEvent(self, event: QMouseEvent): + if event.button() == Qt.RightButton: + event.accept() + self._fit_and_center_animation() + return + elif event.button() == Qt.LeftButton: + event.accept() + factor = 1.25 + new_zoom = self._zoom_factor * factor + if self.min_zoom <= new_zoom <= self.max_zoom: + self.scale(factor, factor) + self._zoom_factor = new_zoom + self._user_has_zoomed = True + if self._svg_bytes and _SVG_SUPPORT: + self._sharpen_timer.start(self._SHARPEN_DELAY_MS) + return + super().mouseDoubleClickEvent(event) + + def mouseMoveEvent(self, event: QMouseEvent): + if self.dragging and self._svg_bytes and _SVG_SUPPORT: + self._sharpen_timer.start(150) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QMouseEvent): + if event.button() in (Qt.RightButton, Qt.LeftButton): + self.dragging = False + self.setCursor(Qt.ArrowCursor) + if self._svg_bytes and _SVG_SUPPORT: + self._sharpen_timer.start(self._SHARPEN_DELAY_MS) + super().mouseReleaseEvent(event) + + def _fit_and_center_animation(self): + if self.pixmap_item.pixmap().isNull(): + return + self._user_has_zoomed = False + self.fit_to_view() + final_center = self.pixmap_item.sceneBoundingRect().center() + self._create_center_animation(final_center) + + def _create_center_animation(self, target_center: QPointF): + anim_h = QPropertyAnimation(self.horizontalScrollBar(), b"value") + anim_v = QPropertyAnimation(self.verticalScrollBar(), b"value") + + current_zoom = self._zoom_factor + anim_zoom = QPropertyAnimation(self, b"zoom_factor") + anim_zoom.setDuration(400) + anim_zoom.setStartValue(current_zoom) + anim_zoom.setEndValue(1.0) + + for anim in [anim_h, anim_v]: + anim.setDuration(400) + anim.setEasingCurve(QEasingCurve.OutQuad) + + view_center = self.mapToScene(self.viewport().rect().center()) + delta = target_center - view_center + + anim_h.setStartValue(self.horizontalScrollBar().value()) + anim_h.setEndValue(self.horizontalScrollBar().value() + delta.x()) + anim_v.setStartValue(self.verticalScrollBar().value()) + anim_v.setEndValue(self.verticalScrollBar().value() + delta.y()) + + anim_zoom.start() + anim_h.start() + anim_v.start() + + if self._svg_bytes and _SVG_SUPPORT: + anim_zoom.finished.connect(lambda: self._sharpen_timer.start(50)) + + self.center_indicator.setPos(target_center) + self.center_indicator.setVisible(True) + QTimer.singleShot(800, lambda: self.center_indicator.setVisible(False)) + + def _on_zoom_settled(self): + """缩放停止后触发:在后台线程将整张 SVG 光栅化,完成后回到主线程更新。""" + if not (_SVG_SUPPORT and self._svg_bytes): + return + if self._base_scene_w <= 0 or self._base_scene_h <= 0: + return + + # 上一次渲染仍在进行,延迟重试,避免任务堆积 + if self._is_rendering: + self._sharpen_timer.start(self._SHARPEN_DELAY_MS) + return + + current_scale = self.transform().m11() + + vp_rect = self.viewport().rect() + vp_scene = self.mapToScene(vp_rect).boundingRect() + + # skip:缩放比变化 < 10% 且视口位移 < 10% 视口宽高 + if self._last_sharpened_scale > 0: + scale_ok = abs(current_scale / self._last_sharpened_scale - 1.0) < 0.1 + if scale_ok: + prev = self._last_sharpened_vp_center + cur = vp_scene.center() + if (abs(cur.x() - prev.x()) < vp_scene.width() * 0.1 and + abs(cur.y() - prev.y()) < vp_scene.height() * 0.1): + return + + full_rect = QRectF(-self._base_scene_w / 2.0, -self._base_scene_h / 2.0, + self._base_scene_w, self._base_scene_h) + + # 只渲染可见视口与图像的交集:target 像素数 ≤ 视口像素数,无需 cap + clip_rect = vp_scene.intersected(full_rect) + if clip_rect.isEmpty(): + return + + # 递增代次,后台线程完成时若代次已变则丢弃结果 + self._render_gen += 1 + gen = self._render_gen + self._is_rendering = True + + # 捕获渲染参数(不在后台访问 self) + svg_bytes = self._svg_bytes + base_w = self._base_scene_w + base_h = self._base_scene_h + full_raster_w = base_w * current_scale + full_raster_h = base_h * current_scale + offset_x = (clip_rect.x() - full_rect.x()) / full_rect.width() * full_raster_w + offset_y = (clip_rect.y() - full_rect.y()) / full_rect.height() * full_raster_h + target_w = max(1, int(clip_rect.width() * current_scale)) + target_h = max(1, int(clip_rect.height() * current_scale)) + clip_x = clip_rect.x() + clip_y = clip_rect.y() + vp_center = vp_scene.center() + + def background_render(): + renderer = QSvgRenderer(QByteArray(svg_bytes)) + if not renderer.isValid(): + self._call_in_main.emit(lambda: setattr(self, '_is_rendering', False)) + return + + image = QImage(target_w, target_h, QImage.Format_ARGB32_Premultiplied) + image.fill(Qt.transparent) + painter = QPainter(image) + painter.setRenderHint(QPainter.Antialiasing) + painter.setRenderHint(QPainter.TextAntialiasing) + painter.translate(-offset_x, -offset_y) + renderer.render(painter, QRectF(0, 0, full_raster_w, full_raster_h)) + painter.end() + + def apply_on_main(): + self._is_rendering = False + if self._render_gen != gen: + return + pixmap = QPixmap.fromImage(image) + self.pixmap_item.setPixmap(pixmap) + self.pixmap_item.setPos(clip_x, clip_y) + self.pixmap_item.setScale(1.0 / current_scale) + self._last_sharpened_scale = current_scale + self._last_sharpened_vp_center = vp_center + + self._call_in_main.emit(apply_on_main) + + threading.Thread(target=background_render, daemon=True).start() + + def resizeEvent(self, event): + """窗口大小变化:未手动缩放则自适应,已手动缩放则保持""" + if not self._user_has_zoomed: + self.fit_to_view() + super().resizeEvent(event) diff --git a/tools/graphviz_render/src/ui/viewer.py b/tools/graphviz_render/src/ui/viewer.py index 08d35e4d860e99882af4da7e3b7089838613ed32..022e14ff37b459a5614bdfc2018775bac3893cc3 100644 --- a/tools/graphviz_render/src/ui/viewer.py +++ b/tools/graphviz_render/src/ui/viewer.py @@ -19,26 +19,24 @@ # import os -import subprocess -from io import BytesIO -from PIL import Image from data_processing import DataProcessing from data_source import DataSource from transform.zoomable import ZoomableGraphicsView +from transform.zoomable_svg import ZoomableSvgGraphicsView - -from PIL import Image from datetime import datetime -from PyQt5.QtWidgets import (QMainWindow, QLabel, QVBoxLayout,QWidget) +from PyQt5.QtWidgets import (QMainWindow, QLabel, QVBoxLayout, QWidget, QPushButton, QHBoxLayout) from PyQt5.QtCore import Qt, QTimer -from PyQt5.QtGui import QImage, QPixmap, QFont +from PyQt5.QtGui import QFont class GraphvizViewer(QMainWindow): def __init__(self, pipe_name): super().__init__() self.pipe_name = pipe_name self.current_pixmap = None + self.current_svg_bytes = b'' + self._use_svg = False self.setup_ui() def setup_ui(self): @@ -52,20 +50,33 @@ class GraphvizViewer(QMainWindow): layout.setContentsMargins(0, 0, 0, 0) # Remove margins layout.setSpacing(0) # Remove spacing - # Create timestamp label with minimal height + # Top bar: timestamp label + solution toggle button + top_bar = QWidget() + top_bar.setFixedHeight(24) + top_bar_layout = QHBoxLayout(top_bar) + top_bar_layout.setContentsMargins(0, 0, 4, 0) + top_bar_layout.setSpacing(4) + self.timestamp_label = QLabel() self.timestamp_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.timestamp_label.setStyleSheet("background-color: #f0f0f0; padding: 2px;") - self.timestamp_label.setFont(QFont("Arial", 8)) # Smaller font - self.timestamp_label.setFixedHeight(20) # Fixed height for timestamp + self.timestamp_label.setFont(QFont("Arial", 8)) + + self.toggle_btn = QPushButton("高清缩放: 关") + self.toggle_btn.setFixedWidth(100) + self.toggle_btn.setFixedHeight(20) + self.toggle_btn.setFont(QFont("Arial", 8)) + self.toggle_btn.setCheckable(True) + self.toggle_btn.clicked.connect(self._toggle_solution) - # Create zoomable image label + top_bar_layout.addWidget(self.timestamp_label, stretch=1) + top_bar_layout.addWidget(self.toggle_btn) + + # Create zoomable image label (Solution A by default) self.image_label = ZoomableGraphicsView() - # self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - # self.image_label.setMinimumSize(1, 1) # Allow shrinking # Add widgets to main layout - layout.addWidget(self.timestamp_label) + layout.addWidget(top_bar) layout.addWidget(self.image_label) self.data_processing = DataProcessing() @@ -90,24 +101,54 @@ class GraphvizViewer(QMainWindow): # Debounce resize events # self.resize_timer.start(100) - def update_graph(self, pixmap): + def _toggle_solution(self): + """切换渲染方案:方案A(PNG直接缩放)↔ 方案B(SVG高清缩放)""" + self._use_svg = not self._use_svg + + if self._use_svg: + new_view = ZoomableSvgGraphicsView() + self.toggle_btn.setText("高清缩放: 开") + else: + new_view = ZoomableGraphicsView() + self.toggle_btn.setText("高清缩放: 关") + + old_view = self.image_label + + # 让旧视图的后台线程自动放弃待处理结果 + if hasattr(old_view, '_render_gen'): + old_view._render_gen += 1 + if hasattr(old_view, '_sharpen_timer'): + old_view._sharpen_timer.stop() + + layout = self.centralWidget().layout() + layout.replaceWidget(old_view, new_view) + old_view.hide() + old_view.deleteLater() + self.image_label = new_view + + # 将当前图像立即重放到新视图 + if self.current_pixmap is not None: + self.image_label.update_image(self.current_pixmap, self.current_svg_bytes) + + def update_graph(self, pixmap, svg_bytes=b''): if pixmap != self.current_pixmap: self.current_pixmap = pixmap - self.image_label.update_image(self.current_pixmap) + self.current_svg_bytes = svg_bytes + self.image_label.update_image(self.current_pixmap, self.current_svg_bytes) def updateTime(self): # Update timestamp current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self.timestamp_label.setText(current_time) - + def receive_graph_data(self, data): self.updateTime() self.data_processing.receive_data(data) def update_image(self): - if self.update_image is None: + if self.current_pixmap is None: return - self.image_label.update_image(self.current_pixmap) + self.image_label.update_image(self.current_pixmap, self.current_svg_bytes) def closeEvent(self, event): self.data_source.stop() @@ -117,4 +158,3 @@ class GraphvizViewer(QMainWindow): if os.path.exists(self.pipe_name): os.remove(self.pipe_name) event.accept() -