Add All Folders

This commit is contained in:
soorena62
2026-02-08 04:38:10 +03:30
commit 9bb57e4799
191 changed files with 8469 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
Description:
# This project was created to test API layer codes in depth, focusing on all the capabilities, features, and methods implemented in the codes, such as dynamic type checking and type detection at the moment of data entry, connecting output ports to input, etc. in a real environment. This project has a GUI and allows you to create a workflow, select the required nodes, run the workflow, connect nodes, and manually.
Dependencies:
# pip install dagster pysera pandas pyside6 numpy
Running:
# python -m gui.app
View File
+45
View File
@@ -0,0 +1,45 @@
from typing import Any, Dict, Optional
from copy import deepcopy
from api.io_port import DType
class Asset:
"""
Represents the output of a Task.
Stores:
- data: actual payload
- dtype: semantic type (DType instance)
- metadata: optional dictionary
"""
def __init__(self, data: Any, dtype: Optional[DType] = None, metadata: Optional[Dict[str, Any]] = None):
self.data = data
self.dtype = dtype # DO NOT deepcopy dtype
self.metadata = metadata or {}
# Whether this asset should be preserved after workflow execution
self.preserved = True
# Lifecycle
def dismiss(self):
"""Mark asset as disposable."""
self.preserved = False
# Utility
def clone(self) -> "Asset":
"""
Create a deep copy of the asset.
dtype is NOT deepcopied because DType objects are not deepcopy-safe.
"""
return Asset(
data=deepcopy(self.data),
dtype=self.dtype, # keep reference
metadata=deepcopy(self.metadata)
)
# Representation
def __repr__(self):
# Avoid printing full dtype object (may contain nested structures)
dtype_name = self.dtype.__class__.__name__ if self.dtype else "None"
return f"<Asset dtype={dtype_name} preserved={self.preserved}>"
+82
View File
@@ -0,0 +1,82 @@
from typing import Dict, Any
from api.assets import Asset
from api.io_port import InPort, OutPort
class ExecutionContext:
"""
Runtime data container for a workflow execution.
Stores produced assets for OutPorts and allows InPorts to retrieve them.
"""
def __init__(self, execution_id: str | None = None):
self.execution_id = execution_id
# Key: OutPort, Value: Asset
self._data: Dict[OutPort, Asset] = {}
# Arbitrary metadata (optional)
self._metadata: Dict[str, Any] = {}
# Store produced data
def put(self, output_port: OutPort, asset: Asset):
"""
Register an asset produced by an OutPort.
Also updates output_port.produced_asset.
"""
self._data[output_port] = asset
output_port.produced_asset = asset
# Retrieve data
def get(self, port):
"""
Retrieve asset for:
- OutPort → return its asset
- InPort → return asset from its connected OutPort
"""
if isinstance(port, OutPort):
return self._data.get(port)
if isinstance(port, InPort):
if port.connected_output is None:
return None
return self._data.get(port.connected_output)
raise TypeError("ExecutionContext.get() expects InPort or OutPort")
# Check if data exists
def has_data(self, port) -> bool:
if isinstance(port, OutPort):
return port in self._data
if isinstance(port, InPort):
if port.connected_output is None:
return False
return port.connected_output in self._data
return False
# Metadata management
def set_meta(self, key: str, value: Any):
self._metadata[key] = value
def get_meta(self, key: str, default=None):
return self._metadata.get(key, default)
# Debug snapshot
def status_view(self):
"""
Lightweight snapshot of current execution state.
Safe for GUI display.
"""
return {
"execution_id": self.execution_id,
"assets": {
f"{port.parent_task.name}.{port.name}": {
"dtype": port.dtype.__class__.__name__,
"preserved": asset.preserved,
}
for port, asset in self._data.items()
},
"metadata": self._metadata.copy(),
}
+249
View File
@@ -0,0 +1,249 @@
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any, List
# Exceptions
class CompatibilityException(Exception):
def __init__(self, reason: str):
super().__init__(reason)
self.reason = reason
# DType (Semantic Contract)
class DType(ABC):
"""
Pure semantic type. Does NOT contain names.
Names belong to Ports or CompositePart.
"""
def __init__(self, metadata: Optional[Dict[str, Any]] = None):
self.metadata = metadata or {}
self.conditions: Optional[Dict[str, Any]] = None
@abstractmethod
def can_connect_to(self, other: "DType", conditions: Optional[Dict[str, Any]] = None):
pass
def set_conditions(self, conditions: Dict[str, Any]):
self.conditions = conditions
def __repr__(self):
return f"{self.__class__.__name__}(metadata={self.metadata})"
# Composite Types
class CompositePart:
"""
Represents one named component inside a CompositeType.
Example:
CompositePart("image", ImageType("nifti"))
"""
def __init__(self, name: str, dtype: DType):
self.name = name
self.dtype = dtype
def __repr__(self):
return f"CompositePart(name={self.name}, dtype={self.dtype})"
class CompositeType(DType):
"""
CompositeType is a LIST of CompositePart.
Order matters. Names are preserved inside CompositePart.
"""
def __init__(self, parts: List[CompositePart], metadata=None):
super().__init__(metadata)
self.parts = parts
def can_connect_to(self, other: "DType", conditions=None):
if not isinstance(other, CompositeType):
raise CompatibilityException("Expected CompositeType")
if len(self.parts) != len(other.parts):
raise CompatibilityException("CompositeType length mismatch")
for i in range(len(self.parts)):
p1 = self.parts[i]
p2 = other.parts[i]
if p1.name != p2.name:
raise CompatibilityException(
f"CompositeType part name mismatch: {p1.name} vs {p2.name}"
)
p1.dtype.can_connect_to(p2.dtype, conditions)
def __repr__(self):
return f"CompositeType(parts={self.parts})"
# MaskType:
class MaskType(DType):
def __init__(self, metadata=None):
super().__init__(metadata)
def can_connect_to(self, other: DType, conditions=None):
if not isinstance(other, MaskType):
raise CompatibilityException("Target is not a MaskType")
# Image Types:
class ImageType(DType):
def __init__(self, modality: Optional[str] = None, metadata=None):
super().__init__(metadata)
self.modality = modality
def can_connect_to(self, other: DType, conditions=None):
if not isinstance(other, ImageType):
raise CompatibilityException("Target is not an ImageType")
if self.modality and other.modality and self.modality != other.modality:
raise CompatibilityException(
f"Image modality mismatch: {self.modality} vs {other.modality}"
)
class NIFTIImageType(ImageType):
def __init__(self, metadata=None):
super().__init__(modality="nifti", metadata=metadata)
class DICOMImageType(ImageType):
def __init__(self, metadata=None):
super().__init__(modality="dicom", metadata=metadata)
# Table Types:
class TableType(DType):
def __init__(self, columns: List[str], metadata=None):
super().__init__(metadata)
self.columns = columns
def can_connect_to(self, other: DType, conditions=None):
if not isinstance(other, TableType):
raise CompatibilityException("Target is not a TableType")
if self.columns != other.columns:
raise CompatibilityException(
f"Table schema mismatch: {self.columns} vs {other.columns}"
)
class CSVTableType(TableType):
def __init__(self, columns: List[str], delimiter: str = ",", encoding: str = "utf-8", metadata=None):
super().__init__(columns, metadata)
self.delimiter = delimiter
self.encoding = encoding
def can_connect_to(self, other: DType, conditions=None):
if not isinstance(other, CSVTableType):
raise CompatibilityException("CSV tables can only connect to CSV tables")
super().can_connect_to(other, conditions)
if self.delimiter != other.delimiter:
raise CompatibilityException(
f"CSV delimiter mismatch: {self.delimiter} vs {other.delimiter}"
)
# Text Types:
class TextType(DType):
def __init__(self, language: Optional[str] = None, encoding: Optional[str] = None, metadata=None):
super().__init__(metadata)
self.language = language
self.encoding = encoding
def can_connect_to(self, other: DType, conditions=None):
if not isinstance(other, TextType):
raise CompatibilityException("Target is not TextType")
if self.language and other.language and self.language != other.language:
raise CompatibilityException(
f"Language mismatch: {self.language} vs {other.language}"
)
if self.encoding and other.encoding and self.encoding != other.encoding:
raise CompatibilityException(
f"Encoding mismatch: {self.encoding} vs {other.encoding}"
)
# Ports:
class Port(ABC):
def __init__(self, name: str, dtype: DType):
self.name = name
self.dtype = dtype
self.parent_task = None # Set by Workflow builder
def full_name(self):
if self.parent_task:
return f"{self.parent_task.name}.{self.name}"
return self.name
def __repr__(self):
return f"{self.__class__.__name__}({self.full_name()}, {self.dtype})"
# OutPort (fan-out supported):
class OutPort(Port):
def __init__(self, name: str, dtype: DType):
super().__init__(name, dtype)
self._connections: List["InPort"] = []
self.produced_asset = None # Set by ExecutionContext
@property
def connections(self):
return list(self._connections)
def connect(self, in_port: "InPort", conditions: Optional[Dict[str, Any]] = None):
# Fan-out allowed → no limit on connections
in_port.can_connect_to(self, conditions)
self._connections.append(in_port)
in_port._connected_output = self
def disconnect(self, in_port: "InPort"):
if in_port in self._connections:
self._connections.remove(in_port)
in_port._connected_output = None
def get_downstream_ports(self):
return list(self._connections)
# InPort:
class InPort(Port):
def __init__(self, name: str, dtype: DType, required: bool = True):
super().__init__(name, dtype)
self.required = required
self._connected_output: Optional[OutPort] = None
@property
def connected_output(self):
return self._connected_output
def is_connected(self):
return self._connected_output is not None
def is_ready(self, context):
"""
True only if data exists in ExecutionContext.
"""
if not self._connected_output:
return False
return context.has_data(self)
def can_connect_to(self, out_port: OutPort, conditions: Optional[Dict[str, Any]] = None):
self.dtype.can_connect_to(out_port.dtype, conditions)
def disconnect(self):
if self._connected_output:
self._connected_output._connections.remove(self)
self._connected_output = None
def get_upstream_port(self):
return self._connected_output
def validate(self):
if self.required and not self.is_connected():
raise CompatibilityException(f"InPort {self.full_name()} is required but not connected")
+89
View File
@@ -0,0 +1,89 @@
from api.task import Task, Status, TaskEvent
from api.assets import Asset
from api.execution_context import ExecutionContext
from api.io_port import CompatibilityException, InPort, OutPort
class Module(Task):
"""
Leaf node: atomic executable unit.
Executes using its input ports and produces assets on output ports.
"""
def add_in_port(self, port: InPort):
port.parent_task = self
self.in_ports.append(port)
def add_out_port(self, port: OutPort):
port.parent_task = self
self.out_ports.append(port)
# Hooks
def before_run(self, context: ExecutionContext):
pass
def after_run(self, context: ExecutionContext):
pass
def on_error(self, error: Exception):
pass
# Validation
def validate(self):
super().validate()
# Main execution entry point
def run(self, context: ExecutionContext):
self.before_run(context)
if not self.check_inputs_ready(context):
self._set_status(Status.PENDING)
return
self._set_status(Status.RUNNING)
self._emit(TaskEvent.ON_START, {"module": self.name})
try:
# Gather input data
consumed_data = {}
for port in self.in_ports:
if not port.is_ready(context):
raise CompatibilityException(
f"Input {port.full_name()} is not ready"
)
asset = context.get(port)
consumed_data[port.name] = asset.data
# Execute module logic
result = self.execute(consumed_data)
if not isinstance(result, dict):
raise ValueError(f"Module '{self.name}' must return dict")
# Store outputs
for out_port in self.out_ports:
if out_port.name not in result:
raise ValueError(
f"Output '{out_port.name}' missing in module '{self.name}' result"
)
asset = Asset(
data=result[out_port.name],
dtype=out_port.dtype
)
context.put(out_port, asset)
self._set_status(Status.COMPLETED)
self._emit(TaskEvent.ON_FINISH, {"module": self.name})
self.after_run(context)
except Exception as e:
self._set_status(Status.FAILED)
self.on_error(e)
self._emit(TaskEvent.ON_ERROR, {"error": str(e)})
raise e
# Default execute
def execute(self, inputs):
return inputs
+64
View File
@@ -0,0 +1,64 @@
from typing import Set
from api.task import Task, Status
from api.execution_context import ExecutionContext
class Scheduler:
"""
DAG-based execution scheduler.
Decides WHEN a Task can run based on port readiness and orchestrates execution.
"""
def __init__(self):
self._completed: Set[Task] = set()
self._failed: Set[Task] = set()
def run(self, root: Task, context: ExecutionContext):
self._execute_task(root, context)
def _execute_task(self, task: Task, context: ExecutionContext):
if task.status in (Status.COMPLETED, Status.FAILED, Status.STOPPED):
return
if hasattr(task, "children"):
self._run_workflow(task, context)
return
if not task.check_inputs_ready(context):
print(f"[Scheduler] Task '{task.name}' not ready, skipping for now.")
return
try:
task._set_status(Status.RUNNING)
task.run(context)
task._set_status(Status.COMPLETED)
self._completed.add(task)
except Exception as e:
task._set_status(Status.FAILED)
self._failed.add(task)
print(f"[Scheduler] Task '{task.name}' failed: {e}")
raise
def _run_workflow(self, workflow: Task, context: ExecutionContext):
workflow._set_status(Status.RUNNING)
remaining = set(workflow.children)
while remaining:
progress_made = False
for child in list(remaining):
if child.check_inputs_ready(context):
self._execute_task(child, context)
remaining.remove(child)
progress_made = True
if not progress_made:
raise RuntimeError(
f"Workflow '{workflow.name}' deadlock: unresolved dependencies"
)
if any(c.status == Status.FAILED for c in workflow.children):
workflow._set_status(Status.FAILED)
else:
workflow._set_status(Status.COMPLETED)
+78
View File
@@ -0,0 +1,78 @@
from abc import ABC, abstractmethod
from enum import Enum
from typing import List, Any, Optional
from api.io_port import InPort, OutPort
from api.execution_context import ExecutionContext
class Status(Enum):
PENDING = "pending"
READY = "ready"
RUNNING = "running"
PAUSED = "paused"
STOPPED = "stopped"
COMPLETED = "completed"
FAILED = "failed"
class TaskEvent(Enum):
BEFORE_RUN = "before_run"
AFTER_RUN = "after_run"
ON_ERROR = "on_error"
STATUS_CHANGED = "status_changed"
ON_START = "on_start"
ON_FINISH = "on_finish"
class TaskEventListener:
def handle(self, event: TaskEvent, task: "Task", payload: Optional[Any] = None):
pass
class Task(ABC):
def __init__(self, name: str):
self.name = name
self.status = Status.PENDING
self.in_ports: List[InPort] = []
self.out_ports: List[OutPort] = []
self._listeners: List[TaskEventListener] = []
self.parent_task: Optional["Task"] = None
# Event System
def add_listener(self, listener: TaskEventListener):
self._listeners.append(listener)
def _emit(self, event: TaskEvent, payload: Optional[Any] = None):
for listener in self._listeners:
listener.handle(event, self, payload)
def _set_status(self, new_status: Status):
old_status = self.status
self.status = new_status
if old_status != new_status:
self._emit(TaskEvent.STATUS_CHANGED, {"from": old_status, "to": new_status})
# Input Readiness Check
def check_inputs_ready(self, context: ExecutionContext) -> bool:
for port in self.in_ports:
if not port.is_ready(context):
return False
return True
# Execution Wrapper
def run(self, context: ExecutionContext):
"""
Base Task.run() SHOULD NOT be used for Module.
Module overrides run() completely.
"""
raise NotImplementedError(
"Task.run() should not be called for Module. Use Module.run() instead."
)
@abstractmethod
def execute(self, context: ExecutionContext):
pass
+96
View File
@@ -0,0 +1,96 @@
from typing import List
from api.task import Task, Status, TaskEvent
from api.execution_context import ExecutionContext
from api.io_port import InPort, CompatibilityException
class Workflow(Task):
"""
Composite node: contains children Tasks.
Executes tasks in dependency order (topological execution).
"""
def __init__(self, name: str):
super().__init__(name)
self.children: List[Task] = []
def execute(self, context: ExecutionContext):
pass
def add_child(self, task: Task):
task.parent_task = self
self.children.append(task)
def remove_child(self, task: Task):
if task in self.children:
self.children.remove(task)
task.parent_task = None
def get_children(self):
return list(self.children)
def get_all_tasks(self):
tasks = []
for child in self.children:
tasks.append(child)
if isinstance(child, Workflow):
tasks.extend(child.get_all_tasks())
return tasks
def validate(self):
super().validate()
for child in self.children:
child.validate()
def _get_dependencies(self, task: Task) -> List[Task]:
deps = []
for in_port in task.in_ports:
if isinstance(in_port, InPort) and in_port.connected_output:
upstream_task = in_port.connected_output.parent_task
if upstream_task and upstream_task != task:
deps.append(upstream_task)
return deps
def run(self, context: ExecutionContext):
self._set_status(Status.RUNNING)
self._emit(TaskEvent.ON_START, {"workflow": self.name})
remaining = set(self.children)
completed = set()
while remaining:
progress_made = False
for task in list(remaining):
deps = self._get_dependencies(task)
if all(dep in completed for dep in deps):
if task.check_inputs_ready(context):
try:
task.run(context)
completed.add(task)
remaining.remove(task)
progress_made = True
except CompatibilityException as e:
task._set_status(Status.FAILED)
self._emit(TaskEvent.ON_ERROR, {"error": e.reason, "task": task.name})
self._set_status(Status.FAILED)
return
except Exception as e:
task._set_status(Status.FAILED)
self._emit(TaskEvent.ON_ERROR, {"error": str(e), "task": task.name})
self._set_status(Status.FAILED)
return
if not progress_made:
self._set_status(Status.FAILED)
self._emit(TaskEvent.ON_ERROR, {"error": "Dependency deadlock detected"})
return
if all(t.status == Status.COMPLETED for t in self.children):
self._set_status(Status.COMPLETED)
self._emit(TaskEvent.ON_FINISH, {"workflow": self.name})
else:
self._set_status(Status.FAILED)
@@ -0,0 +1,66 @@
from dagster import op, job, In, Out
import pysera
from api.execution_context import ExecutionContext
from api.assets import Asset
@op(out={"image_input": Out(str), "mask_input": Out(str)})
def image_reader_op():
image_input = "data/sample/images"
mask_input = "data/sample/masks"
return {"image_input": image_input, "mask_input": mask_input}
@op(ins={"image_input": In(str), "mask_input": In(str)}, out={"features": Out(Asset)})
def pysera_extractor_op(image_input, mask_input):
output_dir = "results"
result = pysera.process_batch(
image_input=image_input,
mask_input=mask_input,
output_path=output_dir,
num_workers="auto",
enable_parallelism=True,
apply_preprocessing=True,
categories="all",
dimensions="1st,2_5d,3d",
feature_value_mode="REAL_VALUE",
extraction_mode="handcrafted_feature",
report="info",
)
df = result.get("features_extracted")
return {"features": Asset(df, "pysera_features_df")}
@op(ins={"features": In(Asset)}, out={"csv_path": Out(Asset)})
def csv_writer_op(features):
import os
import csv
from datetime import datetime
df = features.data
rows = []
if hasattr(df, "iterrows"):
for _, row in df.iterrows():
rows.append({"name": row[0], "value": row[1]})
os.makedirs("results", exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = os.path.join("results", f"features_dagster_{ts}.csv")
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["name", "value"])
for r in rows:
w.writerow([r["name"], r["value"]])
return {"csv_path": Asset(path, "csv_path")}
@job
def radiuma_pysera_job():
image_mask = image_reader_op()
feats = pysera_extractor_op(
image_input=image_mask["image_input"],
mask_input=image_mask["mask_input"],
)
csv_writer_op(features=feats["features"])
Binary file not shown.
Binary file not shown.
View File
+21
View File
@@ -0,0 +1,21 @@
import sys
from PySide6.QtWidgets import QApplication
from gui.node_editor import NodeEditor
from modules.image_reader import factory as image_reader_factory
from modules.extractor import factory as pysera_factory
from modules.writer import factory as csv_factory
def main():
app = QApplication(sys.argv)
catalog = {
"ImageReader": image_reader_factory,
"PySERAExtractor": pysera_factory,
"CSVWriter": csv_factory
}
editor = NodeEditor(catalog)
editor.resize(1100, 720)
editor.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
+460
View File
@@ -0,0 +1,460 @@
import time
from PySide6.QtWidgets import (
QWidget, QGraphicsView, QGraphicsScene, QListWidget, QListWidgetItem,
QPushButton, QHBoxLayout, QVBoxLayout, QDialog, QTextEdit
)
from PySide6.QtGui import (
QPainter, QPen, QColor, QPainterPath, QTransform, QMouseEvent
)
from PySide6.QtCore import Qt, QRectF
from PySide6.QtWidgets import QGraphicsItem
from api.execution_context import ExecutionContext
from api.workflow import Workflow
from api.scheduler import Scheduler
# PORT ITEM
class PortItem(QGraphicsItem):
R = 6
def __init__(self, parent_node, name, is_output, index):
super().__init__(parent_node)
self.parent_node = parent_node
self.name = name
self.is_output = is_output
self.index = index
self.setFlag(QGraphicsItem.ItemIsSelectable)
def boundingRect(self):
return QRectF(-self.R, -self.R, 2*self.R, 2*self.R)
def paint(self, painter, option, widget=None):
painter.setRenderHint(QPainter.Antialiasing)
color = QColor(34,139,34) if self.is_output else QColor(70,130,180)
painter.setPen(QPen(Qt.black, 1))
painter.setBrush(color)
painter.drawEllipse(self.boundingRect())
# CONNECTION ITEM
class ConnectionItem(QGraphicsItem):
def __init__(self, out_port, in_port, editor):
super().__init__()
self.out_port = out_port
self.in_port = in_port
self.editor = editor
self.setZValue(-1)
self._rect = QRectF()
self._path = QPainterPath()
self.setFlag(QGraphicsItem.ItemIsSelectable)
def update_path(self):
p1 = self.out_port.scenePos()
p2 = self.in_port.scenePos()
new_rect = QRectF(p1, p2).normalized().adjusted(-20, -20, 20, 20)
if new_rect != self._rect:
self.prepareGeometryChange()
self._rect = new_rect
path = QPainterPath(p1)
dx = (p2.x() - p1.x()) * 0.5
path.cubicTo(p1.x() + dx, p1.y(), p2.x() - dx, p2.y(), p2.x(), p2.y())
self._path = path
def boundingRect(self):
return self._rect
def paint(self, painter, option, widget=None):
self.update_path()
painter.setRenderHint(QPainter.Antialiasing)
if self.isSelected():
painter.setPen(QPen(QColor(255, 0, 0), 3))
else:
painter.setPen(QPen(QColor(50, 50, 50), 2))
painter.drawPath(self._path)
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self.editor._remove_connection(self)
super().mousePressEvent(event)
# NODE ITEM
class NodeItem(QGraphicsItem):
W = 200
H = 80
def __init__(self, module, title, editor):
super().__init__()
self.module = module
self.title = title
self.editor = editor
self.running = False
self.in_ports = []
self.out_ports = []
self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable)
self._build_ports()
def highlight(self, active):
self.running = active
self.update()
def _build_ports(self):
y = 30
for i, p in enumerate(self.module.in_ports):
port = PortItem(self, p.name, False, i)
port.setPos(0, y)
self.in_ports.append(port)
y += 20
y = 30
for i, p in enumerate(self.module.out_ports):
port = PortItem(self, p.name, True, i)
port.setPos(self.W, y)
self.out_ports.append(port)
y += 20
def boundingRect(self):
return QRectF(0, 0, self.W, self.H)
def paint(self, painter, option, widget=None):
painter.setRenderHint(QPainter.Antialiasing)
if self.running:
painter.setBrush(QColor("#c8f7c5"))
else:
painter.setBrush(QColor("#f0f0f0"))
painter.setPen(QPen(Qt.black, 1))
painter.drawRect(self.boundingRect())
painter.drawText(10, 20, self.title)
def itemChange(self, change, value):
if change == QGraphicsItem.ItemPositionHasChanged:
self.editor._update_connections_for_node(self)
return super().itemChange(change, value)
# CUSTOM VIEW
class NodeGraphicsView(QGraphicsView):
def __init__(self, scene, editor):
super().__init__(scene)
self.editor = editor
self.setRenderHint(QPainter.Antialiasing)
def mousePressEvent(self, event: QMouseEvent):
pos = self.mapToScene(event.pos())
item = self.scene().itemAt(pos, QTransform())
port = self.editor._find_port(item)
if port:
if port.is_output:
self.editor.dragging_port = port
return
else:
if self.editor.dragging_port is not None:
self.editor._connect(self.editor.dragging_port, port)
self.editor.dragging_port = None
return
super().mousePressEvent(event)
# NODE EDITOR
class NodeEditor(QWidget):
def __init__(self, module_catalog):
super().__init__()
self.setWindowTitle("Node Editor")
self.module_catalog = module_catalog
self.scene = QGraphicsScene()
self.view = NodeGraphicsView(self.scene, self)
self.left = QListWidget()
for name in module_catalog:
QListWidgetItem(name, self.left)
self.log = QListWidget()
self.run_btn = QPushButton("Run")
self.stop_btn = QPushButton("Stop")
self.resume_btn = QPushButton("Resume")
self.cancel_btn = QPushButton("Cancel")
self.show_dag_btn = QPushButton("Show DAG")
self.add_btn = QPushButton("Add Node")
# Layout
main_layout = QHBoxLayout(self)
left_layout = QVBoxLayout()
left_layout.addWidget(self.left)
left_layout.addWidget(self.add_btn)
left_layout.addWidget(self.run_btn)
left_layout.addWidget(self.stop_btn)
left_layout.addWidget(self.resume_btn)
left_layout.addWidget(self.cancel_btn)
left_layout.addWidget(self.show_dag_btn)
left_layout.addWidget(self.log)
main_layout.addLayout(left_layout)
main_layout.addWidget(self.view)
self.left.setMaximumWidth(220)
main_layout.setStretch(1, 1)
# Events
self.add_btn.clicked.connect(self.add_node)
self.run_btn.clicked.connect(self.run_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.show_dag_btn.clicked.connect(self.show_dag)
self.nodes = []
self.connections = []
self.dragging_port = None
self.scheduler = None
self.ctx = None
self.execution_order = []
self.current_index = 0
self.workflow_running = False
self.workflow_paused = False
# LOGGING
def _log(self, text, color="black"):
item = QListWidgetItem(text)
item.setForeground(QColor(color))
self.log.addItem(item)
self.log.scrollToBottom()
# ADD NODE
def add_node(self):
sel = self.left.currentItem()
if not sel:
return
name = sel.text()
module = self.module_catalog[name]()
node = NodeItem(module, name, self)
node.setPos(len(self.nodes)*40, len(self.nodes)*40)
self.scene.addItem(node)
self.nodes.append(node)
self._log(f"Node added: {name}", "blue")
# FIND PORT
def _find_port(self, item):
while item:
if isinstance(item, PortItem):
return item
item = item.parentItem()
return None
# REMOVE CONNECTION
def _remove_connection(self, conn):
if conn in self.connections:
self.scene.removeItem(conn)
self.connections.remove(conn)
self._log("Connection removed", "red")
# CONNECT WITH TYPE CHECK
def _connect(self, out_port_item, in_port_item):
out_port = out_port_item.parent_node.module.out_ports[out_port_item.index]
in_port = in_port_item.parent_node.module.in_ports[in_port_item.index]
# Only output → input
if not out_port_item.is_output or in_port_item.is_output:
self._log("❌ Invalid direction", "red")
return
# Only one connection per input
for c in self.connections:
if c.in_port is in_port_item:
self._log("❌ Input already connected", "red")
return
# Type checking
try:
out_port.connect(in_port)
except Exception as e:
self._log(f"❌ Type mismatch: {e}", "red")
return
# Success
conn = ConnectionItem(out_port_item, in_port_item, self)
self.scene.addItem(conn)
self.connections.append(conn)
self._log(
f"✔ Connected: {out_port_item.parent_node.title}.{out_port.name}"
f"{in_port_item.parent_node.title}.{in_port.name}",
"green"
)
# UPDATE CONNECTIONS ON MOVE
def _update_connections_for_node(self, node):
for c in self.connections:
if c.out_port.parent_node is node or c.in_port.parent_node is node:
c.update_path()
# BUILD GRAPH
def _build_graph(self):
graph = {i: [] for i in range(len(self.nodes))}
for c in self.connections:
out_idx = self.nodes.index(c.out_port.parent_node)
in_idx = self.nodes.index(c.in_port.parent_node)
graph[out_idx].append(in_idx)
return graph
# TOPOLOGICAL SORT
def _topological_sort(self, graph):
indeg = {k: 0 for k in graph}
for u in graph:
for v in graph[u]:
indeg[v] += 1
q = [u for u in graph if indeg[u] == 0]
order = []
while q:
u = q.pop(0)
order.append(u)
for v in graph[u]:
indeg[v] -= 1
if indeg[v] == 0:
q.append(v)
if len(order) != len(graph):
return None
return order
# SHOW DAG
def show_dag(self):
graph = self._build_graph()
text = ""
for src, dsts in graph.items():
src_name = self.nodes[src].title
if dsts:
for d in dsts:
text += f"{src_name}{self.nodes[d].title}\n"
else:
text += f"{src_name} → (no outputs)\n"
dlg = QDialog(self)
dlg.setWindowTitle("DAG Structure")
layout = QVBoxLayout(dlg)
txt = QTextEdit()
txt.setReadOnly(True)
txt.setText(text)
layout.addWidget(txt)
dlg.resize(400, 300)
dlg.exec()
# WORKFLOW EXECUTION
def run_workflow(self):
self._log("Workflow started", "green")
self.ctx = ExecutionContext("gui_run")
self.scheduler = Scheduler()
# Build workflow
wf = Workflow("gui_workflow")
for n in self.nodes:
wf.add_child(n.module)
# Build graph
graph = self._build_graph()
order = self._topological_sort(graph)
if order is None:
self._log("❌ Cycle detected in graph", "red")
return
self.execution_order = order
self.current_index = 0
self.workflow_running = True
self.workflow_paused = False
self._execute_next()
def stop_workflow(self):
self.workflow_paused = True
self._log("Paused", "orange")
def resume_workflow(self):
self.workflow_paused = False
self._log("Resumed", "green")
self._execute_next()
def cancel_workflow(self):
self.workflow_running = False
self._log("Cancelled", "red")
# EXECUTE NEXT NODE
def _execute_next(self):
if not self.workflow_running or self.workflow_paused:
return
if self.current_index >= len(self.execution_order):
self._log("Workflow finished", "green")
self.workflow_running = False
return
idx = self.execution_order[self.current_index]
node = self.nodes[idx]
module = node.module
node.highlight(True)
self._log(f"Running: {node.title}", "blue")
start = time.time()
try:
self.scheduler.run(module, self.ctx)
end = time.time()
duration = round(end - start, 4)
self._log(f"Finished: {node.title} (time: {duration}s)", "green")
self._show_node_output(module)
except Exception as e:
self._log(f"❌ Error in {node.title}: {e}", "red")
node.highlight(False)
self.current_index += 1
self._execute_next()
# SHOW NODE OUTPUT (SUMMARY)
def _show_node_output(self, module):
self._log("Output:", "purple")
for out_port in module.out_ports:
try:
asset = self.ctx.get(out_port)
data = asset.data
summary = self._summarize(data)
self._log(f" {out_port.name}: {summary}", "black")
except Exception as e:
self._log(f" {out_port.name}: <error reading output>", "red")
def _summarize(self, data):
text = str(data)
if len(text) > 200:
return text[:200] + " ... (truncated)"
return text
+49
View File
@@ -0,0 +1,49 @@
from PySide6.QtWidgets import QWidget, QLabel, QVBoxLayout, QFrame
from PySide6.QtGui import QPainter, QColor, QPen
from PySide6.QtCore import Qt, QRectF
class PortWidget(QFrame):
def __init__(self, name: str, dtype_repr: str, is_output: bool = False, parent=None):
super().__init__(parent)
self.name = name
self.dtype_repr = dtype_repr
self.is_output = is_output
self.setFixedSize(14, 14)
self.setToolTip(f"{name}\n{dtype_repr}")
self.color = QColor(34,139,34) if is_output else QColor(70,130,180)
self.setStyleSheet("background:transparent;")
def paintEvent(self, event):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
pen = QPen(Qt.black)
p.setPen(pen)
p.setBrush(self.color)
r = QRectF(1,1,self.width()-2,self.height()-2)
p.drawEllipse(r)
class NodeWidget(QFrame):
def __init__(self, title: str, parent=None):
super().__init__(parent)
self.setFrameShape(QFrame.Box)
self.setStyleSheet("background:#f8f8f8;")
self.title = QLabel(title)
self.title.setStyleSheet("font-weight:bold;")
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(6,6,6,6)
self.layout.addWidget(self.title)
self.in_ports = []
self.out_ports = []
self.status_label = QLabel("status: pending")
self.layout.addWidget(self.status_label)
def add_in_port(self, port_widget: PortWidget):
self.in_ports.append(port_widget)
self.layout.addWidget(port_widget)
def add_out_port(self, port_widget: PortWidget):
self.out_ports.append(port_widget)
self.layout.addWidget(port_widget)
def set_status(self, status_text: str):
self.status_label.setText(f"status: {status_text}")
+55
View File
@@ -0,0 +1,55 @@
import os
import pysera
from api.module import Module
from api.io_port import InPort, OutPort, NIFTIImageType, CSVTableType
def factory():
m = Module("PySERAExtractor")
in_image = InPort("image", NIFTIImageType())
in_mask = InPort("mask", NIFTIImageType())
out_features = OutPort("features", CSVTableType(columns=["name", "value"]))
m.add_in_port(in_image)
m.add_in_port(in_mask)
m.add_out_port(out_features)
def execute(inputs):
image_input = inputs["image"]
mask_input = inputs["mask"]
output_dir = "results"
os.makedirs(output_dir, exist_ok=True)
result = pysera.process_batch(
image_input=image_input,
mask_input=mask_input,
output_path=output_dir,
num_workers="auto",
enable_parallelism=True,
apply_preprocessing=True,
categories="all",
dimensions="1st,2_5d,3d",
feature_value_mode="REAL_VALUE",
extraction_mode="handcrafted_feature",
report="info",
)
df = result.get("features_extracted")
features = []
if df is not None:
for idx, row in df.iterrows():
features.append({
"name": row[0],
"value": row[1]
})
return {
"features": features
}
m.execute = execute
return m
+108
View File
@@ -0,0 +1,108 @@
import os
import numpy as np
import nibabel as nib
import pydicom
import nrrd
import cv2
from api.module import Module
from api.io_port import OutPort, NIFTIImageType
def load_nifti(path):
nii = nib.load(path)
return nii.get_fdata().astype(np.float32)
def load_dicom(path):
if os.path.isdir(path):
files = sorted([
os.path.join(path, f)
for f in os.listdir(path)
if not f.startswith(".")
])
slices = [pydicom.dcmread(f).pixel_array for f in files]
return np.stack(slices).astype(np.float32)
ds = pydicom.dcmread(path)
return ds.pixel_array.astype(np.float32)
def load_nrrd(path):
data, _ = nrrd.read(path)
return data.astype(np.float32)
def load_numpy(path):
return np.load(path).astype(np.float32)
def load_image(path):
img = cv2.imread(path, cv2.IMREAD_UNCHANGED)
if img is None:
raise ValueError(f"Cannot read image file: {path}")
return img.astype(np.float32)
def load_any(path):
path = path.replace("\\", "/").lower()
if path.endswith((".nii", ".nii.gz")):
return load_nifti(path)
if path.endswith(".nrrd"):
return load_nrrd(path)
if path.endswith(".npy"):
return load_numpy(path)
if path.endswith((".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff")):
return load_image(path)
if os.path.isdir(path) or path.endswith(".dcm"):
return load_dicom(path)
raise ValueError(f"Unsupported image format: {path}")
def factory():
m = Module("ImageReader")
out_image = OutPort("image", NIFTIImageType(metadata={"role": "image"}))
out_mask = OutPort("mask", NIFTIImageType(metadata={"role": "mask"}))
m.add_out_port(out_image)
m.add_out_port(out_mask)
def execute(inputs):
image_dir = "data/images"
mask_dir = "data/masks"
image_files = [f for f in os.listdir(image_dir) if not f.startswith(".")]
mask_files = [f for f in os.listdir(mask_dir) if not f.startswith(".")]
if not image_files:
raise ValueError("No image found in data/images")
if not mask_files:
raise ValueError("No mask found in data/masks")
image_path = os.path.join(image_dir, image_files[0])
mask_path = os.path.join(mask_dir, mask_files[0])
try:
image = load_any(image_path)
except Exception as e:
raise ValueError(f"Cannot read image: {image_path} ({e})")
try:
mask = load_any(mask_path)
except Exception as e:
raise ValueError(f"Cannot read mask: {mask_path} ({e})")
return {
"image": image,
"mask": mask
}
m.execute = execute
return m
+43
View File
@@ -0,0 +1,43 @@
import os
import csv
from datetime import datetime
from api.module import Module
from api.io_port import InPort, CSVTableType
def factory():
m = Module("CSVWriter")
inp = InPort("features", CSVTableType(columns=["name", "value"]))
m.add_in_port(inp)
def execute(inputs):
data = inputs["features"]
rows = []
try:
import pandas as pd
if hasattr(data, "iterrows"):
for _, row in data.iterrows():
rows.append({"name": row[0], "value": row[1]})
else:
rows = list(data)
except Exception:
rows = list(data)
os.makedirs("results", exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = os.path.join("results", f"features_{ts}.csv")
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["name", "value"])
for r in rows:
w.writerow([r["name"], r["value"]])
return {
"csv_path": path
}
m.execute = execute
return m
@@ -0,0 +1,13 @@
from core.contracts import RequirementContract, DataContract
def check_compatibility(
provider: DataContract,
requirement: RequirementContract
):
# Central compatibility entry point
try:
requirement.is_satisfied_by(provider)
return True, "Compatible"
except ValueError as e:
return False, str(e)
@@ -0,0 +1,14 @@
from enum import Enum
from pydantic import BaseModel
class CompatibilityLevel(str, Enum):
OK = "OK"
WARNING = "WARNING"
ERROR = "ERROR"
class CompatibilityResult(BaseModel):
level: CompatibilityLevel
message: str
suggestion: str | None = None
+36
View File
@@ -0,0 +1,36 @@
from pydantic import BaseModel
from typing import Literal, Set
class DataContract(BaseModel):
# Describes what a node produces
data_type: str
provides: Set[str] = set()
class ImageDataContract(DataContract):
data_type: Literal["image"] = "image"
modality: Literal["CT", "MR"]
dim: Literal["2D", "3D"]
has_mask: bool = False
provides: Set[str] = {"image"}
class MaskDataContract(DataContract):
data_type: Literal["mask"] = "mask"
modality: Literal["CT", "MR"]
dim: Literal["2D", "3D"]
provides: Set[str] = {"mask"}
class FeatureTableContract(DataContract):
data_type: Literal["table"] = "table"
provides: Set[str] = {"features"}
class RequirementContract(BaseModel):
# Describes what a node requires
data_type: str
def is_satisfied_by(self, provided: DataContract):
raise NotImplementedError
@@ -0,0 +1,21 @@
from pydantic import BaseModel
from typing import Literal, Set, Optional
class ImageDataContract(BaseModel):
data_type: Literal["image"] = "image"
modality: Literal["CT", "PET", "MR"]
dim: Literal["2D", "3D", "2_5D"]
geometry_id: Optional[str] = None
has_mask: bool = False
class MaskDataContract(BaseModel):
data_type: Literal["mask"] = "mask"
modality: Literal["CT", "PET", "MR"]
dim: Literal["2D", "3D", "2_5D"]
geometry_id: Optional[str] = None
class RadiomicsFeatureTableContract(BaseModel):
data_type: Literal["table"] = "table"
+15
View File
@@ -0,0 +1,15 @@
from typing import Dict
from core.contracts import DataContract, RequirementContract
class Node:
# Base class for all processing nodes
name: str
inputs: Dict[str, RequirementContract]
outputs: Dict[str, DataContract]
def __init__(self, name, inputs=None, outputs=None):
self.name = name
self.inputs = inputs or {}
self.outputs = outputs or {}
+12
View File
@@ -0,0 +1,12 @@
class Workflow:
def __init__(self):
self.connections = []
def connect(self, out_node, out_port, in_node, in_port):
self.connections.append((out_node, out_port, in_node, in_port))
def get_connection(self, in_node, in_port):
for o_node, o_port, i_node, i_port in self.connections:
if i_node == in_node and i_port == in_port:
return o_node, o_port
raise RuntimeError("Missing connection")
@@ -0,0 +1,34 @@
class WorkflowExecutor:
def __init__(self, graph):
self.graph = graph
self.cache = {}
def execute_from_writer(self, writer_node):
# Execute dependencies first
inputs = {}
for port_name, requirement in writer_node.inputs.items():
src_node, src_port = self.graph.get_connection(writer_node, port_name)
inputs[port_name] = self._execute_node(src_node)
writer_node.run(**inputs)
def _execute_node(self, node):
if node in self.cache:
return self.cache[node]
# Reader
if hasattr(node, "load_data"):
result = node.load_data()
self.cache[node] = result
return result
# Processor
inputs = {}
for port_name in node.inputs:
src_node, _ = self.graph.get_connection(node, port_name)
inputs[port_name] = self._execute_node(src_node)
result = node.run(**inputs)
self.cache[node] = result
return result
+19
View File
@@ -0,0 +1,19 @@
from core.workflow import Graph
from nodes.image_reader import ImageReader
from nodes.feature_extractor import FeatureExtractor
from nodes.feature_writer import FeatureWriter
graph = Graph()
reader = ImageReader(modality="CT")
extractor = FeatureExtractor()
writer = FeatureWriter()
# ImageReader → FeatureExtractor
ok, msg = graph.can_connect(reader, "image", extractor, "image")
print("Reader → Extractor:", ok, msg)
# FeatureExtractor → FeatureWriter
ok, msg = graph.can_connect(extractor, "features", writer, "features")
print("Extractor → Writer:", ok, msg)
+14
View File
@@ -0,0 +1,14 @@
import sys
from PySide6.QtWidgets import QApplication
from gui.visual_editor import VisualEditor
def main():
app = QApplication(sys.argv)
window = VisualEditor()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
+164
View File
@@ -0,0 +1,164 @@
import sys
from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QTextEdit,
QPushButton, QLabel, QSplitter
)
from PySide6.QtCore import Qt
from PySide6 import QtGui
from NodeGraphQt import NodeGraph, BaseNode
from core.workflow import Graph
from nodes.image_reader import ImageReader
from nodes.feature_extractor import FeatureExtractor
from nodes.feature_writer import FeatureWriter
# GUI node wrappers around core nodes
class ImageReaderNode(BaseNode):
__identifier__ = "radiuma.gui"
NODE_NAME = "ImageReader"
def __init__(self):
super().__init__()
self.add_output("image")
self.core_node = ImageReader()
class FeatureExtractorNode(BaseNode):
__identifier__ = "radiuma.gui"
NODE_NAME = "FeatureExtractor"
def __init__(self):
super().__init__()
self.add_input("image")
self.add_output("features")
self.core_node = FeatureExtractor()
class FeatureWriterNode(BaseNode):
__identifier__ = "radiuma.gui"
NODE_NAME = "FeatureWriter"
def __init__(self):
super().__init__()
self.add_input("features")
self.core_node = FeatureWriter()
class VisualEditor(QMainWindow):
# Visual workflow editor with dynamic semantic validation
def __init__(self):
super().__init__()
self.setWindowTitle("Radiuma R&D Semantic Workflow Editor")
self.resize(1200, 800)
self.graph_engine = Graph()
self.graph = NodeGraph()
self.viewer = self.graph.widget
self.graph.register_node(ImageReaderNode)
self.graph.register_node(FeatureExtractorNode)
self.graph.register_node(FeatureWriterNode)
self.log_panel = QTextEdit()
self.log_panel.setReadOnly(True)
btn_reader = QPushButton("Add ImageReader")
btn_extractor = QPushButton("Add FeatureExtractor")
btn_writer = QPushButton("Add FeatureWriter")
btn_reader.clicked.connect(
lambda: self.graph.create_node("radiuma.gui.ImageReaderNode")
)
btn_extractor.clicked.connect(
lambda: self.graph.create_node("radiuma.gui.FeatureExtractorNode")
)
btn_writer.clicked.connect(
lambda: self.graph.create_node("radiuma.gui.FeatureWriterNode")
)
left_panel = QWidget()
left_layout = QVBoxLayout(left_panel)
left_layout.addWidget(QLabel("Nodes"))
left_layout.addWidget(btn_reader)
left_layout.addWidget(btn_extractor)
left_layout.addWidget(btn_writer)
left_layout.addStretch()
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)
self.graph.port_connected.connect(self._on_port_connected)
self.graph.port_disconnected.connect(self._on_port_disconnected)
def log(self, msg, level="INFO"):
self.log_panel.append(f"[{level}] {msg}")
def _on_port_connected(self, port_a, port_b):
node_a = port_a.node()
node_b = port_b.node()
core_a = getattr(node_a, "core_node", None)
core_b = getattr(node_b, "core_node", None)
if not core_a or not core_b:
self._color_connection(port_a, port_b, (200, 200, 200))
self.log("Missing core node mapping", "ERROR")
return
# Normalize out → in
if port_a.type_() == "out":
out_node, out_port = core_a, port_a.name()
in_node, in_port = core_b, port_b.name()
gui_out, gui_in = port_a, port_b
else:
out_node, out_port = core_b, port_b.name()
in_node, in_port = core_a, port_a.name()
gui_out, gui_in = port_b, port_a
ok, msg = self.graph_engine.can_connect(
out_node, out_port, in_node, in_port
)
if ok:
self._color_connection(gui_out, gui_in, (46, 204, 113))
self.log(msg, "INFO")
else:
self._color_connection(gui_out, gui_in, (231, 76, 60))
self.log(msg, "ERROR")
def _on_port_disconnected(self, port_a, port_b):
self._color_connection(port_a, port_b, (180, 180, 180))
self.log("Disconnected ports", "INFO")
def _color_connection(self, port_a, port_b, color):
# Patch connection paint dynamically
scene = self.graph._viewer.scene()
for item in scene.items():
if hasattr(item, "port1") and hasattr(item, "port2"):
if {item.port1, item.port2} == {port_a, port_b}:
setattr(item, "_custom_color", color)
if not hasattr(item, "_patched"):
original_paint = item.paint
def patched_paint(painter, option, widget=None,
_orig=original_paint, _item=item):
pen = painter.pen()
pen.setColor(QtGui.QColor(*_item._custom_color))
painter.setPen(pen)
_orig(painter, option, widget)
item.paint = patched_paint
item._patched = True
item.update()
return
@@ -0,0 +1,45 @@
from typing import Set
from core.node import Node
from core.contracts import (
RequirementContract,
ImageDataContract,
FeatureTableContract
)
class ImageRequirement(RequirementContract):
data_type: str = "image"
modality: Set[str]
dim: Set[str]
requires_mask: bool = False
def is_satisfied_by(self, provided: ImageDataContract):
# Validate semantic compatibility
if provided.data_type != self.data_type:
raise ValueError("Data type mismatch")
if provided.modality not in self.modality:
raise ValueError("Modality mismatch")
if provided.dim not in self.dim:
raise ValueError("Dimension mismatch")
if self.requires_mask and not provided.has_mask:
raise ValueError("Mask is required but not provided")
class FeatureExtractor(Node):
def __init__(self):
super().__init__(
name="FeatureExtractor",
inputs={
"image": ImageRequirement(
modality={"CT"},
dim={"3D"},
requires_mask=False
)
},
outputs={
"features": FeatureTableContract()
}
)
@@ -0,0 +1,33 @@
import json
from core.node import Node
from core.compatibility_result import CompatibilityResult, CompatibilityLevel
class FeatureWriteRequirement:
data_type: str = "table"
def check(self, provided):
if provided.data_type != self.data_type:
return CompatibilityResult(
level=CompatibilityLevel.ERROR,
message="FeatureWriter requires feature table input"
)
return CompatibilityResult(
level=CompatibilityLevel.OK,
message="Compatible"
)
class FeatureWriter(Node):
def __init__(self, output_path: str):
self.output_path = output_path
super().__init__(
name="FeatureWriter",
inputs={"features": FeatureWriteRequirement()},
outputs={}
)
def run(self, features):
with open(self.output_path, "w", encoding="utf-8") as f:
json.dump(features, f, indent=2)
@@ -0,0 +1,37 @@
import nibabel as nib
from core.node import Node
from core.contracts_radiomics import ImageDataContract
from pathlib import Path
import uuid
class ImageReader(Node):
def __init__(self, path: str):
self.path = Path(path)
# Read image header only
img = nib.load(str(self.path))
header = img.header
# Infer metadata
dim = "3D" if img.ndim == 3 else "2D"
modality = header.get("descrip", b"CT").decode(errors="ignore") or "CT"
geometry_id = str(uuid.uuid4())
super().__init__(
name="ImageReader",
outputs={
"image": ImageDataContract(
modality=modality,
dim=dim,
geometry_id=geometry_id,
has_mask=False
)
}
)
def load_data(self):
# Load full image data only when execution starts
img = nib.load(str(self.path))
return img.get_fdata()
@@ -0,0 +1,35 @@
import nibabel as nib
from core.node import Node
from core.compatibility_result import CompatibilityResult, CompatibilityLevel
class ImageWriteRequirement:
data_type: str = "image"
def check(self, provided):
if provided.data_type != self.data_type:
return CompatibilityResult(
level=CompatibilityLevel.ERROR,
message="ImageWriter requires image input"
)
return CompatibilityResult(
level=CompatibilityLevel.OK,
message="Compatible"
)
class ImageWriter(Node):
def __init__(self, output_path: str):
self.output_path = output_path
super().__init__(
name="ImageWriter",
inputs={"image": ImageWriteRequirement()},
outputs={}
)
def run(self, image_array):
nib.save(
nib.Nifti1Image(image_array, None),
self.output_path
)
@@ -0,0 +1,27 @@
import nibabel as nib
from core.node import Node
from core.contracts_radiomics import MaskDataContract
from pathlib import Path
class MaskReader(Node):
def __init__(self, path: str, geometry_id: str):
self.path = Path(path)
img = nib.load(str(self.path))
dim = "3D" if img.ndim == 3 else "2D"
super().__init__(
name="MaskReader",
outputs={
"mask": MaskDataContract(
modality="CT",
dim=dim,
geometry_id=geometry_id
)
}
)
def load_data(self):
img = nib.load(str(self.path))
return img.get_fdata()
@@ -0,0 +1,23 @@
import pysera
from core.node import Node
from core.contracts_radiomics import RadiomicsFeatureTableContract
class RadiomicsFeatureGenerator(Node):
def __init__(self, output_path="./results"):
self.output_path = output_path
super().__init__(
name="RadiomicsFeatureGenerator",
inputs={"image": None, "mask": None},
outputs={"features": RadiomicsFeatureTableContract()}
)
def run(self, image_path, mask_path):
result = pysera.process_batch(
image_input=image_path,
mask_input=mask_path,
output_path=self.output_path,
report="info"
)
return result
+22
View File
@@ -0,0 +1,22 @@
# Dagster Assets Project
This project demonstrates how to orchestrate a radiomics workflow using **Dagster Assets**.
Assets are declarative building blocks that represent data or computation results. Each asset is materialized in sequence to produce reproducible outputs.
## Features
- Asset-based orchestration for radiomics extraction.
- Clear lineage: images → registration → fusion → filtering → mask alignment → feature extraction → final JSON/Excel.
- Custom IOManager to persist outputs as JSON files in the `artifacts/` directory.
## Requirements
- Python 3.10+
- Dagster
- SimpleITK
- Pandas
- PySERA
Install dependencies:
```bash
pip install dagster simpleitk pandas pysera
# Running:
python run_pipeline.py
+29
View File
@@ -0,0 +1,29 @@
import sys
from dagster import materialize
import radiuma_assets
# Ensure UTF-8 output for logs
sys.stdout.reconfigure(encoding="utf-8")
def run_assets():
print("Starting Dagster Assets run...")
result = materialize(
[
radiuma_assets.all_masks,
radiuma_assets.image_reader,
radiuma_assets.image_registration,
radiuma_assets.image_fusion,
radiuma_assets.image_conversion,
radiuma_assets.image_filter,
radiuma_assets.mask_registration, # align masks to filtered image geometry
radiuma_assets.feature_extraction, # consume filtered images + registered masks
radiuma_assets.image_write,
],
resources={"io_manager": radiuma_assets.json_io_manager},
)
print("Workflow completed successfully." if result.success else "Workflow failed.")
if __name__ == "__main__":
run_assets()
Binary file not shown.
+302
View File
@@ -0,0 +1,302 @@
import os
import pathlib
import time
import json
import sys
from typing import List, Dict
import pandas as pd
import SimpleITK as sitk
from dagster import asset, IOManager, io_manager
import pysera
# Ensure stdout can handle UTF-8 encoding
sys.stdout.reconfigure(encoding="utf-8")
DATA_DIR = os.path.join("data", "images")
MASK_DIR = os.path.join("data", "masks")
ARTIFACTS_DIR = os.path.join("artifacts")
os.makedirs(ARTIFACTS_DIR, exist_ok=True)
# JSON-safe conversion helper
def convert_paths_and_dfs(obj):
# Convert nested objects to JSON-safe types
if isinstance(obj, dict):
return {k: convert_paths_and_dfs(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [convert_paths_and_dfs(v) for v in obj]
elif isinstance(obj, tuple):
return [convert_paths_and_dfs(v) for v in obj]
elif isinstance(obj, pathlib.Path):
return str(obj)
elif isinstance(obj, pd.DataFrame):
return obj.to_dict(orient="records")
else:
return obj
# Custom IOManager: persist asset outputs as JSON in artifacts/
class JsonFileIOManager(IOManager):
def handle_output(self, context, obj):
file_path = os.path.join(ARTIFACTS_DIR, f"{context.asset_key.path[-1]}.json")
safe_obj = convert_paths_and_dfs(obj)
with open(file_path, "w", encoding="utf-8") as f:
json.dump(safe_obj, f, indent=2, ensure_ascii=False)
context.log.info(f"Output written to {file_path}")
def load_input(self, context):
file_path = os.path.join(ARTIFACTS_DIR, f"{context.upstream_output.asset_key.path[-1]}.json")
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
context.log.info(f"Input loaded from {file_path}")
return data
@io_manager
def json_io_manager(_):
return JsonFileIOManager()
# Masks discovery
@asset
def all_masks() -> List[str]:
files = [os.path.join(MASK_DIR, f) for f in os.listdir(MASK_DIR) if f.endswith(".nii.gz")]
if not files:
raise FileNotFoundError("No masks found in data/masks")
return files
# Image reader (force float32 and persist)
@asset
def image_reader() -> List[str]:
reader_dir = os.path.join(ARTIFACTS_DIR, "reader")
os.makedirs(reader_dir, exist_ok=True)
files = [os.path.join(DATA_DIR, f) for f in os.listdir(DATA_DIR) if f.endswith(".nii.gz")]
if not files:
raise FileNotFoundError("No images found in data/images")
converted_paths = []
for path in files:
img = sitk.ReadImage(path)
print(f"[reader] raw {os.path.basename(path)} -> dim={img.GetDimension()}, type={img.GetPixelIDTypeAsString()}")
img_float = sitk.Cast(img, sitk.sitkFloat32)
print(f"[reader] casted {os.path.basename(path)} -> type={img_float.GetPixelIDTypeAsString()}")
out_path = os.path.join(reader_dir, f"reader_{os.path.basename(path)}")
sitk.WriteImage(img_float, out_path)
converted_paths.append(out_path)
return converted_paths
# Utilities for registration and I/O
def write_nifti(image: sitk.Image, out_path: str):
os.makedirs(os.path.dirname(out_path), exist_ok=True)
sitk.WriteImage(image, out_path)
def cast_to_float32(img: sitk.Image, label: str) -> sitk.Image:
casted = sitk.Cast(img, sitk.sitkFloat32)
print(f"[registration] {label}: dim={casted.GetDimension()}, type={casted.GetPixelIDTypeAsString()}")
return casted
def make_initial_transform(fixed: sitk.Image, moving: sitk.Image) -> sitk.Transform:
dim = fixed.GetDimension()
if dim == 2:
return sitk.CenteredTransformInitializer(
fixed, moving, sitk.Euler2DTransform(), sitk.CenteredTransformInitializerFilter.GEOMETRY
)
elif dim == 3:
return sitk.CenteredTransformInitializer(
fixed, moving, sitk.VersorRigid3DTransform(), sitk.CenteredTransformInitializerFilter.GEOMETRY
)
else:
raise RuntimeError(f"Unsupported image dimension: {dim}")
# Image registration (robust casting + 2D/3D support)
@asset
def image_registration(image_reader: List[str]) -> List[str]:
fixed_raw = sitk.ReadImage(image_reader[0])
print(f"[registration] fixed_raw: dim={fixed_raw.GetDimension()}, type={fixed_raw.GetPixelIDTypeAsString()}")
fixed = cast_to_float32(fixed_raw, "fixed_cast")
R = sitk.ImageRegistrationMethod()
if fixed.GetDimension() == 2:
R.SetMetricAsMeanSquares()
R.SetInterpolator(sitk.sitkLinear)
else:
R.SetMetricAsMattesMutualInformation(numberOfHistogramBins=50)
R.SetMetricSamplingStrategy(R.RANDOM)
R.SetMetricSamplingPercentage(0.2)
R.SetInterpolator(sitk.sitkLinear)
R.SetOptimizerAsRegularStepGradientDescent(
learningRate=2.0, minStep=1e-4, numberOfIterations=200, gradientMagnitudeTolerance=1e-8
)
R.SetOptimizerScalesFromPhysicalShift()
R.SetShrinkFactorsPerLevel(shrinkFactors=[4, 2, 1])
R.SetSmoothingSigmasPerLevel(smoothingSigmas=[2, 1, 0])
R.SmoothingSigmasAreSpecifiedInPhysicalUnitsOn()
out_paths = []
for img_path in image_reader:
moving_raw = sitk.ReadImage(img_path)
print(f"[registration] moving_raw: {os.path.basename(img_path)} dim={moving_raw.GetDimension()}, type={moving_raw.GetPixelIDTypeAsString()}")
moving = cast_to_float32(moving_raw, f"moving_cast:{os.path.basename(img_path)}")
init_tx = make_initial_transform(fixed, moving)
R.SetInitialTransform(init_tx, inPlace=False)
final_tx = R.Execute(fixed, moving)
registered = sitk.Resample(moving, fixed, final_tx, sitk.sitkLinear, 0.0, sitk.sitkFloat32)
out_path = os.path.join(ARTIFACTS_DIR, f"registered_{os.path.basename(img_path)}")
write_nifti(registered, out_path)
out_paths.append(out_path)
return out_paths
# Fusion (robust intensity normalization)
@asset
def image_fusion(image_registration: List[str]) -> List[str]:
fused_paths = []
import numpy as np
for img_path in image_registration:
img = sitk.ReadImage(img_path)
arr = sitk.GetArrayFromImage(img)
p5, p95 = np.percentile(arr, [5, 95])
arr = np.clip(arr, p5, p95)
arr = (arr - p5) / (p95 - p5) if p95 > p5 else arr * 0.0
fused_img = sitk.GetImageFromArray(arr)
fused_img.CopyInformation(img)
out_path = os.path.join(ARTIFACTS_DIR, f"fused_{os.path.basename(img_path)}")
write_nifti(fused_img, out_path)
fused_paths.append(out_path)
return fused_paths
# Conversion (ensures consistent naming)
@asset
def image_conversion(image_fusion: List[str]) -> List[str]:
converted_paths = []
for img_path in image_fusion:
img = sitk.ReadImage(img_path)
out_path = os.path.join(ARTIFACTS_DIR, f"converted_{os.path.basename(img_path)}")
write_nifti(img, out_path)
converted_paths.append(out_path)
return converted_paths
# Filter (Gaussian smoothing)
@asset
def image_filter(image_conversion: List[str]) -> List[str]:
filtered_paths = []
for img_path in image_conversion:
img = sitk.ReadImage(img_path)
filtered_img = sitk.SmoothingRecursiveGaussian(img, sigma=1.0)
out_path = os.path.join(ARTIFACTS_DIR, f"filtered_{os.path.basename(img_path)}")
write_nifti(filtered_img, out_path)
filtered_paths.append(out_path)
return filtered_paths
# Mask registration (nearest neighbor to filtered image geometry)
@asset
def mask_registration(image_filter: List[str], all_masks: List[str]) -> List[str]:
if not image_filter or not all_masks:
raise FileNotFoundError("Missing filtered images or masks for mask_registration.")
registered_mask_paths = []
# Pair masks to images if lengths match; otherwise, resample all masks to the first filtered image
if len(image_filter) == len(all_masks):
pairs = zip(image_filter, all_masks)
else:
ref_path = image_filter[0]
pairs = [(ref_path, m) for m in all_masks]
for ref_img_path, mask_path in pairs:
ref_img = sitk.ReadImage(ref_img_path)
mask_img = sitk.ReadImage(mask_path)
identity = sitk.Transform(ref_img.GetDimension(), sitk.sitkIdentity)
resampled_mask = sitk.Resample(
mask_img, ref_img, identity, sitk.sitkNearestNeighbor, 0, mask_img.GetPixelID()
)
out_path = os.path.join(ARTIFACTS_DIR, f"mask_registered_{os.path.basename(mask_path)}")
sitk.WriteImage(resampled_mask, out_path)
registered_mask_paths.append(out_path)
return registered_mask_paths
# Feature extraction (PySeRA, returns JSON-serializable summary)
@asset
def feature_extraction(image_filter: List[str], mask_registration: List[str]) -> Dict[str, list]:
results = []
for img, mask in zip(image_filter, mask_registration):
start = time.time()
result = pysera.process_batch(
image_input=img,
mask_input=mask,
output_path=ARTIFACTS_DIR,
categories="diag,morph,glcm,glrlm,glszm,ngtdm,ngldm",
dimensions="1st,3D",
bin_size=25,
roi_num=2,
roi_selection_mode="per_region",
apply_preprocessing=True,
feature_value_mode="REAL_VALUE",
min_roi_volume=50,
enable_parallelism=True,
num_workers=4,
report="info",
temporary_files_path=r"C:\\Users\\Omen16\\AppData\\Local\\ViSERA\\res\\memory\\memmap\\pysera_temp",
IBSI_based_parameters={
"radiomics_DataType": "CT",
"radiomics_DiscType": "FBS",
"radiomics_isScale": 0,
"radiomics_VoxInterp": "Nearest",
"radiomics_ROIInterp": "Nearest",
"radiomics_isotVoxSize": 2.0,
"radiomics_isotVoxSize2D": 2.0,
"radiomics_isIsot2D": 0,
"radiomics_isGLround": 0,
"radiomics_isReSegRng": 0,
"radiomics_isOutliers": 0,
"radiomics_isQuntzStat": 1,
"radiomics_ReSegIntrvl01": -1000,
"radiomics_ReSegIntrvl02": 400,
"radiomics_ROI_PV": 0.5,
"radiomics_qntz": "Uniform",
"radiomics_IVH_Type": 3,
"radiomics_IVH_DiscCont": 1,
"radiomics_IVH_binSize": 2.0,
},
)
elapsed = round(time.time() - start, 2)
# Persist detailed per-case result for inspection
safe_result = convert_paths_and_dfs(result)
case_json = os.path.join(ARTIFACTS_DIR, f"{os.path.basename(img)}_radiomics.json")
with open(case_json, "w", encoding="utf-8") as f:
json.dump(safe_result, f, indent=2, ensure_ascii=False)
print(f"Radiomics for {os.path.basename(img)} completed in {elapsed:.2f} seconds")
# Append summary record
results.append({
"image": img,
"mask": mask,
"elapsed_seconds": elapsed,
"result_file": case_json,
})
return {"radiomics_results": results}
# Final writer (summary JSON)
@asset
def image_write(feature_extraction: Dict[str, list]) -> str:
out_path = os.path.join(ARTIFACTS_DIR, "final_output.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump(feature_extraction, f, indent=2, ensure_ascii=False)
return out_path
+78
View File
@@ -0,0 +1,78 @@
alembic==1.17.2
annotated-types==0.7.0
antlr4-python3-runtime==4.13.2
certifi==2025.11.12
charset-normalizer==3.4.4
click==8.3.1
colorama==0.4.6
coloredlogs==14.0
connected-components-3d==3.26.1
dagster==1.12.6
dagster-pipes==1.12.6
dagster_shared==1.12.6
dataclasses==0.6
docstring_parser==0.17.0
et_xmlfile==2.0.0
filelock==3.20.0
fsspec==2025.12.0
greenlet==3.3.0
grpcio==1.76.0
grpcio-health-checking==1.76.0
humanfriendly==10.0
idna==3.11
ImageIO==2.37.2
Jinja2==3.1.6
joblib==1.5.2
lazy_loader==0.4
Mako==1.3.10
markdown-it-py==4.0.0
MarkupSafe==3.0.3
mdurl==0.1.2
networkx==3.6.1
nibabel==5.3.3
numpy==2.2.6
opencv-python==4.12.0.88
openpyxl==3.1.5
packaging==25.0
pandas==2.3.3
pathlib_abc==0.5.2
pillow==12.0.0
platformdirs==4.5.1
protobuf==6.33.2
psutil==7.1.3
pydantic==2.12.5
pydantic_core==2.41.5
pydicom==3.0.1
Pygments==2.19.2
pynrrd==1.1.3
pyreadline3==3.5.4
pysera==2.1.5
python-dateutil==2.9.0.post0
python-dotenv==1.2.1
pytz==2025.2
pywin32==311
PyYAML==6.0.3
requests==2.32.5
rich==14.2.0
rt-utils==1.2.7
scikit-image==0.25.2
scikit-learn==1.8.0
scipy==1.16.3
setuptools==80.9.0
simpleitk==2.5.3
six==1.17.0
SQLAlchemy==2.0.45
structlog==25.5.0
tabulate==0.9.0
threadpoolctl==3.6.0
tifffile==2025.12.12
tomli==2.3.0
tomlkit==0.13.3
toposort==1.10
tqdm==4.67.1
typing-inspection==0.4.2
typing_extensions==4.15.0
tzdata==2025.2
universal_pathlib==0.3.7
urllib3==2.6.2
watchdog==6.0.0
+26
View File
@@ -0,0 +1,26 @@
## README For **Dagster_Minimal_Mode**
```markdown
# Dagster Minimal Mode Project
This project demonstrates the radiomics workflow in **Dagster Minimal Mode**.
Minimal Mode is a lightweight configuration of Dagster, focusing on simplicity and reduced overhead.
## Features
- Minimal orchestration setup for radiomics extraction.
- Direct execution of ops/assets without full Dagster deployment.
- Simplified configuration for quick testing and prototyping.
## Requirements
- Python 3.10+
- Dagster (minimal mode enabled)
- SimpleITK
- Pandas
- PySERA
Install dependencies:
```bash
pip install dagster simpleitk pandas pysera
# Running:
python radiuma_pipeline.py
@@ -0,0 +1,11 @@
from dagster import op
import pysera
@op
def extract_features(inputs):
result = pysera.process_batch(
image_input="data/images/ourT1.nii.gz",
mask_input="data/masks/ourT1_mask.nii.gz",
output_path="artifacts"
)
return result
@@ -0,0 +1,74 @@
# ops_reader.py
from dagster import op
import os
import pysera
@op
def read_images():
# Match Radiuma.exe by processing a single pair or a flat folder.
# Here we keep folders; PySERA will find matching pairs.
image_dir = "data/images"
mask_dir = "data/masks"
return {"image_dir": image_dir, "mask_dir": mask_dir}
@op
def extract_features(data):
# Exact PySERA config mirrored from Radiuma.exe logs
result = pysera.process_batch(
image_input=data["image_dir"],
mask_input=data["mask_dir"],
output_path="./artifacts/results",
# Core run behavior
enable_parallelism=False,
num_workers=1,
apply_preprocessing=True,
roi_selection_mode="per_region",
roi_num=2,
min_roi_volume=50,
feature_value_mode="REAL_VALUE",
# Feature scope
categories="diag,morph,glcm,glrlm,glszm,ngtdm,ngldm",
dimensions="1st,2D",
bin_size=25,
# Logging/report
report="info",
# Temp path (matching Radiuma.exe run)
temporary_files_path=r"C:\Users\Omen16\AppData\Local\ViSERA\res\memory\memmap\pysera_temp",
# IBSI-based parameters mirrored from Radiuma.exe
IBSI_based_parameters={
"radiomics_DataType": "CT",
"radiomics_DiscType": "FBS",
"radiomics_isScale": 0,
"radiomics_VoxInterp": "Nearest",
"radiomics_ROIInterp": "Nearest",
"radiomics_isotVoxSize": 1.0,
"radiomics_isotVoxSize2D": 2.0,
"radiomics_isIsot2D": 0,
"radiomics_isGLround": 0,
"radiomics_isReSegRng": 0,
"radiomics_isOutliers": 0,
"radiomics_isQuntzStat": 1,
"radiomics_ReSegIntrvl01": -1000,
"radiomics_ReSegIntrvl02": 400,
"radiomics_ROI_PV": 0.5,
"radiomics_qntz": "Uniform",
"radiomics_IVH_Type": 3,
"radiomics_IVH_DiscCont": 1,
"radiomics_IVH_binSize": 2.0,
},
)
return result
@op
def write_report(result):
os.makedirs("artifacts", exist_ok=True)
report_path = "artifacts/radiomics_batch_report_radiuma_match.txt"
with open(report_path, "w") as f:
f.write(f"Success: {result['success']}\n")
f.write(f"Processed files: {result['processed_files']}\n")
f.write(f"Processing time: {result['processing_time']:.2f} seconds\n")
f.write(f"Output path: {result['output_path']}\n")
f.write("Note: Excel with Radiomics_Features, Parameters, Report is saved in output_path.\n")
return report_path
@@ -0,0 +1,9 @@
from dagster import op
@op
def write_report(features):
with open("artifacts/final_report.txt", "w", encoding="utf-8") as f:
f.write("# Radiomics Report\n")
f.write(f"Extracted {len(features)} features\n")
for k, v in features.items():
f.write(f"{k},{v}\n")
@@ -0,0 +1,8 @@
# radiuma_pipeline.py
from dagster import job
from ops_reader import read_images, extract_features, write_report
@job
def radiuma_job():
result = extract_features(read_images())
write_report(result)
@@ -0,0 +1,97 @@
alembic==1.17.2
annotated-types==0.7.0
antlr4-python3-runtime==4.13.2
anyio==4.12.0
backoff==2.2.1
certifi==2025.11.12
charset-normalizer==3.4.4
click==8.3.1
colorama==0.4.6
coloredlogs==14.0
connected-components-3d==3.26.1
dagit==1.12.6
dagster==1.12.6
dagster-graphql==1.12.6
dagster-pipes==1.12.6
dagster-webserver==1.12.6
dagster_shared==1.12.6
dataclasses==0.6
docstring_parser==0.17.0
et_xmlfile==2.0.0
filelock==3.20.0
fsspec==2025.12.0
gql==3.5.3
graphene==3.4.3
graphql-core==3.2.6
graphql-relay==3.2.0
greenlet==3.3.0
grpcio==1.76.0
grpcio-health-checking==1.76.0
h11==0.16.0
httptools==0.7.1
humanfriendly==10.0
idna==3.11
ImageIO==2.37.2
Jinja2==3.1.6
joblib==1.5.2
lazy_loader==0.4
Mako==1.3.10
markdown-it-py==4.0.0
MarkupSafe==3.0.3
mdurl==0.1.2
multidict==6.7.0
networkx==3.6.1
nibabel==5.3.3
numpy==2.2.6
opencv-python==4.12.0.88
openpyxl==3.1.5
packaging==25.0
pandas==2.3.3
pathlib_abc==0.5.2
pillow==12.0.0
platformdirs==4.5.1
propcache==0.4.1
protobuf==6.33.2
psutil==7.1.3
pydantic==2.12.5
pydantic_core==2.41.5
pydicom==3.0.1
Pygments==2.19.2
pynrrd==1.1.3
pyreadline3==3.5.4
pysera==2.1.5
python-dateutil==2.9.0.post0
python-dotenv==1.2.1
pytz==2025.2
pywin32==311
PyYAML==6.0.3
requests==2.32.5
requests-toolbelt==1.0.0
rich==14.2.0
rt-utils==1.2.7
scikit-image==0.25.2
scikit-learn==1.8.0
scipy==1.16.3
setuptools==80.9.0
simpleitk==2.5.3
six==1.17.0
SQLAlchemy==2.0.45
starlette==0.50.0
structlog==25.5.0
tabulate==0.9.0
threadpoolctl==3.6.0
tifffile==2025.10.16
tomli==2.3.0
tomlkit==0.13.3
toposort==1.10
tqdm==4.67.1
typing-inspection==0.4.2
typing_extensions==4.15.0
tzdata==2025.2
universal_pathlib==0.3.7
urllib3==2.6.2
uvicorn==0.38.0
watchdog==6.0.0
watchfiles==1.1.1
websockets==15.0.1
yarl==1.22.0
+28
View File
@@ -0,0 +1,28 @@
## README **Dagster_op**
```markdown
# Dagster Ops Project
This project demonstrates the same radiomics workflow using **Dagster Ops**.
Ops are imperative functions that define computation steps. They are connected in a job graph to form the pipeline.
## Features
- Op-based orchestration for radiomics extraction.
- Explicit control of execution order via job definitions.
- Each op corresponds to one stage: image reading, registration, fusion, filtering, mask registration, feature extraction, and writing results.
## Requirements
- Python 3.10+
- Dagster
- SimpleITK
- Pandas
- PySERA
Install dependencies:
```bash
pip install dagster simpleitk pandas pysera
# Running:
dagster job execute -f dagster_op_runner.py
Or:
run.bat
View File
+72
View File
@@ -0,0 +1,72 @@
import json
import threading
from pathlib import Path
from loguru import logger
class WorkflowState:
def __init__(self, state_dir: Path):
self.state_dir = state_dir
self.state_file = state_dir / "state.json"
self._lock = threading.Lock()
self._state = {"steps": {}, "cancelled": False}
self.state_dir.mkdir(parents=True, exist_ok=True)
if self.state_file.exists():
self._state = json.loads(self.state_file.read_text())
def mark_success(self, step_id: str, outputs: dict):
with self._lock:
self._state["steps"][step_id] = {"status": "success", "outputs": outputs}
self._write()
def mark_failed(self, step_id: str, error: str):
with self._lock:
self._state["steps"][step_id] = {"status": "failed", "error": error}
self._write()
def get_status(self, step_id: str):
return self._state["steps"].get(step_id, {}).get("status", "pending")
def get_outputs(self, step_id: str):
return self._state["steps"].get(step_id, {}).get("outputs", {})
def set_cancelled(self, flag: bool):
with self._lock:
self._state["cancelled"] = flag
self._write()
def is_cancelled(self) -> bool:
return self._state.get("cancelled", False)
def _write(self):
self.state_file.write_text(json.dumps(self._state, indent=2))
class Node:
def __init__(self, step_id: str, run_fn, inputs: list, outputs: list):
self.step_id = step_id
self.run_fn = run_fn
self.inputs = inputs
self.outputs = outputs
class Workflow:
def __init__(self, nodes: list[Node], state: WorkflowState):
self.nodes = nodes
self.state = state
def execute(self):
for node in self.nodes:
status = self.state.get_status(node.step_id)
if status == "success":
logger.info(f"Skip {node.step_id}: already successful.")
continue
if self.state.is_cancelled():
logger.warning("Execution cancelled. Stopping workflow.")
break
try:
logger.info(f"Run {node.step_id}")
outputs = node.run_fn(self.state)
self.state.mark_success(node.step_id, outputs)
logger.info(f"Success {node.step_id}")
except Exception as e:
logger.exception(f"Failed {node.step_id}: {e}")
self.state.mark_failed(node.step_id, str(e))
break # Stop on failure, allow resume later
@@ -0,0 +1,39 @@
from dagster import op, job, In
import os, json
from app.features.pysera_node import run_pysera
DATA_DIR = os.path.join("data", "images")
MASK_DIR = os.path.join("data", "masks")
OUTPUT_DIR = os.path.join("app", "storage", "artifacts")
os.makedirs(OUTPUT_DIR, exist_ok=True)
@op(ins={"filename": In(str)})
def load_image(filename: str) -> str:
# Return normalized image file path (do not load arrays/objects)
return os.path.normpath(os.path.join(DATA_DIR, filename))
@op(ins={"filename": In(str)})
def load_mask(filename: str) -> str:
# Return normalized mask file path (do not load arrays/objects)
return os.path.normpath(os.path.join(MASK_DIR, filename))
@op
def extract_features(img_path: str, mask_path: str) -> str:
# Validate file paths and call PySERA
if not os.path.exists(img_path):
raise FileNotFoundError(f"Image path not found: {img_path}")
if not os.path.exists(mask_path):
raise FileNotFoundError(f"Mask path not found: {mask_path}")
features = run_pysera(img_path, mask_path)
out_path = os.path.join(OUTPUT_DIR, "CT_pitch_features.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump(features, f, indent=2)
return out_path
@job
def radiuma_job():
# Compose ops: produce file paths, then extract features
img = load_image()
mask = load_mask()
extract_features(img, mask)
@@ -0,0 +1,79 @@
import os
import pysera
from app.engine import radiuma_assets
from app.engine.dagster_builder import radiuma_job
from dagster import materialize, FilesystemIOManager
# Main workflow runner for Radiuma using PySERA and Dagster
def run_workflow():
# Define input image and mask paths
image_path = os.path.normpath("data/images/CT_pitch.nii.gz")
mask_path = os.path.normpath("data/masks/CT_pitch_mask.nii.gz")
output_dir = os.path.normpath("./results")
# Run PySERA radiomics workflow
# Changed dimensions from "3d" to "1st,2D"
result = pysera.process_batch(
image_input=image_path,
mask_input=mask_path,
output_path=output_dir,
categories="all",
dimensions="1st,2D", # <-- only change applied here
apply_preprocessing=True,
)
print("✅ PySERA workflow finished." if result.get("success") else "❌ PySERA workflow failed.")
def run_pipeline():
# Run Dagster job with sample configuration
result = radiuma_job.execute_in_process(
run_config={
"ops": {
"load_image": {"inputs": {"filename": {"value": "CT_pitch.nii.gz"}}},
"load_mask": {"inputs": {"filename": {"value": "CT_pitch_mask.nii.gz"}}},
}
}
)
print("✅ Dagster job finished.")
print(result)
def run_assets():
# Ensure artifacts directory exists
base_dir = os.path.normpath("app/storage/artifacts")
os.makedirs(base_dir, exist_ok=True)
# Use FilesystemIOManager for asset storage
io_manager = FilesystemIOManager(base_dir=base_dir)
# Materialize all assets defined in radiuma_assets
result = materialize(
[
radiuma_assets.raw_image,
radiuma_assets.raw_mask,
radiuma_assets.registered_image,
radiuma_assets.fused_image,
radiuma_assets.features,
],
resources={"io_manager": io_manager},
)
if result.success:
print("✅ Assets materialized successfully.")
print(f"Storage base_dir: {base_dir}")
# Print all materialized asset keys
for event in result.get_asset_materialization_events():
print(f"Asset built: {event.asset_key}")
else:
print("❌ Asset execution failed.")
print(result)
if __name__ == "__main__":
print("🚀 Starting Radiuma unified run...\n")
run_workflow()
run_pipeline()
run_assets()
print("\n🎯 All workflows completed in one run.")
@@ -0,0 +1,57 @@
import os
import SimpleITK as sitk
from dagster import asset
DATA_DIR = os.path.join("data", "images")
MASK_DIR = os.path.join("data", "masks")
ARTIFACTS_DIR = os.path.join("app", "storage", "artifacts")
os.makedirs(ARTIFACTS_DIR, exist_ok=True)
@asset
def raw_image() -> str:
# Return existing image path
path = os.path.normpath(os.path.join(DATA_DIR, "CT_pitch.nii.gz"))
if not os.path.exists(path):
raise FileNotFoundError(f"raw_image: file not found at {path}")
return path
@asset
def raw_mask() -> str:
path = os.path.normpath(os.path.join(MASK_DIR, "CT_pitch_mask.nii.gz"))
if not os.path.exists(path):
raise FileNotFoundError(f"raw_mask: file not found at {path}")
return path
@asset
def registered_image(raw_image: str) -> str:
# Read the raw image and write a registered copy
img = sitk.ReadImage(raw_image)
registered = sitk.Cast(img, img.GetPixelID()) # identity registration
out_path = os.path.normpath(os.path.join(ARTIFACTS_DIR, "CT_pitch_registered.nii.gz"))
sitk.WriteImage(registered, out_path)
return out_path
@asset
def fused_image(registered_image: str, raw_mask: str) -> str:
img = sitk.ReadImage(registered_image)
mask = sitk.ReadImage(raw_mask)
mask_uint8 = sitk.Cast(mask, sitk.sitkUInt8)
fused = sitk.Mask(img, mask_uint8)
out_path = os.path.normpath(os.path.join(ARTIFACTS_DIR, "CT_pitch_fused.nii.gz"))
sitk.WriteImage(fused, out_path)
return out_path
@asset
def features(fused_image: str) -> str:
import json, numpy as np
img = sitk.ReadImage(fused_image)
arr = sitk.GetArrayFromImage(img)
feats = {
"shape": list(arr.shape),
"spacing": list(img.GetSpacing()),
"intensity_sum": float(np.sum(arr)),
}
out_path = os.path.normpath(os.path.join(ARTIFACTS_DIR, "CT_pitch_asset_features.json"))
with open(out_path, "w", encoding="utf-8") as f:
json.dump(feats, f, indent=2)
return out_path
@@ -0,0 +1,69 @@
import json
import numpy as np
from dagster import op
import SimpleITK as sitk
from pathlib import Path
# Write Your codes Here:
ARTIFACTS = Path("artifacts")
ARTIFACTS.mkdir(exist_ok=True)
@op
def image_reader(path: str):
"""Medical image reading (MRI, CT, PET)."""
img = sitk.ReadImage(path)
# Save the processed version for display in the GUI (optional)
sitk.WriteImage(img, str(ARTIFACTS / "processed_image.nii.gz"))
return img
@op
def image_registration(fixed_img, moving_img):
"""Aligning two medical images."""
reg = sitk.ImageRegistrationMethod()
reg.SetMetricAsMeanSquares()
reg.SetOptimizerAsGradientDescent(learningRate=1.0, numberOfIterations=100)
reg.SetInterpolator(sitk.sitkLinear)
transform = reg.Execute(fixed_img, moving_img)
registered = sitk.Resample(
moving_img, fixed_img, transform, sitk.sitkLinear, 0.0, moving_img.GetPixelID()
)
sitk.WriteImage(registered, str(ARTIFACTS / "registered_image.nii.gz"))
return registered
@op
def image_fusion(img1, img2):
"""Combining two medical images (simple fusion)."""
fused = sitk.Cast((img1 + img2) / 2, sitk.sitkFloat32)
sitk.WriteImage(fused, str(ARTIFACTS / "fused_image.nii.gz"))
return fused
@op
def image_extraction(img):
"""Extracting simple features from images."""
arr = sitk.GetArrayFromImage(img)
features = {
"mean_intensity": float(np.mean(arr)),
"std_intensity": float(np.std(arr)),
"min_intensity": float(np.min(arr)),
"max_intensity": float(np.max(arr)),
}
(ARTIFACTS / "features.json").write_text(json.dumps(features, indent=2))
return features
@op
def image_writer(img):
"""Save final output."""
out_path = ARTIFACTS / "output.nii.gz"
sitk.WriteImage(img, str(out_path))
return str(out_path)
OPS = {
"reader": image_reader,
"registration": image_registration,
"fusion": image_fusion,
"extraction": image_extraction,
"writer": image_writer,
}
@@ -0,0 +1,39 @@
import os
import json
import numpy as np
import SimpleITK as sitk
def run_pysera(image_path: str, mask_path: str):
# Read image and mask from file paths
image = sitk.ReadImage(str(image_path))
mask = sitk.ReadImage(str(mask_path))
# Ensure mask is binary {0,1} and integer typed
mask = sitk.Cast(mask, sitk.sitkUInt8)
# Treat any non-zero voxel as 1
mask = sitk.BinaryThreshold(
mask,
lowerThreshold=1,
upperThreshold=1_000_000,
insideValue=1,
outsideValue=0,
)
# Use proper radius vector for BinaryErode based on image dimension
dim = image.GetDimension()
radius = [1] * dim # e.g., [1,1,1] for 3D or [1,1] for 2D
# Safe morphological operation on the binary mask
eroded = sitk.BinaryErode(mask, radius)
mask_array = sitk.GetArrayFromImage(mask)
eroded_array = sitk.GetArrayFromImage(eroded)
mask_border = np.logical_xor(mask_array, eroded_array)
# Example features (replace with your real extraction pipeline)
features = {
"voxel_spacing": tuple(image.GetSpacing()),
"mask_voxels": int(np.sum(mask_array)),
"mask_border_voxels": int(np.sum(mask_border)),
}
return features
@@ -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())
+214
View File
@@ -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.")
+24
View File
@@ -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()
+368
View File
@@ -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
+133
View File
@@ -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")
+14
View File
@@ -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()
+61
View File
@@ -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()
+179
View File
@@ -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()
+42
View File
@@ -0,0 +1,42 @@
import yaml
from dagster_radiuma.jobs import radiomics_job, radiomics_batch_job
import app.gui.visual_editor as visual_editor
def run_radiomics_pipeline(config_file: str):
# Load YAML config
with open(config_file, "r", encoding="utf-8") as f:
run_config = yaml.safe_load(f)
# Check which job to run
if "radiomics_batch_job" in run_config:
print("[INFO] Batch mode detected → Running Batch Workflow")
result = radiomics_batch_job.execute_in_process(
run_config=run_config["radiomics_batch_job"]
)
print(f"[RESULT] Batch workflow success = {result.success}")
elif "radiomics_job" in run_config:
print("[INFO] Single case detected → Running Single Workflow")
result = radiomics_job.execute_in_process(
run_config=run_config["radiomics_job"]
)
print(f"[RESULT] Single workflow success = {result.success}")
else:
raise ValueError(
"Config file must contain either 'radiomics_job' or 'radiomics_batch_job' section."
)
if __name__ == "__main__":
print("[INFO] Radiuma Workflow Editor started.")
use_gui = True #
if use_gui:
visual_editor.main()
else:
run_radiomics_pipeline("configs.yaml")
+27
View File
@@ -0,0 +1,27 @@
from app.engine.core import Node
from app.nodes.imaging import image_reader_fn, image_registration_fn, image_filter_fn, image_writer_fn
def build_nodes():
return [
Node("image_reader", image_reader_fn, inputs=[], outputs=["image_path"]),
Node("image_registration", image_registration_fn, inputs=["image_path"], outputs=["registered_path"]),
Node("image_filter", image_filter_fn, inputs=["registered_path"], outputs=["filtered_path"]),
Node("image_writer", image_writer_fn, inputs=["filtered_path"], outputs=["final_output_path"]),
]
# from app.engine.core import Node
# from app.nodes.imaging import image_reader_fn, image_registration_fn, image_filter_fn, image_writer_fn
# from app.features.pysera_node import pysera_extract_fn
# def build_nodes():
# return [
# Node("image_reader", image_reader_fn, [], ["image_path"]),
# Node("image_registration", image_registration_fn, ["image_path"], ["registered_path"]),
# Node("image_filter", image_filter_fn, ["registered_path"], ["filtered_path"]),
# Node("pysera_extract", pysera_extract_fn, ["filtered_path"], ["features_path"]),
# Node("image_writer", image_writer_fn, ["filtered_path"], ["final_output_path"]),
# ]
+54
View File
@@ -0,0 +1,54 @@
from pathlib import Path
import numpy as np
import SimpleITK as sitk
ARTIFACTS = Path("app/storage/artifacts")
ARTIFACTS.mkdir(parents=True, exist_ok=True)
def image_reader_fn(state):
# Decide input file (DICOM series or NIfTI)
input_image_path = Path("data/images/CT_AVM.nii.gz") # adapt as needed
img = sitk.ReadImage(str(input_image_path))
out_path = ARTIFACTS / "image_reader.nii.gz"
sitk.WriteImage(img, str(out_path))
return {"image_path": str(out_path)}
def image_registration_fn(state):
fixed_path = state.get_outputs("image_reader").get("image_path")
moving_path = fixed_path # demo: register image to itself; replace with real moving image
fixed = sitk.ReadImage(fixed_path)
moving = sitk.ReadImage(moving_path)
# Simple rigid registration (demo)
registration_method = sitk.ImageRegistrationMethod()
registration_method.SetMetricAsMeanSquares()
registration_method.SetOptimizerAsRegularStepGradientDescent(
learningRate=1.0, minStep=1e-4, numberOfIterations=100
)
registration_method.SetInterpolator(sitk.sitkLinear)
initial_transform = sitk.CenteredTransformInitializer(
fixed, moving, sitk.Euler3DTransform(), sitk.CenteredTransformInitializerFilter.GEOMETRY
)
registration_method.SetInitialTransform(initial_transform, inPlace=False)
final_transform = registration_method.Execute(fixed, moving)
resampled = sitk.Resample(moving, fixed, final_transform, sitk.sitkLinear, 0.0, moving.GetPixelID())
out_path = ARTIFACTS / "registered.nii.gz"
sitk.WriteImage(resampled, str(out_path))
return {"registered_path": str(out_path), "transform": "euler3d"}
def image_filter_fn(state):
reg_path = state.get_outputs("image_registration").get("registered_path")
img = sitk.ReadImage(reg_path)
# Example filter: Gaussian smoothing
filtered = sitk.DiscreteGaussian(img, variance=1.5)
out_path = ARTIFACTS / "filtered.nii.gz"
sitk.WriteImage(filtered, str(out_path))
return {"filtered_path": str(out_path)}
def image_writer_fn(state):
# Writer here is just saving; already done by previous steps; simulate final export
filtered_path = state.get_outputs("image_filter").get("filtered_path")
final_path = ARTIFACTS / "final_output.nii.gz"
Path(final_path).write_bytes(Path(filtered_path).read_bytes())
return {"final_output_path": str(final_path)}
@@ -0,0 +1,13 @@
{
"shape": [
58,
248,
175
],
"spacing": [
0.8125,
0.8125,
2.3970494270324707
],
"intensity_sum": 95678796.0
}
@@ -0,0 +1,9 @@
{
"voxel_spacing": [
0.8125,
0.8125,
2.3970494270324707
],
"mask_voxels": 2517200,
"mask_border_voxels": 0
}

Some files were not shown because too many files have changed in this diff Show More