diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 642ff51..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "python.REPL.enableREPLSmartSend": false -} \ No newline at end of file diff --git a/global_state.py b/global_state.py deleted file mode 100644 index 9ba2dd8..0000000 --- a/global_state.py +++ /dev/null @@ -1,31 +0,0 @@ -import logging -from collections import defaultdict - -class GlobalState: - def __init__(self): - self.listeners = defaultdict(list) - - self.raw_params = { - "use_auto_wb": False, - "use_camera_wb": True, - "no_auto_bright": True, - "output_bps": 16, - "gamma": (2.222, 4.5), # Default sRGB gamma - } - self.raw_image_data = None - - def subscribe(self, event_name: str, callback): - """Register a function to be called when an event is dispatched.""" - logging.info(f"Subscribing '{callback.__qualname__}' to event '{event_name}'") - self.listeners[event_name].append(callback) - - def dispatch(self, event_name: str, *args, **kwargs): - """Call all registered callbacks for a given event.""" - logging.debug(f"Dispatching event '{event_name}' with data: {kwargs}") - if event_name in self.listeners: - for callback in self.listeners[event_name]: - try: - # Pass the arguments and keyword arguments to the callback - callback(*args, **kwargs) - except Exception as e: - logging.error(f"Error in event callback for '{event_name}': {e}") \ No newline at end of file diff --git a/img.png b/img.png new file mode 100644 index 0000000..02c7ac5 Binary files /dev/null and b/img.png differ diff --git a/negstation.py b/negstation.py index e3bdaa4..fe28bdf 100755 --- a/negstation.py +++ b/negstation.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -import ui -if __name__=="__main__": - ui.LayoutManager.run() \ No newline at end of file +import negstation + +if __name__ == "__main__": + negstation.NegStation().run() diff --git a/negstation/__init__.py b/negstation/__init__.py new file mode 100644 index 0000000..d5fa86f --- /dev/null +++ b/negstation/__init__.py @@ -0,0 +1 @@ +from .negstation import EditorManager as NegStation \ No newline at end of file diff --git a/negstation/event_bus.py b/negstation/event_bus.py new file mode 100644 index 0000000..b0c73bc --- /dev/null +++ b/negstation/event_bus.py @@ -0,0 +1,44 @@ +import threading +import queue +import logging + + +class EventBus: + def __init__(self, logger: logging.Logger): + self.logger = logger + self.subscribers = {} + self.event_queue = queue.Queue() + self.main_queue = queue.Queue() + threading.Thread(target=self._dispatch_loop, daemon=True).start() + + def subscribe(self, event_type: str, callback: callable, main_thread: bool = False): + self.logger.debug(f"Subscribed to {event_type}") + self.subscribers.setdefault(event_type, []).append((callback, main_thread)) + + def publish_deferred(self, event_type: str, data=None): + self.logger.debug(f"publish {event_type}") + self.event_queue.put((event_type, data)) + + def _dispatch_loop(self): + while True: + event_type, data = self.event_queue.get() + self.logger.debug(f"Dispatching {event_type}") + for callback, main_thread in self.subscribers.get(event_type, []): + if main_thread: + self.main_queue.put((callback, data)) + else: + try: + callback(data) + except Exception as e: + self.logger.error( + f"Error in background handler '{ + event_type}': {e}" + ) + + def process_main_queue(self): + while True: + try: + callback, data = self.main_queue.get_nowait() + callback(data) + except queue.Empty: + break diff --git a/negstation/image_pipeline.py b/negstation/image_pipeline.py new file mode 100644 index 0000000..7c3b7ca --- /dev/null +++ b/negstation/image_pipeline.py @@ -0,0 +1,18 @@ +import numpy as np + +from .event_bus import EventBus + + +class ImagePipeline: + def __init__(self, bus: EventBus): + self.bus = bus + self.stages = {} + + 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 get_stage(self, name: str): + return self.stages.get(name) diff --git a/negstation/layout_manager.py b/negstation/layout_manager.py new file mode 100644 index 0000000..095e758 --- /dev/null +++ b/negstation/layout_manager.py @@ -0,0 +1,43 @@ +import dearpygui.dearpygui as dpg +import logging +import json +import os + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..negstation import EditorManager + + +class LayoutManager: + INI_PATH = "negstation_layout.ini" + WIDGET_DATA_PATH = "negstation_widgets.json" + + def __init__(self, manager: "EditorManager", logger: logging.Logger): + self.manager = manager + self.logger = logger + + def save_layout(self): + self.logger.info("Saving layout...") + dpg.save_init_file(self.INI_PATH) + widget_data = [ + {"widget_type": type(w).__name__, "config": w.get_config()} + for w in self.manager.widgets + ] + with open(self.WIDGET_DATA_PATH, "w") as f: + json.dump(widget_data, f, indent=4) + self.logger.info("Layout saved successfully.") + + def load_layout(self): + self.logger.info("Loading layout...") + if not os.path.exists(self.WIDGET_DATA_PATH): + return + with open(self.WIDGET_DATA_PATH, "r") as f: + widget_data = json.load(f) + for data in widget_data: + if data.get("widget_type") in self.manager.widget_classes: + self.manager._add_widget(widget_type=data.get("widget_type")) + + if os.path.exists(self.INI_PATH): + dpg.configure_app(init_file=self.INI_PATH) + self.logger.info(f"Applied UI layout from {self.INI_PATH}") diff --git a/negstation/negstation.py b/negstation/negstation.py new file mode 100644 index 0000000..ed09258 --- /dev/null +++ b/negstation/negstation.py @@ -0,0 +1,110 @@ +import dearpygui.dearpygui as dpg +import logging +import os +import importlib +import inspect +import sys +from pathlib import Path +from importlib.machinery import SourceFileLoader + +from .event_bus import EventBus +from .image_pipeline import ImagePipeline +from .layout_manager import LayoutManager + +from .widgets.base_widget import BaseWidget + +logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +class EditorManager: + def __init__(self): + dpg.create_context() + self.bus = EventBus(logger) + self.pipeline = ImagePipeline(self.bus) + self.layout_manager = LayoutManager(self, logger) + self.widgets = [] + self.widget_classes = {} + + def _discover_and_register_widgets(self, directory="widgets"): + logging.info(f"Discovering widgets in '{directory}' directory...") + dir_path = Path(directory) + if not dir_path.is_dir(): + logging.error(f"Path '{directory}' is not a directory") + return + + parent = str(dir_path.parent.resolve()) + if parent not in sys.path: + sys.path.insert(0, parent) + pkg_name = dir_path.name # e.g. 'widgets' + + # 1) Load the package’s own BaseWidget + try: + base_mod = importlib.import_module(f"{pkg_name}.base_widget") + ModuleBaseWidget = getattr(base_mod, "BaseWidget") + except Exception: + ModuleBaseWidget = None + + for py_file in dir_path.glob("*.py"): + if py_file.name.startswith("__"): + continue + + module_name = f"{pkg_name}.{py_file.stem}" + try: + module = importlib.import_module(module_name) + for name, cls in inspect.getmembers(module, inspect.isclass): + # 2) Use the BaseWidget defined *in* widgets/base_widget.py + if ( + ModuleBaseWidget + and issubclass(cls, ModuleBaseWidget) + and cls is not ModuleBaseWidget + ): + logging.info(f" -> Found and registered widget: {name}") + self._register_widget(name, cls) + except Exception as e: + logging.error(f"Failed to import widget '{py_file.name}': {e}") + + def _register_widget(self, name: str, widget_class: object): + if name in self.widget_classes: + logging.warning(f"Widget '{name}' is already registered. Overwriting.") + self.widget_classes[name] = widget_class + + def _add_widget(self, widget_type: str): + WidgetClass = self.widget_classes[widget_type] + instance = WidgetClass(self, logger) + self.widgets.append(instance) + instance.create() + + def setup(self): + self._discover_and_register_widgets( + f"{os.path.dirname(os.path.realpath(__file__))}/widgets" + ) + self.layout_manager.load_layout() + + dpg.create_viewport(title="NegStation", width=960, height=400) + dpg.configure_app(docking=True, docking_space=True) + + with dpg.viewport_menu_bar(): + with dpg.menu(label="File"): + dpg.add_menu_item( + label="Save Layout", callback=self.layout_manager.save_layout + ) + + with dpg.menu(label="View"): + for widget_name in sorted(self.widget_classes.keys()): + dpg.add_menu_item( + label=self.widget_classes[widget_name].name, + callback=lambda s, a, ud: self._add_widget(ud), + user_data=widget_name, + ) + + def run(self): + self.setup() + dpg.setup_dearpygui() + dpg.show_viewport() + while dpg.is_dearpygui_running(): + self.bus.process_main_queue() + for w in self.widgets: + w.update() + dpg.render_dearpygui_frame() + dpg.destroy_context() diff --git a/widgets/__init__.py b/negstation/widgets/__init__.py similarity index 100% rename from widgets/__init__.py rename to negstation/widgets/__init__.py diff --git a/negstation/widgets/base_widget.py b/negstation/widgets/base_widget.py new file mode 100644 index 0000000..5b82576 --- /dev/null +++ b/negstation/widgets/base_widget.py @@ -0,0 +1,33 @@ +import dearpygui.dearpygui as dpg +import logging + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..negstation import EditorManager + + +class BaseWidget: + name: str = "BaseWidget" + + def __init__(self, manager: "EditorManager", logger: logging.Logger): + self.manager = manager + self.logger = logger + self.window_tag = dpg.generate_uuid() + self.config = {} + + def create(self): + raise NotImplementedError + + def update(self): + pass + + def get_config(self): + return self.config + + def _on_window_close(self): + try: + dpg.delete_item(self.window_tag) + self.manager.widgets.remove(self) + except ValueError: + pass diff --git a/negstation/widgets/open_image_widget.py b/negstation/widgets/open_image_widget.py new file mode 100644 index 0000000..3cdf428 --- /dev/null +++ b/negstation/widgets/open_image_widget.py @@ -0,0 +1,59 @@ +import dearpygui.dearpygui as dpg +from PIL import Image +import numpy as np + +from .base_widget import BaseWidget + + +class OpenImageWidget(BaseWidget): + name: str = "Open Image" + + def __init__(self, manager, logger, stage_out="loaded_image"): + super().__init__(manager, logger) + self.stage_out = stage_out + self.dialog_tag = dpg.generate_uuid() + self.output_tag = dpg.generate_uuid() + + def create(self): + with dpg.file_dialog( + directory_selector=False, + show=False, + callback=self._on_file_selected, + tag=self.dialog_tag, + height=300, + 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) + + 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) + else None + ) + if not selection: + return + self.logger.info(f"Selected file '{selection}'") + try: + 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) + except Exception as e: + self.logger.error(f"Failed to load image {selection}: {e}") diff --git a/negstation/widgets/stage_viewer_widget.py b/negstation/widgets/stage_viewer_widget.py new file mode 100644 index 0000000..51d69a0 --- /dev/null +++ b/negstation/widgets/stage_viewer_widget.py @@ -0,0 +1,226 @@ +import dearpygui.dearpygui as dpg +import numpy as np + +from .base_widget import BaseWidget + + +# class StageViewerWidget(BaseWidget): +# name: str = "Stage Viewer" + +# def __init__(self, manager, logger): +# super().__init__(manager, logger) +# # ensure 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 +# self.texture_tag = dpg.generate_uuid() +# self.image_item = None + +# manager.bus.subscribe("pipeline_stages", self.on_stage_list, main_thread=True) +# manager.bus.subscribe("pipeline_stage", self.on_stage_data, main_thread=True) + +# def create(self): +# with dpg.window( +# label="Stage Viewer", +# tag=self.window_tag, +# width=400, +# height=400, +# on_close=self._on_window_close, +# ): +# self.combo = dpg.add_combo(label="Stage", items=[], callback=self.on_select) +# # placeholder 1×1 texture +# dpg.add_dynamic_texture( +# width=1, +# height=1, +# default_value=[0.0, 0.0, 0.0, 0.0], +# tag=self.texture_tag, +# parent=self.registry, +# ) +# self.image_item = dpg.add_image(self.texture_tag) + +# def on_stage_list(self, stages): +# self.stages = stages +# dpg.configure_item(self.combo, items=stages) +# if not self.current and stages: +# self.current = stages[0] +# dpg.set_value(self.combo, self.current) + +# def on_select(self, sender, stage_name): +# self.current = stage_name +# img = self.manager.pipeline.get_stage(stage_name) +# if img is not None: +# self.update_texture(img) + +# def on_stage_data(self, data): +# name, img = data +# if name == self.current: +# self.update_texture(img) + +# def update_texture(self, img: np.ndarray): +# h, w, _ = img.shape +# flat = img.flatten().tolist() + +# # recreate texture at correct size +# if dpg.does_item_exist(self.texture_tag): +# dpg.delete_item(self.texture_tag) +# dpg.add_dynamic_texture( +# width=w, +# height=h, +# default_value=flat, +# tag=self.texture_tag, +# parent=self.registry, +# ) + +# # determine available window size +# win_w, win_h = dpg.get_item_rect_size(self.window_tag) +# # reserve space for combo box (approx 30px) +# available_h = max(win_h - 30, 1) +# # compute scale to fit +# scale = min(win_w / w, available_h / h) + +# disp_w = int(w * scale) +# disp_h = int(h * scale) + +# # update image widget +# dpg.configure_item( +# self.image_item, texture_tag=self.texture_tag, width=disp_w, height=disp_h +# ) + + +class StageViewerWidget(BaseWidget): + """ + A robust, zoomable stage viewer using a Dear PyGui Plot to display + dynamic textures without ever deleting them—avoiding segfaults. + """ + name = "Stage Viewer" + + def __init__(self, manager, logger): + super().__init__(manager, logger) + self.manager.bus.subscribe("pipeline_stages", self._on_stage_list, main_thread=True) + self.manager.bus.subscribe("pipeline_stage", self._on_stage_data, main_thread=True) + + # one‐time flags and tags + self._initialized = False + self.texture_tag = dpg.generate_uuid() + self.image_draw_tag = dpg.generate_uuid() + self.plot_tag = dpg.generate_uuid() + self.xaxis_tag = dpg.generate_uuid() + self.yaxis_tag = dpg.generate_uuid() + self.last_size = (1, 1) + self.current_stage = None + self.needs_fit = False + + def create(self): + if dpg.does_item_exist(self.window_tag): + return + + # ensure a texture registry exists + if not hasattr(self.manager, "texture_registry"): + self.manager.texture_registry = dpg.add_texture_registry(tag=dpg.generate_uuid()) + + with dpg.window(label="Stage Viewer", + tag=self.window_tag, + on_close=self._on_window_close, + width=600, height=600): + + # stage selector + self.combo = dpg.add_combo(label="Stage", items=[], callback=self._on_select) + + # plot container, equal_aspects ensures no distortion + with dpg.plot(label="Image Plot", tag=self.plot_tag, height=-1, width=-1, equal_aspects=True): + self.xaxis_tag = dpg.add_plot_axis(dpg.mvXAxis, + no_tick_labels=True, no_gridlines=True) + self.yaxis_tag = dpg.add_plot_axis(dpg.mvYAxis, + no_tick_labels=True, no_gridlines=True) + + # resize handler to refit on window/plot size changes + with dpg.item_handler_registry() as handler: + dpg.add_item_resize_handler(callback=lambda s,a,u: self._fit_image(), user_data=None) + dpg.bind_item_handler_registry(self.window_tag, handler) + + def _on_stage_list(self, stages): + dpg.configure_item(self.combo, items=stages) + if not self.current_stage and stages: + self.current_stage = stages[0] + dpg.set_value(self.combo, self.current_stage) + + def _on_select(self, sender, stage_name): + self.current_stage = stage_name + img = self.manager.pipeline.get_stage(stage_name) + if img is not None: + self._update_image(img) + + def _on_stage_data(self, data): + name, img = data + if name == self.current_stage: + self._update_image(img) + + def _update_image(self, img: np.ndarray): + h, w, _ = img.shape + self.last_size = (w, h) + + # First time: create texture & draw-image inside the plot + if not self._initialized: + dpg.add_dynamic_texture(w, h, img, tag=self.texture_tag, + parent=self.manager.texture_registry) + dpg.draw_image(self.texture_tag, + pmin=(0, h), pmax=(w, 0), + tag=self.image_draw_tag, + parent=self.plot_tag) + self._initialized = True + else: + # Subsequent updates: just set_value and adjust draw coords + dpg.set_value(self.texture_tag, img) + dpg.configure_item(self.image_draw_tag, + pmin=(0, self.last_size[1]), + pmax=(self.last_size[0], 0)) + + # show & focus window + dpg.configure_item(self.window_tag, show=True) + dpg.focus_item(self.window_tag) + + # flag to refit axes + self.needs_fit = True + + def _fit_image(self): + """Adjust plot axes so the image fills the available space.""" + if not self._initialized or not self.needs_fit: + return + + # get plot area size + plot_w = dpg.get_item_width(self.window_tag) + plot_h = dpg.get_item_height(self.window_tag) - 30 # reserve combo height + if plot_w <= 0 or plot_h <= 0: + return + + img_w, img_h = self.last_size + if img_w <= 0 or img_h <= 0: + return + + plot_aspect = plot_w / plot_h + img_aspect = img_w / img_h + + if img_aspect > plot_aspect: + x_min, x_max = 0, img_w + needed_h = img_w / plot_aspect + center_y = img_h / 2 + y_min = center_y - needed_h / 2 + y_max = center_y + needed_h / 2 + else: + y_min, y_max = 0, img_h + needed_w = img_h * plot_aspect + center_x = img_w / 2 + x_min = center_x - needed_w / 2 + x_max = center_x + needed_w / 2 + + dpg.set_axis_limits(self.xaxis_tag, x_min, x_max) + dpg.set_axis_limits(self.yaxis_tag, y_min, y_max) + self.needs_fit = False + + def update(self): + # If we flagged a refit, do it now + if self.needs_fit: + self._fit_image() diff --git a/negstation_layout.ini b/negstation_layout.ini index 81996a0..8775e0d 100644 --- a/negstation_layout.ini +++ b/negstation_layout.ini @@ -1,129 +1,63 @@ [Window][WindowOverViewport_11111111] Pos=0,19 -Size=1280,701 +Size=960,381 Collapsed=0 -[Window][###28] -Pos=433,517 -Size=847,203 -Collapsed=0 -DockId=0x00000002,0 - -[Window][###30] -Pos=310,553 -Size=970,167 -Collapsed=0 -DockId=0x0000000C,0 - [Window][Debug##Default] Pos=60,60 Size=400,400 Collapsed=0 -[Window][###31] -Pos=373,547 -Size=907,173 +[Window][###28] +Pos=143,94 +Size=400,400 Collapsed=0 -DockId=0x0000000E,0 - -[Window][###29] -Pos=346,572 -Size=934,148 -Collapsed=0 -DockId=0x00000010,0 - -[Window][###50] -Pos=310,19 -Size=970,532 -Collapsed=0 -DockId=0x0000000D,0 - -[Window][###49] -Pos=346,19 -Size=934,551 -Collapsed=0 -DockId=0x0000000D,0 - -[Window][###32] -Pos=0,19 -Size=308,701 -Collapsed=0 -DockId=0x00000011,0 - -[Window][###51] -Pos=290,19 -Size=990,550 -Collapsed=0 -DockId=0x0000000D,0 - -[Window][###73] -Pos=310,19 -Size=970,532 -Collapsed=0 -DockId=0x0000000D,0 [Window][###33] +Pos=-2,48 +Size=300,150 +Collapsed=0 + +[Window][###37] +Pos=580,56 +Size=400,400 +Collapsed=0 + +[Window][###22] Pos=0,19 -Size=371,701 +Size=207,381 Collapsed=0 -DockId=0x00000019,0 +DockId=0x00000005,0 -[Window][###55] -Pos=0,497 -Size=288,223 +[Window][###27] +Pos=498,19 +Size=462,381 Collapsed=0 -DockId=0x00000016,0 +DockId=0x00000004,0 -[Window][###78] -Pos=0,464 -Size=288,256 +[Window][###31] +Pos=516,19 +Size=444,381 Collapsed=0 -DockId=0x00000018,0 +DockId=0x00000002,0 -[Window][###57] -Pos=0,674 -Size=371,355 +[Window][###41] +Pos=185,19 +Size=821,577 Collapsed=0 -DockId=0x0000001A,0 +DockId=0x00000003,0 -[Window][###53] -Pos=373,19 -Size=907,526 +[Window][###38] +Pos=249,45 +Size=192,274 Collapsed=0 -DockId=0x0000000D,0 - -[Window][###59] -Pos=0,19 -Size=371,701 -Collapsed=0 -DockId=0x00000019,1 [Docking][Data] -DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0,19 Size=1280,701 Split=X - DockNode ID=0x00000013 Parent=0x7C6B3D9B SizeRef=371,701 Split=Y Selected=0x1834836D - DockNode ID=0x00000015 Parent=0x00000013 SizeRef=288,476 Split=Y Selected=0x1834836D - DockNode ID=0x00000017 Parent=0x00000015 SizeRef=288,443 Split=Y Selected=0x1834836D - DockNode ID=0x00000019 Parent=0x00000017 SizeRef=288,453 Selected=0x1834836D - DockNode ID=0x0000001A Parent=0x00000017 SizeRef=288,246 Selected=0x3BEDC6B0 - DockNode ID=0x00000018 Parent=0x00000015 SizeRef=288,256 Selected=0xF475F06A - DockNode ID=0x00000016 Parent=0x00000013 SizeRef=288,223 Selected=0x412D95D0 - DockNode ID=0x00000014 Parent=0x7C6B3D9B SizeRef=907,701 Split=X - DockNode ID=0x00000011 Parent=0x00000014 SizeRef=308,701 Selected=0x2554AADD - DockNode ID=0x00000012 Parent=0x00000014 SizeRef=970,701 Split=X - DockNode ID=0x00000009 Parent=0x00000012 SizeRef=319,701 Selected=0x5F94F9BD - DockNode ID=0x0000000A Parent=0x00000012 SizeRef=959,701 Split=X - DockNode ID=0x00000007 Parent=0x0000000A SizeRef=340,701 Selected=0x5F94F9BD - DockNode ID=0x00000008 Parent=0x0000000A SizeRef=938,701 Split=X - DockNode ID=0x00000005 Parent=0x00000008 SizeRef=300,701 Selected=0x5F94F9BD - DockNode ID=0x00000006 Parent=0x00000008 SizeRef=978,701 Split=X - DockNode ID=0x00000003 Parent=0x00000006 SizeRef=318,701 Selected=0x5F94F9BD - DockNode ID=0x00000004 Parent=0x00000006 SizeRef=960,701 Split=Y - DockNode ID=0x00000001 Parent=0x00000004 SizeRef=979,496 Split=Y - DockNode ID=0x0000000F Parent=0x00000001 SizeRef=1123,551 Split=Y Selected=0x89CD1AA0 - DockNode ID=0x0000000B Parent=0x0000000F SizeRef=1280,532 Split=Y Selected=0xB4AD3310 - DockNode ID=0x0000000D Parent=0x0000000B SizeRef=1088,526 CentralNode=1 Selected=0xCE6D6070 - DockNode ID=0x0000000E Parent=0x0000000B SizeRef=1088,173 Selected=0x62F4D00D - DockNode ID=0x0000000C Parent=0x0000000F SizeRef=1280,167 Selected=0x5F94F9BD - DockNode ID=0x00000010 Parent=0x00000001 SizeRef=1123,148 Selected=0x99D84869 - DockNode ID=0x00000002 Parent=0x00000004 SizeRef=979,203 Selected=0xA4B861D9 +DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0,19 Size=960,381 Split=X + DockNode ID=0x00000005 Parent=0x7C6B3D9B SizeRef=207,381 Selected=0xEE087978 + DockNode ID=0x00000006 Parent=0x7C6B3D9B SizeRef=751,381 Split=X + DockNode ID=0x00000001 Parent=0x00000006 SizeRef=514,381 Split=X + DockNode ID=0x00000003 Parent=0x00000001 SizeRef=287,381 CentralNode=1 + DockNode ID=0x00000004 Parent=0x00000001 SizeRef=462,381 Selected=0x26E8F608 + DockNode ID=0x00000002 Parent=0x00000006 SizeRef=444,381 Selected=0x62F4D00D diff --git a/negstation_widgets.json b/negstation_widgets.json index 170a414..1259061 100644 --- a/negstation_widgets.json +++ b/negstation_widgets.json @@ -1,26 +1,10 @@ [ { - "widget_type": "LogWidget", - "config": { - "label": "LogWidget" - } + "widget_type": "OpenImageWidget", + "config": {} }, { - "widget_type": "CamControlWidget", - "config": { - "label": "CamControlWidget" - } - }, - { - "widget_type": "ImageViewerWidget", - "config": { - "label": "ImageViewerWidget" - } - }, - { - "widget_type": "RawSettingsWidget", - "config": { - "label": "RawSettingsWidget" - } + "widget_type": "StageViewerWidget", + "config": {} } ] \ No newline at end of file diff --git a/raw_processor.py b/raw_processor.py deleted file mode 100644 index b4f84f6..0000000 --- a/raw_processor.py +++ /dev/null @@ -1,60 +0,0 @@ -import rawpy -import numpy as np -import queue -import threading -import logging -import dearpygui.dearpygui as dpg - -class RawProcessor: - """ - A background service that listens for new RAW files, processes them, - and dispatches the resulting RGB data. - """ - def __init__(self, global_state): - self.global_state = global_state - self.work_queue = queue.Queue() - - # Subscribe to the event from the camera widget - self.global_state.subscribe("NEW_IMAGE_CAPTURED", self.add_to_queue) - - # Start the processor's own background thread - self.worker_thread = threading.Thread(target=self._process_worker, daemon=True) - self.worker_thread.start() - logging.info("RAW Processor thread started.") - - def add_to_queue(self, image_path: str): - """Adds a new RAW file path to the processing queue.""" - if image_path.lower().endswith(('.cr2')): - logging.info(f"RAW Processor: Queued {image_path} for processing.") - self.work_queue.put(image_path) - else: - # Not a supported raw file, hope for the best the viewer supports it - try: - width, height, channels, data = dpg.load_image(image_path) - rgba_float32 = np.array(data, dtype=np.float32) - rgba_float32 = rgba_float32.reshape(height, width, 4) - self.global_state.raw_image_data = rgba_float32.copy() - self.global_state.dispatch("PROCESSED_IMAGE_READY", image_data=rgba_float32.copy()) - - except Exception as e: - logging.error(f"Failed to load standard image {image_path}: {e}", exc_info=True) - - def _process_worker(self): - """The background thread that performs RAW processing and data conversion.""" - while True: - raw_path = self.work_queue.get() - try: - logging.info(f"Processing {raw_path}...") - with rawpy.imread(raw_path) as raw: - rgb_uint8_potentially_corrupt = raw.postprocess(**self.global_state.raw_params) - rgb_uint8 = rgb_uint8_potentially_corrupt.copy() - logging.info("Defensive copy complete. Starting conversion...") - rgb_float32 = (rgb_uint8 / pow(2,self.global_state.raw_params['output_bps'])).astype(np.float32) - alpha_channel = np.ones((rgb_float32.shape[0], rgb_float32.shape[1], 1), dtype=np.float32) - rgba_float32_data = np.concatenate((rgb_float32, alpha_channel), axis=2) - logging.info(f"Processing and conversion complete for {raw_path}.") - self.global_state.raw_image_data = rgba_float32_data.copy() - self.global_state.dispatch("PROCESSED_IMAGE_READY", image_data=rgba_float32_data.copy()) - - except Exception as e: - logging.error(f"Failed to process RAW file {raw_path}: {e}", exc_info=True) diff --git a/ui.py b/ui.py deleted file mode 100644 index 49205a5..0000000 --- a/ui.py +++ /dev/null @@ -1,132 +0,0 @@ -import dearpygui.dearpygui as dpg -import os -import json -import logging -import importlib -import inspect -from collections import deque - -import global_state -import raw_processor -from widgets.base_widget import BaseWidget - -class DpgLogHandler(logging.Handler): - def __init__(self): - super().__init__() - self.log_queue = deque(maxlen=200) - def emit(self, record): - msg = self.format(record) - self.log_queue.append(msg) - print(msg) - def get_all_logs(self): - return "\n".join(self.log_queue) - -log_handler = DpgLogHandler() -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[log_handler]) - -INI_PATH = "negstation_layout.ini" -WIDGET_DATA_PATH = "negstation_widgets.json" - -class LayoutManager: - def __init__(self): - self.active_widgets = {} - self.widget_classes = {} - self.updating_widgets = [] - self.global_state = global_state.GlobalState() - - self.texture_registry = dpg.add_texture_registry() - self.raw_processor = raw_processor.RawProcessor(self.global_state) - - def discover_and_register_widgets(self, directory="widgets"): - """Dynamically discovers and registers widgets from a given directory.""" - logging.info(f"Discovering widgets in '{directory}' directory...") - for filename in os.listdir(directory): - if filename.endswith(".py") and not filename.startswith("__"): - module_name = f"{directory}.{filename[:-3]}" - try: - # Dynamically import the module - module = importlib.import_module(module_name) - - # Find all classes in the module that are subclasses of BaseWidget - for name, cls in inspect.getmembers(module, inspect.isclass): - if issubclass(cls, BaseWidget) and cls is not BaseWidget: - logging.info(f" -> Found and registered widget: {name}") - self.register_widget(name, cls) - except ImportError as e: - logging.error(f"Failed to import widget module {module_name}: {e}") - - def register_widget(self, name: str, widget_class: object): - """Adds a widget class to the registry.""" - if name in self.widget_classes: - logging.warning(f"Widget '{name}' is already registered. Overwriting.") - self.widget_classes[name] = widget_class - - def add_widget(self, widget_type: str): - if widget_type not in self.widget_classes: logging.error(f"Unknown widget type '{widget_type}'"); return - if widget_type in self.active_widgets: - widget_tag = self.active_widgets[widget_type].window_tag - if dpg.does_item_exist(widget_tag): - logging.info(f"Showing existing widget: {widget_type}"); dpg.configure_item(widget_tag, show=True); dpg.focus_item(widget_tag) - return - config = {"label": widget_type} - WidgetClass = self.widget_classes[widget_type] - widget_instance = WidgetClass(widget_type, config, self, self.global_state) - logging.info(f"Creating new widget of type: {widget_type}") - self.active_widgets[widget_type] = widget_instance - widget_instance.create() - - def save_layout(self): - logging.info("Saving layout..."); dpg.save_init_file(INI_PATH) - widget_data = [{"widget_type": w_type, "config": w.get_config()} for w_type, w in self.active_widgets.items()] - with open(WIDGET_DATA_PATH, 'w') as f: json.dump(widget_data, f, indent=4) - logging.info("Layout saved successfully.") - - def load_layout(self): - logging.info("Loading layout..."); - if not os.path.exists(WIDGET_DATA_PATH): return - with open(WIDGET_DATA_PATH, 'r') as f: widget_data = json.load(f) - for data in widget_data: - if data.get("widget_type") in self.widget_classes: self.add_widget(widget_type=data.get("widget_type")) - if os.path.exists(INI_PATH): dpg.configure_app(init_file=INI_PATH); logging.info(f"Applied UI layout from {INI_PATH}") - - def update_all_widgets(self): - """Calls per-frame update methods on widgets that need it.""" - if "LogWidget" in self.active_widgets: - # We need to pass the handler to the update method - self.active_widgets["LogWidget"].update_logs(log_handler) - for w in self.updating_widgets: - if w in self.active_widgets: - self.active_widgets[w].update() - - @staticmethod - def run(): - dpg.create_context() - dpg.create_viewport(title='Dynamic Docking Layout with Menu', width=1280, height=720) - - layout_manager = LayoutManager() - layout_manager.discover_and_register_widgets() - - with dpg.viewport_menu_bar(): - with dpg.menu(label="File"): - dpg.add_menu_item(label="Save Layout", callback=layout_manager.save_layout) - - with dpg.menu(label="View"): - for widget_name in sorted(layout_manager.widget_classes.keys()): - dpg.add_menu_item( - label=f"Show {widget_name}", - callback=lambda s, a, ud: layout_manager.add_widget(ud), - user_data=widget_name - ) - - dpg.configure_app(docking=True, docking_space=True) - dpg.setup_dearpygui() - - layout_manager.load_layout() - - dpg.show_viewport() - - while dpg.is_dearpygui_running(): - layout_manager.update_all_widgets() - dpg.render_dearpygui_frame() - - dpg.destroy_context() diff --git a/widgets/base_widget.py b/widgets/base_widget.py deleted file mode 100644 index 482ed54..0000000 --- a/widgets/base_widget.py +++ /dev/null @@ -1,24 +0,0 @@ -import dearpygui.dearpygui as dpg -import logging - -class BaseWidget: - """A base class to handle common functionality for all widgets.""" - def __init__(self, widget_type: str, config: dict, layout_manager, global_state): - self.widget_type = widget_type - self.config = config - self.layout_manager = layout_manager - self.global_state = global_state - self.window_tag = f"widget_win_{self.widget_type}" - - def create(self): - raise NotImplementedError - - def get_config(self) -> dict: - return self.config - - def update(self): - raise NotImplementedError - - def _on_window_close(self, sender, app_data, user_data): - logging.info(f"Hiding widget: {self.widget_type}") - dpg.configure_item(self.window_tag, show=False) \ No newline at end of file diff --git a/widgets/camcontrol_widget.py b/widgets/camcontrol_widget.py deleted file mode 100644 index f42e949..0000000 --- a/widgets/camcontrol_widget.py +++ /dev/null @@ -1,308 +0,0 @@ -# in widgets/camera_widget.py -import dearpygui.dearpygui as dpg -import gphoto2 as gp -import logging -import threading -import queue -import tempfile -import os - -from .base_widget import BaseWidget - -# Set up a logger specific to this widget for clear debugging -logger = logging.getLogger(__name__) -logger.setLevel("DEBUG") - -class CamControlWidget(BaseWidget): - def __init__(self, widget_type: str, config: dict, layout_manager, app_state): - super().__init__(widget_type, config, layout_manager, app_state) - layout_manager.updating_widgets.append("CamControlWidget") - - # A dictionary to map user-friendly camera names to technical port strings - self.camera_map = {} - - # Queues for communicating with the worker thread - self.command_queue = queue.Queue() - self.result_queue = queue.Queue() - - # Start the background worker thread. It's a daemon so it exits with the app. - self.worker_thread = threading.Thread(target=self._camera_worker, daemon=True) - self.worker_thread.start() - - self.command_queue.put(('DETECT', None)) - - def create(self): - if dpg.does_item_exist(self.window_tag): return - - with dpg.window(label="Camera Control", tag=self.window_tag, on_close=self._on_window_close, width=320, height=160): - - # This group contains all controls visible when disconnected - with dpg.group(tag=f"connection_group_{self.widget_type}") as self.connection_group_tag: - dpg.add_button(label="Detect Cameras", callback=lambda: self.command_queue.put(('DETECT', None)), width=-1) - with dpg.group(horizontal=True): - self.camera_combo_tag = dpg.add_combo(label="Camera", items=[], width=-115) - - def connect_callback(): - selected_name = dpg.get_value(self.camera_combo_tag) - if selected_name and selected_name in self.camera_map: - port = self.camera_map[selected_name] - self.command_queue.put(('CONNECT', port)) - else: - logger.warning("No camera selected to connect to.") - - self.connect_button = dpg.add_button(label="Connect", callback=connect_callback) - - # This button is only visible when connected - self.disconnect_button_tag = dpg.add_button( - label="Disconnect", - callback=lambda: self.command_queue.put(('DISCONNECT', None)), - show=False, - width=-1 - ) - - self.status_text_tag = dpg.add_text("Status: Disconnected") - - # This is only visible when connected - with dpg.group(tag=f"connected_group_{self.widget_type}", show=False) as self.connected_group_tag: - dpg.add_separator() - - dpg.add_button(label="Capture Image", width=-1, callback=lambda: self.command_queue.put(('CAPTURE', None))) - self.capture_path_text_tag = dpg.add_text("Last Capture: None") - - dpg.add_separator() - - dpg.add_button(label="Refresh Config", width=-1, callback=lambda: self.command_queue.put(('GET_CONFIG', None))) - # A child window makes the table scrollable - with dpg.child_window(height=-1): - # The table will be populated dynamically - self.config_table_tag = dpg.add_table(header_row=True, borders_innerV=True) - dpg.add_table_column(parent=self.config_table_tag, label="Setting") - dpg.add_table_column(parent=self.config_table_tag, label="Value", width_fixed=True) - - def update(self): - try: - # Check for a result from the worker thread without blocking - result_type, data = self.result_queue.get_nowait() - - if result_type == 'STATUS': - dpg.set_value(self.status_text_tag, f"Status: {data}") - - elif result_type == 'CAMERAS_DETECTED': - # data is a list of (name, port) tuples - self.camera_map = {name: port for name, port in data} - camera_names = list(self.camera_map.keys()) - dpg.configure_item(self.camera_combo_tag, items=camera_names) - if camera_names: - dpg.set_value(self.camera_combo_tag, camera_names[0]) - - elif result_type == 'CONNECTED' and data is True: - # Hide connection controls and show the disconnect button - dpg.configure_item(self.connection_group_tag, show=False) - dpg.configure_item(self.disconnect_button_tag, show=True) - dpg.configure_item(self.connected_group_tag, show=True) - # Automatically fetch config on connect - self.command_queue.put(('GET_CONFIG', None)) - - elif result_type == 'DISCONNECTED' and data is True: - # Hide disconnect button and show connection controls - dpg.configure_item(self.connection_group_tag, show=True) - dpg.configure_item(self.disconnect_button_tag, show=False) - dpg.configure_item(self.connected_group_tag, show=False) - - elif result_type == 'CONFIG_DATA': - # Clear any old settings from the table - dpg.delete_item(self.config_table_tag, children_only=True) - # Re-add columns because they were deleted - dpg.add_table_column(parent=self.config_table_tag, label="Setting") - dpg.add_table_column(parent=self.config_table_tag, label="Value", width_fixed=True) - - # Dynamically create a UI element for each setting - for key, item_data in data.items(): - # But only if it is one of the following fields: - if item_data['label'] not in [ - "ISO Speed", "Auto ISO", "WhiteBalance", "Focus Mode", - "Aperture", "F-Number", "Image Quality", "Focus Mode 2", - "Shutter Speed", "Picture Style", "Image Format", "Shutter Speed 2" - ]: - continue - with dpg.table_row(parent=self.config_table_tag): - dpg.add_text(item_data['label']) - - # Create a combo box for settings with choices - if item_data['type'] in [gp.GP_WIDGET_RADIO, gp.GP_WIDGET_MENU] and item_data['choices']: - dpg.add_combo(items=item_data['choices'], default_value=item_data['value'], width=200, - callback=lambda s, a, u: self.command_queue.put(('SET_CONFIG', (u, a))), - user_data=key) - # Otherwise, create a simple text input - else: - dpg.add_input_text(default_value=str(item_data['value']), width=200, on_enter=True, - callback=lambda s, a, u: self.command_queue.put(('SET_CONFIG', (u, a))), - user_data=key) - - elif result_type == 'CAPTURE_COMPLETE': - # The 'data' variable contains the file path sent from the worker - file_path = data - self.global_state.dispatch("NEW_IMAGE_CAPTURED", image_path=file_path) - dpg.set_value(self.capture_path_text_tag, f"Last Capture: {file_path}") - - except queue.Empty: - # This is expected when there are no new results from the worker - pass - - def _camera_worker(self): - camera = None - logger.info("Camera worker thread started.") - - while True: - try: - command, args = self.command_queue.get() - logger.info(f"Camera worker received command: {command}") - - if command == 'DETECT': - try: - # Autodetect returns a list of (name, port) tuples - camera_list = list(gp.Camera.autodetect()) - if not camera_list: - self.result_queue.put(('STATUS', 'No cameras found.')) - self.result_queue.put(('CAMERAS_DETECTED', [])) - continue - - self.result_queue.put(('CAMERAS_DETECTED', camera_list)) - self.result_queue.put(('STATUS', f'Detected {len(camera_list)} camera(s).')) - except Exception as e: - logger.error(f"Error during camera detection: {e}") - self.result_queue.put(('STATUS', 'Error during detection.')) - - elif command == 'CONNECT': - if camera is not None: - self.result_queue.put(('STATUS', 'A camera is already connected.')) - continue - - port = args - if not port: - self.result_queue.put(('STATUS', 'No camera port selected.')) - continue - - try: - camera = gp.Camera() - port_info_list = gp.PortInfoList(); port_info_list.load() - port_index = port_info_list.lookup_path(port) - camera.set_port_info(port_info_list[port_index]) - - camera.init() - self.result_queue.put(('STATUS', f"Connected successfully!")) - self.result_queue.put(('CONNECTED', True)) - except gp.GPhoto2Error as ex: - camera = None # Ensure camera is None on failure - logger.error(f"GPhoto2 Error on connect: {ex}") - self.result_queue.put(('STATUS', f"Error: {ex}")) - self.result_queue.put(('CONNECTED', False)) - - elif command == 'DISCONNECT': - if camera: - try: - camera.exit() - camera = None # Critical to update state - self.result_queue.put(('STATUS', 'Disconnected.')) - self.result_queue.put(('DISCONNECTED', True)) - except gp.GPhoto2Error as ex: - logger.error(f"Error on disconnect: {ex}") - self.result_queue.put(('STATUS', f"Error on disconnect: {ex}")) - else: - self.result_queue.put(('STATUS', 'Already disconnected.')) - - elif command == 'GET_CONFIG': - if camera is None: - self.result_queue.put(('STATUS', 'Not connected.')) - continue - try: - config_dict = self._get_config_as_dict(camera) - self.result_queue.put(('CONFIG_DATA', config_dict)) - self.result_queue.put(('STATUS', 'Configuration loaded.')) - except Exception as e: - logger.error(f"Could not get camera config: {e}") - self.result_queue.put(('STATUS', f"Error getting config: {e}")) - - elif command == 'SET_CONFIG': - if camera is None: continue - key, value = args - try: - config = camera.get_config() - widget = config.get_child_by_name(key) - - # Check the widget type to set the value correctly - widget_type = widget.get_type() - if widget_type in (gp.GP_WIDGET_RADIO, gp.GP_WIDGET_MENU): - # For choice-based widgets, value is a string - widget.set_value(str(value)) - elif widget_type == gp.GP_WIDGET_TEXT: - widget.set_value(str(value)) - # Add more type checks if needed (e.g., for integers, floats) - - camera.set_config(config) - self.result_queue.put(('STATUS', f"Set '{key}' to '{value}'")) - except gp.GPhoto2Error as e: - logger.error(f"Failed to set config '{key}': {e}") - self.result_queue.put(('STATUS', f"Error setting '{key}': {e}")) - - elif command == 'CAPTURE': - if camera is None: - self.result_queue.put(('STATUS', 'Not connected.')) - continue - - self.result_queue.put(('STATUS', 'Capturing...')) - try: - # This captures the image to the camera's internal RAM - file_path = camera.capture(gp.GP_CAPTURE_IMAGE) - logger.info(f"Image captured on camera: {file_path.folder}{file_path.name}") - - # Define a destination on the computer in the system's temp directory - temp_dir = tempfile.gettempdir() - destination_path = os.path.join(temp_dir, f"{file_path.name}") - - # Download the file from the camera to the destination - logger.info(f"Downloading image to: {destination_path}") - camera_file = camera.file_get(file_path.folder, file_path.name, gp.GP_FILE_TYPE_NORMAL) - camera_file.save(destination_path) - - # Send the path of the completed file back to the UI - self.result_queue.put(('CAPTURE_COMPLETE', destination_path)) - self.result_queue.put(('STATUS', 'Capture successful!')) - - except gp.GPhoto2Error as ex: - logger.error(f"GPhoto2 Error during capture: {ex}") - self.result_queue.put(('STATUS', f'Capture Error: {ex}')) - - except Exception as e: - # Broad exception to catch any other errors in the worker loop - logger.error(f"An unexpected exception occurred in the camera worker: {e}") - self.result_queue.put(('STATUS', f'Worker Error: {e}')) - - def _get_config_as_dict(self, camera): - """ - Helper function to recursively get all camera configuration values - and return them as a simplified dictionary. - """ - config_dict = {} - # Get the camera's full configuration tree - config = camera.get_config() - - # We're interested in top-level sections like 'capturesettings' and 'imgsettings' - for section in config.get_children(): - for child in section.get_children(): - # Skip read-only or unreadable widgets - if child.get_readonly(): - continue - - try: - # Store all relevant info for building a UI - config_dict[child.get_name()] = { - 'label': child.get_label(), - 'type': child.get_type(), - 'value': child.get_value(), - 'choices': list(child.get_choices()) if child.get_type() in [gp.GP_WIDGET_RADIO, gp.GP_WIDGET_MENU] else None - } - except gp.GPhoto2Error: - # Some settings might not be available depending on camera mode - continue - return config_dict \ No newline at end of file diff --git a/widgets/image_viewer_widget.py b/widgets/image_viewer_widget.py deleted file mode 100644 index 45a704a..0000000 --- a/widgets/image_viewer_widget.py +++ /dev/null @@ -1,104 +0,0 @@ -# in widgets/image_viewer_widget.py -import dearpygui.dearpygui as dpg -from .base_widget import BaseWidget -import logging -import numpy as np - -class ImageViewerWidget(BaseWidget): - """ - Displays a zoomable image inside a plot. This definitive version uses a - "reuse and reconfigure" pattern, creating DPG items only once and updating - them on subsequent loads to ensure stability and avoid segmentation faults. - """ - def __init__(self, widget_type: str, config: dict, layout_manager, global_state): - super().__init__(widget_type, config, layout_manager, global_state) - - self.global_state.subscribe("PROCESSED_IMAGE_READY", self.on_new_image_data) - layout_manager.updating_widgets.append("ImageViewerWidget") - - # --- Initialize state --- - # A flag to know if the DPG items have been created yet. - self.is_initialized = False - # Generate the tags once. They will be reused for the widget's lifetime. - self.texture_tag = dpg.generate_uuid() - self.image_draw_tag = dpg.generate_uuid() - - self.last_image_size = (1, 1) - self.needs_fit = False - - def create(self): - """Creates the DPG window and plot container. Does NOT create textures or drawings.""" - if dpg.does_item_exist(self.window_tag): return - - with dpg.window(label="Image Viewer", tag=self.window_tag, on_close=self._on_window_close, width=800, height=600): - with dpg.plot(label="Image Plot", no_menus=True, height=-1, width=-1, equal_aspects=True) as self.plot_tag: - self.xaxis_tag = dpg.add_plot_axis(dpg.mvXAxis, no_tick_labels=True, no_gridlines=True) - self.yaxis_tag = dpg.add_plot_axis(dpg.mvYAxis, no_tick_labels=True, no_gridlines=True) - - with dpg.item_handler_registry(tag=f"my_window_handler_{self.window_tag}") as handler: - dpg.add_item_resize_handler(callback=self.fit_image_to_plot, user_data=self) - dpg.bind_item_handler_registry(self.window_tag, handler) - - def on_new_image_data(self, image_data: np.ndarray): - """Handles receiving a processed NumPy array, creating/updating items safely.""" - logging.info("ImageViewer received new processed image data.") - try: - height, width, channels = image_data.shape - self.last_image_size = (width, height) - - # --- THE "REUSE AND RECONFIGURE" LOGIC --- - if not self.is_initialized: - # FIRST RUN: Create the texture and drawing items for the first time. - logging.info("First image load: creating new texture and drawing items.") - dpg.add_dynamic_texture(width, height, image_data, tag=self.texture_tag, parent=self.layout_manager.texture_registry) - dpg.draw_image(self.texture_tag, (0, height), (width, 0), tag=self.image_draw_tag, parent=self.plot_tag) - self.is_initialized = True - else: - # SUBSEQUENT RUNS: Update the existing items. NO DELETION. - logging.info("Subsequent image load: updating existing texture and drawing.") - dpg.set_value(self.texture_tag, image_data) - dpg.configure_item(self.image_draw_tag, pmin=(0, height), pmax=(width, 0)) - - # Set the dirty flag to trigger a fit on the next frame in all cases. - self.needs_fit = True - - dpg.configure_item(self.window_tag, show=True) - dpg.focus_item(self.window_tag) - - except Exception as e: - logging.error(f"ImageViewer failed to process image data: {e}", exc_info=True) - - def fit_image_to_plot(self): - """Calculates and MANUALLY sets the plot's axis limits for the initial fit.""" - # This function is correct and necessary. - plot_width = dpg.get_item_width(self.window_tag) - plot_height = dpg.get_item_height(self.window_tag) - if plot_width <= 0 or plot_height <= 0: return - img_width, img_height = self.last_image_size - if img_width <= 0 or img_height <= 0: return - - plot_aspect = plot_width / plot_height - img_aspect = img_width / img_height - - if img_aspect > plot_aspect: - x_min, x_max = 0, img_width - required_y_span = img_width / plot_aspect - center_y = img_height / 2 - y_min = center_y - required_y_span / 2 - y_max = center_y + required_y_span / 2 - else: - y_min, y_max = 0, img_height - required_x_span = img_height * plot_aspect - center_x = img_width / 2 - x_min = center_x - required_x_span / 2 - x_max = center_x + required_x_span / 2 - - dpg.set_axis_limits(self.xaxis_tag, x_min, x_max) - dpg.set_axis_limits(self.yaxis_tag, y_min, y_max) - - def update(self): - """On update, check if the image needs to be refit.""" - if self.needs_fit: - if dpg.is_item_visible(self.plot_tag): - self.fit_image_to_plot() - self.needs_fit = False \ No newline at end of file diff --git a/widgets/log_widget.py b/widgets/log_widget.py deleted file mode 100644 index da6535c..0000000 --- a/widgets/log_widget.py +++ /dev/null @@ -1,17 +0,0 @@ -import dearpygui.dearpygui as dpg -from .base_widget import BaseWidget - -class LogWidget(BaseWidget): - """A widget to display captured log messages.""" - def create(self): - if dpg.does_item_exist(self.window_tag): return - with dpg.window(label="Log Viewer", tag=self.window_tag, on_close=self._on_window_close, width=500, height=300): - self.text_item_tag = dpg.add_input_text( - multiline=True, readonly=True, width=-1, height=-1, default_value="Log initialized.\n" - ) - - def update_logs(self, log_handler): - """Called every frame to update the text with new logs.""" - if dpg.is_item_visible(self.window_tag): - log_content = log_handler.get_all_logs() - dpg.set_value(self.text_item_tag, log_content) \ No newline at end of file diff --git a/widgets/raw_settings_widget.py b/widgets/raw_settings_widget.py deleted file mode 100644 index 400fb32..0000000 --- a/widgets/raw_settings_widget.py +++ /dev/null @@ -1,36 +0,0 @@ -# in widgets/raw_settings_widget.py -import dearpygui.dearpygui as dpg -from .base_widget import BaseWidget - -class RawSettingsWidget(BaseWidget): - """A widget to control the rawpy processing parameters stored in GlobalState.""" - def create(self): - if dpg.does_item_exist(self.window_tag): return - - with dpg.window(label="RAW Development", tag=self.window_tag, on_close=self._on_window_close): - dpg.add_text("rawpy Postprocessing Settings") - dpg.add_separator() - - # Create UI elements that directly modify the shared state dictionary - dpg.add_checkbox( - label="Auto White Balance", - default_value=self.global_state.raw_params["use_auto_wb"], - callback=lambda s, a, u: self.global_state.raw_params.update({"use_auto_wb": a}) - ) - dpg.add_checkbox( - label="Use Camera White Balance", - default_value=self.global_state.raw_params["use_camera_wb"], - callback=lambda s, a, u: self.global_state.raw_params.update({"use_camera_wb": a}) - ) - dpg.add_checkbox( - label="Disable Auto-Brightness", - default_value=self.global_state.raw_params["no_auto_bright"], - callback=lambda s, a, u: self.global_state.raw_params.update({"no_auto_bright": a}) - ) - dpg.add_radio_button( - label="Output BPS", - items=["8", "16"], - default_value=str(self.global_state.raw_params["output_bps"]), - callback=lambda s, a, u: self.global_state.raw_params.update({"output_bps": int(a)}), - horizontal=True - ) \ No newline at end of file