Simple picture output
This commit is contained in:
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"python.REPL.enableREPLSmartSend": false
|
||||
}
|
@ -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}")
|
@ -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
|
||||
|
||||
|
@ -10,5 +10,11 @@
|
||||
"config": {
|
||||
"label": "CamControlWidget"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widget_type": "ImageViewerWidget",
|
||||
"config": {
|
||||
"label": "ImageViewerWidget"
|
||||
}
|
||||
}
|
||||
]
|
2
ui.py
2
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)
|
||||
|
@ -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
|
101
widgets/image_viewer_widget.py
Normal file
101
widgets/image_viewer_widget.py
Normal 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
|
Reference in New Issue
Block a user