New design

This commit is contained in:
2025-07-30 22:00:29 +02:00
parent 47a13d3083
commit d0cbc63859
22 changed files with 580 additions and 842 deletions

View File

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

View File

@ -1,31 +0,0 @@
import logging
from collections import defaultdict
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}'")
self.listeners[event_name].append(callback)
def dispatch(self, event_name: str, *args, **kwargs):
"""Call all registered callbacks for a given event."""
logging.debug(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}")

BIN
img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import ui
if __name__=="__main__": import negstation
ui.LayoutManager.run()
if __name__ == "__main__":
negstation.NegStation().run()

1
negstation/__init__.py Normal file
View File

@ -0,0 +1 @@
from .negstation import EditorManager as NegStation

44
negstation/event_bus.py Normal file
View File

@ -0,0 +1,44 @@
import threading
import queue
import logging
class EventBus:
def __init__(self, logger: logging.Logger):
self.logger = logger
self.subscribers = {}
self.event_queue = queue.Queue()
self.main_queue = queue.Queue()
threading.Thread(target=self._dispatch_loop, daemon=True).start()
def subscribe(self, event_type: str, callback: callable, main_thread: bool = False):
self.logger.debug(f"Subscribed to {event_type}")
self.subscribers.setdefault(event_type, []).append((callback, main_thread))
def publish_deferred(self, event_type: str, data=None):
self.logger.debug(f"publish {event_type}")
self.event_queue.put((event_type, data))
def _dispatch_loop(self):
while True:
event_type, data = self.event_queue.get()
self.logger.debug(f"Dispatching {event_type}")
for callback, main_thread in self.subscribers.get(event_type, []):
if main_thread:
self.main_queue.put((callback, data))
else:
try:
callback(data)
except Exception as e:
self.logger.error(
f"Error in background handler '{
event_type}': {e}"
)
def process_main_queue(self):
while True:
try:
callback, data = self.main_queue.get_nowait()
callback(data)
except queue.Empty:
break

View File

@ -0,0 +1,18 @@
import numpy as np
from .event_bus import EventBus
class ImagePipeline:
def __init__(self, bus: EventBus):
self.bus = bus
self.stages = {}
def add_stage(self, name: str, img: np.ndarray):
self.stages[name] = img.astype(np.float32)
# notify widgets of updated stage list and data
self.bus.publish_deferred("pipeline_stages", list(self.stages.keys()))
self.bus.publish_deferred("pipeline_stage", (name, self.stages[name]))
def get_stage(self, name: str):
return self.stages.get(name)

View File

@ -0,0 +1,43 @@
import dearpygui.dearpygui as dpg
import logging
import json
import os
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..negstation import EditorManager
class LayoutManager:
INI_PATH = "negstation_layout.ini"
WIDGET_DATA_PATH = "negstation_widgets.json"
def __init__(self, manager: "EditorManager", logger: logging.Logger):
self.manager = manager
self.logger = logger
def save_layout(self):
self.logger.info("Saving layout...")
dpg.save_init_file(self.INI_PATH)
widget_data = [
{"widget_type": type(w).__name__, "config": w.get_config()}
for w in self.manager.widgets
]
with open(self.WIDGET_DATA_PATH, "w") as f:
json.dump(widget_data, f, indent=4)
self.logger.info("Layout saved successfully.")
def load_layout(self):
self.logger.info("Loading layout...")
if not os.path.exists(self.WIDGET_DATA_PATH):
return
with open(self.WIDGET_DATA_PATH, "r") as f:
widget_data = json.load(f)
for data in widget_data:
if data.get("widget_type") in self.manager.widget_classes:
self.manager._add_widget(widget_type=data.get("widget_type"))
if os.path.exists(self.INI_PATH):
dpg.configure_app(init_file=self.INI_PATH)
self.logger.info(f"Applied UI layout from {self.INI_PATH}")

110
negstation/negstation.py Normal file
View File

