diff --git a/GUI_draft_live.py b/GUI_draft_live.py deleted file mode 100644 index 90089d8827ccae9665dc4797e7fba4e5bba094d0..0000000000000000000000000000000000000000 --- a/GUI_draft_live.py +++ /dev/null @@ -1,1181 +0,0 @@ -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()