Add All Folders
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,24 @@
|
||||
from PySide6.QtWidgets import QGraphicsLineItem, QMessageBox
|
||||
from PySide6.QtGui import QPen, QColor
|
||||
|
||||
class ConnectionLine(QGraphicsLineItem):
|
||||
def __init__(self, source_port, target_port):
|
||||
super().__init__()
|
||||
self.source_port = source_port
|
||||
self.target_port = target_port
|
||||
self.update_position()
|
||||
|
||||
# Validate connection types
|
||||
if self.source_port.port_type != self.target_port.port_type:
|
||||
# Invalid connection → red line + warning
|
||||
self.setPen(QPen(QColor("red"), 2))
|
||||
QMessageBox.warning(None, "Invalid Connection",
|
||||
f"Cannot connect {self.source_port.port_type} → {self.target_port.port_type}")
|
||||
else:
|
||||
# Valid connection → black line
|
||||
self.setPen(QPen(QColor("blue"), 2))
|
||||
|
||||
def update_position(self):
|
||||
src_pos = self.source_port.scenePos()
|
||||
tgt_pos = self.target_port.scenePos()
|
||||
self.setLine(src_pos.x(), src_pos.y(), tgt_pos.x(), tgt_pos.y())
|
||||
@@ -0,0 +1,214 @@
|
||||
from PySide6.QtWidgets import QGraphicsScene, QGraphicsEllipseItem, QGraphicsLineItem
|
||||
from PySide6.QtGui import QBrush, QColor, QPen
|
||||
from PySide6.QtCore import QPointF
|
||||
from app.gui.node_widget import NodeWidget
|
||||
|
||||
|
||||
class Port(QGraphicsEllipseItem):
|
||||
"""An input/output port that is placed next to each node."""
|
||||
def __init__(self, parent_node: NodeWidget, kind: str, offset_x: float, offset_y: float):
|
||||
super().__init__(-5, -5, 10, 10, parent_node)
|
||||
self.parent_node = parent_node
|
||||
self.kind = kind # "input" Or "output"
|
||||
self.setBrush(QBrush(QColor("#2e7d32") if kind == "input" else QColor("#1565c0")))
|
||||
self.setPos(parent_node.rect().x() + offset_x, parent_node.rect().y() + offset_y)
|
||||
self.setZValue(1.0)
|
||||
|
||||
|
||||
class ConnectionLine(QGraphicsLineItem):
|
||||
"""Connection between two ports."""
|
||||
def __init__(self, source_port: Port, target_port: Port):
|
||||
super().__init__()
|
||||
self.source_port = source_port
|
||||
self.target_port = target_port
|
||||
pen = QPen(QColor("#555"))
|
||||
pen.setWidth(2)
|
||||
self.setPen(pen)
|
||||
self.update_positions()
|
||||
|
||||
def update_positions(self):
|
||||
src = self.source_port.mapToScene(QPointF(0, 0))
|
||||
tgt = self.target_port.mapToScene(QPointF(0, 0))
|
||||
self.setLine(src.x(), src.y(), tgt.x(), tgt.y())
|
||||
|
||||
|
||||
class GraphScene(QGraphicsScene):
|
||||
"""Graph scene with nodes, ports, and connections. Only connected nodes are exported."""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.connections: list[ConnectionLine] = []
|
||||
# Manual connection mode: You click on an outgoing port, then click on the destination incoming port.
|
||||
self._pending_source_port: Port | None = None
|
||||
|
||||
# ---------- Helper tool for adding default nodes ----------
|
||||
def add_default_medical_nodes(self):
|
||||
"""Adds five medical nodes and installs their ports."""
|
||||
# Create nodes
|
||||
reader = NodeWidget("Image Reader", [], ["output"])
|
||||
registration = NodeWidget("Image Registration", ["input"], ["output"])
|
||||
fusion = NodeWidget("Image Fusion", ["input1", "input2"], ["output"])
|
||||
extraction = NodeWidget("Image Extraction", ["input"], ["output"])
|
||||
writer = NodeWidget("Image Writer", ["input"], [])
|
||||
|
||||
# Initial placement
|
||||
reader.setPos(50, 50)
|
||||
registration.setPos(250, 50)
|
||||
fusion.setPos(450, 50)
|
||||
extraction.setPos(650, 50)
|
||||
writer.setPos(850, 50)
|
||||
|
||||
# Add to scene
|
||||
for n in (reader, registration, fusion, extraction, writer):
|
||||
self.addItem(n)
|
||||
|
||||
# Install ports for each node (a simple I/O is enough)
|
||||
# Reader: Output only
|
||||
reader.output_port = Port(reader, "output", 150, 45)
|
||||
|
||||
# Registration: Input + Output
|
||||
registration.input_port = Port(registration, "input", 0, 45)
|
||||
registration.output_port = Port(registration, "output", 150, 45)
|
||||
|
||||
# Fusion: Two inputs + one output
|
||||
fusion.input_port_1 = Port(fusion, "input", 0, 30)
|
||||
fusion.input_port_2 = Port(fusion, "input", 0, 60)
|
||||
fusion.output_port = Port(fusion, "output", 150, 45)
|
||||
|
||||
# Extraction: Input + Output
|
||||
extraction.input_port = Port(extraction, "input", 0, 45)
|
||||
extraction.output_port = Port(extraction, "output", 150, 45)
|
||||
|
||||
# Writer: Input only
|
||||
writer.input_port = Port(writer, "input", 0, 45)
|
||||
|
||||
# Register the port click handle for manual connection
|
||||
self._install_port_handlers([reader, registration, fusion, extraction, writer])
|
||||
|
||||
def _install_port_handlers(self, nodes: list[NodeWidget]):
|
||||
"""Mouse handles for ports so the user can make connections."""
|
||||
all_ports: list[Port] = []
|
||||
for n in nodes:
|
||||
for attr in dir(n):
|
||||
if attr.endswith("port"):
|
||||
p = getattr(n, attr)
|
||||
if isinstance(p, Port):
|
||||
all_ports.append(p)
|
||||
|
||||
for port in all_ports:
|
||||
port.mousePressEvent = lambda event, p=port: self._on_port_clicked(p)
|
||||
|
||||
def _on_port_clicked(self, port: Port):
|
||||
"""Ports click logic: output first, then input; connection is made."""
|
||||
if port.kind == "output":
|
||||
# Start connection
|
||||
self._pending_source_port = port
|
||||
elif port.kind == "input" and self._pending_source_port is not None:
|
||||
# Connection completion
|
||||
line = ConnectionLine(self._pending_source_port, port)
|
||||
self.addItem(line)
|
||||
self.connections.append(line)
|
||||
# Update positions when nodes move
|
||||
self._pending_source_port = None
|
||||
|
||||
# ----------Export/Load Graph ----------
|
||||
def export_graph(self):
|
||||
"""Exports only nodes involved in connections."""
|
||||
data = {"nodes": [], "connections": []}
|
||||
used_nodes = set()
|
||||
|
||||
for conn in self.connections:
|
||||
src_node = conn.source_port.parent_node
|
||||
tgt_node = conn.target_port.parent_node
|
||||
used_nodes.add(src_node)
|
||||
used_nodes.add(tgt_node)
|
||||
data["connections"].append({
|
||||
"source": src_node.title.toPlainText(),
|
||||
"target": tgt_node.title.toPlainText()
|
||||
})
|
||||
|
||||
for node in used_nodes:
|
||||
data["nodes"].append({
|
||||
"id": node.title.toPlainText(), # Persistent ID based on title
|
||||
"type": node.title.toPlainText(),
|
||||
"pos": [node.pos().x(), node.pos().y()]
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
def save_graph(self, filename: str):
|
||||
import json
|
||||
from pathlib import Path
|
||||
Path(filename).write_text(json.dumps(self.export_graph(), indent=2))
|
||||
|
||||
def load_graph(self, filename: str):
|
||||
"""Rebuild ports and connections from saved file.
|
||||
Note: Here we assume that the default nodes are present on the scene; we only draw the connections.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
if not Path(filename).exists():
|
||||
return
|
||||
data = json.loads(Path(filename).read_text())
|
||||
|
||||
# Create a mapping from title → node in the scene
|
||||
title_to_node = {}
|
||||
for item in self.items():
|
||||
if isinstance(item, NodeWidget):
|
||||
title_to_node[item.title.toPlainText()] = item
|
||||
|
||||
# Rebuild connections
|
||||
self._rebuild_ports_if_missing(title_to_node)
|
||||
for conn in data.get("connections", []):
|
||||
src = title_to_node.get(conn["source"])
|
||||
tgt = title_to_node.get(conn["target"])
|
||||
if not src or not tgt:
|
||||
continue
|
||||
|
||||
# Select default input/output port based on node name
|
||||
src_port = getattr(src, "output_port", None)
|
||||
tgt_port = getattr(tgt, "input_port", None)
|
||||
|
||||
# Fusion has two inputs; if the destination is Fusion and the first input is busy, take the second input
|
||||
if tgt.title.toPlainText() == "Image Fusion":
|
||||
# If there is no previous connection to input_port_1, connect to it; otherwise connect to input_port_2
|
||||
candidates = [tgt.input_port_1, tgt.input_port_2]
|
||||
tgt_port = candidates[0]
|
||||
for c in self.connections:
|
||||
if c.target_port is candidates[0]:
|
||||
tgt_port = candidates[1]
|
||||
break
|
||||
|
||||
if src_port and tgt_port:
|
||||
line = ConnectionLine(src_port, tgt_port)
|
||||
self.addItem(line)
|
||||
self.connections.append(line)
|
||||
|
||||
def _rebuild_ports_if_missing(self, title_to_node: dict):
|
||||
"""If the node ports haven't been created yet for some reason, we'll create them here."""
|
||||
for title, n in title_to_node.items():
|
||||
# Reader
|
||||
if title == "Image Reader" and not hasattr(n, "output_port"):
|
||||
n.output_port = Port(n, "output", 150, 45)
|
||||
# Registration
|
||||
if title == "Image Registration":
|
||||
if not hasattr(n, "input_port"):
|
||||
n.input_port = Port(n, "input", 0, 45)
|
||||
if not hasattr(n, "output_port"):
|
||||
n.output_port = Port(n, "output", 150, 45)
|
||||
# Fusion
|
||||
if title == "Image Fusion":
|
||||
if not hasattr(n, "input_port_1"):
|
||||
n.input_port_1 = Port(n, "input", 0, 30)
|
||||
if not hasattr(n, "input_port_2"):
|
||||
n.input_port_2 = Port(n, "input", 0, 60)
|
||||
if not hasattr(n, "output_port"):
|
||||
n.output_port = Port(n, "output", 150, 45)
|
||||
# Extraction
|
||||
if title == "Image Extraction":
|
||||
if not hasattr(n, "input_port"):
|
||||
n.input_port = Port(n, "input", 0, 45)
|
||||
if not hasattr(n, "output_port"):
|
||||
n.output_port = Port(n, "output", 150, 45)
|
||||
# Writer
|
||||
if title == "Image Writer" and not hasattr(n, "input_port"):
|
||||
n.input_port = Port(n, "input", 0, 45)
|
||||
@@ -0,0 +1,60 @@
|
||||
from PySide6.QtWidgets import QPushButton, QHBoxLayout, QWidget
|
||||
from dagster_radiuma.jobs import radiomics_job, radiomics_batch_job
|
||||
|
||||
class WorkflowControls(QWidget):
|
||||
def __init__(self, log_panel, history_panel, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.log_panel = log_panel
|
||||
self.history_panel = history_panel
|
||||
|
||||
# دکمهها
|
||||
self.start_btn = QPushButton("Start")
|
||||
self.stop_btn = QPushButton("Stop")
|
||||
self.resume_btn = QPushButton("Resume")
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
|
||||
layout = QHBoxLayout()
|
||||
layout.addWidget(self.start_btn)
|
||||
layout.addWidget(self.stop_btn)
|
||||
layout.addWidget(self.resume_btn)
|
||||
layout.addWidget(self.cancel_btn)
|
||||
self.setLayout(layout)
|
||||
|
||||
# اتصال
|
||||
self.start_btn.clicked.connect(self.start_workflow)
|
||||
self.stop_btn.clicked.connect(self.stop_workflow)
|
||||
self.resume_btn.clicked.connect(self.resume_workflow)
|
||||
self.cancel_btn.clicked.connect(self.cancel_workflow)
|
||||
|
||||
self.current_run_id = None
|
||||
|
||||
def log(self, message):
|
||||
if hasattr(self.log_panel, "append"):
|
||||
self.log_panel.append(message)
|
||||
if hasattr(self.history_panel, "append"):
|
||||
self.history_panel.append(message)
|
||||
|
||||
def start_workflow(self):
|
||||
self.log("[GUI] Start → Dagster job starting...")
|
||||
result = radiomics_job.execute_in_process()
|
||||
self.current_run_id = result.run_id
|
||||
self.log(f"[GUI] Workflow started. Success={result.success}")
|
||||
|
||||
def stop_workflow(self):
|
||||
if self.current_run_id:
|
||||
self.log(f"[GUI] Stop → Terminating run {self.current_run_id}")
|
||||
# dagster_api.terminate_run(self.current_run_id)
|
||||
self.log("[GUI] Workflow stopped.")
|
||||
|
||||
def resume_workflow(self):
|
||||
if self.current_run_id:
|
||||
self.log(f"[GUI] Resume → Restarting run {self.current_run_id}")
|
||||
# dagster_api.resume_run(self.current_run_id)
|
||||
self.log("[GUI] Workflow resumed. [Recovered]")
|
||||
|
||||
def cancel_workflow(self):
|
||||
if self.current_run_id:
|
||||
self.log(f"[GUI] Cancel → Cancelling run {self.current_run_id}")
|
||||
# dagster_api.cancel_run(self.current_run_id)
|
||||
self.log("[GUI] Workflow cancelled.")
|
||||
@@ -0,0 +1,24 @@
|
||||
# app/gui/napari_main.py
|
||||
import napari
|
||||
from pathlib import Path
|
||||
import SimpleITK as sitk
|
||||
import numpy as np
|
||||
|
||||
def sitk_to_numpy(img):
|
||||
return sitk.GetArrayFromImage(img) # z, y, x
|
||||
|
||||
def run_napari_viewer():
|
||||
viewer = napari.Viewer(title="Radiuma Mini - Viewer")
|
||||
# Load outputs if they exist
|
||||
filtered = Path("app/storage/artifacts/filtered.nii.gz")
|
||||
mask = Path("data/masks/CT_AVM_mask.nii.gz")
|
||||
if filtered.exists():
|
||||
arr = sitk_to_numpy(sitk.ReadImage(str(filtered)))
|
||||
viewer.add_image(arr, name="filtered", blending="translucent")
|
||||
if mask.exists():
|
||||
marr = sitk_to_numpy(sitk.ReadImage(str(mask)))
|
||||
viewer.add_labels(marr, name="mask")
|
||||
napari.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_napari_viewer()
|
||||
@@ -0,0 +1,368 @@
|
||||
import SimpleITK as sitk
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pysera
|
||||
from NodeGraphQt import BaseNode
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLineEdit, QTreeWidget, QTreeWidgetItem
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Helpers for safe input resolution
|
||||
# -------------------------------
|
||||
|
||||
def _resolve_array_from_input(node: BaseNode, port_index: int, upstream_attr_name: str):
|
||||
"""
|
||||
Try to get a NumPy array from node.get_input(port_index).
|
||||
If it's a Port (NodeGraphQt Port), traverse to the connected upstream node
|
||||
and read the given upstream_attr_name as a fallback payload.
|
||||
"""
|
||||
val = node.get_input(port_index)
|
||||
# direct array
|
||||
if isinstance(val, np.ndarray):
|
||||
return val
|
||||
# try to traverse port connections
|
||||
try:
|
||||
if hasattr(val, 'connected_ports'):
|
||||
ports = val.connected_ports()
|
||||
if ports:
|
||||
upstream_node = ports[0].node()
|
||||
upstream_val = getattr(upstream_node, upstream_attr_name, None)
|
||||
if isinstance(upstream_val, np.ndarray):
|
||||
return upstream_val
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_features_from_input(node: BaseNode, port_index: int, upstream_attr_name: str):
|
||||
"""
|
||||
Resolve features payload from input. Accepts pandas.DataFrame, dict, list, or str.
|
||||
If input returns a Port, read upstream node attribute.
|
||||
"""
|
||||
val = node.get_input(port_index)
|
||||
# direct usable types
|
||||
if hasattr(val, "to_csv"): # pandas DataFrame
|
||||
return val
|
||||
if isinstance(val, (dict, list, str)):
|
||||
return val
|
||||
# fallback via upstream
|
||||
try:
|
||||
if hasattr(val, 'connected_ports'):
|
||||
ports = val.connected_ports()
|
||||
if ports:
|
||||
upstream_node = ports[0].node()
|
||||
upstream_val = getattr(upstream_node, upstream_attr_name, None)
|
||||
if hasattr(upstream_val, "to_csv") or isinstance(upstream_val, (dict, list, str)):
|
||||
return upstream_val
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Node Definitions
|
||||
# -------------------------------
|
||||
|
||||
class ImageReaderNode(BaseNode):
|
||||
__identifier__ = 'image.io'
|
||||
NODE_NAME = 'Image Reader'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_output('image')
|
||||
self.add_output('mask')
|
||||
# store payloads for upstream fallback resolution
|
||||
self._image_array = None
|
||||
self._mask_array = None
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
image_path = r"C:/Users/Omen16/Documents/Radiuma_Mini/data/images/CT_AVM.nii.gz"
|
||||
mask_path = r"C:/Users/Omen16/Documents/Radiuma_Mini/data/masks/CT_AVM_mask.nii.gz"
|
||||
|
||||
image = sitk.ReadImage(image_path)
|
||||
mask = sitk.ReadImage(mask_path)
|
||||
|
||||
img_arr = sitk.GetArrayFromImage(image)
|
||||
msk_arr = sitk.GetArrayFromImage(mask)
|
||||
|
||||
# save locally and on ports
|
||||
self._image_array = img_arr
|
||||
self._mask_array = msk_arr
|
||||
|
||||
print("Reader: loaded image & mask.")
|
||||
self.set_output(0, img_arr)
|
||||
self.set_output(1, msk_arr)
|
||||
except Exception as e:
|
||||
print("Reader error:", e)
|
||||
|
||||
|
||||
class ImageWriterNode(BaseNode):
|
||||
__identifier__ = 'image.io'
|
||||
NODE_NAME = 'Image Writer'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_input('image')
|
||||
self.add_input('features')
|
||||
|
||||
def run(self):
|
||||
# resolve image array robustly (if input is a Port, traverse upstream)
|
||||
image_array = _resolve_array_from_input(self, 0, '_image_array')
|
||||
features = _resolve_features_from_input(self, 1, '_features')
|
||||
|
||||
# image save
|
||||
if isinstance(image_array, np.ndarray) and image_array.size > 0:
|
||||
try:
|
||||
sitk_image = sitk.GetImageFromArray(image_array)
|
||||
sitk.WriteImage(sitk_image, "output_image.nii.gz")
|
||||
print("Writer: image saved.")
|
||||
except Exception as e:
|
||||
print("Writer image save error:", e)
|
||||
else:
|
||||
print("Writer: no image to save.")
|
||||
|
||||
# features save
|
||||
if features is not None:
|
||||
try:
|
||||
if hasattr(features, "to_csv"):
|
||||
features.to_csv("features.csv", index=False)
|
||||
elif isinstance(features, dict):
|
||||
pd.DataFrame([features]).to_csv("features.csv", index=False)
|
||||
elif isinstance(features, list):
|
||||
pd.DataFrame(features).to_csv("features.csv", index=False)
|
||||
else:
|
||||
pd.DataFrame([{"features": str(features)}]).to_csv("features.csv", index=False)
|
||||
print("Writer: features saved.")
|
||||
except Exception as e:
|
||||
print("Writer features save error:", e)
|
||||
else:
|
||||
print("Writer: no features to save.")
|
||||
|
||||
|
||||
class ImageFilterNode(BaseNode):
|
||||
__identifier__ = 'image.proc'
|
||||
NODE_NAME = 'Image Filter'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_input('image')
|
||||
self.add_output('filtered_image')
|
||||
self._filtered_array = None
|
||||
|
||||
def run(self):
|
||||
# resolve input image array robustly
|
||||
image_array = _resolve_array_from_input(self, 0, '_image_array')
|
||||
if not isinstance(image_array, np.ndarray) or image_array.size == 0:
|
||||
print("Filter: no valid image.")
|
||||
return
|
||||
try:
|
||||
image = sitk.GetImageFromArray(image_array)
|
||||
filtered = sitk.SmoothingRecursiveGaussian(image, sigma=2.0)
|
||||
filtered_arr = sitk.GetArrayFromImage(filtered)
|
||||
|
||||
self._filtered_array = filtered_arr
|
||||
self.set_output(0, filtered_arr)
|
||||
print("Filter: applied Gaussian smoothing.")
|
||||
except Exception as e:
|
||||
print("Filter error:", e)
|
||||
|
||||
|
||||
class ImageRegistrationNode(BaseNode):
|
||||
__identifier__ = 'image.proc'
|
||||
NODE_NAME = 'Image Registration'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_input('fixed_image')
|
||||
self.add_input('moving_image')
|
||||
self.add_output('registered_image')
|
||||
self._registered_array = None
|
||||
|
||||
def run(self):
|
||||
fixed_array = _resolve_array_from_input(self, 0, '_image_array')
|
||||
moving_array = _resolve_array_from_input(self, 1, '_image_array')
|
||||
|
||||
if not isinstance(fixed_array, np.ndarray) or not isinstance(moving_array, np.ndarray):
|
||||
print("Registration: invalid inputs.")
|
||||
return
|
||||
|
||||
try:
|
||||
fixed = sitk.GetImageFromArray(fixed_array)
|
||||
moving = sitk.GetImageFromArray(moving_array)
|
||||
|
||||
registration = sitk.ImageRegistrationMethod()
|
||||
registration.SetMetricAsMeanSquares()
|
||||
registration.SetOptimizerAsGradientDescent(
|
||||
learningRate=1.0,
|
||||
numberOfIterations=50
|
||||
)
|
||||
registration.SetInterpolator(sitk.sitkLinear)
|
||||
|
||||
initial_transform = sitk.CenteredTransformInitializer(
|
||||
fixed,
|
||||
moving,
|
||||
sitk.Euler3DTransform(),
|
||||
sitk.CenteredTransformInitializerFilter.GEOMETRY
|
||||
)
|
||||
registration.SetInitialTransform(initial_transform, inPlace=False)
|
||||
|
||||
final_transform = registration.Execute(fixed, moving)
|
||||
registered = sitk.Resample(
|
||||
moving, fixed, final_transform,
|
||||
sitk.sitkLinear, 0.0, moving.GetPixelID()
|
||||
)
|
||||
|
||||
reg_arr = sitk.GetArrayFromImage(registered)
|
||||
self._registered_array = reg_arr
|
||||
self.set_output(0, reg_arr)
|
||||
print("Registration: complete.")
|
||||
except Exception as e:
|
||||
print("Registration error:", e)
|
||||
|
||||
|
||||
class ImageFusionNode(BaseNode):
|
||||
__identifier__ = 'image.proc'
|
||||
NODE_NAME = 'Image Fusion'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_input('image_A')
|
||||
self.add_input('image_B')
|
||||
self.add_output('fused_image')
|
||||
self._fused_array = None
|
||||
|
||||
def run(self):
|
||||
img_a = _resolve_array_from_input(self, 0, '_image_array')
|
||||
img_b = _resolve_array_from_input(self, 1, '_image_array')
|
||||
|
||||
if not isinstance(img_a, np.ndarray) or not isinstance(img_b, np.ndarray):
|
||||
print("Fusion: invalid inputs.")
|
||||
return
|
||||
|
||||
try:
|
||||
fused = (img_a.astype(np.float32) * 0.5 + img_b.astype(np.float32) * 0.5)
|
||||
self._fused_array = fused
|
||||
self.set_output(0, fused)
|
||||
print("Fusion: complete.")
|
||||
except Exception as e:
|
||||
print("Fusion error:", e)
|
||||
|
||||
|
||||
class FeatureExtractionNode(BaseNode):
|
||||
__identifier__ = 'analysis'
|
||||
NODE_NAME = 'Feature Extraction'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_input('image')
|
||||
self.add_input('mask')
|
||||
self.add_output('features')
|
||||
self._features = None
|
||||
|
||||
def run(self):
|
||||
image_array = _resolve_array_from_input(self, 0, '_image_array')
|
||||
mask_array = _resolve_array_from_input(self, 1, '_mask_array')
|
||||
|
||||
if not isinstance(image_array, np.ndarray) or not isinstance(mask_array, np.ndarray):
|
||||
print("Extraction: invalid image/mask.")
|
||||
return
|
||||
|
||||
try:
|
||||
result = pysera.process_batch(
|
||||
image_input=image_array,
|
||||
mask_input=mask_array,
|
||||
output_path="./results",
|
||||
categories="all",
|
||||
dimensions="3d",
|
||||
apply_preprocessing=True
|
||||
)
|
||||
features = result.get("features_extracted")
|
||||
self._features = features
|
||||
self.set_output(0, features)
|
||||
|
||||
# End of processing report
|
||||
print(f"Extraction finished. Success={result.get('success')}, "
|
||||
f"Features={len(features) if features is not None else 0}, "
|
||||
f"Time={result.get('processing_time')}s")
|
||||
|
||||
except Exception as e:
|
||||
print("PySERA error:", e)
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Node Catalog Widget
|
||||
# -------------------------------
|
||||
|
||||
class NodeCatalog(QWidget):
|
||||
def __init__(self, graph):
|
||||
super().__init__()
|
||||
self.graph = graph
|
||||
self.node_count = 0
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.search_bar = QLineEdit()
|
||||
self.search_bar.setPlaceholderText("Search nodes...")
|
||||
layout.addWidget(self.search_bar)
|
||||
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setHeaderHidden(True)
|
||||
layout.addWidget(self.tree)
|
||||
|
||||
self.categories = {
|
||||
"Image I/O": [
|
||||
(ImageReaderNode, "icons/camera.png"),
|
||||
(ImageWriterNode, "icons/save.png")
|
||||
],
|
||||
"Image Processing": [
|
||||
(ImageFilterNode, "icons/filter.png"),
|
||||
(ImageRegistrationNode, "icons/transform.png"),
|
||||
(ImageFusionNode, "icons/fusion.png")
|
||||
],
|
||||
"Feature Extraction": [
|
||||
(FeatureExtractionNode, "icons/chart.png")
|
||||
]
|
||||
}
|
||||
|
||||
self.populate_tree()
|
||||
self.search_bar.textChanged.connect(self.filter_nodes)
|
||||
self.tree.itemDoubleClicked.connect(self.add_node)
|
||||
|
||||
def populate_tree(self):
|
||||
self.tree.clear()
|
||||
for category, nodes in self.categories.items():
|
||||
cat_item = QTreeWidgetItem([category])
|
||||
self.tree.addTopLevelItem(cat_item)
|
||||
for node_cls, icon_path in nodes:
|
||||
node_item = QTreeWidgetItem([node_cls.NODE_NAME])
|
||||
node_item.setData(0, Qt.UserRole, node_cls)
|
||||
node_item.setIcon(0, QIcon(icon_path))
|
||||
cat_item.addChild(node_item)
|
||||
|
||||
def filter_nodes(self, text):
|
||||
text = text.lower()
|
||||
for i in range(self.tree.topLevelItemCount()):
|
||||
cat_item = self.tree.topLevelItem(i)
|
||||
visible_cat = False
|
||||
for j in range(cat_item.childCount()):
|
||||
node_item = cat_item.child(j)
|
||||
node_name = node_item.text(0).lower()
|
||||
is_match = text in node_name
|
||||
node_item.setHidden(not is_match)
|
||||
if is_match:
|
||||
visible_cat = True
|
||||
cat_item.setHidden(not visible_cat)
|
||||
|
||||
def add_node(self, item, column):
|
||||
node_cls = item.data(0, Qt.UserRole)
|
||||
if node_cls:
|
||||
node = node_cls()
|
||||
# grid placement to avoid overlap
|
||||
spacing_x, spacing_y = 200, 120
|
||||
x = spacing_x * (self.node_count % 4)
|
||||
y = spacing_y * (self.node_count // 4)
|
||||
self.graph.add_node(node, pos=(x, y))
|
||||
self.node_count += 1
|
||||
@@ -0,0 +1,133 @@
|
||||
from PySide6.QtWidgets import (
|
||||
QMessageBox, QGraphicsRectItem, QGraphicsTextItem,
|
||||
QPushButton, QGraphicsProxyWidget, QTableWidget,
|
||||
QTableWidgetItem, QDialog, QVBoxLayout
|
||||
)
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
from pathlib import Path
|
||||
import napari
|
||||
import json
|
||||
from .port_item import PortItem # Import PortItem with type support
|
||||
|
||||
class NodeWidget(QGraphicsRectItem):
|
||||
def __init__(self, title: str, inputs: list[tuple[str, str]], outputs: list[tuple[str, str]]):
|
||||
"""
|
||||
inputs/outputs: list of (label, port_type)
|
||||
Example:
|
||||
inputs=[("Image", "Image"), ("Mask", "Mask")]
|
||||
outputs=[("Features", "Features")]
|
||||
"""
|
||||
super().__init__(0, 0, 160, 90)
|
||||
self.setBrush(QBrush(QColor("#e0e0e0")))
|
||||
self.title = QGraphicsTextItem(title, self)
|
||||
self.title.setPos(10, 5)
|
||||
|
||||
self.status_text = QGraphicsTextItem("", self)
|
||||
self.status_text.setPos(10, 40)
|
||||
|
||||
# Create input ports
|
||||
self.input_ports = []
|
||||
y_offset = 20
|
||||
for label, port_type in inputs:
|
||||
port = PortItem(label, port_type, is_input=True, parent=self)
|
||||
port.setPos(0, y_offset)
|
||||
self.input_ports.append(port)
|
||||
y_offset += 20
|
||||
|
||||
# Create output ports
|
||||
self.output_ports = []
|
||||
y_offset = 20
|
||||
for label, port_type in outputs:
|
||||
port = PortItem(label, port_type, is_input=False, parent=self)
|
||||
port.setPos(150, y_offset)
|
||||
self.output_ports.append(port)
|
||||
y_offset += 20
|
||||
|
||||
# Output Display Button
|
||||
self.btn_view = QPushButton("View Output")
|
||||
proxy = QGraphicsProxyWidget(self)
|
||||
proxy.setWidget(self.btn_view)
|
||||
proxy.setPos(10, 60)
|
||||
self.btn_view.clicked.connect(self.view_output)
|
||||
|
||||
def set_status(self, status: str):
|
||||
# Change node color and status text based on execution state
|
||||
if status == "running":
|
||||
self.setBrush(QBrush(QColor("yellow")))
|
||||
self.status_text.setPlainText("⏳ Running")
|
||||
elif status == "success":
|
||||
self.setBrush(QBrush(QColor("lightgreen")))
|
||||
self.status_text.setPlainText("✔ Success")
|
||||
elif status == "failed":
|
||||
self.setBrush(QBrush(QColor("red")))
|
||||
self.status_text.setPlainText("❌ Failed")
|
||||
else:
|
||||
self.setBrush(QBrush(QColor("#e0e0e0")))
|
||||
self.status_text.setPlainText("")
|
||||
|
||||
def view_output(self):
|
||||
node_type = self.title.toPlainText()
|
||||
|
||||
# Image Reader Or Image Filter → Processed Image
|
||||
if node_type in ["Image Reader", "Image Filter"]:
|
||||
file_path = Path("artifacts/processed_image.nii.gz")
|
||||
if file_path.exists():
|
||||
viewer = napari.Viewer()
|
||||
viewer.open(str(file_path))
|
||||
else:
|
||||
QMessageBox.warning(None, "Output not found", f"No output image for {node_type}")
|
||||
|
||||
# Mask Reader → Mask
|
||||
elif node_type == "Mask Reader":
|
||||
file_path = Path("artifacts/mask.nii.gz")
|
||||
if file_path.exists():
|
||||
viewer = napari.Viewer()
|
||||
viewer.open(str(file_path))
|
||||
else:
|
||||
QMessageBox.warning(None, "Output not found", "No mask file found")
|
||||
|
||||
# Image Registration → Registered image
|
||||
elif node_type == "Image Registration":
|
||||
file_path = Path("artifacts/registered_image.nii.gz")
|
||||
if file_path.exists():
|
||||
viewer = napari.Viewer()
|
||||
viewer.open(str(file_path))
|
||||
else:
|
||||
QMessageBox.warning(None, "Output not found", "No registered image found")
|
||||
|
||||
# Image Fusion → Composite Image
|
||||
elif node_type == "Image Fusion":
|
||||
file_path = Path("artifacts/fused_image.nii.gz")
|
||||
if file_path.exists():
|
||||
viewer = napari.Viewer()
|
||||
viewer.open(str(file_path))
|
||||
else:
|
||||
QMessageBox.warning(None, "Output not found", "No fused image found")
|
||||
|
||||
# Image Extraction Or PySERA Extract → Features table
|
||||
elif node_type in ["Image Extraction", "PySERA Extract"]:
|
||||
file_path = Path("artifacts/features.json")
|
||||
if file_path.exists():
|
||||
features = json.loads(file_path.read_text())
|
||||
dialog = QDialog()
|
||||
dialog.setWindowTitle("Radiomics Features")
|
||||
layout = QVBoxLayout(dialog)
|
||||
table = QTableWidget()
|
||||
table.setRowCount(len(features))
|
||||
table.setColumnCount(2)
|
||||
table.setHorizontalHeaderLabels(["Feature", "Value"])
|
||||
for i, (key, value) in enumerate(features.items()):
|
||||
table.setItem(i, 0, QTableWidgetItem(key))
|
||||
table.setItem(i, 1, QTableWidgetItem(str(value)))
|
||||
layout.addWidget(table)
|
||||
dialog.exec()
|
||||
else:
|
||||
QMessageBox.warning(None, "Output not found", "No features.json found")
|
||||
|
||||
# Writer → Final Output File
|
||||
elif node_type in ["Writer", "Image Writer"]:
|
||||
file_path = Path("artifacts/output.nii.gz")
|
||||
if file_path.exists():
|
||||
QMessageBox.information(None, "Writer Output", f"Output file saved: {file_path}")
|
||||
else:
|
||||
QMessageBox.warning(None, "Output not found", "No output file found")
|
||||
@@ -0,0 +1,14 @@
|
||||
from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsTextItem
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
|
||||
class PortItem(QGraphicsEllipseItem):
|
||||
def __init__(self, label: str, port_type: str, is_input: bool, parent=None):
|
||||
super().__init__(-5, -5, 10, 10, parent)
|
||||
# Color based on input/output
|
||||
self.setBrush(QBrush(QColor("green") if is_input else QColor("blue")))
|
||||
self.label = QGraphicsTextItem(label, parent)
|
||||
self.label.setDefaultTextColor(QColor("black"))
|
||||
self.label.setPos(10 if is_input else -50, -7)
|
||||
self.is_input = is_input
|
||||
# Port type (e.g. "Image", "Mask", "Features")
|
||||
self.port_type = port_type
|
||||
@@ -0,0 +1,53 @@
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel, QTableWidget, QTableWidgetItem
|
||||
|
||||
class FeatureViewer(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Radiomics Features Viewer")
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.status = QLabel("Load features.json to view results")
|
||||
self.table = QTableWidget()
|
||||
self.btn_load = QPushButton("Load Features")
|
||||
|
||||
layout.addWidget(self.status)
|
||||
layout.addWidget(self.table)
|
||||
layout.addWidget(self.btn_load)
|
||||
|
||||
self.btn_load.clicked.connect(self.load_features)
|
||||
|
||||
def load_features(self):
|
||||
features_path = Path("artifacts/features.json")
|
||||
if not features_path.exists():
|
||||
self.status.setText("No features.json found! Run workflow first.")
|
||||
return
|
||||
|
||||
try:
|
||||
features = json.loads(features_path.read_text())
|
||||
except Exception as e:
|
||||
self.status.setText(f"Error reading features.json: {e}")
|
||||
return
|
||||
|
||||
# Displaying features in a table
|
||||
self.table.setRowCount(len(features))
|
||||
self.table.setColumnCount(2)
|
||||
self.table.setHorizontalHeaderLabels(["Feature", "Value"])
|
||||
|
||||
for i, (key, value) in enumerate(features.items()):
|
||||
self.table.setItem(i, 0, QTableWidgetItem(key))
|
||||
self.table.setItem(i, 1, QTableWidgetItem(str(value)))
|
||||
|
||||
self.status.setText("Features loaded successfully!")
|
||||
|
||||
def run_gui():
|
||||
app = QApplication(sys.argv)
|
||||
viewer = FeatureViewer()
|
||||
viewer.resize(500, 400)
|
||||
viewer.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_gui()
|
||||
@@ -0,0 +1,61 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel, QFileDialog
|
||||
from app.engine.dagster_runner import run_workflow_with_config
|
||||
# Write Your Code Here:
|
||||
|
||||
class MainWindow(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Radiuma Mini - Workflow Runner")
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.status = QLabel("Idle")
|
||||
self.btn_select_image = QPushButton("Select Image File")
|
||||
self.btn_select_mask = QPushButton("Select Mask File")
|
||||
self.btn_run = QPushButton("Run Workflow")
|
||||
|
||||
layout.addWidget(self.status)
|
||||
layout.addWidget(self.btn_select_image)
|
||||
layout.addWidget(self.btn_select_mask)
|
||||
layout.addWidget(self.btn_run)
|
||||
|
||||
self.image_file = None
|
||||
self.mask_file = None
|
||||
|
||||
self.btn_select_image.clicked.connect(self.select_image)
|
||||
self.btn_select_mask.clicked.connect(self.select_mask)
|
||||
self.btn_run.clicked.connect(self.run_workflow)
|
||||
|
||||
def select_image(self):
|
||||
file_path, _ = QFileDialog.getOpenFileName(self, "Select Image", "data/images", "NIfTI files (*.nii.gz)")
|
||||
if file_path:
|
||||
self.image_file = Path(file_path).name
|
||||
self.status.setText(f"Selected image: {self.image_file}")
|
||||
|
||||
def select_mask(self):
|
||||
file_path, _ = QFileDialog.getOpenFileName(self, "Select Mask", "data/masks", "NIfTI files (*.nii.gz)")
|
||||
if file_path:
|
||||
self.mask_file = Path(file_path).name
|
||||
self.status.setText(f"Selected mask: {self.mask_file}")
|
||||
|
||||
def run_workflow(self):
|
||||
if not self.image_file or not self.mask_file:
|
||||
self.status.setText("Please select both image and mask files!")
|
||||
return
|
||||
self.status.setText("Running workflow...")
|
||||
result = run_workflow_with_config(self.image_file, self.mask_file)
|
||||
if result.success:
|
||||
self.status.setText("Workflow completed successfully!")
|
||||
else:
|
||||
self.status.setText("Workflow failed!")
|
||||
|
||||
def run_gui():
|
||||
app = QApplication(sys.argv)
|
||||
w = MainWindow()
|
||||
w.resize(400, 200)
|
||||
w.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_gui()
|
||||
@@ -0,0 +1,179 @@
|
||||
import sys
|
||||
import datetime
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication, QMainWindow, QSplitter, QTextEdit, QPushButton, QWidget, QVBoxLayout
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QTextCharFormat, QTextCursor
|
||||
|
||||
from NodeGraphQt import NodeGraph
|
||||
from NodeGraphQt.errors import NodeRegistrationError
|
||||
|
||||
from dagster import DagsterInstance, execute_job
|
||||
import yaml
|
||||
from dagster_radiuma.jobs import radiomics_job, radiomics_batch_job
|
||||
|
||||
from app.gui.node_catalog import (
|
||||
NodeCatalog,
|
||||
ImageReaderNode,
|
||||
ImageWriterNode,
|
||||
ImageRegistrationNode,
|
||||
ImageFilterNode,
|
||||
ImageFusionNode,
|
||||
FeatureExtractionNode
|
||||
)
|
||||
|
||||
from app.gui.gui_controls import WorkflowControls
|
||||
|
||||
|
||||
class VisualEditor(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Radiuma Workflow Editor")
|
||||
self.resize(1200, 800)
|
||||
|
||||
# Graph
|
||||
self.graph = NodeGraph()
|
||||
|
||||
# Register nodes with unique alias: identifier + ClassName
|
||||
def reg(cls):
|
||||
key = f"{cls.__identifier__}.{cls.__name__}"
|
||||
try:
|
||||
self.graph.register_node(cls, alias=key)
|
||||
except NodeRegistrationError:
|
||||
pass
|
||||
|
||||
reg(ImageReaderNode)
|
||||
reg(ImageWriterNode)
|
||||
reg(ImageRegistrationNode)
|
||||
reg(ImageFilterNode)
|
||||
reg(ImageFusionNode)
|
||||
reg(FeatureExtractionNode)
|
||||
|
||||
# Viewer
|
||||
self.viewer = self.graph.widget
|
||||
|
||||
# Node catalog
|
||||
self.catalog = NodeCatalog(self.graph)
|
||||
|
||||
# Log panel
|
||||
self.log_panel = QTextEdit()
|
||||
self.log_panel.setReadOnly(True)
|
||||
self.log_panel.setPlaceholderText("Logs will appear here...")
|
||||
|
||||
# Workflow controls (Start/Stop/Resume/Cancel)
|
||||
self.controls = WorkflowControls(self.log_panel, self.log_panel)
|
||||
# اتصال متدهای کنترل به Dagster
|
||||
self.controls.start_btn.clicked.connect(self.start_workflow)
|
||||
self.controls.stop_btn.clicked.connect(self.stop_workflow)
|
||||
self.controls.resume_btn.clicked.connect(self.resume_workflow)
|
||||
self.controls.cancel_btn.clicked.connect(self.cancel_workflow)
|
||||
|
||||
# Left panel layout
|
||||
left_panel = QWidget()
|
||||
left_layout = QVBoxLayout(left_panel)
|
||||
left_layout.addWidget(self.catalog)
|
||||
left_layout.addWidget(self.controls)
|
||||
|
||||
# Splitters
|
||||
top_splitter = QSplitter(Qt.Horizontal)
|
||||
top_splitter.addWidget(left_panel)
|
||||
top_splitter.addWidget(self.viewer)
|
||||
|
||||
main_splitter = QSplitter(Qt.Vertical)
|
||||
main_splitter.addWidget(top_splitter)
|
||||
main_splitter.addWidget(self.log_panel)
|
||||
main_splitter.setSizes([600, 200])
|
||||
|
||||
self.setCentralWidget(main_splitter)
|
||||
|
||||
# Dagster instance
|
||||
self.instance = DagsterInstance.ephemeral()
|
||||
self.current_run_id = None
|
||||
|
||||
# Initial log
|
||||
self.log("Radiuma Workflow Editor started.", "INFO")
|
||||
self.log("Custom nodes registered with alias: identifier + ClassName.", "INFO")
|
||||
|
||||
def log(self, message, level="INFO"):
|
||||
fmt = QTextCharFormat()
|
||||
if level == "INFO":
|
||||
fmt.setForeground(QColor("green"))
|
||||
elif level == "WARNING":
|
||||
fmt.setForeground(QColor("orange"))
|
||||
elif level == "ERROR":
|
||||
fmt.setForeground(QColor("red"))
|
||||
else:
|
||||
fmt.setForeground(QColor("black"))
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
cursor = self.log_panel.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
cursor.insertText(f"[{timestamp}] [{level}] {message}\n", fmt)
|
||||
self.log_panel.setTextCursor(cursor)
|
||||
|
||||
# --- کنترل اجرای Dagster ---
|
||||
import yaml
|
||||
|
||||
def start_workflow(self):
|
||||
# Log start of workflow from GUI
|
||||
self.log("[GUI] Start → Dagster job starting...", "INFO")
|
||||
|
||||
try:
|
||||
# Load configuration from configs.yaml
|
||||
with open("configs.yaml", "r", encoding="utf-8") as f:
|
||||
run_config = yaml.safe_load(f)
|
||||
|
||||
# Check which job type is defined in the config
|
||||
if "radiomics_batch_job" in run_config:
|
||||
# Batch mode → run multiple cases automatically
|
||||
self.log("[GUI] Batch mode detected → Running Batch Workflow", "INFO")
|
||||
result = radiomics_batch_job.execute_in_process(
|
||||
run_config=run_config["radiomics_batch_job"],
|
||||
instance=self.instance
|
||||
)
|
||||
elif "radiomics_job" in run_config:
|
||||
# Single case mode → run one case only
|
||||
self.log("[GUI] Single case detected → Running Single Workflow", "INFO")
|
||||
result = radiomics_job.execute_in_process(
|
||||
run_config=run_config["radiomics_job"],
|
||||
instance=self.instance
|
||||
)
|
||||
else:
|
||||
# Config file does not contain required job section
|
||||
self.log("[GUI] Config file missing required job section.", "ERROR")
|
||||
return
|
||||
|
||||
# Save run_id and log success/failure
|
||||
self.current_run_id = result.run_id
|
||||
self.log(f"[GUI] Workflow finished. run_id={self.current_run_id} Success={result.success}", "INFO")
|
||||
|
||||
except Exception as e:
|
||||
# Log any unexpected errors
|
||||
self.log(f"[GUI] Workflow error: {e}", "ERROR")
|
||||
|
||||
def stop_workflow(self):
|
||||
if self.current_run_id:
|
||||
self.instance.cancel_run(self.current_run_id)
|
||||
self.log(f"[GUI] Workflow stopped. run_id={self.current_run_id}", "WARNING")
|
||||
|
||||
def resume_workflow(self):
|
||||
if self.current_run_id:
|
||||
self.instance.resume_run(self.current_run_id)
|
||||
self.log(f"[GUI] Workflow resumed. run_id={self.current_run_id}", "INFO")
|
||||
|
||||
def cancel_workflow(self):
|
||||
if self.current_run_id:
|
||||
self.instance.cancel_run(self.current_run_id)
|
||||
self.log(f"[GUI] Workflow cancelled. run_id={self.current_run_id}", "ERROR")
|
||||
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
window = VisualEditor()
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user