import numpy as np import cv2 class PhotoSlice: 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 # False = default winding, True = explicit left/right swap by the user. # Persists through squarize/reset_rotation so manual orientation wins. self.swapped = False @property def bbox(self): return self._bbox @bbox.setter def bbox(self, value): self._bbox = np.array(value, dtype=np.float64).reshape(4, 2) self._rect_cache = None self._rect_cache_tol = None 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]]) 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 # A user-requested left/right swap overrides the geometric inference, # so manual orientation survives normalize_top_left(). if getattr(self, "swapped", False): ordered = ordered[[1, 0, 3, 2]] self.bbox = ordered def swap_left_right(self): # Mirror left/right: swap the two top corners and the two bottom # corners, keeping the top (edge 0->1) and bottom edges in place. # [TL, TR, BR, BL] <-> [TR, TL, BL, BR]. self.swapped = not self.swapped self.bbox = self.bbox[[1, 0, 3, 2]] self._rect_cache = None self._rect_cache_tol = None def toggle_locked(self, locked=None): if locked is not None: self.locked = locked else: self.locked = not self.locked 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 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 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 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 def polys_iou(poly1, poly2): a = np.array(poly1, dtype=np.float32).reshape(-1, 2) b = np.array(poly2, dtype=np.float32).reshape(-1, 2) if len(a) < 3 or len(b) < 3: return 0.0 # Fast reject for disjoint axis-aligned bounds. 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])) if 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: return 0.0 area_a = cv2.contourArea(a) area_b = cv2.contourArea(b) if area_a <= 0 or area_b <= 0: return 0.0 try: inter_area, _ = cv2.intersectConvexConvex(a, b) except cv2.error: return 0.0 if inter_area <= 0: return 0.0 union_area = area_a + area_b - inter_area if union_area <= 0: return 0.0 return float(inter_area / union_area)