2026-02-24 13:29:15 +00:00
|
|
|
import numpy as np
|
|
|
|
|
import cv2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# PhotoSlice: class definition for ScanSlicer behavior.
|
2026-02-25 11:09:06 +00:00
|
|
|
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
|
2026-09-11 21:33:59 +02:00
|
|
|
# False = default winding, True = explicit left/right swap by the user.
|
|
|
|
|
# Persists through squarize/reset_rotation so manual orientation wins.
|
|
|
|
|
self.swapped = False
|
2026-02-25 11:09:06 +00:00
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
|
2026-09-11 21:33:59 +02:00
|
|
|
# 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]]
|
|
|
|
|
|
2026-02-25 11:09:06 +00:00
|
|
|
self.bbox = ordered
|
|
|
|
|
|
2026-09-11 21:33:59 +02:00
|
|
|
# swap_left_right: function definition for ScanSlicer behavior.
|
|
|
|
|
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
|
|
|
|
|
|
2026-02-25 11:09:06 +00:00
|
|
|
# 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
|
2026-02-25 11:45:33 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# polys_iou: function definition for ScanSlicer behavior.
|
|
|
|
|
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)
|