Add All Folders
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user