@ -0,0 +1,110 @@
import dearpygui.dearpygui as dpg
import logging
import os
import importlib
import inspect
import sys
from pathlib import Path
from importlib.machinery import SourceFileLoader
from .event_bus import EventBus
from .image_pipeline import ImagePipeline
from .layout_manager import LayoutManager
from .widgets.base_widget import BaseWidget
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
class EditorManager:
def __init__(self):
dpg.create_context()
self.bus = EventBus(logger)
self.pipeline = ImagePipeline(self.bus)
self.layout_manager = LayoutManager(self, logger)
self.widgets = []
self.widget_classes = {}
def _discover_and_register_widgets(self, directory="widgets"):
logging.info(f"Discovering widgets in '{directory}' directory...")
dir_path = Path(directory)
if not dir_path.is_dir():
logging.error(f"Path '{directory}' is not a directory")
return
parent = str(dir_path.parent.resolve())
if parent not in sys.path:
sys.path.insert(0, parent)
pkg_name = dir_path.name # e.g. 'widgets'
# 1) Load the packages own BaseWidget
try:
base_mod = importlib.import_module(f"{pkg_name}.base_widget")
ModuleBaseWidget = getattr(base_mod, "BaseWidget")
except Exception:
ModuleBaseWidget = None
for py_file in dir_path.glob("*.py"):
if py_file.name.startswith("__"):
continue
module_name = f"{pkg_name}.{py_file.stem}"
try:
module = importlib.import_module(module_name)
for name, cls in inspect.getmembers(module, inspect.isclass):
# 2) Use the BaseWidget defined *in* widgets/base_widget.py
if (
ModuleBaseWidget
and issubclass(cls, ModuleBaseWidget)
and cls is not ModuleBaseWidget
):
logging.info(f" -> Found and registered widget: {name}")
self._register_widget(name, cls)
except Exception as e:
logging.error(f"Failed to import widget '{py_file.name}': {e}")
def _register_widget(self, name: str, widget_class: object):
if name in self.widget_classes:
logging.warning(f"Widget '{name}' is already registered. Overwriting.")
self.widget_classes[name] = widget_class
def _add_widget(self, widget_type: str):
WidgetClass = self.widget_classes[widget_type]
instance = WidgetClass(self, logger)
self.widgets.append(instance)
instance.create()
def setup(self):
self._discover_and_register_widgets(
f"{os.path.dirname(os.path.realpath(__file__))}/widgets"
)
self.layout_manager.load_layout()
dpg.create_viewport(title="NegStation", width=960, height=400)
dpg.configure_app(docking=True, docking_space=True)
with dpg.viewport_menu_bar():
with dpg.menu(label="File"):
dpg.add_menu_item(
label="Save Layout", callback=self.layout_manager.save_layout
)
with dpg.menu(label="View"):
for widget_name in sorted(self.widget_classes.keys()):
dpg.add_menu_item(
label=self.widget_classes[widget_name].name,
callback=lambda s, a, ud: self._add_widget(ud),
user_data=widget_name,
)
def run(self):
self.setup()
dpg.setup_dearpygui()
dpg.show_viewport()
while dpg.is_dearpygui_running():
self.bus.process_main_queue()
for w in self.widgets:
w.update()
dpg.render_dearpygui_frame()
dpg.destroy_context()

View File

@ -0,0 +1,33 @@
import dearpygui.dearpygui as dpg
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..negstation import EditorManager
class BaseWidget:
name: str = "BaseWidget"
def __init__(self, manager: "EditorManager", logger: logging.Logger):
self.manager = manager
self.logger = logger
self.window_tag = dpg.generate_uuid()
self.config = {}
def create(self):
raise NotImplementedError
def update(self):
pass
def get_config(self):
return self.config
def _on_window_close(self):
try:
dpg.delete_item(self.window_tag)
self.manager.widgets.remove(self)
except ValueError:
pass

View File

