ScanSlicer/scanslicer/canvas_state.py
dan 426a18f342 chore: prune boilerplate and refresh stale docs
- Remove 257 redundant per-definition comment stubs across the package
- Drop dead nonlocal declarations in autoslicer_detection.py
- Document the swapped flag and swap_left_right in architecture docs
- Remove unsupported 'legacy override' claims from autotune/runtime docs
2026-09-11 23:07:30 +02:00

252 lines
8.1 KiB
Python

import numpy as np
from PySide6.QtCore import QPointF, QRectF
from .slices import PhotoSlice, polys_iou
class CanvasStateMixin:
def fit_image_to_view(self):
if self.image is None:
return
view_w = max(1, float(self.width()))
view_h = max(1, float(self.height()))
image_w = max(1, float(self.image.width()))
image_h = max(1, float(self.image.height()))
self.zoom = min(view_w / image_w, view_h / image_h) * self.AUTO_FIT_SCALE
shown_w = image_w * self.zoom
shown_h = image_h * self.zoom
self.pan_offset = QPointF((view_w - shown_w) / 2.0, (view_h - shown_h) / 2.0)
def reset_view(self):
if self.image is None:
return False
self.user_transformed_view = False
self.fit_image_to_view()
self.update()
return True
def set_image(self, image, new_image=False):
if self.image is None or new_image:
self.user_transformed_view = False
self.slices = []
self.set_active_slice(None)
self.box_dragging = None
self.image = image
if self.image is not None and not self.user_transformed_view:
self.fit_image_to_view()
self.update()
def _clamp_slice_to_image(self, slice_obj):
if slice_obj is None:
return None
slice_obj.bbox = self._clamp_bbox_to_image(slice_obj.bbox)
return slice_obj
def _coerce_slice_in_bounds(self, bbx):
if isinstance(bbx, PhotoSlice):
return self._clamp_slice_to_image(bbx)
return PhotoSlice(self._clamp_bbox_to_image(bbx))
def add_bbox(self, bbx):
self.slices.append(self._coerce_slice_in_bounds(bbx))
self.set_active_slice(len(self.slices) - 1)
self.update()
@staticmethod
def _aabb_overlaps(poly_a, poly_b):
a = np.array(poly_a, dtype=np.float32).reshape(-1, 2)
b = np.array(poly_b, dtype=np.float32).reshape(-1, 2)
a_min_x = float(np.min(a[:, 0]))
a_max_x = float(np.max(a[:, 0]))
a_min_y = float(np.min(a[:, 1]))
a_max_y = float(np.max(a[:, 1]))
b_min_x = float(np.min(b[:, 0]))
b_max_x = float(np.max(b[:, 0]))
b_min_y = float(np.min(b[:, 1]))
b_max_y = float(np.max(b[:, 1]))
return not (
a_max_x < b_min_x
or b_max_x < a_min_x
or a_max_y < b_min_y
or b_max_y < a_min_y
)
def update_bboxes(self, bbxs=None):
if bbxs is not None:
active_slice = None
if self.active_slice_index is not None and 0 <= self.active_slice_index < len(self.slices):
active_slice = self.slices[self.active_slice_index]
merged_slices = []
for sl in self.slices:
if sl.locked:
merged_slices.append(self._clamp_slice_to_image(sl))
for box in bbxs:
box = self._clamp_bbox_to_image(box)
overlaps_locked = False
for sl in merged_slices:
if not self._aabb_overlaps(box, sl.bbox):
continue
if polys_iou(box, sl.bbox) > 0.3:
overlaps_locked = True
break
if not overlaps_locked:
merged_slices.append(PhotoSlice(box))
self.slices = merged_slices
if active_slice is not None and active_slice in self.slices:
self.set_active_slice(self.slices.index(active_slice))
else:
self.set_active_slice(None)
self.update()
def set_active_slice(self, index):
if index is None:
normalized = None
elif 0 <= int(index) < len(self.slices):
normalized = int(index)
else:
normalized = None
if normalized == self.active_slice_index:
return
self.active_slice_index = normalized
self.active_slice_changed.emit(-1 if normalized is None else normalized)
self.update()
def get_active_slice(self):
if self.active_slice_index is None:
return None
if not (0 <= self.active_slice_index < len(self.slices)):
self.set_active_slice(None)
return None
return self.slices[self.active_slice_index]
def set_active_slice_locked(self, locked):
active_slice = self.get_active_slice()
if active_slice is None:
return False
active_slice.toggle_locked(bool(locked))
self.update()
return True
def set_all_slices_locked(self, locked):
for s in self.slices:
s.toggle_locked(bool(locked))
self.update()
return len(self.slices)
def are_all_slices_locked(self):
if len(self.slices) == 0:
return False
return all(s.locked for s in self.slices)
def remove_active_slice(self):
if self.active_slice_index is None:
return False
if not (0 <= self.active_slice_index < len(self.slices)):
self.set_active_slice(None)
return False
remove_index = self.active_slice_index
self.slices.pop(remove_index)
if len(self.slices) == 0:
self.set_active_slice(None)
else:
new_index = min(remove_index, len(self.slices) - 1)
# Force refresh even if the numeric index is unchanged.
self.active_slice_index = None
self.set_active_slice(new_index)
self.update()
return True
def squarize_active_slice(self):
active_slice = self.get_active_slice()
if active_slice is None:
return False
active_slice.squarize()
self.update()
return True
def swap_active_slice_left_right(self):
active_slice = self.get_active_slice()
if active_slice is None:
return False
active_slice.swap_left_right()
self.update()
return True
def reset_active_slice_rotation(self):
active_slice = self.get_active_slice()
if active_slice is None:
return False
active_slice.reset_rotation()
active_slice.bbox = self._clamp_bbox_to_image(active_slice.bbox)
self.update()
return True
def set_rotate_handles_mode(self, enabled):
self.rotate_handles_mode = bool(enabled)
def _image_rect_view(self):
if self.image is None:
return QRectF()
return QRectF(
self.pan_offset.x(),
self.pan_offset.y(),
self.image.width() * self.zoom,
self.image.height() * self.zoom,
)
def _view_to_image(self, p):
return np.array(
[
(p.x() - self.pan_offset.x()) / self.zoom,
(p.y() - self.pan_offset.y()) / self.zoom,
],
dtype=np.float64,
)
def _clamp_view_pos_to_canvas(self, p):
max_x = max(0.0, float(self.width() - 1))
max_y = max(0.0, float(self.height() - 1))
return QPointF(
min(max(p.x(), 0.0), max_x),
min(max(p.y(), 0.0), max_y),
)
def _clamp_image_point(self, point):
if self.image is None:
return np.array(point, dtype=np.float64)
max_x = max(0.0, float(self.image.width() - 1))
max_y = max(0.0, float(self.image.height() - 1))
p = np.array(point, dtype=np.float64).reshape(2)
p[0] = min(max(p[0], 0.0), max_x)
p[1] = min(max(p[1], 0.0), max_y)
return p
def _clamp_bbox_to_image(self, bbox):
if self.image is None:
return np.array(bbox, dtype=np.float64)
max_x = max(0.0, float(self.image.width() - 1))
max_y = max(0.0, float(self.image.height() - 1))
pts = np.array(bbox, dtype=np.float64).reshape(4, 2)
pts[:, 0] = np.clip(pts[:, 0], 0.0, max_x)
pts[:, 1] = np.clip(pts[:, 1], 0.0, max_y)
return pts
def _image_to_view(self, p):
return QPointF(
p[0] * self.zoom + self.pan_offset.x(),
p[1] * self.zoom + self.pan_offset.y(),
)