New pipeline widget framework

This commit is contained in:
2025-08-01 18:49:27 +02:00
parent af4371ebe4
commit 8b4105a261
9 changed files with 304 additions and 191 deletions

View File

@ -6,13 +6,35 @@ from .event_bus import EventBus
class ImagePipeline:
def __init__(self, bus: EventBus):
self.bus = bus
self.stages = {}
self.stages = []
self.stagedata = {}
def add_stage(self, name: str, img: np.ndarray):
self.stages[name] = img.astype(np.float32)
# notify widgets of updated stage list and data
self.bus.publish_deferred("pipeline_stages", list(self.stages.keys()))
self.bus.publish_deferred("pipeline_stage", (name, self.stages[name]))
def register_stage(self, name: str):
self.stages.append(name)
self.stagedata[name] = None
self.bus.publish_deferred("pipeline_stages", self.stages)
return len(self.stages) - 1
def get_stage(self, name: str):
return self.stages.get(name)
def rename_stage(self, id: int, name: str):
if id >= 0 and id < len(self.stages):
self.stages[id] = name
self.bus.publish_deferred("pipeline_stages", self.stages)
def publish(self, id: int, img: np.ndarray):
self.stagedata[id] = img.astype(np.float32)
self.bus.publish_deferred("pipeline_stage", (id, self.stagedata[id]))
def get_stage_data(self, id: int):
if id >= 0 and id < len(self.stages):
return self.stagedata[id]
else:
return None
def get_stage_name(self, id:int):
if id >= 0 and id < len(self.stages):
return self.stages[id]
else:
return None
def republish_stages(self):
self.bus.publish_deferred("pipeline_stages", self.stages)

View File

@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
class EditorManager:
def __init__(self):
dpg.create_context()
self.texture_registry = dpg.add_texture_registry()
self.bus = EventBus(logger)
self.pipeline = ImagePipeline(self.bus)
self.layout_manager = LayoutManager(self, logger)

View File

@ -11,24 +11,68 @@ class BaseWidget:
name: str = "BaseWidget"
register: bool = False
def __init__(self, manager: "EditorManager", logger: logging.Logger):
def __init__(
self,
manager: "EditorManager",
logger: logging.Logger,
window_width: int = 300,
window_height: int = 200,
):
self.manager = manager
self.logger = logger
self.window_width = window_width
self.window_height = window_height
self.window_offset_x = 0
self.window_offset_y = 0
self.window_tag = dpg.generate_uuid()
self.config = {}
def create(self):
"""Called by negstation itself, creates the window"""
with dpg.window(
label=self.name,
tag=self.window_tag,
width=self.window_width,
height=self.window_height,
on_close=self._on_window_close,
):
self.window_handler = dpg.add_item_handler_registry()
dpg.add_item_resize_handler(
callback=self._on_window_resize, parent=self.window_handler
)
dpg.bind_item_handler_registry(self.window_tag, self.window_handler)
self.create_content()
def create_content(self):
"""Must be implemented by the widget, creates the content of the window"""
raise NotImplementedError
def update(self):
"""Must be implemented by the widget, is called in the render loop every frame"""
pass
def on_resize(self, width: int, height: int):
"""Must be implemented by the widget, is called after a resize"""
pass
# Internal but public funtions
def get_config(self):
"""Caled by negstation itself, returns the saved widget config"""
return self.config
# Callbacks
def _on_window_close(self):
try:
dpg.delete_item(self.window_tag)
self.manager.widgets.remove(self)
except ValueError:
pass
def _on_window_resize(self, data):
win_w, win_h = dpg.get_item_rect_size(self.window_tag)
self.window_height = win_h
self.window_width = win_w
self.on_resize(win_w, win_h)

View File

@ -1,25 +1,24 @@
import dearpygui.dearpygui as dpg
import numpy as np
from .pipeline_stage_widget import PipelineStageWidget
class InvertStage(PipelineStageWidget):
name = "Invert Image"
register = True
has_pipeline_in = True
has_pipeline_out = True
def __init__(self, manager, logger):
super().__init__(manager, logger)
self.stage_out = "inverted_image"
super().__init__(manager, logger, default_stage_out="inverted_image")
def create_pipeline_stage_content(self):
dpg.add_text("Inversion is happening here")
def create_content(self):
dpg.add_button(label="Invert", callback=lambda s, a, u: self._do_invert())
def on_stage(self, img):
self._do_invert()
def _do_invert(self):
if self.img is None:
def on_pipeline_data(self, img):
if img is None:
return
inverted = self.img.copy()
inverted = img.copy()
inverted[...,:3] = 1.0 - inverted[...,:3]
self.publish(inverted)
self.publish_stage(inverted)