@ -0,0 +1,59 @@
import dearpygui.dearpygui as dpg
from PIL import Image
import numpy as np
from .base_widget import BaseWidget
class OpenImageWidget(BaseWidget):
name: str = "Open Image"
def __init__(self, manager, logger, stage_out="loaded_image"):
super().__init__(manager, logger)
self.stage_out = stage_out
self.dialog_tag = dpg.generate_uuid()
self.output_tag = dpg.generate_uuid()
def create(self):
with dpg.file_dialog(
directory_selector=False,
show=False,
callback=self._on_file_selected,
tag=self.dialog_tag,
height=300,
width=400,
):
dpg.add_file_extension(".*")
with dpg.window(
label="Open Image File",
tag=self.window_tag,
width=300,
height=150,
on_close=self._on_window_close,
):
dpg.add_input_text(label="Stage Output Name", tag=self.output_tag)
dpg.add_button(label="Open File...", callback=self._on_open_file)
dpg.set_value(self.output_tag, self.stage_out)
def _on_open_file(self):
dpg.configure_item(self.dialog_tag, show=True)
def _on_file_selected(self, sender, app_data):
# app_data[0] is dict with selected file paths
selection = (
f"{app_data['current_path']}/{list(app_data['selections'].keys())[0]}"
if isinstance(app_data, dict)
else None
)
if not selection:
return
self.logger.info(f"Selected file '{selection}'")
try:
img = Image.open(selection).convert("RGBA")
arr = np.asarray(img).astype(np.float32) / 255.0 # normalize to [0,1]
# Publish into pipeline
self.manager.pipeline.add_stage(dpg.get_value(self.output_tag), arr)
except Exception as e:
self.logger.error(f"Failed to load image {selection}: {e}")

View File

