diff --git a/negstation/negstation.py b/negstation/negstation.py index d9d5548..9e170f7 100644 --- a/negstation/negstation.py +++ b/negstation/negstation.py @@ -13,7 +13,7 @@ from .layout_manager import LayoutManager from .widgets.base_widget import BaseWidget -logging.basicConfig(level=logging.DEBUG, +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger(__name__) diff --git a/negstation/widgets/export_widget.py b/negstation/widgets/export_widget.py index 409c89b..a443687 100644 --- a/negstation/widgets/export_widget.py +++ b/negstation/widgets/export_widget.py @@ -1,5 +1,42 @@ +# import dearpygui.dearpygui as dpg +# import numpy as np + +# from .pipeline_stage_widget import PipelineStageWidget + + +# class ExportStage(PipelineStageWidget): +# name = "Export Image" +# register = True +# has_pipeline_in = True +# has_pipeline_out = False + +# def __init__(self, manager, logger): +# super().__init__(manager, logger, default_stage_out="opened_image") +# self.manager.bus.subscribe( +# "process_full_res", self._on_process_full_res, True) + +# def create_pipeline_stage_content(self): +# dpg.add_text("Some export fields") + +# def _on_process_full_res(self, data): +# self.logger.info("Starting full res pipeline export") + +# def on_pipeline_data(self, img): +# if img is None: +# return +# self.logger.info("low res image received, ignore") + +# def on_full_res_pipeline_data(self, img): +# if img is None: +# return +# h, w, _ = img.shape +# self.logger.info(f"Full res image received: {w}x{h}") + + +import os import dearpygui.dearpygui as dpg import numpy as np +from PIL import Image from .pipeline_stage_widget import PipelineStageWidget @@ -11,23 +48,95 @@ class ExportStage(PipelineStageWidget): has_pipeline_out = False def __init__(self, manager, logger): - super().__init__(manager, logger, default_stage_out="opened_image") - self.manager.bus.subscribe( - "process_full_res", self._on_process_full_res, True) + # we don’t register an output stage — this widget only consumes + super().__init__(manager, logger, default_stage_out="unused") + # tags for our “Save As” dialog + self._save_dialog_tag = dpg.generate_uuid() + self._save_path = None def create_pipeline_stage_content(self): - dpg.add_text("Some export fields") + # Button to pop up the file-save dialog + dpg.add_button(label="Save As…", + callback=lambda s,a,u: dpg.configure_item(self._save_dialog_tag, show=True)) - def _on_process_full_res(self, data): - self.logger.info("Starting full res pipeline export") + # File dialog for choosing export path & extension + with dpg.file_dialog( + directory_selector=False, + show=False, + callback=self._on_save_selected, + tag=self._save_dialog_tag, + width=400, + height=300 + ): + dpg.add_file_extension("PNG {.png}") + dpg.add_file_extension("JPEG {.jpg,.jpeg}") + dpg.add_file_extension("TIFF {.tif,.tiff}") + dpg.add_file_extension("All files {.*}") - def on_pipeline_data(self, img): + with dpg.child_window(autosize_x=True, autosize_y=True, horizontal_scrollbar=True): + self.path_label = dpg.add_text("...") + + def _on_save_selected(self, sender, app_data): + """ + Called when the user picks a filename in the Save As… dialog. + Stores the path for the next full-res pass. + """ + # app_data is a dict with 'current_path' and 'selections' + path = os.path.join( + app_data["current_path"], + app_data["file_name"] + ) + self._save_path = path + self.logger.info(f"Export path set to: {path}") + dpg.set_value(self.path_label, path) + + def on_pipeline_data(self, img: np.ndarray): + # ignore all previews + return + + def on_full_res_pipeline_data(self, img: np.ndarray): + """ + Receives the full-resolution NumPy image when the user fires + the “Run full-res pipeline” action. Saves via Pillow. + """ if img is None: + self.logger.error("on_full_res_pipeline_data called with None image") + return + if not self._save_path: + self.logger.warning("No export path set — click Save As… first") return - self.logger.info("low res image received, ignore") - def on_full_res_pipeline_data(self, img): - if img is None: - return - h, w, _ = img.shape - self.logger.info(f"Full res image received: {w}x{h}") + # Decide bit depth by extension + ext = os.path.splitext(self._save_path)[-1].lower() + # Convert floats → uint; or leave ints alone + if np.issubdtype(img.dtype, np.floating): + if ext in (".tif", ".tiff"): + arr = np.clip(img * 65535.0, 0, 65535).astype(np.uint16) + else: + arr = np.clip(img * 255.0, 0, 255).astype(np.uint8) + else: + arr = img + + # Determine PIL mode + mode = None + if arr.ndim == 2: + mode = "L" + elif arr.ndim == 3: + c = arr.shape[2] + if c == 3: + mode = "RGB" + elif c == 4: + mode = "RGBA" + + try: + im = Image.fromarray(arr, mode) if mode else Image.fromarray(arr) + + # JPEG doesn’t support alpha — drop it + if ext in (".jpg", ".jpeg") and im.mode == "RGBA": + im = im.convert("RGB") + + im.save(self._save_path) + except Exception as e: + self.logger.error(f"Failed to save image to {self._save_path}: {e}") + else: + self.logger.info(f"Saved full-resolution image to {self._save_path}") diff --git a/negstation/widgets/histogram_widget.py b/negstation/widgets/histogram_widget.py index 38308c7..b4f675f 100644 --- a/negstation/widgets/histogram_widget.py +++ b/negstation/widgets/histogram_widget.py @@ -41,6 +41,9 @@ class HistogramWidget(PipelineStageWidget): self.img = img self.needs_redraw = True + def on_full_res_pipeline_data(self, img): + pass + def update(self): # TODO move calculations to on_pipeline_data if not self.needs_redraw or self.img is None: diff --git a/negstation_layout.ini b/negstation_layout.ini index 21a609b..3763de2 100644 --- a/negstation_layout.ini +++ b/negstation_layout.ini @@ -88,7 +88,7 @@ DockId=0x00000011,0 [Window][###35] Pos=272,19 -Size=710,588 +Size=700,588 Collapsed=0 DockId=0x00000033,0 @@ -100,7 +100,7 @@ DockId=0x00000020,0 [Window][###81] Pos=272,609 -Size=710,191 +Size=700,191 Collapsed=0 DockId=0x00000010,0 @@ -117,8 +117,8 @@ Collapsed=0 DockId=0x00000036,1 [Window][###111] -Pos=974,630 -Size=226,170 +Pos=974,666 +Size=226,134 Collapsed=0 DockId=0x00000015,0 @@ -142,7 +142,7 @@ DockId=0x00000022,0 [Window][###103] Pos=984,19 -Size=216,679 +Size=216,634 Collapsed=0 DockId=0x0000003F,0 @@ -208,7 +208,7 @@ DockId=0x00000031,0 [Window][###95] Pos=974,19 -Size=226,609 +Size=226,645 Collapsed=0 DockId=0x00000014,0 @@ -225,14 +225,14 @@ Collapsed=0 DockId=0x00000039,0 [Window][###123] -Pos=984,634 -Size=216,166 +Pos=984,655 +Size=216,145 Collapsed=0 DockId=0x0000003E,0 [Window][###102] Pos=984,19 -Size=216,781 +Size=216,613 Collapsed=0 DockId=0x0000003D,0 @@ -286,8 +286,8 @@ DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0,19 Siz DockNode ID=0x00000021 Parent=0x0000001D SizeRef=216,579 Split=Y Selected=0x714F2F7B DockNode ID=0x00000019 Parent=0x00000021 SizeRef=216,391 Selected=0x714F2F7B DockNode ID=0x0000001A Parent=0x00000021 SizeRef=216,388 Split=Y Selected=0x7E9438EA - DockNode ID=0x00000014 Parent=0x0000001A SizeRef=216,609 Selected=0x36EF55AB - DockNode ID=0x00000015 Parent=0x0000001A SizeRef=216,170 Selected=0x7E9438EA + DockNode ID=0x00000014 Parent=0x0000001A SizeRef=216,645 Selected=0x36EF55AB + DockNode ID=0x00000015 Parent=0x0000001A SizeRef=216,134 Selected=0x7E9438EA DockNode ID=0x00000022 Parent=0x0000001D SizeRef=216,200 Selected=0x0D80EC84 DockNode ID=0x0000001E Parent=0x0000001C SizeRef=216,435 Selected=0x7740BFE4 DockNode ID=0x00000024 Parent=0x00000027 SizeRef=216,781 Split=Y Selected=0xCF08B82F