Spaces:
Sleeping
Sleeping
| """ | |
| MLSTRUCT-FP - DB - IMAGE - RECT PHOTO | |
| Image of the true rect photo from the base image plan. | |
| """ | |
| __all__ = ['RectFloorPhoto'] | |
| from MLStructFP.db.image._base import BaseImage, TYPE_IMAGE | |
| from MLStructFP.utils import GeomPoint2D | |
| from MLStructFP._types import TYPE_CHECKING, Dict, List, Union, Tuple, Optional, NumberType | |
| import cv2 | |
| import gc | |
| import math | |
| import numpy as np | |
| import os | |
| import time | |
| if TYPE_CHECKING: | |
| from MLStructFP.db._c_rect import Rect | |
| from MLStructFP.db._floor import Floor | |
| IMAGES_TO_CLEAR_MEMORY: int = 10000 | |
| MAX_STORED_FLOORS: int = 2 | |
| def _im_show(title: str, img: 'np.ndarray') -> None: | |
| """ | |
| Image show using cv2. | |
| :param title: Title | |
| :param img: Image | |
| """ | |
| w, h, _ = img.shape | |
| f = 1 # Factor | |
| _w = 768 | |
| _h = 1024 | |
| if w > _w or h > _h: | |
| fx = _w / w | |
| fy = _h / h | |
| f = min(fx, fy, 1) | |
| if f != 1: | |
| w = int(math.ceil(abs(w * f))) | |
| h = int(math.ceil(abs(h * f))) | |
| img = cv2.resize(img, (h, w), interpolation=cv2.INTER_AREA) | |
| cv2.imshow(title, img) | |
| def _show_img( | |
| img: Union['np.ndarray', List['np.ndarray']], | |
| title: Union['str', List[str]] = '' | |
| ) -> None: | |
| """ | |
| Show image with cv2. | |
| :param img: Image | |
| :param title: Image titles | |
| """ | |
| if isinstance(img, list): | |
| for k in range(len(img)): | |
| if title == '': | |
| _im_show(f'Image {k + 1}', img[k]) | |
| else: | |
| assert isinstance(title, list) | |
| _im_show(title[k], img[k]) | |
| else: | |
| if title == '': | |
| title = 'Image' | |
| _im_show(title, img) | |
| cv2.waitKey(0) | |
| cv2.destroyAllWindows() | |
| def _sgn(x) -> float: | |
| """ | |
| Returns the sign of x. | |
| :param x: Number | |
| :return: Sign | |
| """ | |
| if x < 0: | |
| return -1 | |
| elif x == 0: | |
| return 0 | |
| else: | |
| return 1 | |
| def _rotate_image(image: 'np.ndarray', angle: float, bound: bool = True) -> 'np.ndarray': | |
| """ | |
| Rotate image around center. | |
| :param image: Image | |
| :param angle: Rotation angle | |
| :param bound: Expands image to avoid cropping | |
| :return: Rotated image | |
| """ | |
| if angle == 0: | |
| return image | |
| elif bound: | |
| return _rotate_image_bound(image, angle) | |
| (h, w) = image.shape[:2] | |
| image_center = (w // 2, h // 2) | |
| rot_mat = cv2.getRotationMatrix2D(image_center, angle, 1.0) | |
| result = cv2.warpAffine(image, rot_mat, image.shape[1::-1], flags=cv2.INTER_LINEAR) | |
| return result | |
| def _rotate_image_bound(mat: 'np.ndarray', angle: float) -> 'np.ndarray': | |
| """ | |
| Rotates an image (angle in degrees) and expands image to avoid cropping. | |
| :param mat: Image | |
| :param angle: Rotation angle | |
| :return: Rotated image | |
| """ | |
| height, width = mat.shape[:2] # image shape has 3 dimensions | |
| image_center = ( | |
| width / 2, | |
| height / 2) # getRotationMatrix2D needs coordinates in reverse order (width, height) compared to shape | |
| rotation_mat = cv2.getRotationMatrix2D(image_center, angle, 1.) | |
| # rotation calculates the cos and sin, taking absolutes of those. | |
| abs_cos = abs(rotation_mat[0, 0]) | |
| abs_sin = abs(rotation_mat[0, 1]) | |
| # find the new width and height bounds | |
| bound_w = int(height * abs_sin + width * abs_cos) | |
| bound_h = int(height * abs_cos + width * abs_sin) | |
| # subtract old image center (bringing image back to origin) and adding the new image center coordinates | |
| rotation_mat[0, 2] += bound_w / 2 - image_center[0] | |
| rotation_mat[1, 2] += bound_h / 2 - image_center[1] | |
| # rotate image with the new bounds and translated rotation matrix | |
| rotated_mat = cv2.warpAffine(mat, rotation_mat, (bound_w, bound_h)) | |
| return rotated_mat | |
| def _show_frame_image(image: 'np.ndarray', x1: int, y1: int, x2: int, y2: int) -> None: | |
| """ | |
| Write image frame and display. | |
| :param image: Image | |
| :param x1: Min x | |
| :param y1: Min y | |
| :param x2: Max x | |
| :param y2: Max y | |
| """ | |
| image = image.copy() | |
| w = 100 | |
| color = [255, 255, 255] | |
| image[y1:y2, x1:x1 + w] = color | |
| image[y1:y2, x2 - w:x2] = color | |
| image[y1:y1 + w, x1:x2] = color | |
| image[y2 - w:y2, x1:x2] = color | |
| _show_img(image, 'Frame') | |
| def _show_dot_image( | |
| image: 'np.ndarray', | |
| points: List[Union[Tuple, 'GeomPoint2D']], | |
| colors: Optional[List[List]] = None | |
| ) -> None: | |
| """ | |
| Write image dot and display. | |
| :param image: Image | |
| :param points: List of points | |
| """ | |
| image = image.copy() | |
| def _dot(_x: Union[int, float], _y: Union[int, float], color: List[int]) -> None: | |
| """ | |
| Create a dot on image. | |
| :param _x: X pos | |
| :param _y: Y pos | |
| :param color: Color of the point | |
| """ | |
| x1 = int(_x) | |
| y1 = int(_y) | |
| w = 75 | |
| x1 = int(x1 - w / 2) | |
| y1 = int(y1 - w / 2) | |
| x2 = x1 + w | |
| y2 = y1 + w | |
| image[y1:y2, x1:x1 + w] = color | |
| image[y1:y2, x2 - w:x2] = color | |
| image[y1:y1 + w, x1:x2] = color | |
| image[y2 - w:y2, x1:x2] = color | |
| if colors is None: | |
| colors = [] | |
| for i in range(len(points)): | |
| colors.append([255, 255, 255]) | |
| for j in range(len(points)): | |
| p = points[j] | |
| if not isinstance(p, tuple): | |
| _dot(p.x, p.y, colors[j]) | |
| else: | |
| _dot(p[0], p[1], colors[j]) | |
| _show_img(image, 'Frame') | |
| class RectFloorPhoto(BaseImage): | |
| """ | |
| Floor rect photo. | |
| """ | |
| _empty_color: int | |
| _floor_center_d: Dict[str, 'GeomPoint2D'] # No rotation image size in pixels | |
| _floor_images: Dict[str, 'np.ndarray'] | |
| _invert: bool | |
| _kernel: 'np.ndarray' | |
| _processed_images: int | |
| _verbose: bool | |
| def __init__( | |
| self, | |
| path: str = '', | |
| save_images: bool = False, | |
| image_size_px: int = 64, | |
| empty_color: int = -1, | |
| invert: bool = False | |
| ) -> None: | |
| """ | |
| Constructor. | |
| :param path: Image path | |
| :param save_images: Save images on path | |
| :param image_size_px: Image size (width/height), bigger images are expensive, double the width, quad the size | |
| :param empty_color: Empty base color. If -1, disable empty replace color | |
| :param invert: Invert color | |
| """ | |
| BaseImage.__init__(self, path, save_images, image_size_px) | |
| assert -1 <= empty_color <= 255 | |
| assert isinstance(invert, bool) | |
| self._empty_color = empty_color # Color to replace empty data | |
| self._invert = invert | |
| self._processed_images = 0 | |
| self._verbose = False | |
| # Create filter kernel | |
| # self._kernel = np.array([[0, -1, 0], | |
| # [-1, 5, -1], | |
| # [0, -1, 0]]) | |
| self._kernel = np.array([[-1, -1, -1], | |
| [-1, 9, -1], | |
| [-1, -1, -1]]) | |
| # Store loaded images | |
| self._floor_images = {} | |
| self._floor_center_d = {} | |
| def _parse_image(self, ip: str, mutator_scale_x: float = 1, mutator_scale_y: float = 1, | |
| mutator_angle: float = 0, verbose: bool = False) -> Tuple['np.ndarray', 'GeomPoint2D']: | |
| """ | |
| Process image. | |
| :param ip: Image path | |
| :param mutator_scale_x: Mutator scale on x-axis | |
| :param mutator_scale_y: Mutator scale on y-axis | |
| :param mutator_angle: Mutator angle | |
| :param verbose: Verbose mode | |
| :return: Parsed image, center | |
| """ | |
| # Make default empty color | |
| pixels: 'np.ndarray' | |
| if self._empty_color >= 0: | |
| image: 'np.ndarray' = cv2.imread(ip, cv2.IMREAD_UNCHANGED) | |
| if image is None: | |
| raise RectFloorPhotoFileLoadException(ip) | |
| if len(image.shape) == 3 and image.shape[2] == 4: | |
| # Make mask of where the transparent bits are | |
| trans_mask = image[:, :, 3] == 0 | |
| # Replace areas of transparency with white and not transparent | |
| image[trans_mask] = [self._empty_color, self._empty_color, self._empty_color, 255] | |
| pixels = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) | |
| else: | |
| pixels = image | |
| else: | |
| pixels = cv2.imread(ip) | |
| if pixels is None: | |
| raise RectFloorPhotoFileLoadException(ip) | |
| # Turn all black lines to white | |
| if len(pixels.shape) == 3 and np.max(pixels) == 0: | |
| image = cv2.imread(ip, cv2.IMREAD_UNCHANGED) | |
| trans_mask = image[:, :, 3] == 0 | |
| image[trans_mask] = [255, 255, 255, 255] # Turn all black to white to invert | |
| pixels = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) | |
| if self._invert: | |
| pixels = 255 - pixels | |
| # If image has only 1 channel, convert back to 3 | |
| if len(pixels.shape) == 2: | |
| pixels = np.stack([pixels, pixels, pixels], axis=-1) | |
| # Flip image | |
| if mutator_scale_x != 1 or mutator_scale_y != 1: | |
| if mutator_scale_x < 0: | |
| pixels = cv2.flip(pixels, 1) | |
| if mutator_scale_y < 0: | |
| pixels = cv2.flip(pixels, 0) | |
| # Transform image due to mutators | |
| sy, sx, _ = pixels.shape | |
| sx = int(math.ceil(abs(sx * mutator_scale_x))) | |
| sy = int(math.ceil(abs(sy * mutator_scale_y))) | |
| pixels = cv2.resize(pixels, (sx, sy)) | |
| source_pixels: Optional['np.ndarray'] = None | |
| if verbose: | |
| source_pixels = pixels.copy() | |
| cy, cx = pixels.shape[:2] # Source center pixel | |
| pixels = _rotate_image(pixels, mutator_angle) | |
| if verbose: | |
| _show_img([source_pixels, pixels]) | |
| return pixels, GeomPoint2D(cx, cy) | |
| def _get_floor_image(self, floor: 'Floor', store: bool = True) -> Tuple['np.ndarray', 'GeomPoint2D']: | |
| """ | |
| Get floor image numpy class. | |
| :param floor: Floor object | |
| :param store: Store results for faster future queries | |
| :return: Image array | |
| """ | |
| floor_hash = f'{floor.id}{floor.mutator_angle}{floor.mutator_scale_x}{floor.mutator_scale_y}' | |
| if floor_hash in self._floor_images.keys(): | |
| return self._floor_images[floor_hash], self._floor_center_d[floor_hash] | |
| ip = floor.image_path | |
| if self._verbose: | |
| print(f'Loading image: {ip}') | |
| pixels, pc = self._parse_image(ip, floor.mutator_scale_x, floor.mutator_scale_y, | |
| floor.mutator_angle, self._verbose) | |
| # Store | |
| if store: | |
| self._floor_images[floor_hash] = pixels | |
| self._floor_center_d[floor_hash] = pc | |
| if self._verbose: | |
| print('Storing', ip, floor_hash) | |
| if len(self._floor_images) >= MAX_STORED_FLOORS: | |
| key_iter = iter(self._floor_images.keys()) | |
| k1: str = next(key_iter) | |
| del self._floor_images[k1] # Remove | |
| del self._floor_center_d[k1] | |
| if self._verbose: | |
| print('Removing', ip, k1) | |
| # Return pixels and center | |
| return pixels, pc | |
| def make_rect(self, rect: 'Rect', crop_length: NumberType = 5) -> Tuple[int, 'np.ndarray']: | |
| """ | |
| Generate image for the perimeter of a given rectangle. | |
| :param rect: Rectangle | |
| :param crop_length: Size of crop from center of the rect to any edge in meters | |
| :return: Returns the image index on the library array | |
| """ | |
| return self._make(rect.floor, rect.get_mass_center(), crop_length, crop_length, rect) | |
| def make_region(self, xmin: NumberType, xmax: NumberType, ymin: NumberType, ymax: NumberType, | |
| floor: 'Floor') -> Tuple[int, 'np.ndarray']: | |
| """ | |
| Generate image for a given region. | |
| :param xmin: Minimum x-axis (image coordinates) | |
| :param xmax: Maximum x-axis (image coordinates) | |
| :param ymin: Minimum y-axis (image coordinates) | |
| :param ymax: Maximum y-axis (image coordinates) | |
| :param floor: Floor object | |
| :return: Returns the image index on the library array | |
| """ | |
| assert xmax > xmin and ymax > ymin | |
| t0 = time.time() | |
| dx = (xmax - xmin) / 2 | |
| dy = (ymax - ymin) / 2 | |
| mk = self._make(floor, GeomPoint2D(xmin + dx, ymin + dy), dx, dy, None) | |
| self._last_make_region_time = time.time() - t0 | |
| return mk | |
| def _make(self, floor: 'Floor', cr: 'GeomPoint2D', dx: float, dy: float, rect: Optional['Rect'] | |
| ) -> Tuple[int, 'np.ndarray']: | |
| """ | |
| Generate image from a given coordinate (x, y). | |
| :param floor: Object floor to process | |
| :param cr: Coordinate to process | |
| :param dx: Half crop distance on x-axis (m) | |
| :param dy: Half crop distance on y-axis (m) | |
| :param rect: Optional rect | |
| :return: Returns the image index on the library array | |
| """ | |
| assert dx > 0 and dy > 0 | |
| image, original_shape = self._get_floor_image(floor) | |
| # Compute crop area | |
| sc = floor.image_scale | |
| sx = floor.mutator_scale_x | |
| sy = -floor.mutator_scale_y | |
| # Image size in px | |
| h, w, _ = image.shape | |
| # Original center (non rotated) | |
| rc2 = original_shape.clone() | |
| rc2.scale(0.5) | |
| # Compute true point based on rotation | |
| ax = sc / _sgn(sx) | |
| ay = sc / _sgn(sy) | |
| cr.rotate(GeomPoint2D(), -floor.mutator_angle) | |
| # Scale to pixels | |
| cr.x *= ax | |
| cr.y *= ay | |
| if sx < 0: | |
| cr.x = original_shape.x - cr.x | |
| if sy > 0: | |
| cr.y = original_shape.y - cr.y | |
| # Compute the distance to original center and angle | |
| r = cr.dist(rc2) | |
| theta = cr.angle(rc2) | |
| del rc2 | |
| # Create point from current center using radius and computed angle | |
| cr.x = w / 2 + r * math.cos(theta + math.pi * (1 - floor.mutator_angle / 180)) | |
| cr.y = h / 2 + r * math.sin(theta + math.pi * (1 - floor.mutator_angle / 180)) | |
| if self._verbose: | |
| if rect is not None: | |
| print(f'Processing rect ID <{rect.id}>') | |
| ce = GeomPoint2D(w / 2, h / 2) | |
| _show_dot_image(image, [ce, cr], [[255, 255, 255], [255, 0, 0]]) | |
| # Scale back | |
| cr.x /= ax | |
| cr.y /= ay | |
| # Create region | |
| xmin = cr.x - dx | |
| xmax = cr.x + dx | |
| ymin = cr.y - dy | |
| ymax = cr.y + dy | |
| image: 'np.ndarray' = self._get_floor_image(floor)[0] | |
| figname = f'{rect.id}' if rect else f'{floor.id}-x-{xmin:.2f}-{xmax:.2f}-y-{ymin:.2f}-{ymax:.2f}' | |
| # Get cropped and resized box | |
| out_img: 'np.ndarray' = self._get_crop_image( | |
| image=image, | |
| x1=int(xmin * ax), | |
| x2=int(xmax * ax), | |
| y1=int(ymin * ay), | |
| y2=int(ymax * ay), | |
| rect=rect) | |
| if self._save_images: | |
| assert self._path != '', 'Path cannot be empty' | |
| filesave = os.path.join(self._path, figname + '.png') | |
| cv2.imwrite(filesave, out_img) | |
| # Save to array | |
| out_img_rgb = cv2.cvtColor(out_img, cv2.COLOR_BGR2RGB) | |
| if self.save: | |
| self._images.append(out_img_rgb) # Save as rgb | |
| self._names.append(figname) | |
| self._processed_images += 1 | |
| if self._processed_images % IMAGES_TO_CLEAR_MEMORY == 0: | |
| gc.collect() | |
| # Clear memory | |
| del out_img | |
| # Returns the image index on the library array | |
| return len(self._names) - 1, out_img_rgb # Images array can change during export | |
| def _get_empty_image(self) -> 'np.ndarray': | |
| """ | |
| :return: Desired output image with default empty color | |
| """ | |
| img: 'np.ndarray' = np.ones((self._image_size, self._image_size, 3)) * self._empty_color | |
| img = img.astype(TYPE_IMAGE) | |
| return img | |
| def _get_crop_image( | |
| self, | |
| image: 'np.ndarray', | |
| x1: int, | |
| x2: int, | |
| y1: int, | |
| y2: int, | |
| rect: Optional['Rect'] | |
| ) -> 'np.ndarray': | |
| """ | |
| Create crop image. | |
| :param image: Source image array | |
| :param x1: Min pos (x axis) | |
| :param x2: Max pos (x axis) | |
| :param y1: Min pos (y axis) | |
| :param y2: Max pos (y axis) | |
| :param rect: Rect object from the image | |
| :return: Cropped image | |
| """ | |
| t = time.time() | |
| x = [x1, x2] | |
| y = [y1, y2] | |
| x1, x2 = min(x), max(x) | |
| y1, y2 = min(y), max(y) | |
| ww = int(x2 - x1) | |
| hh = int(y2 - y1) | |
| if len(image.shape) == 2: | |
| w, h = image.shape | |
| out_img = np.zeros((ww, hh)) | |
| else: | |
| w, h, c = image.shape | |
| out_img = np.zeros((ww, hh, c)) | |
| dx = 0 | |
| if x1 < 0: | |
| dx = - x1 | |
| x1 = 0 | |
| if x1 + hh > h: | |
| hh = max(0, h - x1) | |
| dy = 0 | |
| if y1 < 0: | |
| dy = -y1 | |
| y1 = 0 | |
| if y1 + ww > w: | |
| ww = max(0, w - y1) | |
| if ww - dy > 0 and hh - dx > 0: | |
| _x2 = x1 + hh - dx | |
| _y2 = y1 + ww - dy | |
| if self._verbose: | |
| print(f'\tRead from x:{x1}->{_x2} to y:{y1}->{_y2}') | |
| print(f'\t{dy}', dx, y1, x1, ww, hh) | |
| _show_frame_image(image, x1, y1, x2, y2) | |
| try: | |
| out_img[dy:dy + ww, dx:dx + hh] = image[y1:_y2, x1:_x2] | |
| except ValueError as e: | |
| if rect is not None: | |
| print(f'Shape inconsistency at rect ID <{rect.id}>, Floor ID {rect.floor.id}') | |
| raise RectFloorPhotoShapeException(str(e)) | |
| """ | |
| Good: INTER_AREA | |
| Not good: INTER_LANCZOS4, INTER_BITS, INTER_CUBIC, INTER_LINEAR, | |
| INTER_LINEAR_EXACT | |
| Bad: INTER_NEAREST | |
| """ | |
| out_rz: 'np.ndarray' = cv2.resize(out_img, (self._image_size, self._image_size), | |
| interpolation=cv2.INTER_AREA) | |
| if self._verbose: | |
| print('Process finished in {0} seconds'.format(int(time.time() - t))) | |
| im = out_rz.astype(TYPE_IMAGE) | |
| _alpha = -5 | |
| if self._empty_color == 0: | |
| adjusted: 'np.ndarray' = cv2.convertScaleAbs(im, alpha=_alpha, beta=0) | |
| else: | |
| adjusted = im | |
| # Apply kernel | |
| image_kernel = cv2.filter2D(adjusted, -1, self._kernel) | |
| if self._verbose: | |
| _show_img([im, adjusted, cv2.convertScaleAbs(im, alpha=2 * _alpha, beta=0), 255 - adjusted, image_kernel], | |
| ['Base', 'Adjusted', 'Adjusted2', 'Negative adjusted', 'By kernel']) | |
| return image_kernel | |
| def close(self, **kwargs) -> None: | |
| """ | |
| Close and delete all generated figures. | |
| """ | |
| self._names.clear() | |
| self._processed_images = 0 | |
| self._images.clear() | |
| self._floor_images.clear() | |
| self._floor_center_d.clear() | |
| gc.collect() | |
| class RectFloorPhotoShapeException(Exception): | |
| """ | |
| Custom exception from rect floor generation image. | |
| """ | |
| class RectFloorPhotoFileLoadException(Exception): | |
| """ | |
| Exception thrown if the image could not be loaded. | |
| """ | |