diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..642ff51 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.REPL.enableREPLSmartSend": false +} \ No newline at end of file diff --git a/global_state.py b/global_state.py index 9a638d2..959d644 100644 --- a/global_state.py +++ b/global_state.py @@ -1,3 +1,22 @@ +import logging +from collections import defaultdict + class GlobalState: def __init__(self): - pass \ No newline at end of file + self.listeners = defaultdict(list) + + 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.info(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/negstation_layout.ini b/negstation_layout.ini index 4ac5569..38863ed 100644 --- a/negstation_layout.ini +++ b/negstation_layout.ini @@ -4,34 +4,82 @@ Size=1280,701 Collapsed=0 [Window][###28] -Pos=321,420 -Size=959,300 +Pos=433,517 +Size=847,203 Collapsed=0 DockId=0x00000002,0 [Window][###30] -Pos=0,19 -Size=319,701 +Pos=310,553 +Size=970,167 Collapsed=0 -DockId=0x0000000B,0 +DockId=0x0000000C,0 [Window][Debug##Default] Pos=60,60 Size=400,400 Collapsed=0 -[Docking][Data] -DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0,19 Size=1280,701 Split=X - DockNode ID=0x0000000B Parent=0x7C6B3D9B SizeRef=319,701 Selected=0x5F94F9BD - DockNode ID=0x0000000C Parent=0x7C6B3D9B SizeRef=959,701 Split=X - DockNode ID=0x00000009 Parent=0x0000000C SizeRef=319,701 Selected=0x5F94F9BD - DockNode ID=0x0000000A Parent=0x0000000C 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,399 CentralNode=1 - DockNode ID=0x00000002 Parent=0x00000004 SizeRef=979,300 Selected=0xA4B861D9 +[Window][###31] +Pos=0,19 +Size=344,701 +Collapsed=0 +DockId=0x0000000D,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=0x0000000B,0 + +[Window][###49] +Pos=346,19 +Size=934,551 +Collapsed=0 +DockId=0x0000000B,0 + +[Window][###32] +Pos=0,19 +Size=308,701 +Collapsed=0 +DockId=0x00000011,0 + +[Window][###51] +Pos=310,19 +Size=970,532 +Collapsed=0 +DockId=0x0000000B,0 + +[Window][###73] +Pos=310,19 +Size=970,532 +Collapsed=0 +DockId=0x0000000B,0 + +[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 diff --git a/negstation_widgets.json b/negstation_widgets.json index 4ab4e08..fda2c96 100644 --- a/negstation_widgets.json +++ b/negstation_widgets.json @@ -10,5 +10,11 @@ "config": { "label": "CamControlWidget" } + }, + { + "widget_type": "ImageViewerWidget", + "config": { + "label": "ImageViewerWidget" + } } ] \ No newline at end of file diff --git a/ui.py b/ui.py index 756843f..9feebdb 100644 --- a/ui.py +++ b/ui.py @@ -102,6 +102,8 @@ 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) diff --git a/widgets/camcontrol_widget.py b/widgets/camcontrol_widget.py index 397ce1a..f42e949 100644 --- a/widgets/camcontrol_widget.py +++ b/widgets/camcontrol_widget.py @@ -4,6 +4,8 @@ import gphoto2 as gp import logging import threading import queue +import tempfile +import os from .base_widget import BaseWidget @@ -18,7 +20,7 @@ class CamControlWidget(BaseWidget): # 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() @@ -26,6 +28,8 @@ class CamControlWidget(BaseWidget): # 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 @@ -58,6 +62,23 @@ class CamControlWidget(BaseWidget): 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 @@ -78,11 +99,51 @@ class CamControlWidget(BaseWidget): # 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 @@ -101,7 +162,6 @@ class CamControlWidget(BaseWidget): try: # Autodetect returns a list of (name, port) tuples camera_list = list(gp.Camera.autodetect()) - logger.debug(f"Cameras found: {str(camera_list)}") if not camera_list: self.result_queue.put(('STATUS', 'No cameras found.')) self.result_queue.put(('CAMERAS_DETECTED', [])) @@ -151,7 +211,98 @@ class CamControlWidget(BaseWidget): 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}')) \ No newline at end of file + 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 new file mode 100644 index 0000000..30afbd7 --- /dev/null +++ b/widgets/image_viewer_widget.py @@ -0,0 +1,101 @@ +# in widgets/image_viewer_widget.py +import dearpygui.dearpygui as dpg +from .base_widget import BaseWidget +import logging + +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. + """ + 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 + 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 + + def create(self): + """Creates the DPG window, plot, and the necessary mouse handlers.""" + 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}") + try: + width, height, channels, data = dpg.load_image(image_path) + 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) + + 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 + 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) + + def fit_image_to_plot(self): + """Calculates and MANUALLY sets the plot's axis limits for the initial fit.""" + 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