Skip to content
Snippets Groups Projects
Commit aacaf6df authored by s224389's avatar s224389
Browse files

Delete GUI_draft_live.py

parent f07540f5
No related branches found
No related tags found
No related merge requests found
import sys
import math
import csv # <-- Added
import numpy as np
# For smoothing the path
from scipy.signal import savgol_filter
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QGraphicsView, QGraphicsScene,
QGraphicsEllipseItem, QGraphicsPixmapItem, QPushButton,
QHBoxLayout, QVBoxLayout, QWidget, QFileDialog, QGraphicsTextItem,
QSlider, QLabel, QCheckBox, QGridLayout, QSizePolicy
)
from PyQt5.QtGui import QPixmap, QPen, QBrush, QColor, QFont, QImage
from PyQt5.QtCore import Qt, QRectF, QSize
# live_wire.py must contain something like:
# from skimage import exposure
# from skimage.filters import gaussian
# def preprocess_image(image, sigma=3, clip_limit=0.01): ...
# def compute_cost_image(path, user_radius, sigma=3, clip_limit=0.01): ...
# def find_path(cost_image, points): ...
# ...
from live_wire import compute_cost_image, find_path, preprocess_image
# ------------------------------------------------------------------------
# A pan & zoom QGraphicsView
# ------------------------------------------------------------------------
class PanZoomGraphicsView(QGraphicsView):
def __init__(self, parent=None):
super().__init__(parent)
self.setDragMode(QGraphicsView.NoDrag) # We'll handle panning manually
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
self._panning = False
self._pan_start = None
# Let it expand in layouts
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
def wheelEvent(self, event):
""" Zoom in/out with mouse wheel. """
zoom_in_factor = 1.25
zoom_out_factor = 1 / zoom_in_factor
if event.angleDelta().y() > 0:
self.scale(zoom_in_factor, zoom_in_factor)
else:
self.scale(zoom_out_factor, zoom_out_factor)
event.accept()
def mousePressEvent(self, event):
""" If left button: Start panning (unless overridden). """
if event.button() == Qt.LeftButton:
self._panning = True
self._pan_start = event.pos()
self.setCursor(Qt.ClosedHandCursor)
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
""" If panning, translate the scene. """
if self._panning and self._pan_start is not None:
delta = event.pos() - self._pan_start
self._pan_start = event.pos()
self.translate(delta.x(), delta.y())
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
""" End panning. """
if event.button() == Qt.LeftButton:
self._panning = False
self.setCursor(Qt.ArrowCursor)
super().mouseReleaseEvent(event)
# ------------------------------------------------------------------------
# A specialized PanZoomGraphicsView for the circle editor
# ------------------------------------------------------------------------
class CircleEditorGraphicsView(PanZoomGraphicsView):
def __init__(self, circle_editor_widget, parent=None):
super().__init__(parent)
self._circle_editor_widget = circle_editor_widget
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
# Check if user clicked on the circle item
clicked_item = self.itemAt(event.pos())
if clicked_item is not None:
# climb up parent chain
it = clicked_item
while it is not None and not hasattr(it, "boundingRect"):
it = it.parentItem()
if isinstance(it, DraggableCircleItem):
# Let normal item-dragging occur, no pan
return QGraphicsView.mousePressEvent(self, event)
super().mousePressEvent(event)
def wheelEvent(self, event):
"""
If the mouse is hovering over the circle, we adjust the circle's radius
instead of zooming the image.
"""
pos_in_widget = event.pos()
item_under = self.itemAt(pos_in_widget)
if item_under is not None:
it = item_under
while it is not None and not hasattr(it, "boundingRect"):
it = it.parentItem()
if isinstance(it, DraggableCircleItem):
delta = event.angleDelta().y()
step = 1 if delta > 0 else -1
old_r = it.radius()
new_r = max(1, old_r + step)
it.set_radius(new_r)
self._circle_editor_widget.update_slider_value(new_r)
event.accept()
return
super().wheelEvent(event)
# ------------------------------------------------------------------------
# Draggable circle item (centered at (x, y) with radius)
# ------------------------------------------------------------------------
class DraggableCircleItem(QGraphicsEllipseItem):
def __init__(self, x, y, radius=20, color=Qt.red, parent=None):
super().__init__(0, 0, 2*radius, 2*radius, parent)
self._r = radius
pen = QPen(color)
brush = QBrush(color)
self.setPen(pen)
self.setBrush(brush)
# Enable item-based dragging
self.setFlags(QGraphicsEllipseItem.ItemIsMovable |
QGraphicsEllipseItem.ItemIsSelectable |
QGraphicsEllipseItem.ItemSendsScenePositionChanges)
# Position so that (x, y) is the center
self.setPos(x - radius, y - radius)
def set_radius(self, r):
old_center = self.sceneBoundingRect().center()
self._r = r
self.setRect(0, 0, 2*r, 2*r)
new_center = self.sceneBoundingRect().center()
diff_x = old_center.x() - new_center.x()
diff_y = old_center.y() - new_center.y()
self.moveBy(diff_x, diff_y)
def radius(self):
return self._r
# ------------------------------------------------------------------------
# Circle editor widget with slider + done
# ------------------------------------------------------------------------
class CircleEditorWidget(QWidget):
def __init__(self, pixmap, init_radius=20, done_callback=None, parent=None):
super().__init__(parent)
self._pixmap = pixmap
self._done_callback = done_callback
self._init_radius = init_radius
layout = QVBoxLayout(self)
self.setLayout(layout)
#
# 1) ADD A CENTERED LABEL ABOVE THE IMAGE, WITH BIGGER FONT
#
label_instructions = QLabel("Scale the dot to be of the size of your ridge")
label_instructions.setAlignment(Qt.AlignCenter)
big_font = QFont("Arial", 20)
big_font.setBold(True)
label_instructions.setFont(big_font)
layout.addWidget(label_instructions)
#
# 2) THE SPECIALIZED GRAPHICS VIEW THAT SHOWS THE IMAGE
#
self._graphics_view = CircleEditorGraphicsView(circle_editor_widget=self)
self._scene = QGraphicsScene(self)
self._graphics_view.setScene(self._scene)
layout.addWidget(self._graphics_view)
# Show the image
self._image_item = QGraphicsPixmapItem(self._pixmap)
self._scene.addItem(self._image_item)
# Put circle in center
cx = self._pixmap.width() / 2
cy = self._pixmap.height() / 2
self._circle_item = DraggableCircleItem(cx, cy, radius=self._init_radius, color=Qt.red)
self._scene.addItem(self._circle_item)
# Fit in view
self._graphics_view.setSceneRect(QRectF(self._pixmap.rect()))
self._graphics_view.fitInView(self._image_item, Qt.KeepAspectRatio)
#
# 3) CONTROLS BELOW
#
bottom_layout = QHBoxLayout()
layout.addLayout(bottom_layout)
# label + slider
self._lbl_size = QLabel(f"size ({self._init_radius})")
bottom_layout.addWidget(self._lbl_size)
self._slider = QSlider(Qt.Horizontal)
self._slider.setRange(1, 200)
self._slider.setValue(self._init_radius)
bottom_layout.addWidget(self._slider)
# done button
self._btn_done = QPushButton("Done")
bottom_layout.addWidget(self._btn_done)
# Connect signals
self._slider.valueChanged.connect(self._on_slider_changed)
self._btn_done.clicked.connect(self._on_done_clicked)
def _on_slider_changed(self, value):
self._circle_item.set_radius(value)
self._lbl_size.setText(f"size ({value})")
def _on_done_clicked(self):
final_radius = self._circle_item.radius()
if self._done_callback is not None:
self._done_callback(final_radius)
def update_slider_value(self, new_radius):
self._slider.blockSignals(True)
self._slider.setValue(new_radius)
self._slider.blockSignals(False)
self._lbl_size.setText(f"size ({new_radius})")
def sizeHint(self):
return QSize(800, 600)
# ------------------------------------------------------------------------
# Labeled point item
# ------------------------------------------------------------------------
class LabeledPointItem(QGraphicsEllipseItem):
def __init__(self, x, y, label="", radius=4, color=Qt.red, removable=True, z_value=0, parent=None):
super().__init__(0, 0, 2*radius, 2*radius, parent)
self._x = x
self._y = y
self._r = radius
self._removable = removable
pen = QPen(color)
brush = QBrush(color)
self.setPen(pen)
self.setBrush(brush)
self.setZValue(z_value)
self._text_item = None
if label:
self._text_item = QGraphicsTextItem(self)
self._text_item.setPlainText(label)
self._text_item.setDefaultTextColor(QColor("black"))
font = QFont("Arial", 14)
font.setBold(True)
self._text_item.setFont(font)
self._scale_text_to_fit()
self.set_pos(x, y)
def _scale_text_to_fit(self):
if not self._text_item:
return
self._text_item.setScale(1.0)
circle_diam = 2 * self._r
raw_rect = self._text_item.boundingRect()
text_w = raw_rect.width()
text_h = raw_rect.height()
if text_w > circle_diam or text_h > circle_diam:
scale_factor = min(circle_diam / text_w, circle_diam / text_h)
self._text_item.setScale(scale_factor)
self._center_label()
def _center_label(self):
if not self._text_item:
return
ellipse_w = 2 * self._r
ellipse_h = 2 * self._r
raw_rect = self._text_item.boundingRect()
scale_factor = self._text_item.scale()
scaled_w = raw_rect.width() * scale_factor
scaled_h = raw_rect.height() * scale_factor
tx = (ellipse_w - scaled_w) * 0.5
ty = (ellipse_h - scaled_h) * 0.5
self._text_item.setPos(tx, ty)
def set_pos(self, x, y):
"""Positions the circle so its center is at (x, y)."""
self._x = x
self._y = y
self.setPos(x - self._r, y - self._r)
def get_pos(self):
return (self._x, self._y)
def distance_to(self, x_other, y_other):
return math.sqrt((self._x - x_other)**2 + (self._y - y_other)**2)
def is_removable(self):
return self._removable
# ------------------------------------------------------------------------
# The original ImageGraphicsView with pan & zoom
# ------------------------------------------------------------------------
class ImageGraphicsView(PanZoomGraphicsView):
def __init__(self, parent=None):
super().__init__(parent)
self.scene = QGraphicsScene(self)
self.setScene(self.scene)
# Image display
self.image_item = QGraphicsPixmapItem()
self.scene.addItem(self.image_item)
self.anchor_points = [] # List[(x, y)]
self.point_items = [] # LabeledPointItem
self.full_path_points = [] # QGraphicsEllipseItems for path
self._full_path_xy = [] # entire path coords (smoothed)
self.dot_radius = 4
self.path_radius = 1
self.radius_cost_image = 2
self._img_w = 0
self._img_h = 0
self._mouse_pressed = False
self._press_view_pos = None
self._drag_threshold = 5
self._was_dragging = False
self._dragging_idx = None
self._drag_offset = (0, 0)
self._drag_counter = 0
# Cost images
self.cost_image_original = None
self.cost_image = None
# Rainbow toggle => start with OFF
self._rainbow_enabled = False
# Smoothing parameters
self._savgol_window_length = 7
def set_rainbow_enabled(self, enabled: bool):
self._rainbow_enabled = enabled
self._rebuild_full_path()
def toggle_rainbow(self):
self._rainbow_enabled = not self._rainbow_enabled
self._rebuild_full_path()
def set_savgol_window_length(self, wlen: int):
if wlen < 3:
wlen = 3
if wlen % 2 == 0:
wlen += 1
self._savgol_window_length = wlen
self._rebuild_full_path()
# --------------------------------------------------------------------
# LOADING
# --------------------------------------------------------------------
def load_image(self, path):
pixmap = QPixmap(path)
if not pixmap.isNull():
self.image_item.setPixmap(pixmap)
self.setSceneRect(QRectF(pixmap.rect()))
self._img_w = pixmap.width()
self._img_h = pixmap.height()
self._clear_all_points()
self.resetTransform()
self.fitInView(self.image_item, Qt.KeepAspectRatio)
# By default, add S/E
s_x, s_y = 0.15 * self._img_w, 0.5 * self._img_h
e_x, e_y = 0.85 * self._img_w, 0.5 * self._img_h
self._insert_anchor_point(-1, s_x, s_y, label="S", removable=False, z_val=100, radius=6)
self._insert_anchor_point(-1, e_x, e_y, label="E", removable=False, z_val=100, radius=6)
# --------------------------------------------------------------------
# ANCHOR POINTS
# --------------------------------------------------------------------
def _insert_anchor_point(self, idx, x, y, label="", removable=True, z_val=0, radius=4):
x_clamped = self._clamp(x, radius, self._img_w - radius)
y_clamped = self._clamp(y, radius, self._img_h - radius)
if idx < 0:
# Insert before E if there's at least 2 anchors
if len(self.anchor_points) >= 2:
idx = len(self.anchor_points) - 1
else:
idx = len(self.anchor_points)
self.anchor_points.insert(idx, (x_clamped, y_clamped))
color = Qt.green if label in ("S", "E") else Qt.red
item = LabeledPointItem(x_clamped, y_clamped,
label=label, radius=radius, color=color,
removable=removable, z_value=z_val)
self.point_items.insert(idx, item)
self.scene.addItem(item)
def _add_guide_point(self, x, y):
# Ensure we clamp properly
x_clamped = self._clamp(x, self.dot_radius, self._img_w - self.dot_radius)
y_clamped = self._clamp(y, self.dot_radius, self._img_h - self.dot_radius)
self._revert_cost_to_original()
if not self._full_path_xy:
self._insert_anchor_point(-1, x_clamped, y_clamped,
label="", removable=True, z_val=1, radius=self.dot_radius)
else:
self._insert_anchor_between_subpath(x_clamped, y_clamped)
self._apply_all_guide_points_to_cost()
self._rebuild_full_path()
def _insert_anchor_between_subpath(self, x_new, y_new):
# If somehow we have no path yet
if not self._full_path_xy:
self._insert_anchor_point(-1, x_new, y_new)
return
# Find nearest point in the current full path
best_idx = None
best_d2 = float('inf')
for i, (px, py) in enumerate(self._full_path_xy):
dx = px - x_new
dy = py - y_new
d2 = dx*dx + dy*dy
if d2 < best_d2:
best_d2 = d2
best_idx = i
if best_idx is None:
self._insert_anchor_point(-1, x_new, y_new)
return
def approx_equal(xa, ya, xb, yb, tol=1e-3):
return (abs(xa - xb) < tol) and (abs(ya - yb) < tol)
def is_anchor(coord):
cx, cy = coord
for (ax, ay) in self.anchor_points:
if approx_equal(ax, ay, cx, cy):
return True
return False
# Walk left
left_anchor_pt = None
iL = best_idx
while iL >= 0:
px, py = self._full_path_xy[iL]
if is_anchor((px, py)):
left_anchor_pt = (px, py)
break
iL -= 1
# Walk right
right_anchor_pt = None
iR = best_idx
while iR < len(self._full_path_xy):
px, py = self._full_path_xy[iR]
if is_anchor((px, py)):
right_anchor_pt = (px, py)
break
iR += 1
# If we can't find distinct anchors on left & right,
# just insert before E.
if not left_anchor_pt or not right_anchor_pt:
self._insert_anchor_point(-1, x_new, y_new)
return
if left_anchor_pt == right_anchor_pt:
self._insert_anchor_point(-1, x_new, y_new)
return
# Convert anchor coords -> anchor_points indices
left_idx = None
right_idx = None
for i, (ax, ay) in enumerate(self.anchor_points):
if approx_equal(ax, ay, left_anchor_pt[0], left_anchor_pt[1]):
left_idx = i
if approx_equal(ax, ay, right_anchor_pt[0], right_anchor_pt[1]):
right_idx = i
if left_idx is None or right_idx is None:
self._insert_anchor_point(-1, x_new, y_new)
return
# Insert between them
if left_idx < right_idx:
insert_idx = left_idx + 1
else:
insert_idx = right_idx + 1
self._insert_anchor_point(insert_idx, x_new, y_new, label="", removable=True,
z_val=1, radius=self.dot_radius)
# --------------------------------------------------------------------
# COST IMAGE
# --------------------------------------------------------------------
def _revert_cost_to_original(self):
if self.cost_image_original is not None:
self.cost_image = self.cost_image_original.copy()
def _apply_all_guide_points_to_cost(self):
if self.cost_image is None:
return
for i, (ax, ay) in enumerate(self.anchor_points):
if self.point_items[i].is_removable():
self._lower_cost_in_circle(ax, ay, self.radius_cost_image)
def _lower_cost_in_circle(self, x_f, y_f, radius):
if self.cost_image is None:
return
h, w = self.cost_image.shape
row_c = int(round(y_f))
col_c = int(round(x_f))
if not (0 <= row_c < h and 0 <= col_c < w):
return
global_min = self.cost_image.min()
r_s = max(0, row_c - radius)
r_e = min(h, row_c + radius + 1)
c_s = max(0, col_c - radius)
c_e = min(w, col_c + radius + 1)
for rr in range(r_s, r_e):
for cc in range(c_s, c_e):
dist = math.sqrt((rr - row_c)**2 + (cc - col_c)**2)
if dist <= radius:
self.cost_image[rr, cc] = global_min
# --------------------------------------------------------------------
# PATH BUILDING
# --------------------------------------------------------------------
def _rebuild_full_path(self):
for item in self.full_path_points:
self.scene.removeItem(item)
self.full_path_points.clear()
self._full_path_xy.clear()
if len(self.anchor_points) < 2 or self.cost_image is None:
return
big_xy = []
for i in range(len(self.anchor_points) - 1):
xA, yA = self.anchor_points[i]
xB, yB = self.anchor_points[i + 1]
sub_xy = self._compute_subpath_xy(xA, yA, xB, yB)
if i == 0:
big_xy.extend(sub_xy)
else:
if len(sub_xy) > 1:
big_xy.extend(sub_xy[1:])
if len(big_xy) >= self._savgol_window_length:
arr_xy = np.array(big_xy)
smoothed = savgol_filter(
arr_xy,
window_length=self._savgol_window_length,
polyorder=2,
axis=0
)
big_xy = smoothed.tolist()
self._full_path_xy = big_xy[:]
n_points = len(big_xy)
for i, (px, py) in enumerate(big_xy):
fraction = i / (n_points - 1) if n_points > 1 else 0
color = Qt.red
if self._rainbow_enabled:
color = self._rainbow_color(fraction)
path_item = LabeledPointItem(px, py, label="",
radius=self.path_radius,
color=color,
removable=False,
z_value=0)
self.full_path_points.append(path_item)
self.scene.addItem(path_item)
# Keep anchor labels on top
for p_item in self.point_items:
if p_item._text_item:
p_item.setZValue(100)
def _compute_subpath_xy(self, xA, yA, xB, yB):
if self.cost_image is None:
return []
h, w = self.cost_image.shape
rA, cA = int(round(yA)), int(round(xA))
rB, cB = int(round(yB)), int(round(xB))
rA = max(0, min(rA, h - 1))
cA = max(0, min(cA, w - 1))
rB = max(0, min(rB, h - 1))
cB = max(0, min(cB, w - 1))
try:
path_rc = find_path(self.cost_image, [(rA, cA), (rB, cB)])
except ValueError as e:
print("Error in find_path:", e)
return []
# Convert from (row, col) to (x, y)
return [(c, r) for (r, c) in path_rc]
def _rainbow_color(self, fraction):
hue = int(300 * fraction)
saturation = 255
value = 255
return QColor.fromHsv(hue, saturation, value)
# --------------------------------------------------------------------
# MOUSE EVENTS
# --------------------------------------------------------------------
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self._mouse_pressed = True
self._was_dragging = False
self._press_view_pos = event.pos()
idx = self._find_item_near(event.pos(), threshold=10)
if idx is not None:
self._dragging_idx = idx
self._drag_counter = 0
scene_pos = self.mapToScene(event.pos())
px, py = self.point_items[idx].get_pos()
self._drag_offset = (scene_pos.x() - px, scene_pos.y() - py)
self.setCursor(Qt.ClosedHandCursor)
return
elif event.button() == Qt.RightButton:
self._remove_point_by_click(event.pos())
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self._dragging_idx is not None:
scene_pos = self.mapToScene(event.pos())
x_new = scene_pos.x() - self._drag_offset[0]
y_new = scene_pos.y() - self._drag_offset[1]
r = self.point_items[self._dragging_idx]._r
x_clamped = self._clamp(x_new, r, self._img_w - r)
y_clamped = self._clamp(y_new, r, self._img_h - r)
self.point_items[self._dragging_idx].set_pos(x_clamped, y_clamped)
self._drag_counter += 1
# Update path every 4 moves
if self._drag_counter >= 4:
self._drag_counter = 0
self._revert_cost_to_original()
self._apply_all_guide_points_to_cost()
self.anchor_points[self._dragging_idx] = (x_clamped, y_clamped)
self._rebuild_full_path()
else:
if self._mouse_pressed and (event.buttons() & Qt.LeftButton):
dist = (event.pos() - self._press_view_pos).manhattanLength()
if dist > self._drag_threshold:
self._was_dragging = True
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
super().mouseReleaseEvent(event)
if event.button() == Qt.LeftButton and self._mouse_pressed:
self._mouse_pressed = False
self.setCursor(Qt.ArrowCursor)
if self._dragging_idx is not None:
idx = self._dragging_idx
self._dragging_idx = None
self._drag_offset = (0, 0)
newX, newY = self.point_items[idx].get_pos()
self.anchor_points[idx] = (newX, newY)
self._revert_cost_to_original()
self._apply_all_guide_points_to_cost()
self._rebuild_full_path()
else:
# No drag => add point
if not self._was_dragging:
scene_pos = self.mapToScene(event.pos())
x, y = scene_pos.x(), scene_pos.y()
self._add_guide_point(x, y)
self._was_dragging = False
def _remove_point_by_click(self, view_pos):
idx = self._find_item_near(view_pos, threshold=10)
if idx is None:
return
if not self.point_items[idx].is_removable():
return
self.scene.removeItem(self.point_items[idx])
self.point_items.pop(idx)
self.anchor_points.pop(idx)
self._revert_cost_to_original()
self._apply_all_guide_points_to_cost()
self._rebuild_full_path()
def _find_item_near(self, view_pos, threshold=10):
scene_pos = self.mapToScene(view_pos)
x_click, y_click = scene_pos.x(), scene_pos.y()
closest_idx = None
min_dist = float('inf')
for i, itm in enumerate(self.point_items):
d = itm.distance_to(x_click, y_click)
if d < min_dist:
min_dist = d
closest_idx = i
if closest_idx is not None and min_dist <= threshold:
return closest_idx
return None
# --------------------------------------------------------------------
# UTILS
# --------------------------------------------------------------------
def _clamp(self, val, mn, mx):
return max(mn, min(val, mx))
def _clear_all_points(self):
for it in self.point_items:
self.scene.removeItem(it)
self.point_items.clear()
self.anchor_points.clear()
for p in self.full_path_points:
self.scene.removeItem(p)
self.full_path_points.clear()
self._full_path_xy.clear()
def clear_guide_points(self):
i = 0
while i < len(self.anchor_points):
if self.point_items[i].is_removable():
self.scene.removeItem(self.point_items[i])
del self.point_items[i]
del self.anchor_points[i]
else:
i += 1
for it in self.full_path_points:
self.scene.removeItem(it)
self.full_path_points.clear()
self._full_path_xy.clear()
self._revert_cost_to_original()
self._apply_all_guide_points_to_cost()
self._rebuild_full_path()
def get_full_path_xy(self):
return self._full_path_xy
# ------------------------------------------------------------------------
# Advanced Settings Widget
# ------------------------------------------------------------------------
class AdvancedSettingsWidget(QWidget):
"""
Shows toggle rainbow, circle editor, line smoothing slider, contrast slider,
plus two image previews (contrasted-blurred and cost).
The images should maintain aspect ratio upon resize.
"""
def __init__(self, main_window, parent=None):
super().__init__(parent)
self._main_window = main_window
self._last_cb_pix = None # store QPixmap for contrasted-blurred
self._last_cost_pix = None # store QPixmap for cost
main_layout = QVBoxLayout()
self.setLayout(main_layout)
# A small grid for controls
controls_layout = QGridLayout()
# 1) Rainbow toggle
self.btn_toggle_rainbow = QPushButton("Toggle Rainbow")
self.btn_toggle_rainbow.clicked.connect(self._on_toggle_rainbow)
controls_layout.addWidget(self.btn_toggle_rainbow, 0, 0)
# 2) Circle editor
self.btn_circle_editor = QPushButton("Calibrate Kernel Size")
self.btn_circle_editor.clicked.connect(self._main_window.open_circle_editor)
controls_layout.addWidget(self.btn_circle_editor, 0, 1)
# 3) Line smoothing slider + label
self._lab_smoothing = QLabel("Line smoothing (3)")
controls_layout.addWidget(self._lab_smoothing, 1, 0)
self.line_smoothing_slider = QSlider(Qt.Horizontal)
self.line_smoothing_slider.setRange(3, 51)
self.line_smoothing_slider.setValue(3)
self.line_smoothing_slider.valueChanged.connect(self._on_line_smoothing_slider)
controls_layout.addWidget(self.line_smoothing_slider, 1, 1)
# 4) Contrast slider + label
self._lab_contrast = QLabel("Contrast (0.01)")
controls_layout.addWidget(self._lab_contrast, 2, 0)
self.contrast_slider = QSlider(Qt.Horizontal)
self.contrast_slider.setRange(1, 20)
self.contrast_slider.setValue(1) # i.e. 0.01
self.contrast_slider.setSingleStep(1)
self.contrast_slider.valueChanged.connect(self._on_contrast_slider)
controls_layout.addWidget(self.contrast_slider, 2, 1)
main_layout.addLayout(controls_layout)
# We'll set a minimum width so that the main window expands
# rather than overlapping the image
self.setMinimumWidth(350)
# Now a vertical layout for the two images, each with a label above it
images_layout = QVBoxLayout()
# 1) Contrasted-blurred label + image
self.label_cb_title = QLabel("Contrasted Blurred Image")
self.label_cb_title.setAlignment(Qt.AlignCenter)
images_layout.addWidget(self.label_cb_title)
self.label_contrasted_blurred = QLabel()
self.label_contrasted_blurred.setAlignment(Qt.AlignCenter)
self.label_contrasted_blurred.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
images_layout.addWidget(self.label_contrasted_blurred)
# 2) Cost image label + image
self.label_cost_title = QLabel("Current COST IMAGE")
self.label_cost_title.setAlignment(Qt.AlignCenter)
images_layout.addWidget(self.label_cost_title)
self.label_cost_image = QLabel()
self.label_cost_image.setAlignment(Qt.AlignCenter)
self.label_cost_image.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
images_layout.addWidget(self.label_cost_image)
main_layout.addLayout(images_layout)
def showEvent(self, event):
""" When shown, ask parent to resize to accommodate. """
super().showEvent(event)
if self.parentWidget():
self.parentWidget().adjustSize()
def resizeEvent(self, event):
"""
Keep the images at correct aspect ratio by re-scaling
our stored pixmaps to the new label sizes.
"""
super().resizeEvent(event)
self._update_labels()
def _update_labels(self):
if self._last_cb_pix is not None:
scaled_cb = self._last_cb_pix.scaled(
self.label_contrasted_blurred.size(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation
)
self.label_contrasted_blurred.setPixmap(scaled_cb)
if self._last_cost_pix is not None:
scaled_cost = self._last_cost_pix.scaled(
self.label_cost_image.size(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation
)
self.label_cost_image.setPixmap(scaled_cost)
def _on_toggle_rainbow(self):
self._main_window.toggle_rainbow()
def _on_line_smoothing_slider(self, value):
self._lab_smoothing.setText(f"Line smoothing ({value})")
self._main_window.image_view.set_savgol_window_length(value)
def _on_contrast_slider(self, value):
clip_limit = value / 100.0
self._lab_contrast.setText(f"Contrast ({clip_limit:.2f})")
self._main_window.update_contrast(clip_limit)
def update_displays(self, contrasted_img_np, cost_img_np):
"""
Called by main_window to refresh the two images in the advanced panel.
We'll store them as QPixmaps, then do the re-scale in _update_labels().
"""
cb_pix = self._np_array_to_qpixmap(contrasted_img_np)
cost_pix = self._np_array_to_qpixmap(cost_img_np, normalize=True)
self._last_cb_pix = cb_pix
self._last_cost_pix = cost_pix
self._update_labels()
def _np_array_to_qpixmap(self, arr, normalize=False):
if arr is None:
return None
arr_ = arr.copy()
if normalize:
mn, mx = arr_.min(), arr_.max()
if abs(mx - mn) < 1e-12:
arr_[:] = 0
else:
arr_ = (arr_ - mn) / (mx - mn)
arr_ = np.clip(arr_, 0, 1)
arr_255 = (arr_ * 255).astype(np.uint8)
h, w = arr_255.shape
qimage = QImage(arr_255.data, w, h, w, QImage.Format_Grayscale8)
return QPixmap.fromImage(qimage)
# ------------------------------------------------------------------------
# Main Window
# ------------------------------------------------------------------------
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Test GUI")
self._last_loaded_pixmap = None
self._circle_calibrated_radius = 6
self._last_loaded_file_path = None
# For the contrast slider
self._current_clip_limit = 0.01
# Outer widget + layout
self._main_widget = QWidget()
self._main_layout = QHBoxLayout(self._main_widget)
# The "left" part: container for the image area + its controls
self._left_panel = QVBoxLayout()
# We'll make a container widget for the left panel, so we can set stretches:
self._left_container = QWidget()
self._left_container.setLayout(self._left_panel)
# Now we add them to the main layout with 70%:30% ratio
self._main_layout.addWidget(self._left_container, 7) # 70%
# We haven't added the advanced widget yet, but we'll do so with ratio=3 => 30%
self._advanced_widget = AdvancedSettingsWidget(self)
# Hide it initially
self._advanced_widget.hide()
self._main_layout.addWidget(self._advanced_widget, 3)
self.setCentralWidget(self._main_widget)
# The image view
self.image_view = ImageGraphicsView()
self._left_panel.addWidget(self.image_view)
# Button row
btn_layout = QHBoxLayout()
self.btn_load_image = QPushButton("Load Image")
self.btn_load_image.clicked.connect(self.load_image)
btn_layout.addWidget(self.btn_load_image)
self.btn_export_path = QPushButton("Export Path")
self.btn_export_path.clicked.connect(self.export_path)
btn_layout.addWidget(self.btn_export_path)
self.btn_clear_points = QPushButton("Clear Points")
self.btn_clear_points.clicked.connect(self.clear_points)
btn_layout.addWidget(self.btn_clear_points)
# "Advanced Settings" toggle
self.btn_advanced = QPushButton("Advanced Settings")
self.btn_advanced.setCheckable(True)
self.btn_advanced.clicked.connect(self._toggle_advanced_settings)
btn_layout.addWidget(self.btn_advanced)
self._left_panel.addLayout(btn_layout)
self.resize(1000, 600)
self._old_central_widget = None
self._editor = None
def _toggle_advanced_settings(self, checked):
if checked:
self._advanced_widget.show()
else:
self._advanced_widget.hide()
# Force re-layout
self.adjustSize()
def open_circle_editor(self):
""" Replace central widget with circle editor. """
if not self._last_loaded_pixmap:
print("No image loaded yet! Cannot open circle editor.")
return
old_widget = self.takeCentralWidget()
self._old_central_widget = old_widget
init_radius = self._circle_calibrated_radius
editor = CircleEditorWidget(
pixmap=self._last_loaded_pixmap,
init_radius=init_radius,
done_callback=self._on_circle_editor_done
)
self._editor = editor
self.setCentralWidget(editor)
def _on_circle_editor_done(self, final_radius):
self._circle_calibrated_radius = final_radius
print(f"Circle Editor done. Radius = {final_radius}")
if self._last_loaded_file_path:
cost_img = compute_cost_image(
self._last_loaded_file_path,
self._circle_calibrated_radius,
clip_limit=self._current_clip_limit
)
self.image_view.cost_image_original = cost_img
self.image_view.cost_image = cost_img.copy()
self.image_view._apply_all_guide_points_to_cost()
self.image_view._rebuild_full_path()
self._update_advanced_images()
editor_widget = self.takeCentralWidget()
if editor_widget is not None:
editor_widget.setParent(None)
if self._old_central_widget is not None:
self.setCentralWidget(self._old_central_widget)
self._old_central_widget = None
if self._editor is not None:
self._editor.deleteLater()
self._editor = None
def toggle_rainbow(self):
self.image_view.toggle_rainbow()
def load_image(self):
options = QFileDialog.Options()
file_path, _ = QFileDialog.getOpenFileName(
self, "Open Image", "",
"Images (*.png *.jpg *.jpeg *.bmp *.tif)",
options=options
)
if file_path:
self.image_view.load_image(file_path)
cost_img = compute_cost_image(
file_path,
self._circle_calibrated_radius,
clip_limit=self._current_clip_limit
)
self.image_view.cost_image_original = cost_img
self.image_view.cost_image = cost_img.copy()
pm = QPixmap(file_path)
if not pm.isNull():
self._last_loaded_pixmap = pm
self._last_loaded_file_path = file_path
self._update_advanced_images()
def update_contrast(self, clip_limit):
self._current_clip_limit = clip_limit
if self._last_loaded_file_path:
cost_img = compute_cost_image(
self._last_loaded_file_path,
self._circle_calibrated_radius,
clip_limit=clip_limit
)
self.image_view.cost_image_original = cost_img
self.image_view.cost_image = cost_img.copy()
self.image_view._apply_all_guide_points_to_cost()
self.image_view._rebuild_full_path()
self._update_advanced_images()
def _update_advanced_images(self):
if not self._last_loaded_pixmap:
return
pm_np = self._qpixmap_to_gray_float(self._last_loaded_pixmap)
contrasted_blurred = preprocess_image(
pm_np,
sigma=3,
clip_limit=self._current_clip_limit
)
cost_img_np = self.image_view.cost_image
self._advanced_widget.update_displays(contrasted_blurred, cost_img_np)
def _qpixmap_to_gray_float(self, qpix):
img = qpix.toImage()
img = img.convertToFormat(QImage.Format_ARGB32)
ptr = img.bits()
ptr.setsize(img.byteCount())
arr = np.frombuffer(ptr, np.uint8).reshape((img.height(), img.width(), 4))
rgb = arr[..., :3].astype(np.float32)
gray = rgb.mean(axis=2) / 255.0
return gray
def export_path(self):
"""
Exports the path as a CSV in the format: x, y, TYPE,
ensuring that each anchor influences exactly one path point.
"""
full_xy = self.image_view.get_full_path_xy()
if not full_xy:
print("No path to export.")
return
# We'll consider each anchor point as "USER-PLACED".
# But unlike a distance-threshold approach, we assign each anchor
# to exactly one closest path point.
anchor_points = self.image_view.anchor_points
# For each anchor, find the index of the closest path point
user_placed_indices = set()
for ax, ay in anchor_points:
min_dist = float('inf')
closest_idx = None
for i, (px, py) in enumerate(full_xy):
dist = math.hypot(px - ax, py - ay)
if dist < min_dist:
min_dist = dist
closest_idx = i
if closest_idx is not None:
user_placed_indices.add(closest_idx)
# Ask user for the CSV filename
options = QFileDialog.Options()
file_path, _ = QFileDialog.getSaveFileName(
self, "Export Path", "",
"CSV Files (*.csv);;All Files (*)",
options=options
)
if not file_path:
return
import csv
with open(file_path, 'w', newline='') as csvfile:
writer = csv.writer(csvfile)
writer.writerow(["x", "y", "TYPE"])
for i, (x, y) in enumerate(full_xy):
ptype = "USER-PLACED" if i in user_placed_indices else "PATH"
writer.writerow([x, y, ptype])
print(f"Exported path with {len(full_xy)} points to {file_path}")
def clear_points(self):
self.image_view.clear_guide_points()
def closeEvent(self, event):
super().closeEvent(event)
def main():
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment