diff --git a/global_state.py b/global_state.py index 959d644..9ba2dd8 100644 --- a/global_state.py +++ b/global_state.py @@ -5,6 +5,15 @@ 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}'") @@ -12,7 +21,7 @@ class GlobalState: def dispatch(self, event_name: str, *args, **kwargs): """Call all registered callbacks for a given event.""" - logging.info(f"Dispatching event '{event_name}' with data: {kwargs}") + logging.debug(f"Dispatching event '{event_name}' with data: {kwargs}") if event_name in self.listeners: for callback in self.listeners[event_name]: try: diff --git a/negstation_layout.ini b/negstation_layout.ini index 38863ed..81996a0 100644 --- a/negstation_layout.ini +++ b/negstation_layout.ini @@ -21,10 +21,10 @@ Size=400,400 Collapsed=0 [Window][###31] -Pos=0,19 -Size=344,701 +Pos=373,547 +Size=907,173 Collapsed=0 -DockId=0x0000000D,0 +DockId=0x0000000E,0 [Window][###29] Pos=346,572 @@ -36,13 +36,13 @@ DockId=0x00000010,0 Pos=310,19 Size=970,532 Collapsed=0 -DockId=0x0000000B,0 +DockId=0x0000000D,0 [Window][###49] Pos=346,19 Size=934,551 Collapsed=0 -DockId=0x0000000B,0 +DockId=0x0000000D,0 [Window][###32] Pos=0,19 @@ -51,35 +51,79 @@ Collapsed=0 DockId=0x00000011,0 [Window][###51] -Pos=310,19 -Size=970,532 +Pos=290,19 +Size=990,550 Collapsed=0 -DockId=0x0000000B,0 +DockId=0x0000000D,0 [Window][###73] Pos=310,19 Size=970,532 Collapsed=0 -DockId=0x0000000B,0 +DockId=0x0000000D,0 + +[Window][###33] +Pos=0,19 +Size=371,701 +Collapsed=0 +DockId=0x00000019,0 + +[Window][###55] +Pos=0,497 +Size=288,223 +Collapsed=0 +DockId=0x00000016,0 + +[Window][###78] +Pos=0,464 +Size=288,256 +Collapsed=0 +DockId=0x00000018,0 + +[Window][###57] +Pos=0,674 +Size=371,355 +Collapsed=0 +DockId=0x0000001A,0 + +[Window][###53] +Pos=373,19 +Size=907,526 +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=0x00000011 Parent=0x7C6B3D9B SizeRef=308,701 Selected=0x2554AADD - DockNode ID=0x00000012 Parent=0x7C6B3D9B SizeRef=970,701 Split=X - DockNode ID=0x0000000D Parent=0x00000012 SizeRef=344,701 Selected=0x62F4D00D - DockNode ID=0x0000000E Parent=0x00000012 SizeRef=791,701 Split=X - DockNode ID=0x00000009 Parent=0x0000000E SizeRef=319,701 Selected=0x5F94F9BD - DockNode ID=0x0000000A Parent=0x0000000E 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 CentralNode=1 Selected=0x89CD1AA0 - 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=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 diff --git a/negstation_widgets.json b/negstation_widgets.json index fda2c96..170a414 100644 --- a/negstation_widgets.json +++ b/negstation_widgets.json @@ -16,5 +16,11 @@ "config": { "label": "ImageViewerWidget" } + }, + { + "widget_type": "RawSettingsWidget", + "config": { + "label": "RawSettingsWidget" + } } ] \ No newline at end of file diff --git a/raw_processor.py b/raw_processor.py new file mode 100644 index 0000000..b4f84f6 --- /dev/null +++ b/raw_processor.py @@ -0,0 +1,60 @@ +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 index 9feebdb..49205a5 100644 --- a/ui.py +++ b/ui.py @@ -7,6 +7,7 @@ import inspect from collections import deque import global_state +import raw_processor from widgets.base_widget import BaseWidget class DpgLogHandler(logging.Handler): @@ -33,6 +34,9 @@ class LayoutManager: 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...") @@ -102,8 +106,6 @@ class LayoutManager: layout_manager = LayoutManager() layout_manager.discover_and_register_widgets() - layout_manager.texture_registry = dpg.add_texture_registry() - with dpg.viewport_menu_bar(): with dpg.menu(label="File"): dpg.add_menu_item(label="Save Layout", callback=layout_manager.save_layout) @@ -127,4 +129,4 @@ class LayoutManager: layout_manager.update_all_widgets() dpg.render_dearpygui_frame() - dpg.destroy_context() \ No newline at end of file + dpg.destroy_context() diff --git a/widgets/image_viewer_widget.py b/widgets/image_viewer_widget.py index 30afbd7..45a704a 100644 --- a/widgets/image_viewer_widget.py +++ b/widgets/image_viewer_widget.py @@ -2,75 +2,78 @@ 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 implements - programmatic pan and zoom by handling mouse events manually and repeatedly - calling set_axis_limits, as this is the only reliable method to achieve - both an initial fit and subsequent user interaction. + 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) - # Subscribe to the event that will provide new images - self.global_state.subscribe("NEW_IMAGE_CAPTURED", self.on_new_image) - - # Register for updates to handle the dirty flag and panning + self.global_state.subscribe("PROCESSED_IMAGE_READY", self.on_new_image_data) layout_manager.updating_widgets.append("ImageViewerWidget") - # Initialize tags and state variables - self.texture_tag = None - self.image_draw_tag = None - self.last_image_size = (1, 1) # width, height - self.needs_fit = False # The "dirty flag" for deferred fitting + # --- 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, plot, and the necessary mouse handlers.""" + """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): - # The plot is our canvas. `equal_aspects` is critical for preventing distortion. 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) - def on_new_image(self, image_path: str): - """Loads image data, deletes/recreates the drawing, and flags for a refit.""" - if not image_path: return - logging.info(f"ImageViewer received new image: {image_path}") + 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: - width, height, channels, data = dpg.load_image(image_path) + height, width, channels = image_data.shape self.last_image_size = (width, height) - # Create or update the texture in the registry - if self.texture_tag is not None: - dpg.delete_item(self.texture_tag) - - self.texture_tag = dpg.generate_uuid() - dpg.add_static_texture(width, height, data, tag=self.texture_tag, parent=self.layout_manager.texture_registry) + # --- 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)) - if self.image_draw_tag and dpg.does_item_exist(self.image_draw_tag): - dpg.delete_item(self.image_draw_tag) - - self.image_draw_tag = dpg.draw_image(self.texture_tag, (0, height), (width, 0), parent=self.plot_tag) - - # Set the dirty flag to trigger a fit on the next frame + # 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.configure_item(self.plot_tag, label=image_path) dpg.focus_item(self.window_tag) except Exception as e: - logging.error(f"ImageViewer failed to process image '{image_path}': {e}", exc_info=True) + 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 diff --git a/widgets/raw_settings_widget.py b/widgets/raw_settings_widget.py new file mode 100644 index 0000000..400fb32 --- /dev/null +++ b/widgets/raw_settings_widget.py @@ -0,0 +1,36 @@ +# 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