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