Compare commits
1 Commits
main-merge
...
magic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bb8a30f97 |
18
.idea/workspace.xml
generated
@@ -5,11 +5,12 @@
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="5e47ddf2-717f-442d-8925-70fd842ea97b" name="Changes" comment="">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/assignment_2_improved.iml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/assignment_2_improved.iml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/.idea/runConfigurations/Run_project.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/runConfigurations/Run_Project.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/icon.png" beforeDir="false" afterPath="$PROJECT_DIR$/src/icon.png" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/main.py" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/assets/cowboy_hat.png" beforeDir="false" afterPath="$PROJECT_DIR$/assets/cowboy_hat.png" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/assets/glasses.png" beforeDir="false" afterPath="$PROJECT_DIR$/assets/glasses.png" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/assets/moustache.png" beforeDir="false" afterPath="$PROJECT_DIR$/assets/moustache.png" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/icon.png" beforeDir="false" afterPath="$PROJECT_DIR$/icon.png" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/lena.png" beforeDir="false" afterPath="$PROJECT_DIR$/lena.png" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -41,11 +42,12 @@
|
||||
"Python.Run project.executor": "Run",
|
||||
"Python.demo.executor": "Run",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
|
||||
"git-widget-placeholder": "main",
|
||||
"junie.onboarding.icon.badge.shown": "true",
|
||||
"last_opened_file_path": "/home/kim/Dokumenter/Universitet/Machine Vision/ikt213_project/src",
|
||||
"last_opened_file_path": "C:/Users/ali-a/ikt213g25h/project/ikt213_project",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
@@ -65,8 +67,8 @@
|
||||
<component name="SharedIndexes">
|
||||
<attachedChunks>
|
||||
<set>
|
||||
<option value="bundled-js-predefined-d6986cc7102b-3aa1da707db6-JavaScript-PY-252.26830.99" />
|
||||
<option value="bundled-python-sdk-164cda30dcd9-0af03a5fa574-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-252.26830.99" />
|
||||
<option value="bundled-js-predefined-d6986cc7102b-b598e85cdad2-JavaScript-PY-252.25557.130" />
|
||||
<option value="bundled-python-sdk-1bde30d8e611-7b97d883f26b-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-252.25557.130" />
|
||||
</set>
|
||||
</attachedChunks>
|
||||
</component>
|
||||
@@ -81,6 +83,8 @@
|
||||
<workItem from="1760178939983" duration="46000" />
|
||||
<workItem from="1760179006753" duration="6999000" />
|
||||
<workItem from="1761202447788" duration="4986000" />
|
||||
<workItem from="1762449673210" duration="27000" />
|
||||
<workItem from="1762450070679" duration="216000" />
|
||||
</task>
|
||||
<servers />
|
||||
</component>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 132 B |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 130 B |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 130 B |
BIN
icon.png
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 129 B |
BIN
lena.png
|
Before Width: | Height: | Size: 463 KiB After Width: | Height: | Size: 131 B |
67
src/GUI.py
@@ -83,6 +83,7 @@ class GUI:
|
||||
edit_menu.add_command(label="Toggle Selection Shape (Rect/Circle/Lasso)", accelerator="Ctrl+Shift+C", command=lambda: self._toggleSelectionShape())
|
||||
edit_menu.add_separator()
|
||||
edit_menu.add_command(label="Brush Tool", accelerator="Ctrl+B", command=lambda: self._toggleBrushMode())
|
||||
edit_menu.add_command(label="Magic Scissors", accelerator="Ctrl+M", command=lambda: self._enableMagicScissorsMode())
|
||||
|
||||
image_menu = tk.Menu(menu, tearoff=0)
|
||||
menu.add_cascade(label="Image", menu=image_menu)
|
||||
@@ -168,6 +169,11 @@ class GUI:
|
||||
# Text entry handler from utils/text_entry.py
|
||||
self._textEntryHandler = TextEntryHandler(root)
|
||||
|
||||
# Status bar
|
||||
self._status_var = tk.StringVar(value="Ready")
|
||||
status_bar = tk.Label(root, textvariable=self._status_var, anchor="w", bg="#f0f0f0", relief="sunken")
|
||||
status_bar.pack(side="bottom", fill="x")
|
||||
|
||||
# Key bindings
|
||||
root.bind_all('<Control-z>', lambda event: self._undo())
|
||||
root.bind_all('<Control-y>', lambda event: self._redo())
|
||||
@@ -178,6 +184,10 @@ class GUI:
|
||||
root.bind_all('<Control-Shift-S>', lambda event: self._toggleSelectionMode())
|
||||
root.bind_all('<Control-Shift-C>', lambda event: self._toggleSelectionShape())
|
||||
root.bind_all('<Control-b>', lambda event: self._toggleBrushMode() if self._brushHandler else None)
|
||||
root.bind_all('<Control-m>', lambda event: self._enableMagicScissorsMode())
|
||||
root.bind_all('<Return>', lambda event: self._finalizeMagicScissors())
|
||||
root.bind_all('<Escape>', lambda event: self._cancelMagicScissors())
|
||||
root.bind_all('<BackSpace>', lambda event: self._undoMagicScissorsSegment())
|
||||
|
||||
root.mainloop()
|
||||
|
||||
@@ -191,6 +201,7 @@ class GUI:
|
||||
self._currentImage = ImageContainer()
|
||||
self._currentImage.loadImage(file_path)
|
||||
self._renderCurrentImage()
|
||||
self._updateStatus("Image loaded. Use Edit → Magic Scissors to try the new intelligent lasso.")
|
||||
|
||||
def _renderCurrentImage(self) -> None:
|
||||
if self._currentImage is None or self._currentImage.getImage() is None:
|
||||
@@ -264,12 +275,25 @@ class GUI:
|
||||
def _toggleSelectionMode(self) -> None:
|
||||
"""Toggle interactive selection mode on/off."""
|
||||
if self._areaSelectionHandler:
|
||||
was_magic = self._areaSelectionHandler.selection_type == "magic_scissors"
|
||||
self._areaSelectionHandler.toggle_selection_mode()
|
||||
if self._areaSelectionHandler.selection_mode:
|
||||
self._updateStatus("Selection mode enabled. Choose rectangle, circle, lasso, or Magic Scissors.")
|
||||
else:
|
||||
self._updateStatus("Selection mode disabled.")
|
||||
self._renderCurrentImage()
|
||||
if was_magic and self._areaSelectionHandler.selection_type != "magic_scissors":
|
||||
self._updateStatus("Magic Scissors cancelled.")
|
||||
|
||||
def _toggleSelectionShape(self) -> None:
|
||||
"""Toggle selection shape between rectangle, circle, and lasso."""
|
||||
if self._areaSelectionHandler:
|
||||
was_magic = self._areaSelectionHandler.selection_type == "magic_scissors"
|
||||
self._areaSelectionHandler.toggle_selection_shape()
|
||||
self._updateStatus(f"Selection shape: {self._areaSelectionHandler.selection_type.title()}.")
|
||||
self._renderCurrentImage()
|
||||
if was_magic and self._areaSelectionHandler.selection_type != "magic_scissors":
|
||||
self._updateStatus("Magic Scissors cancelled.")
|
||||
|
||||
def _onMouseClick(self, event) -> None:
|
||||
"""Handle mouse click for selection or brush."""
|
||||
@@ -280,6 +304,8 @@ class GUI:
|
||||
# Handle selection mode - delegate to handler
|
||||
if self._areaSelectionHandler:
|
||||
self._areaSelectionHandler.on_mouse_click(event)
|
||||
if self._areaSelectionHandler.selection_type == "magic_scissors":
|
||||
self._updateStatus("Magic Scissors active: click to set points, move mouse to follow edges, Enter to finalize, Esc to cancel.")
|
||||
|
||||
def _onMouseDrag(self, event) -> None:
|
||||
"""Handle mouse drag for selection or brush."""
|
||||
@@ -341,9 +367,48 @@ class GUI:
|
||||
def _toggleBrushMode(self) -> None:
|
||||
"""Toggle brush mode on/off."""
|
||||
if self._brushHandler:
|
||||
if self._areaSelectionHandler and self._areaSelectionHandler.selection_type == "magic_scissors":
|
||||
self._areaSelectionHandler.cancel_magic_scissors()
|
||||
self._renderCurrentImage()
|
||||
self._updateStatus("Magic Scissors cancelled.")
|
||||
self._brushHandler.toggle_brush_mode()
|
||||
if self._brushHandler.brush_mode:
|
||||
self._updateStatus("Brush mode active. Hold left mouse to paint. Ctrl+B to exit.")
|
||||
else:
|
||||
self._updateStatus("Brush mode inactive.")
|
||||
|
||||
|
||||
def _openCamera(self):
|
||||
camera = CameraWindow()
|
||||
camera.run(self)
|
||||
camera.run(self)
|
||||
|
||||
def _enableMagicScissorsMode(self) -> None:
|
||||
if not self._areaSelectionHandler:
|
||||
return
|
||||
self._areaSelectionHandler.enable_magic_scissors_mode()
|
||||
if self._areaSelectionHandler.selection_type == "magic_scissors":
|
||||
self._updateStatus("Magic Scissors: click to set a start point, move to trace edges, click to add segments, Enter to finalize, Esc to cancel, Backspace to undo last segment.")
|
||||
|
||||
def _finalizeMagicScissors(self) -> None:
|
||||
if not self._areaSelectionHandler:
|
||||
return
|
||||
self._areaSelectionHandler.finalize_magic_scissors()
|
||||
if self._areaSelectionHandler.selection_type != "magic_scissors":
|
||||
self._updateStatus("Magic Scissors selection finalized.")
|
||||
|
||||
def _cancelMagicScissors(self) -> None:
|
||||
if not self._areaSelectionHandler:
|
||||
return
|
||||
self._areaSelectionHandler.cancel_magic_scissors()
|
||||
self._updateStatus("Magic Scissors cancelled.")
|
||||
|
||||
def _undoMagicScissorsSegment(self) -> None:
|
||||
if not self._areaSelectionHandler:
|
||||
return
|
||||
self._areaSelectionHandler.undo_magic_scissors_segment()
|
||||
if self._areaSelectionHandler.selection_type == "magic_scissors":
|
||||
self._updateStatus("Magic Scissors: last segment removed. Continue tracing, Enter to finalize.")
|
||||
|
||||
def _updateStatus(self, message: str) -> None:
|
||||
if hasattr(self, "_status_var") and self._status_var is not None:
|
||||
self._status_var.set(message)
|
||||
@@ -41,19 +41,18 @@ class SelectionArea:
|
||||
"""Set the lasso selection path."""
|
||||
if not path:
|
||||
return
|
||||
|
||||
self.lasso_path = path
|
||||
|
||||
normalized_path = [(int(x), int(y)) for x, y in path]
|
||||
self.lasso_path = normalized_path
|
||||
self.shape = "lasso"
|
||||
self.is_active = True
|
||||
|
||||
# Calculate bounding box for lasso
|
||||
if path:
|
||||
xs = [p[0] for p in path]
|
||||
ys = [p[1] for p in path]
|
||||
self.left = min(xs)
|
||||
self.top = min(ys)
|
||||
self.right = max(xs)
|
||||
self.bottom = max(ys)
|
||||
|
||||
xs = [p[0] for p in normalized_path]
|
||||
ys = [p[1] for p in normalized_path]
|
||||
self.left = min(xs)
|
||||
self.top = min(ys)
|
||||
self.right = max(xs)
|
||||
self.bottom = max(ys)
|
||||
|
||||
def get_coordinates(self) -> Optional[Tuple[int, int, int, int]]:
|
||||
"""Get the selection coordinates as a tuple."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import tkinter as tk
|
||||
from tkinter import Menu
|
||||
from PIL import Image
|
||||
from typing import Optional, Callable
|
||||
from typing import Optional, Callable, List, Tuple
|
||||
import sys
|
||||
import os
|
||||
|
||||
@@ -13,6 +13,7 @@ if _parent_dir not in sys.path:
|
||||
from SelectionArea import SelectionArea
|
||||
from ImageContainer import ImageContainer
|
||||
from ImageManipulation.ManipulationList import GetImageManipulationList
|
||||
from utils.magic_scissors import MagicScissors
|
||||
|
||||
|
||||
class AreaSelectionHandler:
|
||||
@@ -57,20 +58,34 @@ class AreaSelectionHandler:
|
||||
self._selectionRectangle = None
|
||||
self._lassoPath = [] # List of canvas coordinates for lasso drawing
|
||||
self._lassoLine = None # Canvas line item for lasso path
|
||||
self._magic_scissors: Optional[MagicScissors] = None
|
||||
self._magic_seed: Optional[Tuple[int, int]] = None
|
||||
self._magic_committed_points: List[Tuple[int, int]] = []
|
||||
self._magic_live_path: List[Tuple[int, int]] = []
|
||||
self._magic_committed_canvas_ids: List[int] = []
|
||||
self._magic_live_canvas_id: Optional[int] = None
|
||||
self._magic_last_target: Optional[Tuple[int, int]] = None
|
||||
self._magic_committed_lengths: List[int] = []
|
||||
self._magic_scissors_active: bool = False
|
||||
self._selection_finalized: bool = False
|
||||
|
||||
def toggle_selection_mode(self) -> None:
|
||||
"""Toggle interactive selection mode on/off."""
|
||||
self._selectionMode = not self._selectionMode
|
||||
if self._selectionMode:
|
||||
self._root.config(cursor="crosshair")
|
||||
self._selection_finalized = False
|
||||
else:
|
||||
self._root.config(cursor="")
|
||||
self._reset_magic_scissors(clear_canvas=True)
|
||||
self.clear_selection()
|
||||
self._selection_finalized = False
|
||||
|
||||
def toggle_selection_shape(self) -> None:
|
||||
"""Toggle selection shape between rectangle, circle, and lasso."""
|
||||
if self._selectionArea is None:
|
||||
return
|
||||
self._selection_finalized = False
|
||||
# Cycle through: rectangle -> circle -> lasso -> rectangle
|
||||
current_shape = self._selectionArea.shape
|
||||
if current_shape == "rectangle":
|
||||
@@ -80,11 +95,40 @@ class AreaSelectionHandler:
|
||||
else: # lasso
|
||||
new_shape = "rectangle"
|
||||
|
||||
if self._selectionType == "magic_scissors" and new_shape != "lasso":
|
||||
self._reset_magic_scissors(clear_canvas=True)
|
||||
|
||||
self._selectionArea.set_shape(new_shape)
|
||||
self._selectionType = new_shape
|
||||
# Refresh selection drawing if active
|
||||
if self._selectionArea.is_active and self._selectionArea.is_valid():
|
||||
self._render_image()
|
||||
if self._selectionType != "magic_scissors":
|
||||
self._selection_finalized = False
|
||||
|
||||
def enable_magic_scissors_mode(self) -> None:
|
||||
"""Enable magic scissors selection mode."""
|
||||
current_image = self._get_current_image()
|
||||
if current_image is None or current_image.getImage() is None:
|
||||
return
|
||||
|
||||
if not self._selectionMode:
|
||||
self.toggle_selection_mode()
|
||||
|
||||
self._reset_magic_scissors(clear_canvas=True)
|
||||
|
||||
try:
|
||||
self._magic_scissors = MagicScissors(current_image.getImage())
|
||||
except Exception:
|
||||
self._magic_scissors = None
|
||||
return
|
||||
|
||||
self._selectionArea.clear()
|
||||
self._selectionArea.set_shape("lasso")
|
||||
self._selectionType = "magic_scissors"
|
||||
self._selectionArea.is_active = False
|
||||
self._magic_scissors_active = True
|
||||
self._selection_finalized = False
|
||||
|
||||
def on_mouse_click(self, event) -> bool:
|
||||
"""Handle mouse click for selection.
|
||||
@@ -99,6 +143,12 @@ class AreaSelectionHandler:
|
||||
current_image = self._get_current_image()
|
||||
if current_image is None:
|
||||
return False
|
||||
|
||||
if self._selection_finalized and self._selectionType != "magic_scissors":
|
||||
return False
|
||||
|
||||
if self._selectionType == "magic_scissors":
|
||||
return self._handle_magic_scissors_click(event)
|
||||
|
||||
# Handle lasso mode
|
||||
if self._selectionType == "lasso":
|
||||
@@ -133,6 +183,12 @@ class AreaSelectionHandler:
|
||||
current_image = self._get_current_image()
|
||||
if current_image is None:
|
||||
return False
|
||||
|
||||
if self._selection_finalized and self._selectionType != "magic_scissors":
|
||||
return False
|
||||
|
||||
if self._selectionType == "magic_scissors":
|
||||
return self._handle_magic_scissors_drag(event)
|
||||
|
||||
# Handle lasso mode
|
||||
if self._selectionType == "lasso" and self._lassoPath:
|
||||
@@ -187,6 +243,9 @@ class AreaSelectionHandler:
|
||||
current_image = self._get_current_image()
|
||||
if current_image is None:
|
||||
return False
|
||||
|
||||
if self._selection_finalized and self._selectionType != "magic_scissors":
|
||||
return False
|
||||
|
||||
# Handle lasso mode
|
||||
if self._selectionType == "lasso" and self._lassoPath:
|
||||
@@ -383,7 +442,7 @@ class AreaSelectionHandler:
|
||||
current_image._imageData = cropped_image
|
||||
|
||||
# Clear selection before re-rendering
|
||||
self.clear_selection()
|
||||
self.clear_selection(reset_finalized=False)
|
||||
|
||||
# Re-render the image (without selection)
|
||||
self._render_image()
|
||||
@@ -427,12 +486,234 @@ class AreaSelectionHandler:
|
||||
current_image._imageData = result_image
|
||||
|
||||
# Clear selection before re-rendering
|
||||
self.clear_selection()
|
||||
self.clear_selection(reset_finalized=False)
|
||||
|
||||
# Re-render the image (without selection)
|
||||
self._render_image()
|
||||
|
||||
def clear_selection(self) -> None:
|
||||
def finalize_magic_scissors(self) -> None:
|
||||
"""Finalize the current magic scissors path."""
|
||||
if self._selectionType != "magic_scissors":
|
||||
return
|
||||
|
||||
combined: List[Tuple[int, int]] = []
|
||||
if self._magic_committed_points:
|
||||
combined.extend(self._magic_committed_points)
|
||||
|
||||
if self._magic_live_path:
|
||||
if combined:
|
||||
combined.extend(self._magic_live_path[1:])
|
||||
else:
|
||||
combined.extend(self._magic_live_path)
|
||||
|
||||
if len(combined) >= 3:
|
||||
self._selectionArea.set_lasso_path(combined)
|
||||
self._selectionArea.is_active = True
|
||||
self._selection_finalized = True
|
||||
self._magic_scissors_active = False
|
||||
self._selectionType = "lasso"
|
||||
self._render_image()
|
||||
|
||||
self._reset_magic_scissors(clear_canvas=True)
|
||||
|
||||
def cancel_magic_scissors(self) -> None:
|
||||
"""Cancel the magic scissors selection in progress."""
|
||||
if self._selectionType != "magic_scissors":
|
||||
return
|
||||
self._reset_magic_scissors(clear_canvas=True)
|
||||
self._selectionType = "lasso"
|
||||
self._selectionArea.clear()
|
||||
self._render_image()
|
||||
self._selection_finalized = False
|
||||
self._magic_scissors_active = False
|
||||
|
||||
def undo_magic_scissors_segment(self) -> None:
|
||||
"""Undo the last committed magic scissors segment."""
|
||||
if self._selectionType != "magic_scissors":
|
||||
return
|
||||
if not self._magic_committed_points or not self._magic_committed_lengths:
|
||||
return
|
||||
self._selection_finalized = False
|
||||
|
||||
if self._magic_committed_canvas_ids:
|
||||
canvas_id = self._magic_committed_canvas_ids.pop()
|
||||
try:
|
||||
self._canvas.delete(canvas_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
length = self._magic_committed_lengths.pop()
|
||||
remove_count = min(length, max(0, len(self._magic_committed_points) - 1))
|
||||
for _ in range(remove_count):
|
||||
if len(self._magic_committed_points) <= 1:
|
||||
break
|
||||
self._magic_committed_points.pop()
|
||||
|
||||
if self._magic_committed_points:
|
||||
self._magic_seed = self._magic_committed_points[-1]
|
||||
if self._magic_scissors:
|
||||
self._magic_scissors.set_seed(*self._magic_seed)
|
||||
else:
|
||||
self._magic_seed = None
|
||||
|
||||
self._magic_last_target = None
|
||||
self._remove_magic_live_overlay()
|
||||
|
||||
def _handle_magic_scissors_click(self, event) -> bool:
|
||||
if self._magic_scissors is None:
|
||||
return False
|
||||
|
||||
image_point = self._canvas_to_image_point(event.x, event.y)
|
||||
if image_point is None:
|
||||
return False
|
||||
|
||||
ix, iy = image_point
|
||||
self._selection_finalized = False
|
||||
|
||||
if self._magic_seed is None:
|
||||
self._magic_seed = (ix, iy)
|
||||
self._magic_scissors.set_seed(ix, iy)
|
||||
self._magic_committed_points = [(ix, iy)]
|
||||
self._selectionArea.is_active = False
|
||||
self._magic_last_target = None
|
||||
return True
|
||||
|
||||
if self._magic_last_target != (ix, iy):
|
||||
self._magic_live_path = self._magic_scissors.get_path_to(ix, iy)
|
||||
|
||||
if not self._magic_live_path:
|
||||
return True
|
||||
|
||||
self._commit_magic_segment(self._magic_live_path)
|
||||
self._magic_seed = (ix, iy)
|
||||
self._magic_scissors.set_seed(ix, iy)
|
||||
self._magic_live_path = []
|
||||
self._magic_last_target = None
|
||||
self._remove_magic_live_overlay()
|
||||
return True
|
||||
|
||||
def _handle_magic_scissors_drag(self, event) -> bool:
|
||||
if self._magic_scissors is None or self._magic_seed is None:
|
||||
return False
|
||||
|
||||
image_point = self._canvas_to_image_point(event.x, event.y)
|
||||
if image_point is None:
|
||||
return False
|
||||
|
||||
ix, iy = image_point
|
||||
if self._magic_last_target == (ix, iy):
|
||||
return True
|
||||
|
||||
path = self._magic_scissors.get_path_to(ix, iy)
|
||||
self._magic_live_path = path
|
||||
self._magic_last_target = (ix, iy)
|
||||
self._draw_magic_live_overlay(path)
|
||||
return True
|
||||
|
||||
def _commit_magic_segment(self, segment: List[Tuple[int, int]]) -> None:
|
||||
if not segment:
|
||||
return
|
||||
if not self._magic_committed_points:
|
||||
self._magic_committed_points = segment.copy()
|
||||
self._magic_committed_lengths.append(max(0, len(segment) - 1))
|
||||
else:
|
||||
self._magic_committed_points.extend(segment[1:])
|
||||
self._magic_committed_lengths.append(max(0, len(segment) - 1))
|
||||
|
||||
canvas_points = self._image_points_to_canvas(segment)
|
||||
if len(canvas_points) >= 4:
|
||||
line_id = self._canvas.create_line(
|
||||
*canvas_points,
|
||||
fill="yellow",
|
||||
width=2,
|
||||
smooth=False,
|
||||
)
|
||||
self._magic_committed_canvas_ids.append(line_id)
|
||||
|
||||
def _draw_magic_live_overlay(self, path: List[Tuple[int, int]]) -> None:
|
||||
self._remove_magic_live_overlay()
|
||||
canvas_points = self._image_points_to_canvas(path)
|
||||
if len(canvas_points) >= 4:
|
||||
self._magic_live_canvas_id = self._canvas.create_line(
|
||||
*canvas_points,
|
||||
fill="cyan",
|
||||
width=2,
|
||||
dash=(4, 2),
|
||||
smooth=False,
|
||||
)
|
||||
|
||||
def _remove_magic_live_overlay(self) -> None:
|
||||
if self._magic_live_canvas_id is not None:
|
||||
try:
|
||||
self._canvas.delete(self._magic_live_canvas_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._magic_live_canvas_id = None
|
||||
|
||||
def _reset_magic_scissors(self, clear_canvas: bool = False) -> None:
|
||||
if clear_canvas:
|
||||
for item_id in self._magic_committed_canvas_ids:
|
||||
try:
|
||||
self._canvas.delete(item_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._magic_committed_canvas_ids = []
|
||||
self._remove_magic_live_overlay()
|
||||
|
||||
self._magic_scissors = None
|
||||
self._magic_seed = None
|
||||
self._magic_committed_points = []
|
||||
self._magic_live_path = []
|
||||
self._magic_last_target = None
|
||||
self._magic_committed_lengths = []
|
||||
self._magic_scissors_active = False
|
||||
if self._selectionType == "magic_scissors":
|
||||
self._selectionType = "lasso"
|
||||
|
||||
def _canvas_to_image_point(self, canvas_x: float, canvas_y: float) -> Optional[Tuple[int, int]]:
|
||||
current_image = self._get_current_image()
|
||||
if current_image is None or current_image.getImage() is None:
|
||||
return None
|
||||
|
||||
pil_image = current_image.getImage()
|
||||
img_width, img_height = pil_image.size
|
||||
|
||||
canvas_width, canvas_height = self._get_canvas_dimensions()
|
||||
if canvas_width <= 0 or canvas_height <= 0:
|
||||
return None
|
||||
|
||||
cx = self._canvas.canvasx(canvas_x)
|
||||
cy = self._canvas.canvasy(canvas_y)
|
||||
|
||||
scale_x = img_width / canvas_width
|
||||
scale_y = img_height / canvas_height
|
||||
|
||||
ix = int(max(0, min(img_width - 1, cx * scale_x)))
|
||||
iy = int(max(0, min(img_height - 1, cy * scale_y)))
|
||||
return ix, iy
|
||||
|
||||
def _image_points_to_canvas(self, points: List[Tuple[int, int]]) -> List[float]:
|
||||
current_image = self._get_current_image()
|
||||
if current_image is None or current_image.getImage() is None:
|
||||
return []
|
||||
|
||||
pil_image = current_image.getImage()
|
||||
img_width, img_height = pil_image.size
|
||||
|
||||
canvas_width, canvas_height = self._get_canvas_dimensions()
|
||||
if canvas_width <= 0 or canvas_height <= 0:
|
||||
return []
|
||||
|
||||
scale_x = canvas_width / img_width
|
||||
scale_y = canvas_height / img_height
|
||||
|
||||
coords: List[float] = []
|
||||
for x, y in points:
|
||||
coords.append(x * scale_x)
|
||||
coords.append(y * scale_y)
|
||||
return coords
|
||||
|
||||
def clear_selection(self, reset_finalized: bool = True) -> None:
|
||||
"""Clear the selection rectangle and reset selection state."""
|
||||
if self._selectionRectangle:
|
||||
self._canvas.delete(self._selectionRectangle)
|
||||
@@ -446,6 +727,10 @@ class AreaSelectionHandler:
|
||||
self._selectionEndX = None
|
||||
self._selectionEndY = None
|
||||
self._selectionArea.clear()
|
||||
self._reset_magic_scissors(clear_canvas=True)
|
||||
if reset_finalized:
|
||||
self._selection_finalized = False
|
||||
self._selection_finalized = False
|
||||
|
||||
def draw_selection_highlight(self) -> None:
|
||||
"""Draw a colored highlight over the selected area."""
|
||||
|
||||
138
src/utils/magic_scissors.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import heapq
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from utils.image_utils import pil_to_cv2
|
||||
|
||||
|
||||
class MagicScissors:
|
||||
"""Live-wire / intelligent scissors implementation on top of a PIL image."""
|
||||
|
||||
_EDGE_EPSILON = 1e-6
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pil_image,
|
||||
blur_ksize: int = 3,
|
||||
diagonal_penalty: float = 0.41421356237,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
pil_image: PIL.Image.Image instance to operate on.
|
||||
blur_ksize: Kernel size for the pre-smoothing Gaussian blur. Must be odd.
|
||||
diagonal_penalty: Additional cost added for diagonal neighbor transitions.
|
||||
"""
|
||||
if pil_image is None:
|
||||
raise ValueError("MagicScissors requires a valid PIL image.")
|
||||
|
||||
self._pil_image = pil_image
|
||||
self._blur_ksize = blur_ksize if blur_ksize % 2 == 1 else blur_ksize + 1
|
||||
self._diagonal_penalty = max(0.0, diagonal_penalty)
|
||||
|
||||
self._width: int
|
||||
self._height: int
|
||||
self._cost_map: np.ndarray
|
||||
self._prepare_image()
|
||||
|
||||
self._seed: Optional[Tuple[int, int]] = None
|
||||
self._distances: Optional[np.ndarray] = None
|
||||
self._predecessors: Optional[np.ndarray] = None
|
||||
|
||||
@property
|
||||
def image_size(self) -> Tuple[int, int]:
|
||||
"""Return image dimensions as (width, height)."""
|
||||
return self._width, self._height
|
||||
|
||||
def _prepare_image(self) -> None:
|
||||
"""Convert input image to a cost map suitable for shortest-path search."""
|
||||
cv_img = pil_to_cv2(self._pil_image)
|
||||
gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
if self._blur_ksize > 1:
|
||||
gray = cv2.GaussianBlur(gray, (self._blur_ksize, self._blur_ksize), 0)
|
||||
|
||||
sobel_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
|
||||
sobel_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
|
||||
magnitude = cv2.magnitude(sobel_x, sobel_y)
|
||||
|
||||
max_val = float(magnitude.max())
|
||||
if max_val > 0:
|
||||
strength = magnitude / max_val
|
||||
else:
|
||||
strength = magnitude
|
||||
|
||||
self._cost_map = 1.0 / (strength + self._EDGE_EPSILON)
|
||||
self._height, self._width = strength.shape
|
||||
|
||||
def set_seed(self, x: int, y: int) -> None:
|
||||
"""Run Dijkstra from the given seed pixel and store shortest paths."""
|
||||
if not (0 <= x < self._width and 0 <= y < self._height):
|
||||
raise ValueError("Seed coordinates are out of bounds.")
|
||||
|
||||
self._seed = (int(x), int(y))
|
||||
self._distances = np.full((self._height, self._width), np.inf, dtype=np.float64)
|
||||
self._predecessors = np.full((self._height, self._width, 2), -1, dtype=np.int32)
|
||||
|
||||
self._distances[y, x] = 0.0
|
||||
queue: List[Tuple[float, int, int]] = [(0.0, y, x)]
|
||||
|
||||
neighbors = [
|
||||
(-1, -1),
|
||||
(-1, 0),
|
||||
(-1, 1),
|
||||
(0, -1),
|
||||
(0, 1),
|
||||
(1, -1),
|
||||
(1, 0),
|
||||
(1, 1),
|
||||
]
|
||||
|
||||
while queue:
|
||||
cost, cy, cx = heapq.heappop(queue)
|
||||
if cost > self._distances[cy, cx]:
|
||||
continue
|
||||
|
||||
for dy, dx in neighbors:
|
||||
ny, nx = cy + dy, cx + dx
|
||||
if ny < 0 or ny >= self._height or nx < 0 or nx >= self._width:
|
||||
continue
|
||||
|
||||
step_cost = self._cost_map[ny, nx]
|
||||
if dx != 0 and dy != 0:
|
||||
step_cost += self._diagonal_penalty
|
||||
|
||||
new_cost = cost + step_cost
|
||||
if new_cost < self._distances[ny, nx]:
|
||||
self._distances[ny, nx] = new_cost
|
||||
self._predecessors[ny, nx] = (cx, cy)
|
||||
heapq.heappush(queue, (new_cost, ny, nx))
|
||||
|
||||
def get_path_to(self, x: int, y: int) -> List[Tuple[int, int]]:
|
||||
"""Return the minimum-cost path from the current seed to (x, y)."""
|
||||
if self._seed is None or self._distances is None or self._predecessors is None:
|
||||
raise RuntimeError("No seed has been set. Call set_seed() first.")
|
||||
|
||||
if not (0 <= x < self._width and 0 <= y < self._height):
|
||||
raise ValueError("Target coordinates are out of bounds.")
|
||||
|
||||
if not np.isfinite(self._distances[y, x]):
|
||||
return []
|
||||
|
||||
path: List[Tuple[int, int]] = []
|
||||
cx, cy = int(x), int(y)
|
||||
sx, sy = self._seed
|
||||
|
||||
while True:
|
||||
path.append((cx, cy))
|
||||
if (cx, cy) == (sx, sy):
|
||||
break
|
||||
px, py = self._predecessors[cy, cx]
|
||||
if px < 0 or py < 0:
|
||||
return []
|
||||
cx, cy = int(px), int(py)
|
||||
|
||||
path.reverse()
|
||||
return path
|
||||
|
||||