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

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