| from __future__ import annotations |
|
|
| import cv2 |
| import numpy as np |
|
|
| _FRONT = (0, 1, 2, 3) |
| _BACK = (5, 4, 7, 6) |
| _SIDES = ( |
| (4, 5, 1, 0), |
| (3, 2, 6, 7), |
| (4, 0, 3, 7), |
| (1, 5, 6, 2), |
| ) |
|
|
| _LIGHT = np.array([0.35, -0.55, -1.0]) |
| _LIGHT /= np.linalg.norm(_LIGHT) |
|
|
| _CARD_BG = (40, 37, 34) |
| _CARD_BORDER = (120, 115, 110) |
| _EDGE_COLOR = (150, 145, 140) |
| _SIDE_COLOR = np.array([120, 116, 112], dtype=np.float32) |
|
|
|
|
| def _rotation(yaw: float, pitch: float, roll: float) -> np.ndarray: |
| """Build rotation matrix.""" |
| cy, sy = np.cos(yaw), np.sin(yaw) |
| cp, sp = np.cos(pitch), np.sin(pitch) |
| cr, sr = np.cos(roll), np.sin(roll) |
| ry = np.array([[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]]) |
| rx = np.array([[1, 0, 0], [0, cp, -sp], [0, sp, cp]]) |
| rz = np.array([[cr, -sr, 0], [sr, cr, 0], [0, 0, 1]]) |
| return rz @ ry @ rx |
|
|
|
|
| class Object3D: |
| """Textured 3D slab.""" |
|
|
| def __init__(self, drawing_bgra: np.ndarray, thickness_ratio: float = 0.05) -> None: |
| self.texture = self._make_card(drawing_bgra) |
| h, w = self.texture.shape[:2] |
| self.aspect = w / max(h, 1) |
| self.thickness_ratio = thickness_ratio |
| self._corners = self._build_corners() |
|
|
| @staticmethod |
| def _make_card(drawing_bgra: np.ndarray) -> np.ndarray: |
| """Flatten drawing to card.""" |
| if drawing_bgra.shape[2] == 4: |
| rgb = drawing_bgra[:, :, :3].astype(np.float32) |
| alpha = (drawing_bgra[:, :, 3].astype(np.float32) / 255.0)[:, :, None] |
| else: |
| rgb = drawing_bgra.astype(np.float32) |
| alpha = np.ones(drawing_bgra.shape[:2] + (1,), dtype=np.float32) |
|
|
| h, w = rgb.shape[:2] |
| max_side = 900 |
| if max(h, w) > max_side: |
| k = max_side / max(h, w) |
| rgb = cv2.resize(rgb, (int(w * k), int(h * k)), interpolation=cv2.INTER_AREA) |
| alpha = cv2.resize(alpha, (int(w * k), int(h * k)), interpolation=cv2.INTER_AREA)[:, :, None] |
|
|
| bg = np.full(rgb.shape, _CARD_BG, dtype=np.float32) |
| card = (bg * (1.0 - alpha) + rgb * alpha).astype(np.uint8) |
| cv2.rectangle(card, (0, 0), (card.shape[1] - 1, card.shape[0] - 1), _CARD_BORDER, 2) |
| return card |
|
|
| def _build_corners(self) -> np.ndarray: |
| """Eight slab vertices.""" |
| w = self.aspect * 0.5 |
| h = 0.5 |
| t = max(self.thickness_ratio, 0.005) * 0.5 |
| return np.array( |
| [ |
| (-w, -h, t), (w, -h, t), (w, h, t), (-w, h, t), |
| (-w, -h, -t), (w, -h, -t), (w, h, -t), (-w, h, -t), |
| ], |
| dtype=np.float64, |
| ) |
|
|
| @staticmethod |
| def _project(pts3d: np.ndarray, focal: float, distance: float, cx: float, cy: float): |
| """Perspective project points.""" |
| z = pts3d[:, 2] + distance |
| z = np.maximum(z, 1e-3) |
| x = focal * pts3d[:, 0] / z + cx |
| y = focal * pts3d[:, 1] / z + cy |
| return np.stack([x, y], axis=1), z |
|
|
| @staticmethod |
| def _shade(face_cam: np.ndarray) -> float: |
| """Face lighting factor.""" |
| n = np.cross(face_cam[1] - face_cam[0], face_cam[2] - face_cam[0]) |
| norm = np.linalg.norm(n) |
| if norm < 1e-9: |
| return 0.2 |
| n = n / norm |
| if n[2] > 0: |
| n = -n |
| return float(np.clip(abs(np.dot(n, _LIGHT)), 0.22, 1.0)) |
|
|
| @staticmethod |
| def _quad_area(quad: np.ndarray) -> float: |
| """Screen area of quad.""" |
| x, y = quad[:, 0], quad[:, 1] |
| return 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) |
|
|
| def _blit_texture( |
| self, out: np.ndarray, texture: np.ndarray, quad: np.ndarray, shade: float |
| ) -> None: |
| """Warp texture onto face.""" |
| oh, ow = out.shape[:2] |
| x0 = int(np.floor(quad[:, 0].min())) |
| y0 = int(np.floor(quad[:, 1].min())) |
| x1 = int(np.ceil(quad[:, 0].max())) + 1 |
| y1 = int(np.ceil(quad[:, 1].max())) + 1 |
| x0, y0 = max(0, x0), max(0, y0) |
| x1, y1 = min(ow, x1), min(oh, y1) |
| if x1 - x0 < 2 or y1 - y0 < 2: |
| return |
|
|
| th, tw = texture.shape[:2] |
| src = np.array([[0, 0], [tw - 1, 0], [tw - 1, th - 1], [0, th - 1]], dtype=np.float32) |
| dst = (quad - np.array([x0, y0], dtype=np.float64)).astype(np.float32) |
| try: |
| m = cv2.getPerspectiveTransform(src, dst) |
| except cv2.error: |
| return |
| if not np.all(np.isfinite(m)): |
| return |
|
|
| size = (x1 - x0, y1 - y0) |
| warped = cv2.warpPerspective(texture, m, size, flags=cv2.INTER_LINEAR) |
| mask = cv2.warpPerspective( |
| np.full((th, tw), 255, np.uint8), m, size, flags=cv2.INTER_NEAREST |
| ) |
| warped = np.clip(warped.astype(np.float32) * (0.70 + 0.30 * shade), 0, 255).astype(np.uint8) |
|
|
| roi = out[y0:y1, x0:x1] |
| a = (mask.astype(np.float32) / 255.0)[:, :, None] |
| out[y0:y1, x0:x1] = (roi * (1.0 - a) + warped * a).astype(np.uint8) |
|
|
| def render( |
| self, |
| background: np.ndarray, |
| yaw: float, |
| pitch: float, |
| roll: float = 0.0, |
| scale: float = 1.0, |
| distance: float = 3.0, |
| center: tuple[float, float] | None = None, |
| draw_edges: bool = True, |
| ) -> np.ndarray: |
| """Render object over background.""" |
| out = background.copy() |
| oh, ow = out.shape[:2] |
| cx, cy = center if center is not None else (ow * 0.5, oh * 0.5) |
| focal = 1.7 * oh * max(scale, 0.05) |
|
|
| rot = _rotation(yaw, pitch, roll) |
| cam = self._corners @ rot.T |
| proj, depth = self._project(cam, focal, distance, cx, cy) |
|
|
| faces: list[tuple[float, tuple[int, ...], str]] = [ |
| (float(depth[list(_FRONT)].mean()), _FRONT, "front"), |
| (float(depth[list(_BACK)].mean()), _BACK, "back"), |
| ] |
| faces += [(float(depth[list(f)].mean()), f, "side") for f in _SIDES] |
| faces.sort(key=lambda item: item[0], reverse=True) |
|
|
| back_tex = None |
| for _, idx, kind in faces: |
| centre = cam[list(idx)].mean(axis=0) |
| view = centre + np.array([0.0, 0.0, distance]) |
| if float(np.dot(centre, view)) >= 0.0: |
| continue |
|
|
| quad = proj[list(idx)] |
| if self._quad_area(quad) < 4.0: |
| continue |
| shade = self._shade(cam[list(idx)]) |
|
|
| if kind == "front": |
| self._blit_texture(out, self.texture, quad, shade) |
| elif kind == "back": |
| if back_tex is None: |
| back_tex = cv2.flip(self.texture, 1) |
| back_tex = (back_tex.astype(np.float32) * 0.55).astype(np.uint8) |
| self._blit_texture(out, back_tex, quad, shade) |
| else: |
| color = np.clip(_SIDE_COLOR * shade, 0, 255) |
| cv2.fillConvexPoly( |
| out, quad.astype(np.int32), color.tolist(), lineType=cv2.LINE_AA |
| ) |
|
|
| if draw_edges: |
| cv2.polylines( |
| out, [quad.astype(np.int32)], True, _EDGE_COLOR, 1, cv2.LINE_AA |
| ) |
| return out |
|
|