@ -0,0 +1,226 @@
import dearpygui.dearpygui as dpg
import numpy as np
from .base_widget import BaseWidget
# class StageViewerWidget(BaseWidget):
# name: str = "Stage Viewer"
# def __init__(self, manager, logger):
# super().__init__(manager, logger)
# # ensure texture registry
# if not hasattr(manager, "texture_registry"):
# manager.texture_registry = dpg.add_texture_registry(tag=dpg.generate_uuid())
# self.registry = manager.texture_registry
# self.stages = []
# self.current = None
# self.texture_tag = dpg.generate_uuid()
# self.image_item = None
# manager.bus.subscribe("pipeline_stages", self.on_stage_list, main_thread=True)
# manager.bus.subscribe("pipeline_stage", self.on_stage_data, main_thread=True)
# def create(self):
# with dpg.window(
# label="Stage Viewer",
# tag=self.window_tag,
# width=400,
# height=400,
# on_close=self._on_window_close,
# ):
# self.combo = dpg.add_combo(label="Stage", items=[], callback=self.on_select)
# # placeholder 1×1 texture
# dpg.add_dynamic_texture(
# width=1,
# height=1,
# default_value=[0.0, 0.0, 0.0, 0.0],
# tag=self.texture_tag,
# parent=self.registry,
# )
# self.image_item = dpg.add_image(self.texture_tag)
# def on_stage_list(self, stages):
# self.stages = stages
# dpg.configure_item(self.combo, items=stages)
# if not self.current and stages:
# self.current = stages[0]
# dpg.set_value(self.combo, self.current)
# def on_select(self, sender, stage_name):
# self.current = stage_name
# img = self.manager.pipeline.get_stage(stage_name)
# if img is not None:
# self.update_texture(img)
# def on_stage_data(self, data):
# name, img = data
# if name == self.current:
# self.update_texture(img)
# def update_texture(self, img: np.ndarray):
# h, w, _ = img.shape
# flat = img.flatten().tolist()
# # recreate texture at correct size
# if dpg.does_item_exist(self.texture_tag):
# dpg.delete_item(self.texture_tag)
# dpg.add_dynamic_texture(
# width=w,
# height=h,
# default_value=flat,
# tag=self.texture_tag,
# parent=self.registry,
# )
# # determine available window size
# win_w, win_h = dpg.get_item_rect_size(self.window_tag)
# # reserve space for combo box (approx 30px)
# available_h = max(win_h - 30, 1)
# # compute scale to fit
# scale = min(win_w / w, available_h / h)
# disp_w = int(w * scale)
# disp_h = int(h * scale)
# # update image widget
# dpg.configure_item(
# self.image_item, texture_tag=self.texture_tag, width=disp_w, height=disp_h
# )
class StageViewerWidget(BaseWidget):
"""
A robust, zoomable stage viewer using a Dear PyGui Plot to display
dynamic textures without ever deleting them—avoiding segfaults.
"""
name = "Stage Viewer"
def __init__(self, manager, logger):
super().__init__(manager, logger)
self.manager.bus.subscribe("pipeline_stages", self._on_stage_list, main_thread=True)
self.manager.bus.subscribe("pipeline_stage", self._on_stage_data, main_thread=True)
# onetime flags and tags
self._initialized = False
self.texture_tag = dpg.generate_uuid()
self.image_draw_tag = dpg.generate_uuid()
self.plot_tag = dpg.generate_uuid()
self.xaxis_tag = dpg.generate_uuid()
self.yaxis_tag = dpg.generate_uuid()
self.last_size = (1, 1)
self.current_stage = None
self.needs_fit = False
def create(self):
if dpg.does_item_exist(self.window_tag):
return
# ensure a texture registry exists
if not hasattr(self.manager, "texture_registry"):
self.manager.texture_registry = dpg.add_texture_registry(tag=dpg.generate_uuid())
with dpg.window(label="Stage Viewer",
tag=self.window_tag,
on_close=self._on_window_close,
width=600, height=600):
# stage selector
self.combo = dpg.add_combo(label="Stage", items=[], callback=self._on_select)
# plot container, equal_aspects ensures no distortion
with dpg.plot(label="Image Plot", tag=self.plot_tag, height=-1, width=-1, equal_aspects=True):
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)
# resize handler to refit on window/plot size changes
with dpg.item_handler_registry() as handler:
dpg.add_item_resize_handler(callback=lambda s,a,u: self._fit_image(), user_data=None)
dpg.bind_item_handler_registry(self.window_tag, handler)
def _on_stage_list(self, stages):
dpg.configure_item(self.combo, items=stages)
if not self.current_stage and stages:
self.current_stage = stages[0]
dpg.set_value(self.combo, self.current_stage)
def _on_select(self, sender, stage_name):
self.current_stage = stage_name
img = self.manager.pipeline.get_stage(stage_name)
if img is not None:
self._update_image(img)
def _on_stage_data(self, data):
name, img = data
if name == self.current_stage:
self._update_image(img)
def _update_image(self, img: np.ndarray):
h, w, _ = img.shape
self.last_size = (w, h)
# First time: create texture & draw-image inside the plot
if not self._initialized:
dpg.add_dynamic_texture(w, h, img, tag=self.texture_tag,
parent=self.manager.texture_registry)
dpg.draw_image(self.texture_tag,
pmin=(0, h), pmax=(w, 0),
tag=self.image_draw_tag,
parent=self.plot_tag)
self._initialized = True
else:
# Subsequent updates: just set_value and adjust draw coords
dpg.set_value(self.texture_tag, img)
dpg.configure_item(self.image_draw_tag,
pmin=(0, self.last_size[1]),
pmax=(self.last_size[0], 0))
# show & focus window
dpg.configure_item(self.window_tag, show=True)
dpg.focus_item(self.window_tag)
# flag to refit axes
self.needs_fit = True
def _fit_image(self):
"""Adjust plot axes so the image fills the available space."""
if not self._initialized or not self.needs_fit:
return
# get plot area size
plot_w = dpg.get_item_width(self.window_tag)
plot_h = dpg.get_item_height(self.window_tag) - 30 # reserve combo height
if plot_w <= 0 or plot_h <= 0:
return
img_w, img_h = self.last_size
if img_w <= 0 or img_h <= 0:
return
plot_aspect = plot_w / plot_h
img_aspect = img_w / img_h
if img_aspect > plot_aspect:
x_min, x_max = 0, img_w
needed_h = img_w / plot_aspect
center_y = img_h / 2
y_min = center_y - needed_h / 2
y_max = center_y + needed_h / 2
else:
y_min, y_max = 0, img_h
needed_w = img_h * plot_aspect
center_x = img_w / 2
x_min = center_x - needed_w / 2
x_max = center_x + needed_w / 2
dpg.set_axis_limits(self.xaxis_tag, x_min, x_max)
dpg.set_axis_limits(self.yaxis_tag, y_min, y_max)
self.needs_fit = False
def update(self):
# If we flagged a refit, do it now
if self.needs_fit:
self._fit_image()

