From 542141e5c0899f55126424ac1d97546b72ee34f7 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 25 Feb 2026 11:09:06 +0000 Subject: [PATCH] feat(slices): implement class PhotoSlice --- scanslicer/slices.py | 178 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/scanslicer/slices.py b/scanslicer/slices.py index 77260c4..ebee1c3 100644 --- a/scanslicer/slices.py +++ b/scanslicer/slices.py @@ -3,3 +3,181 @@ import cv2 # PhotoSlice: class definition for ScanSlicer behavior. +class PhotoSlice: + # __init__: function definition for ScanSlicer behavior. + def __init__(self, bbox=None): + self._bbox = None + self._rect_cache = None + self._rect_cache_tol = None + if bbox is None: + self.bbox = np.array([[10, 10], [800, 10], [800, 800], [10, 800]], dtype=np.float64).reshape(4, 2) + else: + self.bbox = np.array(bbox, dtype=np.float64).reshape(4, 2) + self.normalize_top_left() + + self.locked = False + # Side-length handles are an explicit edit mode, enabled by squarize. + self.side_handles_enabled = False + + @property + # bbox: function definition for ScanSlicer behavior. + def bbox(self): + return self._bbox + + @bbox.setter + # bbox: function definition for ScanSlicer behavior. + def bbox(self, value): + self._bbox = np.array(value, dtype=np.float64).reshape(4, 2) + self._rect_cache = None + self._rect_cache_tol = None + + # normalize_top_left: function definition for ScanSlicer behavior. + def normalize_top_left(self): + # Assume the top-left point of the quad is the image upper-left corner. + top_left_index = np.lexsort((self.bbox[:, 0], self.bbox[:, 1]))[0] + ordered = np.roll(self.bbox, -int(top_left_index), axis=0) + + # Resolve winding ambiguity by choosing the variant where edge 0->1 is + # the most horizontal "top" edge. + reversed_winding = np.array([ordered[0], ordered[3], ordered[2], ordered[1]]) + + # horizontal_score: function definition for ScanSlicer behavior. + def horizontal_score(points): + edge = points[1] - points[0] + return (abs(edge[0]) - abs(edge[1]), edge[0]) + + if horizontal_score(reversed_winding) > horizontal_score(ordered): + ordered = reversed_winding + + self.bbox = ordered + + # toggle_locked: function definition for ScanSlicer behavior. + def toggle_locked(self, locked=None): + if locked is not None: + self.locked = locked + else: + self.locked = not self.locked + + # update_corner: function definition for ScanSlicer behavior. + def update_corner(self, ci, x, y): + self._bbox[ci][0] = x + self._bbox[ci][1] = y + self._rect_cache = None + self._rect_cache_tol = None + # Freeform corner edits exit side-handle mode. + self.side_handles_enabled = False + + # is_perfect_rectangle: function definition for ScanSlicer behavior. + def is_perfect_rectangle(self, tol=1e-3): + if self._rect_cache is not None and self._rect_cache_tol == tol: + return self._rect_cache + + points = np.array(self.bbox, dtype=np.float64).reshape(4, 2) + edges = [points[(i + 1) % 4] - points[i] for i in range(4)] + lengths = [np.linalg.norm(e) for e in edges] + if min(lengths) < 1e-6: + self._rect_cache = False + self._rect_cache_tol = tol + return False + + units = [e / l for e, l in zip(edges, lengths)] + + # Adjacent edges should be perpendicular. + for i in range(4): + if abs(np.dot(units[i], units[(i + 1) % 4])) > tol: + self._rect_cache = False + self._rect_cache_tol = tol + return False + + # Opposite edges should be parallel (possibly opposite direction). + if abs(abs(np.dot(units[0], units[2])) - 1.0) > tol: + self._rect_cache = False + self._rect_cache_tol = tol + return False + if abs(abs(np.dot(units[1], units[3])) - 1.0) > tol: + self._rect_cache = False + self._rect_cache_tol = tol + return False + + self._rect_cache = True + self._rect_cache_tol = tol + return True + + # squarize: function definition for ScanSlicer behavior. + def squarize(self): + points = np.array(self.bbox, dtype=np.float64).reshape(4, 2) + center = np.mean(points, axis=0) + + top_width = np.linalg.norm(points[1] - points[0]) + bottom_width = np.linalg.norm(points[2] - points[3]) + left_height = np.linalg.norm(points[3] - points[0]) + right_height = np.linalg.norm(points[2] - points[1]) + + width = (top_width + bottom_width) / 2.0 + height = (left_height + right_height) / 2.0 + + # Preserve current tilt by building the rectangle on the quad's + # averaged horizontal/vertical directions instead of axis-aligning it. + horiz = (points[1] - points[0]) + (points[2] - points[3]) + horiz_norm = np.linalg.norm(horiz) + if horiz_norm < 1e-6: + horiz = points[1] - points[0] + horiz_norm = np.linalg.norm(horiz) + if horiz_norm < 1e-6: + rot_rect = cv2.minAreaRect(points.astype(np.float32)) + box = cv2.boxPoints(rot_rect).astype(np.float64) + self.bbox = box + self.normalize_top_left() + self.side_handles_enabled = True + return + u = horiz / horiz_norm + + vert = (points[3] - points[0]) + (points[2] - points[1]) + # Gram-Schmidt to enforce perpendicular axes. + vert = vert - np.dot(vert, u) * u + vert_norm = np.linalg.norm(vert) + if vert_norm < 1e-6: + vert = np.array([-u[1], u[0]], dtype=np.float64) + vert_norm = np.linalg.norm(vert) + v = vert / max(vert_norm, 1e-6) + + half_w = width / 2.0 + half_h = height / 2.0 + self.bbox = np.array( + [ + center - (u * half_w) - (v * half_h), + center + (u * half_w) - (v * half_h), + center + (u * half_w) + (v * half_h), + center - (u * half_w) + (v * half_h), + ], + dtype=np.float64, + ) + self.normalize_top_left() + self.side_handles_enabled = True + + # reset_rotation: function definition for ScanSlicer behavior. + def reset_rotation(self): + points = np.array(self.bbox, dtype=np.float64).reshape(4, 2) + center = np.mean(points, axis=0) + + top_width = np.linalg.norm(points[1] - points[0]) + bottom_width = np.linalg.norm(points[2] - points[3]) + left_height = np.linalg.norm(points[3] - points[0]) + right_height = np.linalg.norm(points[2] - points[1]) + + width = (top_width + bottom_width) / 2.0 + height = (left_height + right_height) / 2.0 + + half_w = width / 2.0 + half_h = height / 2.0 + self.bbox = np.array( + [ + [center[0] - half_w, center[1] - half_h], + [center[0] + half_w, center[1] - half_h], + [center[0] + half_w, center[1] + half_h], + [center[0] - half_w, center[1] + half_h], + ], + dtype=np.float64, + ) + self.normalize_top_left() + self.side_handles_enabled = True