diff --git a/.gitignore b/.gitignore index 96571bf..d479a67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__ -env \ No newline at end of file +env +out.png \ No newline at end of file diff --git a/negstation/negstation.py b/negstation/negstation.py index 6a756ba..70228da 100644 --- a/negstation/negstation.py +++ b/negstation/negstation.py @@ -13,8 +13,7 @@ from .layout_manager import LayoutManager from .widgets.base_widget import BaseWidget -logging.basicConfig(level=logging.INFO, - format="%(asctime)s %(levelname)s %(message)s") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger(__name__) @@ -62,16 +61,14 @@ 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 = {}): @@ -98,6 +95,18 @@ class EditorManager: def _on_scroll(self, sender, app_data, user_data): self.bus.publish_deferred("mouse_scrolled", app_data) + def _on_release(self, sender, app_data, user_data): + self.bus.publish_deferred( + "mouse_released", + { + "button": ( + "right" + if app_data == 0 + else ("left" if app_data == 1 else ("middle")) + ) + }, + ) + def setup(self): self._discover_and_register_widgets( f"{os.path.dirname(os.path.realpath(__file__))}/widgets" @@ -118,8 +127,7 @@ 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()): @@ -130,16 +138,9 @@ class EditorManager: ) with dpg.handler_registry() as self.handler_registry: - dpg.add_mouse_drag_handler( - callback=self._on_drag, threshold=1.0, button=0 - ) - dpg.add_mouse_drag_handler( - callback=self._on_drag, threshold=1.0, button=1 - ) - dpg.add_mouse_drag_handler( - callback=self._on_drag, threshold=1.0, button=2 - ) + dpg.add_mouse_drag_handler(callback=self._on_drag, threshold=1.0) dpg.add_mouse_wheel_handler(callback=self._on_scroll) + dpg.add_mouse_release_handler(callback=self._on_release) def run(self): self.setup() diff --git a/negstation/widgets/crop_widget.py b/negstation/widgets/crop_widget.py deleted file mode 100644 index de45f2c..0000000 --- a/negstation/widgets/crop_widget.py +++ /dev/null @@ -1,85 +0,0 @@ -import dearpygui.dearpygui as dpg -import numpy as np -from .stage_viewer_widget import PipelineStageViewer - - -class CropWidget(PipelineStageViewer): - name = "Crop Image" - register = True - has_pipeline_in = True - has_pipeline_out = True - - def __init__(self, manager, logger): - super().__init__(manager, logger) - self.crop_start = None # (x, y) - self.crop_end = None # (x, y) - self.crop_active = False - - self.manager.bus.subscribe("img_clicked", self.on_click) - self.manager.bus.subscribe("img_dragged", self.on_drag) - - def create_pipeline_stage_content(self): - super().create_pipeline_stage_content() - - # def on_full_res_pipeline_data(self, img): - # pass - - def on_pipeline_data(self, img): - if img is None: - return - self.img = img - - if self.crop_start and self.crop_end: - x0, y0 = self.crop_start - x1, y1 = self.crop_end - x0, x1 = sorted((int(x0), int(x1))) - y0, y1 = sorted((int(y0), int(y1))) - - x0 = max(0, min(x0, img.shape[1]-1)) - x1 = max(0, min(x1, img.shape[1]-1)) - y0 = max(0, min(y0, img.shape[0]-1)) - y1 = max(0, min(y1, img.shape[0]-1)) - - cropped = img[y0:y1, x0:x1, :] - self.publish_stage(cropped) - else: - self.publish_stage(img) - - self.needs_update = True - - def on_click(self, data): - if data["obj"] is not self: - return - if data["button"] == "left": - self.crop_start = data["pos"] - self.crop_end = data["pos"] - self.crop_active = True - self.needs_update = True - - def on_drag(self, data): - if data["obj"] is not self or not self.crop_active: - return - self.crop_end = data["pos"] - self.needs_update = True - - def update_texture(self, img): - super().update_texture(img) - if self.crop_start and self.crop_end: - # map image coords back to screen coords - x0, y0 = self.crop_start - x1, y1 = self.crop_end - h, w, _ = self.img.shape - img_x, img_y = self.image_position - img_w, img_h = self.scaled_size - - p0 = ( - img_x + x0 / w * img_w, - img_y + y0 / h * img_h - ) - p1 = ( - img_x + x1 / w * img_w, - img_y + y1 / h * img_h - ) - - dpg.draw_rectangle(pmin=p0, pmax=p1, color=(255, 255, 0, 255), - fill=(255, 255, 0, 50), thickness=2, parent=self.drawlist) diff --git a/negstation/widgets/export_widget.py b/negstation/widgets/export_widget.py index a443687..7c7a2d6 100644 --- a/negstation/widgets/export_widget.py +++ b/negstation/widgets/export_widget.py @@ -52,7 +52,7 @@ class ExportStage(PipelineStageWidget): 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 + self._save_path = f"{os.getcwd()}/out.png" def create_pipeline_stage_content(self): # Button to pop up the file-save dialog @@ -74,7 +74,7 @@ class ExportStage(PipelineStageWidget): dpg.add_file_extension("All files {.*}") with dpg.child_window(autosize_x=True, autosize_y=True, horizontal_scrollbar=True): - self.path_label = dpg.add_text("...") + self.path_label = dpg.add_text("out.png") def _on_save_selected(self, sender, app_data): """ diff --git a/negstation/widgets/framing_widget.py b/negstation/widgets/framing_widget.py index 5bc2a80..dda0327 100644 --- a/negstation/widgets/framing_widget.py +++ b/negstation/widgets/framing_widget.py @@ -1,7 +1,7 @@ import dearpygui.dearpygui as dpg import numpy as np import time -from scipy.ndimage import rotate +import scipy.ndimage as snd from .stage_viewer_widget import PipelineStageViewer @@ -15,74 +15,71 @@ class FramingWidget(PipelineStageViewer): 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 + self.needs_publishing = False - # Throttle publishing to a few Hz - self._last_pub_time = 0.0 - self._publish_interval = 0.5 # seconds + # Rotation line endpoints (canvas coords) + self.rot_start = None # (x, y) + self.rot_end = None # (x, y) + self.angle = 0.0 # computed deskew angle + + # Crop rect endpoints (canvas coords) + self.crop_start = None # (x, y) + self.crop_end = None # (x, y) 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) + self.manager.bus.subscribe("img_released", self.on_release) def create_pipeline_stage_content(self): super().create_pipeline_stage_content() + def on_full_res_pipeline_data(self, img): + if img is None: + return + img = self.rotate(img) + self.publish_stage(self.crop(img)) + def on_pipeline_data(self, img): if img is None: return - self.img = img.copy() - self._publish_rotated_and_cropped() + self.original_img = img.copy() self.needs_update = True + # Apply transformation and publish + self.img = self.rotate(img) + self.publish_stage(self.crop(self.img)) + def update_texture(self, img): super().update_texture(img) - # Draw rotation guide if active + + # Draw rotation guide 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) + dpg.draw_line( + p1=p0, + p2=p1, + color=(255, 0, 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 + # Draw crop rect + if self.crop_start and self.crop_end: + p0 = self._pos_to_canvas(self.crop_start) + p1 = self._pos_to_canvas(self.crop_end) + dpg.draw_rectangle( + pmin=p0, + pmax=p1, + color=(255, 0, 255, 255), + thickness=2, + parent=self.drawlist, + ) - 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) + if self.needs_publishing: + img = self.crop(self.img) + self.publish_stage(img) def _pos_to_canvas(self, img_pos): x, y = img_pos @@ -91,28 +88,91 @@ class FramingWidget(PipelineStageViewer): 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: + def on_click(self, data): + if data.get("obj") is not self: + return + x, y = data.get("pos") + if data["button"] == "left": + self.rot_start = (x, y) + self.rot_end = None + elif data["button"] == "right": + self.crop_start = (x,y) + self.crop_end = None + self.needs_update = True + + def on_drag(self, data): + if data.get("obj") is not self: + return + x, y = data.get("pos") + if data["button"] == "left": + self.rot_end = (x, y) + elif data["button"] == "right": + self.crop_end = (x,y) + self.needs_update = True + + def on_scroll(self, data): + if data.get("obj") is not self: + return + + def on_release(self, data): + if data.get("obj") is not self: + return + if data["button"] == "left": + # End of rotation line dragging + 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)) + # Do the rotation + self.img = self.rotate(self.original_img) + # Delete lines + self.rot_start = self.rot_end = None + self.needs_publishing = True + elif data["button"] == "right": + self.needs_publishing = True + + self.needs_update = True + + def rotate(self, img): + img = snd.rotate(img, self.angle, reshape=False) + return img + + def crop(self, img): + """ + Crop `img` according to a rectangle drawn in the DISPLAY, + by converting display‐coords to image‐coords via percentages. + """ + # only crop if user has dragged out a box + if not (self.crop_start and self.crop_end): + return img + + # 1) get actual image dims 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] + + # 2) get display info (set in update_texture) + disp_h, disp_w, _ = self.img.shape + + # 3) unpack the two drag points (in screen coords) + sx, sy = self.crop_start + ex, ey = self.crop_end + + # 4) normalize into [0..1] + nx0 = np.clip(sx / disp_w, 0.0, 1.0) + ny0 = np.clip(sy / disp_h, 0.0, 1.0) + nx1 = np.clip(ex / disp_w, 0.0, 1.0) + ny1 = np.clip(ey / disp_h, 0.0, 1.0) + + # 5) map to image‐pixel coords + x0 = int(nx0 * w) + y0 = int(ny0 * h) + x1 = int(nx1 * w) + y1 = int(ny1 * h) + + # 6) sort & clamp again just in case + x0, x1 = sorted((max(0, x0), min(w, x1))) + y0, y1 = sorted((max(0, y0), min(h, y1))) + + # 7) avoid zero‐area + if x0 == x1 or y0 == y1: + return img + + return img[y0:y1, x0:x1] \ No newline at end of file diff --git a/negstation/widgets/pipeline_stage_widget.py b/negstation/widgets/pipeline_stage_widget.py index 7fb7683..651d2f7 100644 --- a/negstation/widgets/pipeline_stage_widget.py +++ b/negstation/widgets/pipeline_stage_widget.py @@ -81,6 +81,8 @@ class PipelineStageWidget(BaseWidget): self.manager.pipeline.publish( self.pipeline_stage_out_id, img, full_res=self._last_full ) + # Reset last_full + self._last_full = False def get_config(self): return { diff --git a/negstation/widgets/stage_viewer_widget.py b/negstation/widgets/stage_viewer_widget.py index 3ffca1e..aed982e 100644 --- a/negstation/widgets/stage_viewer_widget.py +++ b/negstation/widgets/stage_viewer_widget.py @@ -19,9 +19,11 @@ class PipelineStageViewer(PipelineStageWidget): self.canvas_handler = None self.scaled_size = (0, 0) self.image_position = (0, 0) + self._last_tex_size = (0, 0) self.manager.bus.subscribe("mouse_dragged", self._on_mouse_drag, False) self.manager.bus.subscribe("mouse_scrolled", self._on_mouse_scroll, False) + self.manager.bus.subscribe("mouse_released", self._on_mouse_release, False) def create_pipeline_stage_content(self): # Create an empty dynamic texture @@ -52,6 +54,7 @@ class PipelineStageViewer(PipelineStageWidget): and local_x < img_x + img_w and local_y >= img_y and local_y < img_y + img_h + and dpg.is_item_focused(self.window_tag) ): # calculate the image coordinate x = int((local_x - img_x) * self.img.shape[1] / img_w) @@ -84,6 +87,7 @@ class PipelineStageViewer(PipelineStageWidget): and local_x < img_x + img_w and local_y >= img_y and local_y < img_y + img_h + and dpg.is_item_focused(self.window_tag) ): # calculate the image coordinate x = int((local_x - img_x) * self.img.shape[1] / img_w) @@ -113,6 +117,7 @@ class PipelineStageViewer(PipelineStageWidget): and local_x < img_x + img_w and local_y >= img_y and local_y < img_y + img_h + and dpg.is_item_focused(self.window_tag) ): # calculate the image coordinate x = int((local_x - img_x) * self.img.shape[1] / img_w) @@ -127,6 +132,35 @@ class PipelineStageViewer(PipelineStageWidget): }, ) + def _on_mouse_release(self, data): + mouse_x, mouse_y = dpg.get_mouse_pos(local=False) + canvas_x, canvas_y = dpg.get_item_rect_min(self.drawlist) + local_x = mouse_x - canvas_x + local_y = mouse_y - canvas_y + + img_x, img_y = self.image_position + img_w, img_h = self.scaled_size + + if ( + local_x >= img_x + and local_x < img_x + img_w + and local_y >= img_y + and local_y < img_y + img_h + and dpg.is_item_focused(self.window_tag) + ): + # calculate the image coordinate + x = int((local_x - img_x) * self.img.shape[1] / img_w) + y = int((local_y - img_y) * self.img.shape[0] / img_h) + self.manager.bus.publish_deferred( + "img_released", + { + "stage_id": self.pipeline_stage_in_id, + "pos": (x, y), + "button": data['button'], + "obj":self, + }, + ) + def on_resize(self, width, height): self.needs_update = True @@ -143,20 +177,30 @@ class PipelineStageViewer(PipelineStageWidget): if img is None: return + # Only recreate the texture if its size changed h, w, _ = img.shape flat = img.flatten().tolist() + if (w, h) != self._last_tex_size or not dpg.does_item_exist(self.texture_tag): + # remove old one if present + if dpg.does_item_exist(self.texture_tag): + dpg.delete_item(self.texture_tag) + # create a new dynamic texture at the new size + dpg.add_dynamic_texture( + width=w, + height=h, + default_value=flat, + tag=self.texture_tag, + parent=self.registry, + ) + self._last_tex_size = (w, h) + else: + # just upload new pixels into the existing texture + dpg.set_value(self.texture_tag, flat) - # Replace texture - 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, - ) + # Clear old drawings + dpg.delete_item(self.drawlist, children_only=True) + # Draw the image to the screen win_w, win_h = self.window_width, self.window_height scale = min(win_w / w, win_h / h) disp_w = int(w * scale) @@ -168,9 +212,6 @@ class PipelineStageViewer(PipelineStageWidget): self.scaled_size = (disp_w, disp_h) self.image_position = (x_off, y_off) - # Clear old drawings - dpg.delete_item(self.drawlist, children_only=True) - # Draw image dpg.draw_image( self.texture_tag, diff --git a/negstation_layout.ini b/negstation_layout.ini index a819890..735dc78 100644 --- a/negstation_layout.ini +++ b/negstation_layout.ini @@ -19,7 +19,7 @@ DockId=0x00000007,0 Pos=0,19 Size=270,691 Collapsed=0 -DockId=0x00000041,1 +DockId=0x0000005B,1 [Window][###59] Pos=0,494 @@ -46,9 +46,9 @@ DockId=0x0000000E,0 [Window][###23] Pos=0,19 -Size=270,693 +Size=270,437 Collapsed=0 -DockId=0x00000041,0 +DockId=0x0000005B,0 [Window][###29] Pos=0,19 @@ -192,7 +192,7 @@ DockId=0x00000036,0 Pos=0,19 Size=270,567 Collapsed=0 -DockId=0x00000041,1 +DockId=0x0000005B,1 [Window][###82] Pos=272,601 @@ -257,7 +257,7 @@ DockId=0x00000042,1 Pos=0,19 Size=270,679 Collapsed=0 -DockId=0x00000041,1 +DockId=0x0000005B,1 [Window][###83] Pos=272,612 @@ -278,10 +278,10 @@ Collapsed=0 DockId=0x0000004A,0 [Window][###36] -Pos=272,19 -Size=710,625 +Pos=0,458 +Size=270,254 Collapsed=0 -DockId=0x00000014,0 +DockId=0x0000005C,0 [Window][###48] Pos=0,714 @@ -291,9 +291,9 @@ DockId=0x00000036,1 [Window][###56] Pos=0,19 -Size=270,693 +Size=270,437 Collapsed=0 -DockId=0x00000041,1 +DockId=0x0000005B,1 [Window][###93] Pos=0,714 @@ -340,7 +340,7 @@ DockId=0x00000042,1 Pos=0,19 Size=270,679 Collapsed=0 -DockId=0x00000041,1 +DockId=0x0000005B,1 [Window][###104] Pos=984,19 @@ -370,7 +370,7 @@ DockId=0x00000014,1 Pos=272,19 Size=710,625 Collapsed=0 -DockId=0x00000014,1 +DockId=0x00000014,0 [Window][###169] Pos=272,19 @@ -418,7 +418,9 @@ DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0, DockNode ID=0x00000013 Parent=0x0000000D SizeRef=196,156 Split=Y Selected=0xB4AD3310 DockNode ID=0x0000001F Parent=0x00000013 SizeRef=270,469 Split=Y Selected=0xD36850C8 DockNode ID=0x00000035 Parent=0x0000001F SizeRef=270,693 Split=Y Selected=0xD36850C8 - DockNode ID=0x00000041 Parent=0x00000035 SizeRef=270,679 Selected=0x068DEF00 + DockNode ID=0x00000041 Parent=0x00000035 SizeRef=270,679 Split=Y Selected=0x068DEF00 + DockNode ID=0x0000005B Parent=0x00000041 SizeRef=270,437 Selected=0x068DEF00 + DockNode ID=0x0000005C Parent=0x00000041 SizeRef=270,254 Selected=0xD0D40C1D DockNode ID=0x00000042 Parent=0x00000035 SizeRef=270,100 Selected=0x89CD1AA0 DockNode ID=0x00000036 Parent=0x0000001F SizeRef=270,86 Selected=0xB9AFA00B DockNode ID=0x00000020 Parent=0x00000013 SizeRef=270,154 Selected=0x0531B3D5 @@ -436,7 +438,7 @@ DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0, DockNode ID=0x00000033 Parent=0x0000000F SizeRef=710,580 Split=Y Selected=0x977476CD DockNode ID=0x00000043 Parent=0x00000033 SizeRef=710,591 Split=Y Selected=0x4EE4732B DockNode ID=0x00000045 Parent=0x00000043 SizeRef=710,625 Split=Y Selected=0xD0D40C1D - DockNode ID=0x00000014 Parent=0x00000045 SizeRef=710,584 CentralNode=1 Selected=0xD0D40C1D + DockNode ID=0x00000014 Parent=0x00000045 SizeRef=710,584 CentralNode=1 Selected=0x2349CB28 DockNode ID=0x00000015 Parent=0x00000045 SizeRef=710,195 Selected=0x38436B0F DockNode ID=0x00000046 Parent=0x00000043 SizeRef=710,154 Selected=0x8773D56E DockNode ID=0x00000044 Parent=0x00000033 SizeRef=710,188 Selected=0x72F373AE diff --git a/negstation_widgets.json b/negstation_widgets.json index 92d131c..c3a9e10 100644 --- a/negstation_widgets.json +++ b/negstation_widgets.json @@ -83,7 +83,7 @@ "widget_type": "ExportStage", "config": { "pipeline_config": { - "stage_in": 5, + "stage_in": 6, "stage_out": null } } diff --git a/requirements b/requirements index ad6ee52..7b43456 100644 --- a/requirements +++ b/requirements @@ -2,4 +2,5 @@ pillow gphoto2 dearpygui numpy -rawpy \ No newline at end of file +rawpy +scipy \ No newline at end of file