Framing widget done

This commit is contained in:
2025-08-04 18:12:08 +02:00
parent 2a6498f117
commit 66d542f742
10 changed files with 229 additions and 206 deletions

View File

@ -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)

View File

@ -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):
"""

View File

@ -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 displaycoords to imagecoords 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 imagepixel 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 zeroarea
if x0 == x1 or y0 == y1:
return img
return img[y0:y1, x0:x1]

View File

@ -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 {

View File

@ -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,