Simple picture output

This commit is contained in:
2025-07-28 18:31:42 +02:00
parent 7e729618de
commit 26425c1bfd
7 changed files with 353 additions and 23 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"python.REPL.enableREPLSmartSend": false
}

View File

@ -1,3 +1,22 @@
import logging
from collections import defaultdict
class GlobalState:
def __init__(self):
pass
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}")

View File

@ -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

View File

@ -10,5 +10,11 @@
"config": {
"label": "CamControlWidget"
}
},
{
"widget_type": "ImageViewerWidget",
"config": {
"label": "ImageViewerWidget"
}
}
]

2
ui.py
View File

@ -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)

View File

@ -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}'))
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

View File

@ -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