From 8b4105a2617679e142b262065fb03ef520b59d02 Mon Sep 17 00:00:00 2001 From: Jojojoppe Date: Fri, 1 Aug 2025 18:49:27 +0200 Subject: [PATCH] New pipeline widget framework --- negstation/image_pipeline.py | 38 ++++-- negstation/negstation.py | 1 + negstation/widgets/base_widget.py | 46 ++++++- negstation/widgets/invert_stage.py | 23 ++-- negstation/widgets/open_image_widget.py | 31 ++--- negstation/widgets/pipeline_stage_widget.py | 132 +++++++++++++------- negstation/widgets/stage_viewer_widget.py | 106 +++++----------- negstation_layout.ini | 114 +++++++++++++---- negstation_widgets.json | 4 +- 9 files changed, 304 insertions(+), 191 deletions(-) diff --git a/negstation/image_pipeline.py b/negstation/image_pipeline.py index 7c3b7ca..aba1754 100644 --- a/negstation/image_pipeline.py +++ b/negstation/image_pipeline.py @@ -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) \ No newline at end of file diff --git a/negstation/negstation.py b/negstation/negstation.py index 93c6d0f..774be49 100644 --- a/negstation/negstation.py +++ b/negstation/negstation.py @@ -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) diff --git a/negstation/widgets/base_widget.py b/negstation/widgets/base_widget.py index b5eed03..275b2c0 100644 --- a/negstation/widgets/base_widget.py +++ b/negstation/widgets/base_widget.py @@ -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) diff --git a/negstation/widgets/invert_stage.py b/negstation/widgets/invert_stage.py index e91c326..69bb43f 100644 --- a/negstation/widgets/invert_stage.py +++ b/negstation/widgets/invert_stage.py @@ -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) diff --git a/negstation/widgets/open_image_widget.py b/negstation/widgets/open_image_widget.py index e0ea2c1..accbcf7 100644 --- a/negstation/widgets/open_image_widget.py +++ b/negstation/widgets/open_image_widget.py @@ -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}") diff --git a/negstation/widgets/pipeline_stage_widget.py b/negstation/widgets/pipeline_stage_widget.py index 37250a0..2c233d8 100644 --- a/negstation/widgets/pipeline_stage_widget.py +++ b/negstation/widgets/pipeline_stage_widget.py @@ -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): - # top‐row: 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) diff --git a/negstation/widgets/stage_viewer_widget.py b/negstation/widgets/stage_viewer_widget.py index 2f81a75..e19a72f 100644 --- a/negstation/widgets/stage_viewer_widget.py +++ b/negstation/widgets/stage_viewer_widget.py @@ -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): diff --git a/negstation_layout.ini b/negstation_layout.ini index 30b0b6b..039e88a 100644 --- a/negstation_layout.ini +++ b/negstation_layout.ini @@ -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 diff --git a/negstation_widgets.json b/negstation_widgets.json index 0cf7237..2a0b909 100644 --- a/negstation_widgets.json +++ b/negstation_widgets.json @@ -4,11 +4,11 @@ "config": {} }, { - "widget_type": "StageViewerWidget", + "widget_type": "InvertStage", "config": {} }, { - "widget_type": "InvertStage", + "widget_type": "PipelineStageViewer", "config": {} } ] \ No newline at end of file