Compare commits
11 Commits
47a13d3083
...
af2bbe93cc
Author | SHA1 | Date | |
---|---|---|---|
af2bbe93cc | |||
bc4f10a7ad | |||
38593fc2c5 | |||
c6606931de | |||
a70c4957fd | |||
430a3ac964 | |||
8b4105a261 | |||
af4371ebe4 | |||
679b47262a | |||
9306f2d6ea | |||
d0cbc63859 |
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -1,3 +0,0 @@
|
||||
{
|
||||
"python.REPL.enableREPLSmartSend": false
|
||||
}
|
@ -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
negative_raw.nef
Normal file
BIN
negative_raw.nef
Normal file
Binary file not shown.
BIN
negative_raw.png
Normal file
BIN
negative_raw.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 97 MiB |
@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import ui
|
||||
|
||||
if __name__=="__main__":
|
||||
ui.LayoutManager.run()
|
||||
import negstation
|
||||
|
||||
if __name__ == "__main__":
|
||||
negstation.NegStation().run()
|
||||
|
1
negstation/__init__.py
Normal file
1
negstation/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .negstation import EditorManager as NegStation
|
57
negstation/event_bus.py
Normal file
57
negstation/event_bus.py
Normal file
@ -0,0 +1,57 @@
|
||||
import threading
|
||||
import queue
|
||||
import logging
|
||||
import inspect
|
||||
import types
|
||||
|
||||
|
||||
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
|
||||
|
||||
def unsubscribe_instance(self, instance):
|
||||
for event_type, subs in list(self.subscribers.items()):
|
||||
new_subs = []
|
||||
for callback, main_thread in subs:
|
||||
# if it's a bound method to our instance, skip it
|
||||
if inspect.ismethod(callback) and callback.__self__ is instance:
|
||||
continue
|
||||
new_subs.append((callback, main_thread))
|
||||
if len(new_subs) != len(subs):
|
||||
self.subscribers[event_type] = new_subs
|
47
negstation/image_pipeline.py
Normal file
47
negstation/image_pipeline.py
Normal file
@ -0,0 +1,47 @@
|
||||
import numpy as np
|
||||
|
||||
from .event_bus import EventBus
|
||||
|
||||
|
||||
class ImagePipeline:
|
||||
def __init__(self, bus: EventBus):
|
||||
self.bus = bus
|
||||
self.id_counter = 0
|
||||
self.stages = {}
|
||||
self.stagedata = {}
|
||||
|
||||
def register_stage(self, name: str):
|
||||
self.stages[self.id_counter] = name
|
||||
self.stagedata[self.id_counter] = None
|
||||
self.bus.publish_deferred("pipeline_stages", self.stages)
|
||||
self.id_counter += 1
|
||||
return self.id_counter-1
|
||||
|
||||
def rename_stage(self, id: int, name: str):
|
||||
if id in self.stages:
|
||||
self.stages[id] = name
|
||||
self.bus.publish_deferred("pipeline_stages", self.stages)
|
||||
|
||||
def publish(self, id: int, img: np.ndarray):
|
||||
self.stagedata[id] = img.astype(np.float32)
|
||||
self.bus.publish_deferred("pipeline_stage", (id, self.stagedata[id]))
|
||||
|
||||
def get_stage_data(self, id: int):
|
||||
if id >= 0 and id < len(self.stages):
|
||||
return self.stagedata[id]
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_stage_name(self, id: int):
|
||||
if id >= 0 and id < len(self.stages):
|
||||
return self.stages[id]
|
||||
else:
|
||||
return None
|
||||
|
||||
def republish_stages(self):
|
||||
self.bus.publish_deferred("pipeline_stages", self.stages)
|
||||
|
||||
def remove_stage(self, id: int):
|
||||
del self.stages[id]
|
||||
del self.stagedata[id]
|
||||
self.republish_stages()
|
43
negstation/layout_manager.py
Normal file
43
negstation/layout_manager.py
Normal 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}")
|
123
negstation/negstation.py
Normal file
123
negstation/negstation.py
Normal file
@ -0,0 +1,123 @@
|
||||
import dearpygui.dearpygui as dpg
|
||||
import logging
|
||||
import os
|
||||
import importlib
|
||||
import inspect
|
||||
import sys
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
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.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EditorManager:
|
||||
def __init__(self):
|
||||
dpg.create_context()
|
||||
self.texture_registry = dpg.add_texture_registry()
|
||||
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 package’s 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
|
||||
and cls.register
|
||||
):
|
||||
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)
|
||||
logger.info(f'Created instance: {str(instance)}')
|
||||
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=1200, height=800)
|
||||
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
|
||||
)
|
||||
dpg.add_menu_item(
|
||||
label="Quit", callback=lambda: dpg.stop_dearpygui()
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
try:
|
||||
while dpg.is_dearpygui_running():
|
||||
self.bus.process_main_queue()
|
||||
for w in self.widgets:
|
||||
w.update()
|
||||
dpg.render_dearpygui_frame()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("CTRL-C pressed: exiting...")
|
||||
dpg.destroy_context()
|
90
negstation/widgets/base_widget.py
Normal file
90
negstation/widgets/base_widget.py
Normal file
@ -0,0 +1,90 @@
|
||||
import dearpygui.dearpygui as dpg
|
||||
import logging
|
||||
import gc
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..negstation import EditorManager
|
||||
|
||||
|
||||
class BaseWidget:
|
||||
name: str = "BaseWidget"
|
||||
register: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manager: "EditorManager",
|
||||
logger: logging.Logger,
|
||||
window_width: int = 300,
|
||||
window_height: int = 200,
|
||||
):
|
||||
self.manager = manager
|
||||
self.logger = logger
|
||||
self.window_width = window_width
|
||||
self.window_height = window_height
|
||||
self.window_offset_x = 0
|
||||
self.window_offset_y = 0
|
||||
self.window_tag = dpg.generate_uuid()
|
||||
self.config = {}
|
||||
|
||||
def create(self):
|
||||
"""Called by negstation itself, creates the window"""
|
||||
with dpg.window(
|
||||
label=self.name,
|
||||
tag=self.window_tag,
|
||||
width=self.window_width,
|
||||
height=self.window_height,
|
||||
on_close=self._on_window_close,
|
||||
):
|
||||
self.window_handler = dpg.add_item_handler_registry()
|
||||
dpg.add_item_resize_handler(
|
||||
callback=self._on_window_resize, parent=self.window_handler
|
||||
)
|
||||
|
||||
self.create_content()
|
||||
|
||||
dpg.bind_item_handler_registry(self.window_tag, self.window_handler)
|
||||
|
||||
def create_content(self):
|
||||
"""Must be implemented by the widget, creates the content of the window"""
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self):
|
||||
"""Must be implemented by the widget, is called in the render loop every frame"""
|
||||
pass
|
||||
|
||||
def on_resize(self, width: int, height: int):
|
||||
"""Must be implemented by the widget, is called after a resize"""
|
||||
pass
|
||||
|
||||
# Internal but public funtions
|
||||
|
||||
def get_config(self):
|
||||
"""Caled by negstation itself, returns the saved widget config"""
|
||||
return self.config
|
||||
|
||||
# Callbacks
|
||||
|
||||
def _on_window_close(self):
|
||||
"""Some cleanup after closing a window"""
|
||||
self.manager.bus.unsubscribe_instance(self)
|
||||
|
||||
dpg.delete_item(self.window_tag)
|
||||
dpg.delete_item(self.window_handler)
|
||||
|
||||
self.manager.widgets.remove(self)
|
||||
|
||||
self.manager = None
|
||||
self.logger = None
|
||||
|
||||
gc.collect()
|
||||
|
||||
def _on_window_resize(self, data):
|
||||
win_w, win_h = dpg.get_item_rect_size(self.window_tag)
|
||||
self.window_height = win_h
|
||||
self.window_width = win_w
|
||||
self.on_resize(win_w, win_h)
|
||||
|
||||
def __del__(self):
|
||||
print("Widget deleted")
|
69
negstation/widgets/histogram_widget.py
Normal file
69
negstation/widgets/histogram_widget.py
Normal file
@ -0,0 +1,69 @@
|
||||
import dearpygui.dearpygui as dpg
|
||||
import numpy as np
|
||||
from .pipeline_stage_widget import PipelineStageWidget
|
||||
|
||||
|
||||
class HistogramWidget(PipelineStageWidget):
|
||||
name = "Histogram"
|
||||
register = True
|
||||
has_pipeline_in = True
|
||||
has_pipeline_out = False
|
||||
|
||||
def __init__(self, manager, logger):
|
||||
super().__init__(manager, logger, default_stage_in="monochrome")
|
||||
self.plot_tag = dpg.generate_uuid()
|
||||
self.axis_x = dpg.generate_uuid()
|
||||
self.axis_y = dpg.generate_uuid()
|
||||
self.needs_redraw = False
|
||||
self.img = None
|
||||
self.series_tags = {
|
||||
"R": dpg.generate_uuid(),
|
||||
"G": dpg.generate_uuid(),
|
||||
"B": dpg.generate_uuid(),
|
||||
"L": dpg.generate_uuid(),
|
||||
}
|
||||
|
||||
def create_pipeline_stage_content(self):
|
||||
with dpg.plot(label="Histogram", height=200, width=-1, tag=self.plot_tag):
|
||||
dpg.add_plot_legend()
|
||||
dpg.add_plot_axis(
|
||||
dpg.mvXAxis, tag=self.axis_x)
|
||||
with dpg.plot_axis(dpg.mvYAxis, tag=self.axis_y):
|
||||
for channel, tag in self.series_tags.items():
|
||||
dpg.add_line_series([], [], label=channel, tag=tag)
|
||||
dpg.set_axis_limits(self.axis_x, 0.0, 1.0)
|
||||
dpg.set_axis_limits(self.axis_y, 0.0, 1.0)
|
||||
|
||||
def on_pipeline_data(self, img: np.ndarray):
|
||||
if img is None or img.ndim != 3 or img.shape[2] < 3:
|
||||
return
|
||||
|
||||
self.img = img
|
||||
self.needs_redraw = True
|
||||
|
||||
def update(self):
|
||||
# TODO move calculations to on_pipeline_data
|
||||
if not self.needs_redraw or self.img is None:
|
||||
return
|
||||
|
||||
self.needs_redraw = False
|
||||
img = np.clip(self.img, 0.0, 1.0)
|
||||
|
||||
r, g, b = img[..., 0], img[..., 1], img[..., 2]
|
||||
luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
bins = 64
|
||||
hist_range = (0.0, 1.0)
|
||||
bin_edges = np.linspace(*hist_range, bins)
|
||||
|
||||
def compute_hist(channel):
|
||||
hist, _ = np.histogram(channel, bins=bin_edges)
|
||||
x = bin_edges[:-1]
|
||||
y = np.log1p(hist)
|
||||
y = y / np.max(y)
|
||||
return x.tolist(), y.tolist()
|
||||
|
||||
dpg.set_value(self.series_tags["R"], compute_hist(r))
|
||||
dpg.set_value(self.series_tags["G"], compute_hist(g))
|
||||
dpg.set_value(self.series_tags["B"], compute_hist(b))
|
||||
dpg.set_value(self.series_tags["L"], compute_hist(luminance))
|
24
negstation/widgets/invert_stage.py
Normal file
24
negstation/widgets/invert_stage.py
Normal file
@ -0,0 +1,24 @@
|
||||
import dearpygui.dearpygui as dpg
|
||||
import numpy as np
|
||||
|
||||
from .pipeline_stage_widget import PipelineStageWidget
|
||||
|
||||
|
||||
class InvertStage(PipelineStageWidget):
|
||||
name = "Invert Image"
|
||||
register = True
|
||||
has_pipeline_in = True
|
||||
has_pipeline_out = True
|
||||
|
||||
def __init__(self, manager, logger):
|
||||
super().__init__(manager, logger, default_stage_out="inverted_image")
|
||||
|
||||
def create_pipeline_stage_content(self):
|
||||
dpg.add_text("Inversion is happening here")
|
||||
|
||||
def on_pipeline_data(self, img):
|
||||
if img is None:
|
||||
return
|
||||
inverted = img.copy()
|
||||
inverted[...,:3] = 1.0 - inverted[...,:3]
|
||||
self.publish_stage(inverted)
|
53
negstation/widgets/log_widget.py
Normal file
53
negstation/widgets/log_widget.py
Normal file
@ -0,0 +1,53 @@
|
||||
import dearpygui.dearpygui as dpg
|
||||
import logging
|
||||
from .base_widget import BaseWidget
|
||||
|
||||
|
||||
class DPGLogHandler(logging.Handler):
|
||||
def __init__(self, callback):
|
||||
super().__init__()
|
||||
self.callback = callback
|
||||
|
||||
def emit(self, record):
|
||||
msg = self.format(record)
|
||||
self.callback(msg)
|
||||
|
||||
|
||||
class LogWindowWidget(BaseWidget):
|
||||
name = "Log Window"
|
||||
register = True
|
||||
|
||||
def __init__(self, manager, logger):
|
||||
super().__init__(manager, logger)
|
||||
self.initialized = False
|
||||
self.log_tag = dpg.generate_uuid()
|
||||
self.log_lines = []
|
||||
|
||||
# Create and attach handler
|
||||
self.handler = DPGLogHandler(self._on_log)
|
||||
self.handler.setFormatter(logging.Formatter(
|
||||
'%(asctime)s [%(levelname)s] %(message)s'))
|
||||
self.logger.addHandler(self.handler)
|
||||
|
||||
def create_content(self):
|
||||
dpg.add_text("Live Log Output")
|
||||
dpg.add_separator()
|
||||
dpg.add_child_window(tag=self.log_tag, autosize_x=True,
|
||||
autosize_y=True, horizontal_scrollbar=True)
|
||||
self.initialized = True
|
||||
|
||||
def _on_log(self, msg: str):
|
||||
self.log_lines.append(msg)
|
||||
if self.initialized:
|
||||
dpg.add_text(msg, parent=self.log_tag)
|
||||
dpg.set_y_scroll(self.log_tag, dpg.get_y_scroll_max(self.log_tag))
|
||||
|
||||
def on_resize(self, width: int, height: int):
|
||||
# Optional: could resize child window here if needed
|
||||
pass
|
||||
|
||||
def _on_window_close(self):
|
||||
if self.initialized:
|
||||
self.logger.removeHandler(self.handler)
|
||||
self.handler = None
|
||||
super()._on_window_close()
|
31
negstation/widgets/monochrome_widget.py
Normal file
31
negstation/widgets/monochrome_widget.py
Normal file
@ -0,0 +1,31 @@
|
||||
import dearpygui.dearpygui as dpg
|
||||
import numpy as np
|
||||
|
||||
from .pipeline_stage_widget import PipelineStageWidget
|
||||
|
||||
|
||||
class MonochromeStage(PipelineStageWidget):
|
||||
name = "Monochrome"
|
||||
register = True
|
||||
has_pipeline_in = True
|
||||
has_pipeline_out = True
|
||||
|
||||
def __init__(self, manager, logger):
|
||||
super().__init__(manager, logger, default_stage_out="monochrome")
|
||||
|
||||
def create_pipeline_stage_content(self):
|
||||
dpg.add_text("Converting to grayscale...")
|
||||
|
||||
def on_pipeline_data(self, img):
|
||||
if img is None:
|
||||
return
|
||||
gray = img.copy()
|
||||
rgb = gray[..., :3]
|
||||
alpha = gray[..., 3:] if gray.shape[2] == 4 else np.ones_like(
|
||||
rgb[..., :1])
|
||||
|
||||
luminance = np.dot(rgb, [0.2126, 0.7152, 0.0722])[..., np.newaxis]
|
||||
gray_rgba = np.concatenate(
|
||||
[luminance, luminance, luminance, alpha], axis=-1)
|
||||
|
||||
self.publish_stage(gray_rgba.astype(np.float32))
|
54
negstation/widgets/open_image_widget.py
Normal file
54
negstation/widgets/open_image_widget.py
Normal file
@ -0,0 +1,54 @@
|
||||
import dearpygui.dearpygui as dpg
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
from .pipeline_stage_widget import PipelineStageWidget
|
||||
|
||||
|
||||
class OpenImageWidget(PipelineStageWidget):
|
||||
name = "Open Image"
|
||||
register = True
|
||||
has_pipeline_in = False
|
||||
has_pipeline_out = True
|
||||
|
||||
def __init__(self, manager, logger):
|
||||
super().__init__(manager, logger, default_stage_out="opened_image")
|
||||
self.dialog_tag = dpg.generate_uuid()
|
||||
self.output_tag = dpg.generate_uuid()
|
||||
|
||||
def create_pipeline_stage_content(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(
|
||||
"Image files {.png,.jpg,.jpeg,.bmp .gif,.tif,.tiff}",
|
||||
)
|
||||
dpg.add_file_extension(".*")
|
||||
dpg.add_button(label="Open File...", callback=self._on_open_file)
|
||||
|
||||
def _on_open_file(self):
|
||||
dpg.configure_item(self.dialog_tag, show=True)
|
||||
|
||||
def _on_file_selected(self, sender, app_data):
|
||||
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.publish(self.pipeline_stage_out_id, arr)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to load image {selection}: {e}")
|
208
negstation/widgets/open_raw_widget.py
Normal file
208
negstation/widgets/open_raw_widget.py
Normal file
@ -0,0 +1,208 @@
|
||||
import dearpygui.dearpygui as dpg
|
||||
import rawpy
|
||||
import numpy as np
|
||||
|
||||
from .pipeline_stage_widget import PipelineStageWidget
|
||||
|
||||
|
||||
class OpenRawWidget(PipelineStageWidget):
|
||||
name = "Open RAW File"
|
||||
register = True
|
||||
has_pipeline_in = False
|
||||
has_pipeline_out = True
|
||||
|
||||
def __init__(self, manager, logger):
|
||||
super().__init__(manager, logger, default_stage_out="opened_raw")
|
||||
self.dialog_tag = dpg.generate_uuid()
|
||||
self.output_tag = dpg.generate_uuid()
|
||||
self.config_group = dpg.generate_uuid()
|
||||
self.busy_group = dpg.generate_uuid()
|
||||
self.raw_path = None
|
||||
self.config = {
|
||||
# Demosaic algorithm
|
||||
"demosaic_algorithm": rawpy.DemosaicAlgorithm.AHD,
|
||||
# Output color space
|
||||
"output_color": rawpy.ColorSpace.sRGB,
|
||||
# Bits per sample
|
||||
"output_bps": 16,
|
||||
# White balance
|
||||
"use_camera_wb": True,
|
||||
"use_auto_wb": False,
|
||||
"user_wb": (1.0, 1.0, 1.0, 1.0),
|
||||
# Brightness/exposure
|
||||
"bright": 1.0,
|
||||
"no_auto_bright": False,
|
||||
# Gamma correction (you’ll pass (1.0, config["gamma"]) down)
|
||||
"gamma": 1.0,
|
||||
# Size & quality toggles
|
||||
"half_size": False,
|
||||
"four_color_rgb": False,
|
||||
}
|
||||
|
||||
def get_config(self):
|
||||
return {}
|
||||
|
||||
def create_pipeline_stage_content(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(
|
||||
"RAW files {.nef,.cr2}",
|
||||
)
|
||||
dpg.add_file_extension(".*")
|
||||
|
||||
with dpg.group(tag=self.config_group):
|
||||
with dpg.group(horizontal=True):
|
||||
dpg.add_button(label="Open File...",
|
||||
callback=self._on_open_file)
|
||||
dpg.add_button(label="Reprocess",
|
||||
callback=self._process_and_publish)
|
||||
|
||||
dpg.add_combo(
|
||||
label="Demosaic",
|
||||
items=[alg.name for alg in rawpy.DemosaicAlgorithm],
|
||||
default_value=rawpy.DemosaicAlgorithm.AHD.name,
|
||||
callback=lambda s, a, u: self.config.__setitem__(
|
||||
"demosaic_algorithm", rawpy.DemosaicAlgorithm[a])
|
||||
)
|
||||
dpg.add_combo(
|
||||
label="Color Space",
|
||||
items=[cs.name for cs in rawpy.ColorSpace],
|
||||
default_value=rawpy.ColorSpace.sRGB.name,
|
||||
callback=lambda s, a, u: self.config.__setitem__(
|
||||
"output_color", rawpy.ColorSpace[a])
|
||||
)
|
||||
dpg.add_combo(
|
||||
label="Output Bits",
|
||||
items=["8", "16"],
|
||||
default_value="16",
|
||||
callback=lambda s, a, u: self.config.__setitem__(
|
||||
"output_bps", int(a))
|
||||
)
|
||||
dpg.add_checkbox(
|
||||
label="Use Camera WB",
|
||||
default_value=True,
|
||||
callback=lambda s, a, u: self.config.__setitem__(
|
||||
"use_camera_wb", a)
|
||||
)
|
||||
dpg.add_checkbox(
|
||||
label="Auto WB",
|
||||
default_value=False,
|
||||
callback=lambda s, a, u: self.config.__setitem__(
|
||||
"use_auto_wb", a)
|
||||
)
|
||||
dpg.add_slider_float(
|
||||
label="Manual WB R Gain",
|
||||
default_value=1.0, min_value=0.1, max_value=4.0,
|
||||
callback=lambda s, a, u: self.config.__setitem__(
|
||||
"user_wb", (a, self.config["user_wb"][1], self.config["user_wb"][2], self.config["user_wb"][3]))
|
||||
)
|
||||
dpg.add_slider_float(
|
||||
label="Manual WB G Gain",
|
||||
default_value=1.0, min_value=0.1, max_value=4.0,
|
||||
callback=lambda s, a, u: self.config.__setitem__(
|
||||
"user_wb", (self.config["user_wb"][0], a, a, self.config["user_wb"][3]))
|
||||
)
|
||||
dpg.add_slider_float(
|
||||
label="Manual WB B Gain",
|
||||
default_value=1.0, min_value=0.1, max_value=4.0,
|
||||
callback=lambda s, a, u: self.config.__setitem__(
|
||||
"user_wb", (self.config["user_wb"][0], self.config["user_wb"][1], self.config["user_wb"][2], a))
|
||||
)
|
||||
dpg.add_slider_float(
|
||||
label="Bright",
|
||||
default_value=1.0, min_value=0.1, max_value=4.0,
|
||||
callback=lambda s, a, u: self.config.__setitem__("bright", a)
|
||||
)
|
||||
dpg.add_checkbox(
|
||||
label="No Auto Bright",
|
||||
default_value=False,
|
||||
callback=lambda s, a, u: self.config.__setitem__(
|
||||
"no_auto_bright", a)
|
||||
)
|
||||
dpg.add_slider_float(
|
||||
label="Gamma",
|
||||
default_value=1.0, min_value=0.1, max_value=3.0,
|
||||
callback=lambda s, a, u: self.config.__setitem__("gamma", a)
|
||||
)
|
||||
dpg.add_checkbox(
|
||||
label="Half-size",
|
||||
default_value=False,
|
||||
callback=lambda s, a, u: self.config.__setitem__(
|
||||
"half_size", a)
|
||||
)
|
||||
dpg.add_checkbox(
|
||||
label="4-color RGB",
|
||||
default_value=False,
|
||||
callback=lambda s, a, u: self.config.__setitem__(
|
||||
"four_color_rgb", a)
|
||||
)
|
||||
|
||||
with dpg.group(tag=self.busy_group, show=False):
|
||||
dpg.add_text("Processing...")
|
||||
|
||||
def _on_open_file(self):
|
||||
dpg.configure_item(self.dialog_tag, show=True)
|
||||
|
||||
def _on_file_selected(self, sender, app_data):
|
||||
selection = (
|
||||
f"{app_data['current_path']
|
||||
}/{list(app_data['selections'].keys())[0]}"
|
||||
if isinstance(app_data, dict)
|
||||
else None
|
||||
)
|
||||
if not selection:
|
||||
return
|
||||
self.raw_path = selection
|
||||
self.logger.info(f"Selected file '{selection}'")
|
||||
self._process_and_publish()
|
||||
|
||||
def _process_and_publish(self):
|
||||
if self.raw_path is None:
|
||||
return
|
||||
self.logger.info("Processing RAW image")
|
||||
|
||||
dpg.configure_item(self.config_group, show=False)
|
||||
dpg.configure_item(self.busy_group, show=True)
|
||||
|
||||
with rawpy.imread(self.raw_path) as raw:
|
||||
# Prepare postprocess kwargs from config
|
||||
postprocess_args = {
|
||||
'demosaic_algorithm': self.config["demosaic_algorithm"],
|
||||
'output_color': self.config["output_color"],
|
||||
'output_bps': self.config["output_bps"],
|
||||
'bright': self.config["bright"],
|
||||
'no_auto_bright': self.config["no_auto_bright"],
|
||||
'gamma': (1.0, self.config["gamma"]),
|
||||
'half_size': self.config["half_size"],
|
||||
'four_color_rgb': self.config["four_color_rgb"],
|
||||
}
|
||||
|
||||
if self.config["use_camera_wb"]:
|
||||
postprocess_args['use_camera_wb'] = True
|
||||
elif self.config["use_auto_wb"]:
|
||||
postprocess_args['use_auto_wb'] = True
|
||||
else:
|
||||
postprocess_args['user_wb'] = self.config["user_wb"]
|
||||
|
||||
# Postprocess into RGB
|
||||
rgb = raw.postprocess(**postprocess_args)
|
||||
|
||||
# Normalize to float32 in 0.0-1.0 range depending on output_bps
|
||||
max_val = (2 ** self.config["output_bps"]) - 1
|
||||
rgb_float = rgb.astype(np.float32) / max_val
|
||||
|
||||
# Add alpha channel (fully opaque)
|
||||
h, w, _ = rgb_float.shape
|
||||
alpha = np.ones((h, w, 1), dtype=np.float32)
|
||||
|
||||
rgba = np.concatenate([rgb_float, alpha], axis=2)
|
||||
|
||||
self.manager.pipeline.publish(self.pipeline_stage_out_id, rgba)
|
||||
dpg.configure_item(self.config_group, show=True)
|
||||
dpg.configure_item(self.busy_group, show=False)
|
110
negstation/widgets/pipeline_stage_widget.py
Normal file
110
negstation/widgets/pipeline_stage_widget.py
Normal file
@ -0,0 +1,110 @@
|
||||
import dearpygui.dearpygui as dpg
|
||||
import numpy as np
|
||||
from .base_widget import BaseWidget
|
||||
|
||||
|
||||
class PipelineStageWidget(BaseWidget):
|
||||
name = "Pipeline Stage Widget"
|
||||
register = False
|
||||
has_pipeline_in: bool = False
|
||||
has_pipeline_out: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manager,
|
||||
logger,
|
||||
default_stage_in: str = "pipeline_in",
|
||||
default_stage_out: str = "pipeline_out",
|
||||
window_width: int = 300,
|
||||
window_height: int = 200,
|
||||
):
|
||||
super().__init__(manager, logger, window_width, window_height)
|
||||
self.pipeline_stage_in_id = None
|
||||
self.pipeline_stage_out_id = None
|
||||
self.pipeline_config_group_tag = dpg.generate_uuid()
|
||||
self.stage_in_combo = dpg.generate_uuid()
|
||||
|
||||
if self.has_pipeline_out:
|
||||
self.pipeline_stage_out_id = self.manager.pipeline.register_stage(
|
||||
default_stage_out
|
||||
)
|
||||
|
||||
self.manager.bus.subscribe("pipeline_stages", self._on_stage_list, True)
|
||||
if self.has_pipeline_in:
|
||||
self.pipeline_stage_in_id = 0
|
||||
self.manager.bus.subscribe("pipeline_stage", self._on_stage_data, True)
|
||||
# force getting all available pipeline stages
|
||||
self.manager.pipeline.republish_stages()
|
||||
|
||||
def create_content(self):
|
||||
with dpg.group(tag=self.pipeline_config_group_tag):
|
||||
if self.has_pipeline_in:
|
||||
dpg.add_combo(
|
||||
label="Stage In",
|
||||
items=[],
|
||||
callback=self._on_stage_in_select,
|
||||
default_value=f"{self.manager.pipeline.get_stage_name(0)} : 0",
|
||||
tag=self.stage_in_combo
|
||||
)
|
||||
if self.has_pipeline_out:
|
||||
dpg.add_input_text(
|
||||
label="Stage Out",
|
||||
default_value=self.manager.pipeline.get_stage_name(
|
||||
self.pipeline_stage_out_id
|
||||
),
|
||||
callback=lambda s, a, u: self.manager.pipeline.rename_stage(
|
||||
self.pipeline_stage_out_id, a
|
||||
),
|
||||
)
|
||||
dpg.add_separator()
|
||||
self.create_pipeline_stage_content()
|
||||
|
||||
def publish_stage(self, img: np.ndarray):
|
||||
"""Publishes an image to output stage"""
|
||||
if self.has_pipeline_out:
|
||||
self.manager.pipeline.publish(self.pipeline_stage_out_id, img)
|
||||
|
||||
def create_pipeline_stage_content(self):
|
||||
"""Must be implemented by the widget, creates the content of the window"""
|
||||
raise NotImplementedError
|
||||
|
||||
def on_pipeline_data(self, img: np.ndarray):
|
||||
"""Must be implemented by the widget, is called when there is a new image published on the in stage"""
|
||||
pass
|
||||
|
||||
# Callbacks
|
||||
|
||||
def _on_window_close(self):
|
||||
if self.has_pipeline_out:
|
||||
self.manager.pipeline.remove_stage(self.pipeline_stage_out_id)
|
||||
return super()._on_window_close()
|
||||
|
||||
def _on_stage_list(self, stagelist):
|
||||
if self.has_pipeline_in:
|
||||
stages = [f"{stage} : {id}" for id, stage in stagelist.items()]
|
||||
dpg.configure_item(self.stage_in_combo, items=stages)
|
||||
|
||||
def _on_stage_in_select(self, sender, selected_stage: str):
|
||||
d = selected_stage.split(" : ")
|
||||
name = d[0]
|
||||
id = int(d[1])
|
||||
self.pipeline_stage_in_id = id
|
||||
if self.has_pipeline_in:
|
||||
img = self.manager.pipeline.get_stage_data(id)
|
||||
self.on_pipeline_data(img)
|
||||
|
||||
def _on_stage_data(self, data):
|
||||
pipeline_id = data[0]
|
||||
img = data[1]
|
||||
if self.has_pipeline_in and pipeline_id == self.pipeline_stage_in_id:
|
||||
self.on_pipeline_data(img)
|
||||
|
||||
# Override the window resize callback
|
||||
def _on_window_resize(self, data):
|
||||
win_w, win_h = dpg.get_item_rect_size(self.window_tag)
|
||||
group_w, group_h = dpg.get_item_rect_size(self.pipeline_config_group_tag)
|
||||
group_x, group_y = dpg.get_item_pos(self.pipeline_config_group_tag)
|
||||
self.window_height = win_h - group_h - group_y - 12
|
||||
self.window_width = win_w - 7
|
||||
self.window_offset_y = group_h + group_y + 3
|
||||
self.on_resize(win_w, win_h)
|
90
negstation/widgets/stage_viewer_widget.py
Normal file
90
negstation/widgets/stage_viewer_widget.py
Normal file
@ -0,0 +1,90 @@
|
||||
import dearpygui.dearpygui as dpg
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from .pipeline_stage_widget import PipelineStageWidget
|
||||
|
||||
|
||||
class PipelineStageViewer(PipelineStageWidget):
|
||||
name = "View Image"
|
||||
register = True
|
||||
has_pipeline_in = True
|
||||
has_pipeline_out = False
|
||||
|
||||
def __init__(self, manager, logger):
|
||||
super().__init__(manager, logger, default_stage_in="pipeline_out")
|
||||
self.texture_tag = dpg.generate_uuid()
|
||||
self.img = None
|
||||
self.needs_update = False
|
||||
self.registry = manager.texture_registry
|
||||
|
||||
def create_pipeline_stage_content(self):
|
||||
dpg.add_dynamic_texture(
|
||||
1, 1, [0, 0, 0, 0], tag=self.texture_tag, parent=self.registry
|
||||
)
|
||||
self.image_item = dpg.add_image(self.texture_tag)
|
||||
|
||||
def on_resize(self, width, height):
|
||||
self.needs_update = True
|
||||
|
||||
def on_pipeline_data(self, img):
|
||||
# Resize if needed
|
||||
if img is None:
|
||||
return
|
||||
h, w, _ = img.shape
|
||||
max_dim = 500
|
||||
scale = min(1.0, max_dim / w, max_dim / h)
|
||||
if scale < 1.0:
|
||||
# convert to 0–255 uint8, resize with PIL, back to float32 [0–1]
|
||||
pil = Image.fromarray((img * 255).astype(np.uint8), mode="RGBA")
|
||||
new_w, new_h = int(w * scale), int(h * scale)
|
||||
pil = pil.resize((new_w, new_h), Image.LANCZOS)
|
||||
img = np.asarray(pil).astype(np.float32) / 255.0
|
||||
w, h = new_w, new_h
|
||||
|
||||
self.img = img
|
||||
self.needs_update = True
|
||||
|
||||
def update_texture(self, img: np.ndarray):
|
||||
"""Only call from update function"""
|
||||
# TODO show a smaller version of the image to speed things up
|
||||
if img is None:
|
||||
dpg.configure_item(self.image_item, show=False)
|
||||
return
|
||||
|
||||
h, w, _ = img.shape
|
||||
flat = img.flatten().tolist()
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
win_w, win_h = self.window_width, self.window_height
|
||||
avail_w = win_w
|
||||
avail_h = win_h
|
||||
|
||||
scale = min(avail_w / w, avail_h / h) # , 1.0)
|
||||
disp_w = int(w * scale)
|
||||
disp_h = int(h * scale)
|
||||
|
||||
x_off = (avail_w - disp_w) / 2
|
||||
y_off = self.window_offset_y
|
||||
|
||||
dpg.configure_item(
|
||||
self.image_item,
|
||||
texture_tag=self.texture_tag,
|
||||
pos=(x_off, y_off),
|
||||
width=disp_w,
|
||||
height=disp_h,
|
||||
show=True
|
||||
)
|
||||
|
||||
def update(self):
|
||||
if self.needs_update:
|
||||
self.needs_update = False
|
||||
self.update_texture(self.img)
|
@ -1,129 +1,171 @@
|
||||
[Window][WindowOverViewport_11111111]
|
||||
Pos=0,19
|
||||
Size=1280,701
|
||||
Size=1200,781
|
||||
Collapsed=0
|
||||
|
||||
[Window][###28]
|
||||
Pos=433,517
|
||||
Size=847,203
|
||||
[Window][###33]
|
||||
Pos=301,400
|
||||
Size=499,200
|
||||
Collapsed=0
|
||||
DockId=0x00000002,0
|
||||
|
||||
[Window][###30]
|
||||
Pos=310,553
|
||||
Size=970,167
|
||||
[Window][###39]
|
||||
Pos=0,19
|
||||
Size=299,86
|
||||
Collapsed=0
|
||||
DockId=0x0000000C,0
|
||||
DockId=0x00000007,0
|
||||
|
||||
[Window][###51]
|
||||
Pos=0,19
|
||||
Size=270,469
|
||||
Collapsed=0
|
||||
DockId=0x0000001F,1
|
||||
|
||||
[Window][###59]
|
||||
Pos=0,494
|
||||
Size=299,106
|
||||
Collapsed=0
|
||||
DockId=0x00000006,0
|
||||
|
||||
[Window][###67]
|
||||
Pos=0,107
|
||||
Size=299,385
|
||||
Collapsed=0
|
||||
DockId=0x00000008,0
|
||||
|
||||
[Window][Debug##Default]
|
||||
Pos=60,60
|
||||
Size=400,400
|
||||
Collapsed=0
|
||||
|
||||
[Window][###31]
|
||||
Pos=373,547
|
||||
Size=907,173
|
||||
[Window][###57]
|
||||
Pos=0,120
|
||||
Size=196,373
|
||||
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]
|
||||
[Window][###23]
|
||||
Pos=0,19
|
||||
Size=308,701
|
||||
Size=270,469
|
||||
Collapsed=0
|
||||
DockId=0x0000001F,0
|
||||
|
||||
[Window][###29]
|
||||
Pos=0,19
|
||||
Size=244,423
|
||||
Collapsed=0
|
||||
DockId=0x00000012,0
|
||||
|
||||
[Window][###41]
|
||||
Pos=198,19
|
||||
Size=602,425
|
||||
Collapsed=0
|
||||
DockId=0x00000011,0
|
||||
|
||||
[Window][###51]
|
||||
Pos=290,19
|
||||
Size=990,550
|
||||
[Window][###49]
|
||||
Pos=0,495
|
||||
Size=196,105
|
||||
Collapsed=0
|
||||
DockId=0x0000000D,0
|
||||
DockId=0x0000000C,0
|
||||
|
||||
[Window][###73]
|
||||
Pos=310,19
|
||||
Size=970,532
|
||||
[Window][###42]
|
||||
Pos=246,19
|
||||
Size=554,379
|
||||
Collapsed=0
|
||||
DockId=0x0000000D,0
|
||||
DockId=0x00000011,0
|
||||
|
||||
[Window][###33]
|
||||
[Window][###60]
|
||||
Pos=0,19
|
||||
Size=371,701
|
||||
Size=244,423
|
||||
Collapsed=0
|
||||
DockId=0x00000019,0
|
||||
DockId=0x00000012,1
|
||||
|
||||
[Window][###55]
|
||||
Pos=0,497
|
||||
Size=288,223
|
||||
[Window][###107]
|
||||
Pos=246,400
|
||||
Size=554,200
|
||||
Collapsed=0
|
||||
DockId=0x00000016,0
|
||||
|
||||
[Window][###78]
|
||||
Pos=0,464
|
||||
Size=288,256
|
||||
[Window][###35]
|
||||
Pos=272,19
|
||||
Size=710,625
|
||||
Collapsed=0
|
||||
DockId=0x00000018,0
|
||||
DockId=0x00000011,0
|
||||
|
||||
[Window][###57]
|
||||
Pos=0,674
|
||||
Size=371,355
|
||||
[Window][###43]
|
||||
Pos=0,490
|
||||
Size=270,154
|
||||
Collapsed=0
|
||||
DockId=0x00000020,0
|
||||
|
||||
[Window][###81]
|
||||
Pos=272,646
|
||||
Size=710,154
|
||||
Collapsed=0
|
||||
DockId=0x00000010,0
|
||||
|
||||
[Window][###99]
|
||||
Pos=0,646
|
||||
Size=270,154
|
||||
Collapsed=0
|
||||
DockId=0x00000015,1
|
||||
|
||||
[Window][###87]
|
||||
Pos=0,646
|
||||
Size=270,154
|
||||
Collapsed=0
|
||||
DockId=0x00000015,0
|
||||
|
||||
[Window][###111]
|
||||
Pos=900,19
|
||||
Size=300,781
|
||||
Collapsed=0
|
||||
DockId=0x0000001A,0
|
||||
|
||||
[Window][###53]
|
||||
Pos=373,19
|
||||
Size=907,526
|
||||
[Window][###96]
|
||||
Pos=984,19
|
||||
Size=216,781
|
||||
Collapsed=0
|
||||
DockId=0x0000000D,0
|
||||
DockId=0x0000001D,0
|
||||
|
||||
[Window][###59]
|
||||
Pos=0,19
|
||||
Size=371,701
|
||||
[Window][###127]
|
||||
Pos=984,365
|
||||
Size=216,435
|
||||
Collapsed=0
|
||||
DockId=0x00000019,1
|
||||
DockId=0x0000001E,0
|
||||
|
||||
[Docking][Data]
|
||||
DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0,19 Size=1280,701 Split=X
|
||||
DockNode ID=0x00000013 Parent=0x7C6B3D9B SizeRef=371,701 Split=Y Selected=0x1834836D
|
||||
DockNode ID=0x00000015 Parent=0x00000013 SizeRef=288,476 Split=Y Selected=0x1834836D
|
||||
DockNode ID=0x00000017 Parent=0x00000015 SizeRef=288,443 Split=Y Selected=0x1834836D
|
||||
DockNode ID=0x00000019 Parent=0x00000017 SizeRef=288,453 Selected=0x1834836D
|
||||
DockNode ID=0x0000001A Parent=0x00000017 SizeRef=288,246 Selected=0x3BEDC6B0
|
||||
DockNode ID=0x00000018 Parent=0x00000015 SizeRef=288,256 Selected=0xF475F06A
|
||||
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
|
||||
DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0,19 Size=1200,781 Split=X
|
||||
DockNode ID=0x0000001B Parent=0x7C6B3D9B SizeRef=982,781 Split=X
|
||||
DockNode ID=0x00000019 Parent=0x0000001B SizeRef=898,781 Split=X
|
||||
DockNode ID=0x00000017 Parent=0x00000019 SizeRef=898,781 Split=X
|
||||
DockNode ID=0x00000009 Parent=0x00000017 SizeRef=270,581 Split=Y Selected=0x3BEDC6B0
|
||||
DockNode ID=0x0000000B Parent=0x00000009 SizeRef=196,474 Split=Y Selected=0x3BEDC6B0
|
||||
DockNode ID=0x0000000D Parent=0x0000000B SizeRef=196,99 Split=Y Selected=0x99D84869
|
||||
DockNode ID=0x00000012 Parent=0x0000000D SizeRef=196,423 Selected=0x0F59680E
|
||||
DockNode ID=0x00000013 Parent=0x0000000D SizeRef=196,156 Split=Y Selected=0xB4AD3310
|
||||
DockNode ID=0x00000014 Parent=0x00000013 SizeRef=244,625 Split=Y Selected=0xB4AD3310
|
||||
DockNode ID=0x0000001F Parent=0x00000014 SizeRef=270,469 Selected=0xB4AD3310
|
||||
DockNode ID=0x00000020 Parent=0x00000014 SizeRef=270,154 Selected=0x0531B3D5
|
||||
DockNode ID=0x00000015 Parent=0x00000013 SizeRef=244,154 Selected=0x8773D56E
|
||||
DockNode ID=0x0000000E Parent=0x0000000B SizeRef=196,373 Selected=0x3BEDC6B0
|
||||
DockNode ID=0x0000000C Parent=0x00000009 SizeRef=196,105 Selected=0x4F81AB74
|
||||
DockNode ID=0x0000000A Parent=0x00000017 SizeRef=710,581 Split=X
|
||||
DockNode ID=0x00000003 Parent=0x0000000A SizeRef=299,581 Split=Y Selected=0x52849BCC
|
||||
DockNode ID=0x00000005 Parent=0x00000003 SizeRef=299,473 Split=Y Selected=0x52849BCC
|
||||
DockNode ID=0x00000007 Parent=0x00000005 SizeRef=299,86 Selected=0x52849BCC
|
||||
DockNode ID=0x00000008 Parent=0x00000005 SizeRef=299,385 Selected=0xBD79B41E
|
||||
DockNode ID=0x00000006 Parent=0x00000003 SizeRef=299,106 Selected=0x84DD78D1
|
||||
DockNode ID=0x00000004 Parent=0x0000000A SizeRef=499,581 Split=Y
|
||||
DockNode ID=0x00000001 Parent=0x00000004 SizeRef=800,379 Split=Y Selected=0x7FF1E0B5
|
||||
DockNode ID=0x0000000F Parent=0x00000001 SizeRef=602,425 Split=Y Selected=0x38519A65
|
||||
DockNode ID=0x00000011 Parent=0x0000000F SizeRef=554,379 CentralNode=1 Selected=0x977476CD
|
||||
DockNode ID=0x00000016 Parent=0x0000000F SizeRef=554,200 Selected=0x3A881EEF
|
||||
DockNode ID=0x00000010 Parent=0x00000001 SizeRef=602,154 Selected=0x083320CE
|
||||
DockNode ID=0x00000002 Parent=0x00000004 SizeRef=800,200 Selected=0x1834836D
|
||||
DockNode ID=0x00000018 Parent=0x00000019 SizeRef=300,781 Selected=0x7E9438EA
|
||||
DockNode ID=0x0000001A Parent=0x0000001B SizeRef=300,781 Selected=0x7E9438EA
|
||||
DockNode ID=0x0000001C Parent=0x7C6B3D9B SizeRef=216,781 Split=Y Selected=0x714F2F7B
|
||||
DockNode ID=0x0000001D Parent=0x0000001C SizeRef=216,344 Selected=0x714F2F7B
|
||||
DockNode ID=0x0000001E Parent=0x0000001C SizeRef=216,435 Selected=0x7740BFE4
|
||||
|
||||
|
@ -1,26 +1,30 @@
|
||||
[
|
||||
{
|
||||
"widget_type": "LogWidget",
|
||||
"config": {
|
||||
"label": "LogWidget"
|
||||
}
|
||||
"widget_type": "OpenImageWidget",
|
||||
"config": {}
|
||||
},
|
||||
{
|
||||
"widget_type": "CamControlWidget",
|
||||
"config": {
|
||||
"label": "CamControlWidget"
|
||||
}
|
||||
"widget_type": "PipelineStageViewer",
|
||||
"config": {}
|
||||
},
|
||||
{
|
||||
"widget_type": "ImageViewerWidget",
|
||||
"config": {
|
||||
"label": "ImageViewerWidget"
|
||||
}
|
||||
"widget_type": "InvertStage",
|
||||
"config": {}
|
||||
},
|
||||
{
|
||||
"widget_type": "RawSettingsWidget",
|
||||
"config": {
|
||||
"label": "RawSettingsWidget"
|
||||
}
|
||||
"widget_type": "OpenRawWidget",
|
||||
"config": {}
|
||||
},
|
||||
{
|
||||
"widget_type": "LogWindowWidget",
|
||||
"config": {}
|
||||
},
|
||||
{
|
||||
"widget_type": "MonochromeStage",
|
||||
"config": {}
|
||||
},
|
||||
{
|
||||
"widget_type": "HistogramWidget",
|
||||
"config": {}
|
||||
}
|
||||
]
|
@ -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
132
ui.py
@ -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()
|
@ -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)
|
@ -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
|
@ -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
|
@ -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)
|
@ -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
|
||||
)
|
Reference in New Issue
Block a user