View File

@ -1,129 +1,63 @@
[Window][WindowOverViewport_11111111] [Window][WindowOverViewport_11111111]
Pos=0,19 Pos=0,19
Size=1280,701 Size=960,381
Collapsed=0 Collapsed=0
[Window][###28]
Pos=433,517
Size=847,203
Collapsed=0
DockId=0x00000002,0
[Window][###30]
Pos=310,553
Size=970,167
Collapsed=0
DockId=0x0000000C,0
[Window][Debug##Default] [Window][Debug##Default]
Pos=60,60 Pos=60,60
Size=400,400 Size=400,400
Collapsed=0 Collapsed=0
[Window][###31] [Window][###28]
Pos=373,547 Pos=143,94
Size=907,173 Size=400,400
Collapsed=0 Collapsed=0
DockId=0x0000000E,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=0x0000000D,0
[Window][###49]
Pos=346,19
Size=934,551
Collapsed=0
DockId=0x0000000D,0
[Window][###32]
Pos=0,19
Size=308,701
Collapsed=0
DockId=0x00000011,0
[Window][###51]
Pos=290,19
Size=990,550
Collapsed=0
DockId=0x0000000D,0
[Window][###73]
Pos=310,19
Size=970,532
Collapsed=0
DockId=0x0000000D,0
[Window][###33] [Window][###33]
Pos=-2,48
Size=300,150
Collapsed=0
[Window][###37]
Pos=580,56
Size=400,400
Collapsed=0
[Window][###22]
Pos=0,19 Pos=0,19
Size=371,701 Size=207,381
Collapsed=0 Collapsed=0
DockId=0x00000019,0 DockId=0x00000005,0
[Window][###55] [Window][###27]
Pos=0,497 Pos=498,19
Size=288,223 Size=462,381
Collapsed=0 Collapsed=0
DockId=0x00000016,0 DockId=0x00000004,0
[Window][###78] [Window][###31]
Pos=0,464 Pos=516,19
Size=288,256 Size=444,381
Collapsed=0 Collapsed=0
DockId=0x00000018,0 DockId=0x00000002,0
[Window][###57] [Window][###41]
Pos=0,674 Pos=185,19
Size=371,355 Size=821,577
Collapsed=0 Collapsed=0
DockId=0x0000001A,0 DockId=0x00000003,0
[Window][###53] [Window][###38]
Pos=373,19 Pos=249,45
Size=907,526 Size=192,274
Collapsed=0 Collapsed=0
DockId=0x0000000D,0
[Window][###59]
Pos=0,19
Size=371,701
Collapsed=0
DockId=0x00000019,1
[Docking][Data] [Docking][Data]
DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0,19 Size=1280,701 Split=X DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0,19 Size=960,381 Split=X
DockNode ID=0x00000013 Parent=0x7C6B3D9B SizeRef=371,701 Split=Y Selected=0x1834836D DockNode ID=0x00000005 Parent=0x7C6B3D9B SizeRef=207,381 Selected=0xEE087978
DockNode ID=0x00000015 Parent=0x00000013 SizeRef=288,476 Split=Y Selected=0x1834836D DockNode ID=0x00000006 Parent=0x7C6B3D9B SizeRef=751,381 Split=X
DockNode ID=0x00000017 Parent=0x00000015 SizeRef=288,443 Split=Y Selected=0x1834836D DockNode ID=0x00000001 Parent=0x00000006 SizeRef=514,381 Split=X
DockNode ID=0x00000019 Parent=0x00000017 SizeRef=288,453 Selected=0x1834836D DockNode ID=0x00000003 Parent=0x00000001 SizeRef=287,381 CentralNode=1
DockNode ID=0x0000001A Parent=0x00000017 SizeRef=288,246 Selected=0x3BEDC6B0 DockNode ID=0x00000004 Parent=0x00000001 SizeRef=462,381 Selected=0x26E8F608
DockNode ID=0x00000018 Parent=0x00000015 SizeRef=288,256 Selected=0xF475F06A DockNode ID=0x00000002 Parent=0x00000006 SizeRef=444,381 Selected=0x62F4D00D
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

View File

@ -1,26 +1,10 @@
[ [
{ {
"widget_type": "LogWidget", "widget_type": "OpenImageWidget",
"config": { "config": {}
"label": "LogWidget"
}
}, },
{ {
"widget_type": "CamControlWidget", "widget_type": "StageViewerWidget",
"config": { "config": {}
"label": "CamControlWidget"
}
},
{
"widget_type": "ImageViewerWidget",
"config": {
"label": "ImageViewerWidget"
}
},
{
"widget_type": "RawSettingsWidget",
"config": {
"label": "RawSettingsWidget"
}
} }
] ]

View File

@ -1,60 +0,0 @@
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)

132
ui.py
View File

@ -1,132 +0,0 @@
import dearpygui.dearpygui as dpg
import os
import json
import logging
import importlib
import inspect
from collections import deque
import global_state
import raw_processor
from widgets.base_widget import BaseWidget
class DpgLogHandler(logging.Handler):
def __init__(self):
super().__init__()
self.log_queue = deque(maxlen=200)
def emit(self, record):
msg = self.format(record)
self.log_queue.append(msg)
print(msg)
def get_all_logs(self):
return "\n".join(self.log_queue)
log_handler = DpgLogHandler()
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[log_handler])
INI_PATH = "negstation_layout.ini"
WIDGET_DATA_PATH = "negstation_widgets.json"
class LayoutManager:
def __init__(self):
self.active_widgets = {}
self.widget_classes = {}
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...")
for filename in os.listdir(directory):
if filename.endswith(".py") and not filename.startswith("__"):
module_name = f"{directory}.{filename[:-3]}"
try:
# Dynamically import the module
module = importlib.import_module(module_name)
# Find all classes in the module that are subclasses of BaseWidget
for name, cls in inspect.getmembers(module, inspect.isclass):
if issubclass(cls, BaseWidget) and cls is not BaseWidget:
logging.info(f" -> Found and registered widget: {name}")
self.register_widget(name, cls)
except ImportError as e:
logging.error(f"Failed to import widget module {module_name}: {e}")
def register_widget(self, name: str, widget_class: object):
"""Adds a widget class to the registry."""
if name in self.widget_classes:
logging.warning(f"Widget '{name}' is already registered. Overwriting.")
self.widget_classes[name] = widget_class
def add_widget(self, widget_type: str):
if widget_type not in self.widget_classes: logging.error(f"Unknown widget type '{widget_type}'"); return
if widget_type in self.active_widgets:
widget_tag = self.active_widgets[widget_type].window_tag
if dpg.does_item_exist(widget_tag):
logging.info(f"Showing existing widget: {widget_type}"); dpg.configure_item(widget_tag, show=True); dpg.focus_item(widget_tag)
return
config = {"label": widget_type}
WidgetClass = self.widget_classes[widget_type]
widget_instance = WidgetClass(widget_type, config, self, self.global_state)
logging.info(f"Creating new widget of type: {widget_type}")
self.active_widgets[widget_type] = widget_instance
widget_instance.create()
def save_layout(self):
logging.info("Saving layout..."); dpg.save_init_file(INI_PATH)
widget_data = [{"widget_type": w_type, "config": w.get_config()} for w_type, w in self.active_widgets.items()]
with open(WIDGET_DATA_PATH, 'w') as f: json.dump(widget_data, f, indent=4)
logging.info("Layout saved successfully.")
def load_layout(self):
logging.info("Loading layout...");
if not os.path.exists(WIDGET_DATA_PATH): return
with open(WIDGET_DATA_PATH, 'r') as f: widget_data = json.load(f)
for data in widget_data:
if data.get("widget_type") in self.widget_classes: self.add_widget(widget_type=data.get("widget_type"))
if os.path.exists(INI_PATH): dpg.configure_app(init_file=INI_PATH); logging.info(f"Applied UI layout from {INI_PATH}")
def update_all_widgets(self):
"""Calls per-frame update methods on widgets that need it."""
if "LogWidget" in self.active_widgets:
# We need to pass the handler to the update method
self.active_widgets["LogWidget"].update_logs(log_handler)
for w in self.updating_widgets:
if w in self.active_widgets:
self.active_widgets[w].update()
@staticmethod
def run():
dpg.create_context()
dpg.create_viewport(title='Dynamic Docking Layout with Menu', width=1280, height=720)
layout_manager = LayoutManager()
layout_manager.discover_and_register_widgets()
with dpg.viewport_menu_bar():
with dpg.menu(label="File"):
dpg.add_menu_item(label="Save Layout", callback=layout_manager.save_layout)
with dpg.menu(label="View"):
for widget_name in sorted(layout_manager.widget_classes.keys()):
dpg.add_menu_item(
label=f"Show {widget_name}",
callback=lambda s, a, ud: layout_manager.add_widget(ud),
user_data=widget_name
)
dpg.configure_app(docking=True, docking_space=True)
dpg.setup_dearpygui()
layout_manager.load_layout()
dpg.show_viewport()
while dpg.is_dearpygui_running():
layout_manager.update_all_widgets()
dpg.render_dearpygui_frame()
dpg.destroy_context()

View File

@ -1,24 +0,0 @@
import dearpygui.dearpygui as dpg
import logging
class BaseWidget:
"""A base class to handle common functionality for all widgets."""
def __init__(self, widget_type: str, config: dict, layout_manager, global_state):
self.widget_type = widget_type
self.config = config
self.layout_manager = layout_manager
self.global_state = global_state
self.window_tag = f"widget_win_{self.widget_type}"
def create(self):
raise NotImplementedError
def get_config(self) -> dict:
return self.config
def update(self):
raise NotImplementedError
def _on_window_close(self, sender, app_data, user_data):
logging.info(f"Hiding widget: {self.widget_type}")
dpg.configure_item(self.window_tag, show=False)

View File

@ -1,308 +0,0 @@
# in widgets/camera_widget.py
import dearpygui.dearpygui as dpg
import gphoto2 as gp
import logging
import threading
import queue
import tempfile
import os
from .base_widget import BaseWidget
# Set up a logger specific to this widget for clear debugging
logger = logging.getLogger(__name__)
logger.setLevel("DEBUG")
class CamControlWidget(BaseWidget):
def __init__(self, widget_type: str, config: dict, layout_manager, app_state):
super().__init__(widget_type, config, layout_manager, app_state)
layout_manager.updating_widgets.append("CamControlWidget")
# 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()
# 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
with dpg.window(label="Camera Control", tag=self.window_tag, on_close=self._on_window_close, width=320, height=160):
# This group contains all controls visible when disconnected
with dpg.group(tag=f"connection_group_{self.widget_type}") as self.connection_group_tag:
dpg.add_button(label="Detect Cameras", callback=lambda: self.command_queue.put(('DETECT', None)), width=-1)
with dpg.group(horizontal=True):
self.camera_combo_tag = dpg.add_combo(label="Camera", items=[], width=-115)
def connect_callback():
selected_name = dpg.get_value(self.camera_combo_tag)
if selected_name and selected_name in self.camera_map:
port = self.camera_map[selected_name]
self.command_queue.put(('CONNECT', port))
else:
logger.warning("No camera selected to connect to.")
self.connect_button = dpg.add_button(label="Connect", callback=connect_callback)
# This button is only visible when connected
self.disconnect_button_tag = dpg.add_button(
label="Disconnect",
callback=lambda: self.command_queue.put(('DISCONNECT', None)),
show=False,
width=-1
)
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
result_type, data = self.result_queue.get_nowait()
if result_type == 'STATUS':
dpg.set_value(self.status_text_tag, f"Status: {data}")
elif result_type == 'CAMERAS_DETECTED':
# data is a list of (name, port) tuples
self.camera_map = {name: port for name, port in data}
camera_names = list(self.camera_map.keys())
dpg.configure_item(self.camera_combo_tag, items=camera_names)
if camera_names:
dpg.set_value(self.camera_combo_tag, camera_names[0])
elif result_type == 'CONNECTED' and data is True:
# 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
pass
def _camera_worker(self):
camera = None
logger.info("Camera worker thread started.")
while True:
try:
command, args = self.command_queue.get()
logger.info(f"Camera worker received command: {command}")
if command == 'DETECT':
try:
# Autodetect returns a list of (name, port) tuples
camera_list = list(gp.Camera.autodetect())
if not camera_list:
self.result_queue.put(('STATUS', 'No cameras found.'))
self.result_queue.put(('CAMERAS_DETECTED', []))
continue
self.result_queue.put(('CAMERAS_DETECTED', camera_list))
self.result_queue.put(('STATUS', f'Detected {len(camera_list)} camera(s).'))
except Exception as e:
logger.error(f"Error during camera detection: {e}")
self.result_queue.put(('STATUS', 'Error during detection.'))
elif command == 'CONNECT':
if camera is not None:
self.result_queue.put(('STATUS', 'A camera is already connected.'))
continue
port = args
if not port:
self.result_queue.put(('STATUS', 'No camera port selected.'))
continue
try:
camera = gp.Camera()
port_info_list = gp.PortInfoList(); port_info_list.load()
port_index = port_info_list.lookup_path(port)
camera.set_port_info(port_info_list[port_index])
camera.init()
self.result_queue.put(('STATUS', f"Connected successfully!"))
self.result_queue.put(('CONNECTED', True))
except gp.GPhoto2Error as ex:
camera = None # Ensure camera is None on failure
logger.error(f"GPhoto2 Error on connect: {ex}")
self.result_queue.put(('STATUS', f"Error: {ex}"))
self.result_queue.put(('CONNECTED', False))
elif command == 'DISCONNECT':
if camera:
try:
camera.exit()
camera = None # Critical to update state
self.result_queue.put(('STATUS', 'Disconnected.'))
self.result_queue.put(('DISCONNECTED', True))
except gp.GPhoto2Error as ex:
logger.error(f"Error on disconnect: {ex}")
self.result_queue.put(('STATUS', f"Error on disconnect: {ex}"))
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}'))
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

@ -1,104 +0,0 @@
# in widgets/image_viewer_widget.py
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 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)
self.global_state.subscribe("PROCESSED_IMAGE_READY", self.on_new_image_data)
layout_manager.updating_widgets.append("ImageViewerWidget")
# --- 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 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):
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)
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:
height, width, channels = image_data.shape
self.last_image_size = (width, height)
# --- 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))
# 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.focus_item(self.window_tag)
except Exception as e:
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
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

View File

@ -1,17 +0,0 @@
import dearpygui.dearpygui as dpg
from .base_widget import BaseWidget
class LogWidget(BaseWidget):
"""A widget to display captured log messages."""
def create(self):
if dpg.does_item_exist(self.window_tag): return
with dpg.window(label="Log Viewer", tag=self.window_tag, on_close=self._on_window_close, width=500, height=300):
self.text_item_tag = dpg.add_input_text(
multiline=True, readonly=True, width=-1, height=-1, default_value="Log initialized.\n"
)
def update_logs(self, log_handler):
"""Called every frame to update the text with new logs."""
if dpg.is_item_visible(self.window_tag):
log_content = log_handler.get_all_logs()
dpg.set_value(self.text_item_tag, log_content)

View File

@ -1,36 +0,0 @@
# 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
)