ScanSlicer/scanslicer/slices.py

183 lines
6.8 KiB
Python

import numpy as np
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