View File

@ -2,20 +2,21 @@ import dearpygui.dearpygui as dpg
from PIL import Image
import numpy as np
from .base_widget import BaseWidget
from .pipeline_stage_widget import PipelineStageWidget
class OpenImageWidget(BaseWidget):
name: str = "Open Image"
class OpenImageWidget(PipelineStageWidget):
name = "Open Image"
register = True
has_pipeline_in = False
has_pipeline_out = True
def __init__(self, manager, logger, stage_out="loaded_image"):
super().__init__(manager, logger)
self.stage_out = stage_out
def __init__(self, manager, logger):
super().__init__(manager, logger, default_stage_out="opened_image")
self.dialog_tag = dpg.generate_uuid()
self.output_tag = dpg.generate_uuid()
def create(self):
def create_pipeline_stage_content(self):
with dpg.file_dialog(
directory_selector=False,
show=False,
@ -25,24 +26,12 @@ class OpenImageWidget(BaseWidget):
width=400,
):
dpg.add_file_extension(".*")
with dpg.window(
label="Open Image File",
tag=self.window_tag,
width=300,
height=150,
on_close=self._on_window_close,
):
dpg.add_input_text(label="Stage Output Name", tag=self.output_tag)
dpg.add_button(label="Open File...", callback=self._on_open_file)
dpg.set_value(self.output_tag, self.stage_out)
dpg.add_button(label="Open File...", callback=self._on_open_file)
def _on_open_file(self):
dpg.configure_item(self.dialog_tag, show=True)
def _on_file_selected(self, sender, app_data):
# app_data[0] is dict with selected file paths
selection = (
f"{app_data['current_path']}/{list(app_data['selections'].keys())[0]}"
if isinstance(app_data, dict)
@ -55,6 +44,6 @@ class OpenImageWidget(BaseWidget):
img = Image.open(selection).convert("RGBA")
arr = np.asarray(img).astype(np.float32) / 255.0 # normalize to [0,1]
# Publish into pipeline
self.manager.pipeline.add_stage(dpg.get_value(self.output_tag), arr)
self.manager.pipeline.publish(self.pipeline_stage_out_id, arr)
except Exception as e:
self.logger.error(f"Failed to load image {selection}: {e}")

View File

@ -4,60 +4,100 @@ from .base_widget import BaseWidget
class PipelineStageWidget(BaseWidget):
name = "Pipeline Stage Widget"
register = False
has_pipeline_in: bool = False
has_pipeline_out: bool = False
def __init__(self, manager, logger):
super().__init__(manager, logger)
self.stage_in = ""
self.stage_out = ""
self.img = None
def __init__(
self,
manager,
logger,
default_stage_in: str = "pipeline_in",
default_stage_out: str = "pipeline_out",
window_width: int = 300,
window_height: int = 200,
):
super().__init__(manager, logger, window_width, window_height)
self.pipeline_stage_in_id = None
self.pipeline_stage_out_id = None
self.pipeline_config_group_tag = dpg.generate_uuid()
self.manager.bus.subscribe(
"pipeline_stage", self._on_pipeline, main_thread=True
)
self.manager.bus.subscribe(
"pipeline_stages", self._on_stage_list, main_thread=True
)
def create(self):
with dpg.window(label=self.name, tag=self.window_tag, width=400, height=300):
# toprow: input / output
dpg.add_text("Configuration:")
self.combo = dpg.add_combo(
label="Stage In", items=[], callback=self._on_select
if self.has_pipeline_out:
self.pipeline_stage_out_id = self.manager.pipeline.register_stage(
default_stage_out
)
dpg.add_input_text(
label="Stage Out",
default_value=self.stage_out,
callback=lambda s, a, u: setattr(self, "stage_out", a),
)
dpg.add_separator()
# now let subclasses populate the rest
self.create_content()
def _on_select(self, sender, selected_stage):
self.stage_in = selected_stage
self.img = self.manager.pipeline.get_stage(selected_stage)
self.on_stage(self.img)
def update(self):
pass
self.manager.bus.subscribe("pipeline_stages", self._on_stage_list, True)
if self.has_pipeline_in:
self.pipeline_stage_in_id = 0
self.manager.bus.subscribe("pipeline_stage", self._on_stage_data, True)
# force getting all available pipeline stages
self.manager.pipeline.republish_stages()
def create_content(self):
with dpg.group(tag=self.pipeline_config_group_tag):
if self.has_pipeline_in:
self.stage_in_combo = dpg.add_combo(
label="Stage In",
items=[],
callback=self._on_stage_in_select,
default_value=f"{self.manager.pipeline.get_stage_name(0)} : 0",
)
if self.has_pipeline_out:
dpg.add_input_text(
label="Stage Out",
default_value=self.manager.pipeline.get_stage_name(
self.pipeline_stage_out_id
),
callback=lambda s, a, u: self.manager.pipeline.rename_stage(
self.pipeline_stage_out_id, a
),
)
dpg.add_separator()
self.create_pipeline_stage_content()
def publish_stage(self, img: np.ndarray):
"""Publishes an image to output stage"""
if self.has_pipeline_out:
self.manager.pipeline.publish(self.pipeline_stage_out_id, img)
def create_pipeline_stage_content(self):
"""Must be implemented by the widget, creates the content of the window"""
raise NotImplementedError
def _on_pipeline(self, data):
name, img = data
if name == self.stage_in:
self.img = img
self.on_stage(img)
def _on_stage_list(self, stages):
self.stages = stages
dpg.configure_item(self.combo, items=stages)
def on_stage(self, img: np.ndarray):
def on_pipeline_data(self, img: np.ndarray):
"""Must be implemented by the widget, is called when there is a new image published on the in stage"""
pass
def publish(self, img: np.ndarray):
self.manager.pipeline.add_stage(self.stage_out, img)
# Callbacks
def _on_stage_list(self, stagelist):
if self.has_pipeline_in:
stages = [f"{stage} : {id}" for id, stage in enumerate(stagelist)]
dpg.configure_item(self.stage_in_combo, items=stages)
def _on_stage_in_select(self, sender, selected_stage: str):
d = selected_stage.split(" : ")
name = d[0]
id = int(d[1])
self.pipeline_stage_in_id = id
if self.has_pipeline_in:
img = self.manager.pipeline.get_stage_data(id)
self.on_pipeline_data(img)
def _on_stage_data(self, data):
pipeline_id = data[0]
img = data[1]
if self.has_pipeline_in and pipeline_id == self.pipeline_stage_in_id:
self.on_pipeline_data(img)
# Override the window resize callback
def _on_window_resize(self, data):
win_w, win_h = dpg.get_item_rect_size(self.window_tag)
group_w, group_h = dpg.get_item_rect_size(self.pipeline_config_group_tag)
group_x, group_y = dpg.get_item_pos(self.pipeline_config_group_tag)
self.window_height = win_h - group_h - group_y - 12
self.window_width = win_w - 7
self.window_offset_y = group_h + group_y + 3
self.on_resize(win_w, win_h)

View File

@ -1,114 +1,72 @@
import dearpygui.dearpygui as dpg
import numpy as np
from .base_widget import BaseWidget
from .pipeline_stage_widget import PipelineStageWidget
class StageViewerWidget(BaseWidget):
name: str = "Image Stage Viewer"
class PipelineStageViewer(PipelineStageWidget):
name = "View Image"
register = True
has_pipeline_in = True
has_pipeline_out = False
def __init__(self, manager, logger):
super().__init__(manager, logger)
# Ensure shared texture registry
if not hasattr(manager, "texture_registry"):
manager.texture_registry = dpg.add_texture_registry(
tag=dpg.generate_uuid())
self.registry = manager.texture_registry
self.stages = []
self.current = None
super().__init__(manager, logger, default_stage_in="pipeline_out")
self.texture_tag = dpg.generate_uuid()
self.img = None
self.needs_update = False
self.registry = manager.texture_registry
# Subscribe only to stage list updates
self.manager.bus.subscribe(
"pipeline_stages", self.on_stage_list, main_thread=True)
self.manager.bus.subscribe(
"pipeline_stage", self.on_stage, main_thread=True)
def create_pipeline_stage_content(self):
dpg.add_dynamic_texture(
1, 1, [0, 0, 0, 0], tag=self.texture_tag, parent=self.registry
)
self.image_item = dpg.add_image(self.texture_tag)
def create(self):
with dpg.window(label="Stage Viewer", tag=self.window_tag, width=400, height=400):
# Dropdown for selecting a stage
self.combo = dpg.add_combo(
label="Stage", items=[], callback=self.on_select)
# Placeholder 1×1 texture in registry
dpg.add_dynamic_texture(
1, 1, [0, 0, 0, 0], tag=self.texture_tag, parent=self.registry)
# Image widget that will display the texture
self.image_item = dpg.add_image(self.texture_tag)
def on_resize(self, width, height):
self.needs_update = True
with dpg.item_handler_registry() as handler:
dpg.add_item_resize_handler(
callback=self.on_resize)
dpg.bind_item_handler_registry(self.window_tag, handler)
def on_resize(self, app_data):
if self.img is not None:
self.needs_update = True
# self.update_texture(self.img)
def on_stage_list(self, stages):
# Update dropdown items
self.stages = stages
dpg.configure_item(self.combo, items=stages)
def on_stage(self, stage):
name, img = stage
if name == self.current:
if img is not None:
self.img = img
self.needs_update = True
# self.update_texture(img)
def on_select(self, sender, selected_stage):
# User-picked stage: fetch and render
self.current = selected_stage
img = self.manager.pipeline.get_stage(selected_stage)
if img is not None:
self.img = img
self.needs_update = True
# self.update_texture(img)
def on_pipeline_data(self, img):
self.img = img
self.needs_update = True
def update_texture(self, img: np.ndarray):
# img is a NumPy array with shape (h, w, 4)
"""Only call from update function"""
if img is None:
dpg.configure_item(self.image_item, show=False)
return
h, w, _ = img.shape
flat = img.flatten().tolist()
# 1) Recreate the dynamic texture at the correct size
if dpg.does_item_exist(self.texture_tag):
dpg.delete_item(self.texture_tag)
dpg.add_dynamic_texture(
width=w, height=h,
width=w,
height=h,
default_value=flat,
tag=self.texture_tag,
parent=self.registry
parent=self.registry,
)
# 2) Compute available space: full window width, from just below combo to bottom
win_w, win_h = dpg.get_item_rect_size(self.window_tag)
combo_w, combo_h = dpg.get_item_rect_size(self.combo)
combo_x, combo_y = dpg.get_item_pos(self.combo)
avail_w = win_w - 15
avail_h = win_h - combo_h - combo_y - 15
win_w, win_h = self.window_width, self.window_height
avail_w = win_w
avail_h = win_h
# 3) Compute scale to fit the available rectangle
scale = min(avail_w / w, avail_h / h, 1.0)
disp_w = int(w * scale)
disp_h = int(h * scale)
# 4) Center horizontally, start exactly below the combo
x_off = (avail_w - disp_w) / 2 + 7
y_off = combo_h + combo_y + 7 # flush immediately below the dropdown
x_off = (avail_w - disp_w) / 2
y_off = self.window_offset_y
# 5) Apply to the image widget
dpg.configure_item(
self.image_item,
texture_tag=self.texture_tag,
pos=(x_off, y_off),
width=disp_w,
height=disp_h
height=disp_h,
show=True
)
def update(self):

View File

@ -1,43 +1,103 @@
[Window][WindowOverViewport_11111111]
Pos=0,19
Size=814,581
Size=1109,809
Collapsed=0
[Window][###22]
Pos=0,19
Size=291,244
Collapsed=0
DockId=0x00000005,0
[Window][###27]
Pos=293,19
Size=521,581
Collapsed=0
DockId=0x00000002,0
[Window][Debug##Default]
Pos=60,60
Size=400,400
Collapsed=0
[Window][###41]
Pos=0,312
Size=299,288
[Window][###28]
Pos=0,19
Size=299,379
Collapsed=0
DockId=0x00000003,0
[Window][###27]
Pos=0,19
Size=300,200
Collapsed=0
[Window][###34]
Pos=434,65
Size=300,200
Collapsed=0
[Window][###33]
Pos=0,445
Size=250,383
Collapsed=0
DockId=0x0000000E,0
[Window][###37]
Pos=0,400
Size=299,200
Collapsed=0
DockId=0x00000004,0
[Window][###34]
Pos=0,265
Size=291,335
[Window][###22]
Pos=0,19
Size=288,427
Collapsed=0
DockId=0x00000006,0
DockId=0x00000007,0
[Window][###31]
Pos=0,448
Size=288,152
Collapsed=0
DockId=0x00000008,0
[Window][###23]
Pos=0,19
Size=250,424
Collapsed=0
DockId=0x0000000D,0
[Window][###32]
Pos=0,376
Size=250,224
Collapsed=0
DockId=0x0000000C,0
[Window][###46]
Pos=252,19
Size=569,605
Collapsed=0
DockId=0x00000002,0
[Window][###39]
Pos=252,19
Size=663,581
Collapsed=0
DockId=0x00000002,0
[Window][###53]
Pos=252,19
Size=548,581
Collapsed=0
DockId=0x00000002,0
[Window][###41]
Pos=252,19
Size=857,809
Collapsed=0
DockId=0x00000002,0
[Docking][Data]
DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0,19 Size=814,581 Split=X Selected=0x26E8F608
DockNode ID=0x00000001 Parent=0x7C6B3D9B SizeRef=291,581 Split=Y Selected=0xEE087978
DockNode ID=0x00000003 Parent=0x00000001 SizeRef=299,291 Split=Y Selected=0xEE087978
DockNode ID=0x00000005 Parent=0x00000003 SizeRef=299,244 Selected=0xEE087978
DockNode ID=0x00000006 Parent=0x00000003 SizeRef=299,335 Selected=0xAA145F7D
DockNode ID=0x00000004 Parent=0x00000001 SizeRef=299,288 Selected=0x7FF1E0B5
DockNode ID=0x00000002 Parent=0x7C6B3D9B SizeRef=521,581 CentralNode=1 Selected=0x26E8F608
DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0,19 Size=1109,809 Split=X
DockNode ID=0x00000009 Parent=0x7C6B3D9B SizeRef=250,581 Split=Y Selected=0xD36850C8
DockNode ID=0x0000000B Parent=0x00000009 SizeRef=147,355 Split=Y Selected=0xD36850C8
DockNode ID=0x0000000D Parent=0x0000000B SizeRef=250,304 Selected=0xD36850C8
DockNode ID=0x0000000E Parent=0x0000000B SizeRef=250,275 Selected=0x1834836D
DockNode ID=0x0000000C Parent=0x00000009 SizeRef=147,224 Selected=0x2554AADD
DockNode ID=0x0000000A Parent=0x7C6B3D9B SizeRef=548,581 Split=X
DockNode ID=0x00000005 Parent=0x0000000A SizeRef=288,581 Split=Y Selected=0xEE087978
DockNode ID=0x00000007 Parent=0x00000005 SizeRef=147,427 Selected=0xEE087978
DockNode ID=0x00000008 Parent=0x00000005 SizeRef=147,152 Selected=0x62F4D00D
DockNode ID=0x00000006 Parent=0x0000000A SizeRef=510,581 Split=X
DockNode ID=0x00000001 Parent=0x00000006 SizeRef=299,581 Split=Y Selected=0xA4B861D9
DockNode ID=0x00000003 Parent=0x00000001 SizeRef=299,379 Selected=0xA4B861D9
DockNode ID=0x00000004 Parent=0x00000001 SizeRef=299,200 Selected=0xEDB425AD
DockNode ID=0x00000002 Parent=0x00000006 SizeRef=499,581 CentralNode=1 Selected=0x7FF1E0B5

View File

@ -4,11 +4,11 @@
"config": {}
},
{
"widget_type": "StageViewerWidget",
"widget_type": "InvertStage",
"config": {}
},
{
"widget_type": "InvertStage",
"widget_type": "PipelineStageViewer",
"config": {}
}
]