From 2a6498f1170800169cdb75a6b432acc8df89be24 Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Mon, 4 Aug 2025 12:04:54 +0200 Subject: [PATCH] Start of framing widget --- negstation/negstation.py | 12 ++- negstation/widgets/framing_widget.py | 118 +++++++++++++++++++++++++++ negstation_layout.ini | 49 +++++++---- negstation_widgets.json | 10 +-- 4 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 negstation/widgets/framing_widget.py diff --git a/negstation/negstation.py b/negstation/negstation.py index 1be46be..6a756ba 100644 --- a/negstation/negstation.py +++ b/negstation/negstation.py @@ -13,7 +13,8 @@ from .layout_manager import LayoutManager from .widgets.base_widget import BaseWidget -logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(levelname)s %(message)s") +logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger(__name__) @@ -61,14 +62,16 @@ class EditorManager: and cls is not ModuleBaseWidget and cls.register ): - logging.info(f" -> Found and registered widget: {name}") + 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.") + logging.warning( + f"Widget '{name}' is already registered. Overwriting.") self.widget_classes[name] = widget_class def _add_widget(self, widget_type: str, config: dict = {}): @@ -115,7 +118,8 @@ class EditorManager: "process_full_res", None ), ) - dpg.add_menu_item(label="Quit", callback=lambda: dpg.stop_dearpygui()) + 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()): diff --git a/negstation/widgets/framing_widget.py b/negstation/widgets/framing_widget.py new file mode 100644 index 0000000..5bc2a80 --- /dev/null +++ b/negstation/widgets/framing_widget.py @@ -0,0 +1,118 @@ +import dearpygui.dearpygui as dpg +import numpy as np +import time +from scipy.ndimage import rotate + +from .stage_viewer_widget import PipelineStageViewer + + +class FramingWidget(PipelineStageViewer): + name = "Framing" + register = True + has_pipeline_in = True + has_pipeline_out = True + + def __init__(self, manager, logger): + super().__init__(manager, logger) + + # Rotation line endpoints (canvas coords) + self.rot_start = None # (x, y) + self.rot_end = None # (x, y) + self.angle = 0.0 # computed deskew angle + + # Throttle publishing to a few Hz + self._last_pub_time = 0.0 + self._publish_interval = 0.5 # seconds + + self.manager.bus.subscribe("img_clicked", self.on_click) + self.manager.bus.subscribe("img_dragged", self.on_drag) + self.manager.bus.subscribe("img_scrolled", self.on_scroll) + + def create_pipeline_stage_content(self): + super().create_pipeline_stage_content() + + def on_pipeline_data(self, img): + if img is None: + return + self.img = img.copy() + self._publish_rotated_and_cropped() + self.needs_update = True + + def update_texture(self, img): + super().update_texture(img) + # Draw rotation guide if active + if self.rot_start and self.rot_end: + p0 = self._pos_to_canvas(self.rot_start) + p1 = self._pos_to_canvas(self.rot_end) + dpg.draw_line(p1=p0, p2=p1, color=( + 255, 255, 0, 255), thickness=2, parent=self.drawlist) + + def on_click(self, data): + if data.get("obj") is not self: + return + x, y = data.get("pos") + button = data.get("button") + if button == "right": + self.rot_start = (x, y) + self.rot_end = (x, y) + self.needs_update = True + + def on_drag(self, data): + if data.get("obj") is not self: + return + x, y = data.get("pos") + button = data.get("button") + if button == "right": + self.rot_end = (x, y) + # Update angle on rotation drag + if self.rot_start and self.rot_end: + dx = self.rot_end[0] - self.rot_start[0] + dy = self.rot_end[1] - self.rot_start[1] + self.angle = np.degrees(np.arctan2(dy, dx)) + # Throttle publishes + now = time.time() + if now - self._last_pub_time >= self._publish_interval: + self._publish_rotated_and_cropped() + self._last_pub_time = now + self.needs_update = True + + def on_scroll(self, data): + print(data) + + def _publish_rotated_and_cropped(self): + w, h, _ = self.img.shape + out = self.rotate_and_crop(self.img, self.angle, (0, 0, w, h)) + self.publish_stage(out) + + def _pos_to_canvas(self, img_pos): + x, y = img_pos + ix, iy = self.image_position + iw, ih = self.img.shape[1], self.img.shape[0] + sw, sh = self.scaled_size + return (ix + x / iw * sw, iy + y / ih * sh) + + def rotate_and_crop( + self, + img: np.ndarray, + angle: float, + rect: tuple[int, int, int, int], + cval: float = 0.0 + ) -> np.ndarray: + h, w = img.shape[:2] + x, y, cw, ch = rect + rotated = np.empty_like(img) + for c in range(img.shape[2]): + rotated[..., c] = rotate( + img[..., c], + angle, + reshape=False, + order=1, # bilinear interpolation + mode='constant', + cval=cval, + prefilter=False + ) + x0 = max(0, min(int(x), w - 1)) + y0 = max(0, min(int(y), h - 1)) + x1 = max(0, min(int(x + cw), w)) + y1 = max(0, min(int(y + ch), h)) + return rotated[y0:y1, x0:x1] diff --git a/negstation_layout.ini b/negstation_layout.ini index 7c7359e..a819890 100644 --- a/negstation_layout.ini +++ b/negstation_layout.ini @@ -81,10 +81,10 @@ Collapsed=0 DockId=0x00000012,1 [Window][###107] -Pos=939,19 -Size=261,781 +Pos=958,19 +Size=242,460 Collapsed=0 -DockId=0x00000011,0 +DockId=0x00000059,0 [Window][###35] Pos=272,19 @@ -112,7 +112,7 @@ DockId=0x00000013,1 [Window][###87] Pos=272,646 -Size=684,154 +Size=710,154 Collapsed=0 DockId=0x00000046,0 @@ -168,7 +168,7 @@ DockId=0x0000004E,0 Pos=939,555 Size=261,245 Collapsed=0 -DockId=0x00000016,0 +DockId=0x0000002C,0 [Window][###97] Pos=972,19 @@ -279,9 +279,9 @@ DockId=0x0000004A,0 [Window][###36] Pos=272,19 -Size=684,625 +Size=710,625 Collapsed=0 -DockId=0x00000014,1 +DockId=0x00000014,0 [Window][###48] Pos=0,714 @@ -320,10 +320,10 @@ Collapsed=0 DockId=0x00000057,0 [Window][###131] -Pos=958,653 -Size=242,147 +Pos=958,664 +Size=242,136 Collapsed=0 -DockId=0x00000056,0 +DockId=0x00000016,0 [Window][###128] Pos=60,60 @@ -370,7 +370,7 @@ DockId=0x00000014,1 Pos=272,19 Size=710,625 Collapsed=0 -DockId=0x00000014,0 +DockId=0x00000014,1 [Window][###169] Pos=272,19 @@ -384,6 +384,23 @@ Size=242,368 Collapsed=0 DockId=0x00000058,0 +[Window][###157] +Pos=958,481 +Size=242,181 +Collapsed=0 +DockId=0x0000005A,0 + +[Window][###178] +Pos=60,60 +Size=148,100 +Collapsed=0 + +[Window][###184] +Pos=272,19 +Size=710,625 +Collapsed=0 +DockId=0x00000014,1 + [Docking][Data] DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0,19 Size=1200,781 Split=X DockNode ID=0x00000053 Parent=0x7C6B3D9B SizeRef=956,781 Split=X @@ -460,9 +477,7 @@ DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0, DockNode ID=0x0000002A Parent=0x00000028 SizeRef=216,170 Split=Y Selected=0xF8004A44 DockNode ID=0x0000004D Parent=0x0000002A SizeRef=216,575 Selected=0x7D28643F DockNode ID=0x0000004E Parent=0x0000002A SizeRef=216,204 Selected=0xF8004A44 - DockNode ID=0x0000002C Parent=0x0000002D SizeRef=261,781 Split=Y Selected=0xC8700185 - DockNode ID=0x00000011 Parent=0x0000002C SizeRef=142,534 Selected=0x3A881EEF - DockNode ID=0x00000016 Parent=0x0000002C SizeRef=142,245 Selected=0xC8700185 + DockNode ID=0x0000002C Parent=0x0000002D SizeRef=261,781 Selected=0xC8700185 DockNode ID=0x0000002E Parent=0x00000037 SizeRef=228,781 Split=Y Selected=0x4C2F06CB DockNode ID=0x0000002F Parent=0x0000002E SizeRef=216,560 Selected=0x4C2F06CB DockNode ID=0x00000030 Parent=0x0000002E SizeRef=216,219 Selected=0x04546B8A @@ -473,5 +488,9 @@ DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0, DockNode ID=0x00000055 Parent=0x00000054 SizeRef=158,632 Split=Y Selected=0xA2A5002C DockNode ID=0x00000057 Parent=0x00000055 SizeRef=158,262 Selected=0x85B8A08E DockNode ID=0x00000058 Parent=0x00000055 SizeRef=158,368 Selected=0xA2A5002C - DockNode ID=0x00000056 Parent=0x00000054 SizeRef=158,147 Selected=0x335C99E1 + DockNode ID=0x00000056 Parent=0x00000054 SizeRef=158,147 Split=Y Selected=0x335C99E1 + DockNode ID=0x00000011 Parent=0x00000056 SizeRef=242,643 Split=Y Selected=0x3A881EEF + DockNode ID=0x00000059 Parent=0x00000011 SizeRef=242,460 Selected=0x3A881EEF + DockNode ID=0x0000005A Parent=0x00000011 SizeRef=242,181 Selected=0x6A458F5C + DockNode ID=0x00000016 Parent=0x00000056 SizeRef=242,136 Selected=0x335C99E1 diff --git a/negstation_widgets.json b/negstation_widgets.json index 9707d42..92d131c 100644 --- a/negstation_widgets.json +++ b/negstation_widgets.json @@ -5,7 +5,7 @@ "2": "opened_raw", "3": "monochrome", "4": "oriented_image", - "5": "cropped_image" + "6": "framed_image" }, "widgets": [ { @@ -21,7 +21,7 @@ "widget_type": "PipelineStageViewer", "config": { "pipeline_config": { - "stage_in": 5, + "stage_in": 6, "stage_out": null } } @@ -74,7 +74,7 @@ "widget_type": "HistogramWidget", "config": { "pipeline_config": { - "stage_in": 5, + "stage_in": 6, "stage_out": null } } @@ -103,11 +103,11 @@ } }, { - "widget_type": "CropWidget", + "widget_type": "FramingWidget", "config": { "pipeline_config": { "stage_in": 4, - "stage_out": 5 + "stage_out": 6 } } }