quarrying преди 4 години
ревизия
94f2e6f723

+ 12 - 0
.gitignore

@@ -0,0 +1,12 @@
+*.pyc
+*.jpg
+*.json
+*.pkl
+*.swp
+archive.py
+_run_*.py
+
+local/
+_local/
+__pycache__/
+*.egg-info/

+ 12 - 0
README.md

@@ -0,0 +1,12 @@
+## Overview
+Handy Utilities for Computer Vision
+
+
+## Installation
+
+
+## Dependencies
+- Python 3.5 +
+- NumPy 1.11 +
+- OpenCV 2.0+
+

+ 11 - 0
khandy/__init__.py

@@ -0,0 +1,11 @@
+from .utils_dict import *
+from .utils_feature import *
+from .utils_file_io import *
+from .utils_fs import *
+from .utils_hash import *
+from .utils_list import *
+from .utils_numpy import *
+from .utils_others import *
+
+from .boxes import *
+from .image import *

+ 10 - 0
khandy/boxes/__init__.py

@@ -0,0 +1,10 @@
+from .boxes_clip import *
+from .boxes_overlap import *
+
+from .boxes_transform_flip import *
+from .boxes_transform_rotate import *
+from .boxes_transform_scale import *
+from .boxes_transform_translate import *
+from .boxes_utils import *
+
+from .batched_boxes import *

+ 47 - 0
khandy/boxes/batched_boxes.py

@@ -0,0 +1,47 @@
+import numpy as np
+
+
+def convert_boxes_list_to_batched_boxes(boxes_list):
+    """
+    Args:
+        boxes_list: list or tuple of ndarray with shape (N_i, 4+K)
+        
+    Returns:
+        ndarray with shape (M, 5+K)
+        
+    References:
+        `convert_boxes_to_roi_format` in TorchVision
+        `mmdet.core.bbox.bbox2roi` in mmdetection
+        `modeling.poolers.convert_boxes_to_pooler_format` in detectron2
+    """
+    assert isinstance(boxes_list, (list, tuple))
+    # avoids a copy if there is only a single element in a list
+    if len(boxes_list) == 1:
+        concat_boxes = boxes_list[0]
+    else:
+        concat_boxes = np.concatenate(boxes_list, axis=0)
+    indices_list = [np.full((len(b), 1), i, concat_boxes.dtype) 
+                    for i, b in enumerate(boxes_list)]
+    indices = np.concatenate(indices_list, axis=0)
+    batched_boxes = np.hstack([indices, concat_boxes])
+    return batched_boxes
+    
+    
+def convert_batched_boxes_to_boxes_list(batched_boxes):
+    """
+    References:
+        `convert_boxes_to_roi_format` in TorchVision
+        `mmdet.core.bbox.roi2bbox` in mmdetection
+    """
+    assert isinstance(batched_boxes, np.ndarray)
+    assert batched_boxes.ndim == 2 and batched_boxes.shape[-1] >= 5
+    
+    boxes_list = []
+    indices = np.unique(batched_boxes[:, 0])
+    for index in indices:
+        inds = (batched_boxes[:, 0] == index)
+        boxes = batched_boxes[inds, 1:]
+        boxes_list.append(boxes)
+    return boxes_list
+    
+    

+ 37 - 0
khandy/boxes/boxes_clip.py

@@ -0,0 +1,37 @@
+import numpy as np
+
+
+def clip_boxes(boxes, reference_box, copy=True):
+    """Clip boxes to reference box.
+    
+    References:
+        `clip_to_window` in TensorFlow object detection API.
+    """
+    x_min, y_min, x_max, y_max = reference_box[:4]
+    boxes = np.array(boxes, dtype=np.float32, copy=copy)
+    lower = np.array([[x_min, y_min, x_min, y_min]])
+    upper = np.array([[x_max, y_max, x_max, y_max]])
+    np.clip(boxes[:, :4], lower, upper, boxes[:,:4])
+    return boxes
+    
+    
+def clip_boxes_to_image(boxes, image_width, image_height, subpixel=True, copy=True):
+    """Clip boxes to image boundaries.
+    
+    References:
+        `clip_boxes` in py-faster-rcnn
+        `core.boxes_op_list.clip_to_window` in TensorFlow object detection API.
+        `structures.Boxes.clip` in detectron2
+        
+    Notes:
+        Equivalent to `clip_boxes(boxes, [0,0,image_width-1,image_height-1], copy)`
+    """
+    boxes = np.array(boxes, dtype=np.float32, copy=copy)
+    if not subpixel:
+        image_width -= 1
+        image_height -= 1
+    np.clip(boxes[:, 0], 0, image_width,  boxes[:, 0])
+    np.clip(boxes[:, 1], 0, image_height, boxes[:, 1])
+    np.clip(boxes[:, 2], 0, image_width,  boxes[:, 2])
+    np.clip(boxes[:, 3], 0, image_height, boxes[:, 3])
+    return boxes

+ 110 - 0
khandy/boxes/boxes_overlap.py

@@ -0,0 +1,110 @@
+import numpy as np
+
+
+def paired_intersection(boxes1, boxes2):
+    """
+    Args:
+        boxes1: a numpy array with shape [N, 4] holding N boxes
+        boxes2: a numpy array with shape [N, 4] holding N boxes
+        
+    Returns:
+        a numpy array with shape [N,] representing itemwise intersection area
+        
+    References:
+        `core.box_list_ops.matched_intersection` in Tensorflow object detection API
+        
+    Notes:
+        can called as itemwise_intersection, matched_intersection, aligned_intersection
+    """
+    x_mins1, y_mins1, x_maxs1, y_maxs1 = np.split(boxes1[:,:4], 4, axis=1)
+    x_mins2, y_mins2, x_maxs2, y_maxs2 = np.split(boxes2[:,:4], 4, axis=1)
+    max_xmins = np.maximum(x_mins1, x_mins2)
+    min_xmaxs = np.minimum(x_maxs1, x_maxs2)
+    max_ymins = np.maximum(y_mins1, y_mins2)
+    min_ymaxs = np.minimum(y_maxs1, y_maxs2)
+    intersect_widths = np.maximum(0., min_xmaxs - max_xmins)
+    intersect_heights = np.maximum(0., min_ymaxs - max_ymins)
+    return intersect_widths * intersect_heights
+    
+    
+def pairwise_intersection(boxes1, boxes2):
+    """Compute pairwise intersection areas between boxes.
+    
+    Args:
+        boxes1: a numpy array with shape [N, 4] holding N boxes.
+        boxes2: a numpy array with shape [M, 4] holding M boxes.
+        
+    Returns:
+        a numpy array with shape [N, M] representing pairwise intersection area.
+        
+    References:
+        `core.box_list_ops.intersection` in Tensorflow object detection API
+        `utils.box_list_ops.intersection` in Tensorflow object detection API
+        `core.evaluation.bbox_overlaps.bbox_overlaps` in mmdetection
+    """
+    rows = boxes1.shape[0]
+    cols = boxes2.shape[0]
+    intersect_areas = np.zeros((rows, cols), dtype=boxes1.dtype)
+    if rows * cols == 0:
+        return intersect_areas
+    swap = False
+    if boxes1.shape[0] > boxes2.shape[0]:
+        boxes1, boxes2 = boxes2, boxes1
+        intersect_areas = np.zeros((cols, rows), dtype=boxes1.dtype)
+        swap = True
+
+    for i in range(boxes1.shape[0]):
+        x_start = np.maximum(boxes1[i, 0], boxes2[:, 0])
+        y_start = np.maximum(boxes1[i, 1], boxes2[:, 1])
+        x_end = np.minimum(boxes1[i, 2], boxes2[:, 2])
+        y_end = np.minimum(boxes1[i, 3], boxes2[:, 3])
+        x_end -= x_start
+        y_end -= y_start
+        np.maximum(x_end, 0, x_end)
+        np.maximum(y_end, 0, y_end)
+        x_end *= y_end
+        intersect_areas[i, :] = x_end
+    if swap:
+        intersect_areas = intersect_areas.T
+    return intersect_areas
+    
+    
+def pairwise_overlap_ratio(boxes1, boxes2, ratio_type='iou'):
+    """Compute pairwise overlap ratio between boxes.
+    
+    Args:
+        boxes1: a numpy array with shape [N, 4] holding N boxes
+        boxes2: a numpy array with shape [M, 4] holding M boxes
+        ratio_type:
+            iou: Intersection-over-union (iou).
+            ioa: Intersection-over-area (ioa) between two boxes box1 and box2 is defined as
+                their intersection area over box2's area. Note that ioa is not symmetric,
+                that is, IOA(box1, box2) != IOA(box2, box1).
+                
+    Returns:
+        a numpy array with shape [N, M] representing pairwise overlap ratio.
+        
+    References:
+        `utils.np_box_ops.iou` in Tensorflow object detection API
+        `utils.np_box_ops.ioa` in Tensorflow object detection API
+        `core.evaluation.bbox_overlaps.bbox_overlaps` in mmdetection
+        http://ww2.mathworks.cn/help/vision/ref/bboxoverlapratio.html
+    """
+    intersect_area = pairwise_intersection(boxes1, boxes2)
+    area1 = (boxes1[:, 2] - boxes1[:, 0]) * (boxes1[:, 3] - boxes1[:, 1])
+    area2 = (boxes2[:, 2] - boxes2[:, 0]) * (boxes2[:, 3] - boxes2[:, 1])
+    
+    if ratio_type in ['union', 'iou']:
+        union_area = np.expand_dims(area1, axis=1) - intersect_area
+        union_area += np.expand_dims(area2, axis=0)
+        intersect_area /= union_area
+    elif ratio_type == 'min':
+        min_area = np.minimum(np.expand_dims(area1, axis=1), np.expand_dims(area2, axis=0))
+        intersect_area /= min_area
+    elif ratio_type == 'ioa':
+        intersect_area /= np.expand_dims(area2, axis=0)
+    else:
+        raise ValueError('Unsupported ratio_type. Got {}'.format(ratio_type))
+    return intersect_area
+    
+    

+ 134 - 0
khandy/boxes/boxes_transform_flip.py

@@ -0,0 +1,134 @@
+import numpy as np
+from .boxes_utils import assert_and_normalize_shape
+
+
+def flip_boxes(boxes, x_center=0, y_center=0, direction='h'):
+    """
+    Args:
+        boxes: (N, 4+K)
+        x_center: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        y_center: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        direction: str
+    """
+    assert direction in ['x', 'h', 'horizontal',
+                         'y', 'v', 'vertical', 
+                         'o', 'b', 'both']
+    boxes = np.asarray(boxes, np.float32)
+    ret_boxes = boxes.copy()
+    
+    x_center = np.asarray(x_center, np.float32)
+    y_center = np.asarray(y_center, np.float32)
+    x_center = assert_and_normalize_shape(x_center, boxes.shape[0])
+    y_center = assert_and_normalize_shape(y_center, boxes.shape[0])
+    
+    if direction in ['o', 'b', 'both', 'x', 'h', 'horizontal']:
+        ret_boxes[:, 0] = 2 * x_center - boxes[:, 2] 
+        ret_boxes[:, 2] = 2 * x_center - boxes[:, 0]
+    if direction in ['o', 'b', 'both', 'y', 'v', 'vertical']:
+        ret_boxes[:, 1] = 2 * y_center - boxes[:, 3]
+        ret_boxes[:, 3] = 2 * y_center - boxes[:, 1]
+    return ret_boxes
+    
+    
+def fliplr_boxes(boxes, x_center=0, y_center=0):
+    """
+    Args:
+        boxes: (N, 4+K)
+        x_center: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        y_center: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+    """
+    boxes = np.asarray(boxes, np.float32)
+    ret_boxes = boxes.copy()
+    
+    x_center = np.asarray(x_center, np.float32)
+    y_center = np.asarray(y_center, np.float32)
+    x_center = assert_and_normalize_shape(x_center, boxes.shape[0])
+    y_center = assert_and_normalize_shape(y_center, boxes.shape[0])
+     
+    ret_boxes[:, 0] = 2 * x_center - boxes[:, 2] 
+    ret_boxes[:, 2] = 2 * x_center - boxes[:, 0]
+    return ret_boxes
+    
+    
+def flipud_boxes(boxes, x_center=0, y_center=0):
+    """
+    Args:
+        boxes: (N, 4+K)
+        x_center: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        y_center: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+    """
+    boxes = np.asarray(boxes, np.float32)
+    ret_boxes = boxes.copy()
+    
+    x_center = np.asarray(x_center, np.float32)
+    y_center = np.asarray(y_center, np.float32)
+    x_center = assert_and_normalize_shape(x_center, boxes.shape[0])
+    y_center = assert_and_normalize_shape(y_center, boxes.shape[0])
+    
+    ret_boxes[:, 1] = 2 * y_center - boxes[:, 3]
+    ret_boxes[:, 3] = 2 * y_center - boxes[:, 1]
+    return ret_boxes
+    
+    
+def transpose_boxes(boxes, x_center=0, y_center=0):
+    """
+    Args:
+        boxes: (N, 4+K)
+        x_center: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        y_center: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+    """
+    boxes = np.asarray(boxes, np.float32)
+    ret_boxes = boxes.copy()
+    
+    x_center = np.asarray(x_center, np.float32)
+    y_center = np.asarray(y_center, np.float32)
+    x_center = assert_and_normalize_shape(x_center, boxes.shape[0])
+    y_center = assert_and_normalize_shape(y_center, boxes.shape[0])
+    
+    shift = x_center - y_center
+    ret_boxes[:, 0] = boxes[:, 1] + shift
+    ret_boxes[:, 1] = boxes[:, 0] - shift
+    ret_boxes[:, 2] = boxes[:, 3] + shift
+    ret_boxes[:, 3] = boxes[:, 2] - shift
+    return ret_boxes
+
+
+def flip_boxes_in_image(boxes, image_width, image_height, direction='h'):
+    """
+    Args:
+        boxes: (N, 4+K)
+        image_width: int
+        image_width: int
+        direction: str
+        
+    References:
+        `core.bbox.bbox_flip` in mmdetection
+        `datasets.pipelines.RandomFlip.bbox_flip` in mmdetection
+    """
+    x_center = (image_width - 1) * 0.5
+    y_center = (image_height - 1) * 0.5
+    ret_boxes = flip_boxes(boxes, x_center, y_center, direction=direction)
+    return ret_boxes
+    
+    
+def rot90_boxes_in_image(boxes, image_width, image_height, n=1):
+    """Rotate boxes counter-clockwise by 90 degrees.
+    
+    References:
+        np.rot90
+        tf.image.rot90
+    """
+    n = n % 4
+    if n == 0:
+        ret_boxes = boxes.copy()
+    elif n == 1:
+        ret_boxes = transpose_boxes(boxes)
+        ret_boxes = flip_boxes_in_image(ret_boxes, image_width, image_height, 'v')
+    elif n == 2:
+        ret_boxes = flip_boxes_in_image(boxes, image_width, image_height, 'o')
+    else:
+        ret_boxes = transpose_boxes(boxes)
+        ret_boxes = flip_boxes_in_image(ret_boxes, image_width, image_height, 'h');
+    return ret_boxes
+    
+    

+ 140 - 0
khandy/boxes/boxes_transform_rotate.py

@@ -0,0 +1,140 @@
+import numpy as np
+from .boxes_utils import assert_and_normalize_shape
+
+
+def rotate_boxes(boxes, angle, x_center=0, y_center=0, scale=1, 
+                 degrees=True, return_rotated_boxes=False):
+    """
+    Args:
+        boxes: (N, 4+K)
+        angle: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        x_center: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        y_center: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        scale: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+            scale factor in x and y dimension
+        degrees: bool
+        return_rotated_boxes: bool
+    """
+    boxes = np.asarray(boxes, np.float32)
+    
+    angle = np.asarray(angle, np.float32)
+    x_center = np.asarray(x_center, np.float32)
+    y_center = np.asarray(y_center, np.float32)
+    scale = np.asarray(scale, np.float32)
+    
+    angle = assert_and_normalize_shape(angle, boxes.shape[0])
+    x_center = assert_and_normalize_shape(x_center, boxes.shape[0])
+    y_center = assert_and_normalize_shape(y_center, boxes.shape[0])
+    scale = assert_and_normalize_shape(scale, boxes.shape[0])
+    
+    if degrees:
+        angle = np.deg2rad(angle)
+    cos_val = scale * np.cos(angle)
+    sin_val = scale * np.sin(angle)
+    x_shift = x_center - x_center * cos_val + y_center * sin_val
+    y_shift = y_center - x_center * sin_val - y_center * cos_val
+    
+    x_mins, y_mins = boxes[:,0], boxes[:,1]
+    x_maxs, y_maxs = boxes[:,2], boxes[:,3]
+    x00 = x_mins * cos_val - y_mins * sin_val + x_shift
+    x10 = x_maxs * cos_val - y_mins * sin_val + x_shift
+    x11 = x_maxs * cos_val - y_maxs * sin_val + x_shift
+    x01 = x_mins * cos_val - y_maxs * sin_val + x_shift
+    
+    y00 = x_mins * sin_val + y_mins * cos_val + y_shift
+    y10 = x_maxs * sin_val + y_mins * cos_val + y_shift
+    y11 = x_maxs * sin_val + y_maxs * cos_val + y_shift
+    y01 = x_mins * sin_val + y_maxs * cos_val + y_shift
+    
+    rotated_boxes = np.stack([x00, y00, x10, y10, x11, y11, x01, y01], axis=-1)
+    ret_x_mins = np.min(rotated_boxes[:,0::2], axis=1)
+    ret_y_mins = np.min(rotated_boxes[:,1::2], axis=1)
+    ret_x_maxs = np.max(rotated_boxes[:,0::2], axis=1)
+    ret_y_maxs = np.max(rotated_boxes[:,1::2], axis=1)
+    
+    if boxes.ndim == 4:
+        ret_boxes = np.stack([ret_x_mins, ret_y_mins, ret_x_maxs, ret_y_maxs], axis=-1)
+    else:
+        ret_boxes = boxes.copy()
+        ret_boxes[:, :4] = np.stack([ret_x_mins, ret_y_mins, ret_x_maxs, ret_y_maxs], axis=-1)
+        
+    if not return_rotated_boxes:
+        return ret_boxes
+    else:
+        return ret_boxes, rotated_boxes
+    
+    
+def rotate_boxes_wrt_centers(boxes, angle, scale=1, degrees=True,  
+                             return_rotated_boxes=False):
+    """
+    Args:
+        boxes: (N, 4+K)
+        angle: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        scale: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+            scale factor in x and y dimension
+        degrees: bool
+        return_rotated_boxes: bool
+    """
+    boxes = np.asarray(boxes, np.float32)
+    
+    angle = np.asarray(angle, np.float32)
+    scale = np.asarray(scale, np.float32)
+    angle = assert_and_normalize_shape(angle, boxes.shape[0])
+    scale = assert_and_normalize_shape(scale, boxes.shape[0])
+    
+    if degrees:
+        angle = np.deg2rad(angle)
+    cos_val = scale * np.cos(angle)
+    sin_val = scale * np.sin(angle)
+    
+    x_centers = boxes[:, 2] + boxes[:, 0]
+    y_centers = boxes[:, 3] + boxes[:, 1]
+    x_centers *= 0.5
+    y_centers *= 0.5
+    
+    half_widths = boxes[:, 2] - boxes[:, 0]
+    half_heights = boxes[:, 3] - boxes[:, 1]
+    half_widths *= 0.5
+    half_heights *= 0.5
+    
+    half_widths_cos = half_widths * cos_val
+    half_widths_sin = half_widths * sin_val
+    half_heights_cos = half_heights * cos_val
+    half_heights_sin = half_heights * sin_val
+    
+    x00 = -half_widths_cos + half_heights_sin
+    x10 = half_widths_cos + half_heights_sin
+    x11 = half_widths_cos - half_heights_sin
+    x01 = -half_widths_cos - half_heights_sin
+    x00 += x_centers
+    x10 += x_centers
+    x11 += x_centers
+    x01 += x_centers
+    
+    y00 = -half_widths_sin - half_heights_cos
+    y10 = half_widths_sin - half_heights_cos
+    y11 = half_widths_sin + half_heights_cos
+    y01 = -half_widths_sin + half_heights_cos
+    y00 += y_centers
+    y10 += y_centers
+    y11 += y_centers
+    y01 += y_centers
+    
+    rotated_boxes = np.stack([x00, y00, x10, y10, x11, y11, x01, y01], axis=-1)
+    ret_x_mins = np.min(rotated_boxes[:,0::2], axis=1)
+    ret_y_mins = np.min(rotated_boxes[:,1::2], axis=1)
+    ret_x_maxs = np.max(rotated_boxes[:,0::2], axis=1)
+    ret_y_maxs = np.max(rotated_boxes[:,1::2], axis=1)
+    
+    if boxes.ndim == 4:
+        ret_boxes = np.stack([ret_x_mins, ret_y_mins, ret_x_maxs, ret_y_maxs], axis=-1)
+    else:
+        ret_boxes = boxes.copy()
+        ret_boxes[:, :4] = np.stack([ret_x_mins, ret_y_mins, ret_x_maxs, ret_y_maxs], axis=-1)
+        
+    if not return_rotated_boxes:
+        return ret_boxes
+    else:
+        return ret_boxes, rotated_boxes
+    
+    

+ 86 - 0
khandy/boxes/boxes_transform_scale.py

@@ -0,0 +1,86 @@
+import numpy as np
+from .boxes_utils import assert_and_normalize_shape
+
+
+def scale_boxes(boxes, x_scale=1, y_scale=1, x_center=0, y_center=0, copy=True):
+    """Scale boxes coordinates in x and y dimensions.
+    
+    Args:
+        boxes: (N, 4+K)
+        x_scale: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+            scale factor in x dimension
+        y_scale: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+            scale factor in y dimension
+        x_center: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        y_center: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        
+    References:
+        `core.box_list_ops.scale` in TensorFlow object detection API
+        `utils.box_list_ops.scale` in TensorFlow object detection API
+        `datasets.pipelines.Resize._resize_bboxes` in mmdetection
+        `core.anchor.guided_anchor_target.calc_region` in mmdetection where comments may be misleading!
+        `layers.mask_ops.scale_boxes` in detectron2
+        `mmcv.bbox_scaling`
+    """
+    boxes = np.array(boxes, dtype=np.float32, copy=copy)
+
+    x_scale = np.asarray(x_scale, np.float32)
+    y_scale = np.asarray(y_scale, np.float32)
+    x_scale = assert_and_normalize_shape(x_scale, boxes.shape[0])
+    y_scale = assert_and_normalize_shape(y_scale, boxes.shape[0])
+    
+    x_center = np.asarray(x_center, np.float32)
+    y_center = np.asarray(y_center, np.float32)
+    x_center = assert_and_normalize_shape(x_center, boxes.shape[0])
+    y_center = assert_and_normalize_shape(y_center, boxes.shape[0])
+    
+    x_shift = 1 - x_scale
+    y_shift = 1 - y_scale
+    x_shift *= x_center
+    y_shift *= y_center
+    
+    boxes[:, 0] *= x_scale
+    boxes[:, 1] *= y_scale
+    boxes[:, 2] *= x_scale
+    boxes[:, 3] *= y_scale
+    boxes[:, 0] += x_shift
+    boxes[:, 1] += y_shift
+    boxes[:, 2] += x_shift
+    boxes[:, 3] += y_shift
+    return boxes
+    
+    
+def scale_boxes_wrt_centers(boxes, x_scale=1, y_scale=1, copy=True):
+    """
+    Args:
+        boxes: (N, 4+K)
+        x_scale: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+            scale factor in x dimension
+        y_scale: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+            scale factor in y dimension
+            
+    References:
+        `core.anchor.guided_anchor_target.calc_region` in mmdetection where comments may be misleading!
+        `layers.mask_ops.scale_boxes` in detectron2
+        `mmcv.bbox_scaling`
+    """
+    boxes = np.array(boxes, dtype=np.float32, copy=copy)
+    
+    x_scale = np.asarray(x_scale, np.float32)
+    y_scale = np.asarray(y_scale, np.float32)
+    x_scale = assert_and_normalize_shape(x_scale, boxes.shape[0])
+    y_scale = assert_and_normalize_shape(y_scale, boxes.shape[0])
+    
+    x_factor = (x_scale - 1) * 0.5
+    y_factor = (y_scale - 1) * 0.5
+    x_deltas = boxes[:, 2] - boxes[:, 0]
+    y_deltas = boxes[:, 3] - boxes[:, 1]
+    x_deltas *= x_factor
+    y_deltas *= y_factor
+
+    boxes[:, 0] -= x_deltas
+    boxes[:, 1] -= y_deltas
+    boxes[:, 2] += x_deltas
+    boxes[:, 3] += y_deltas
+    return boxes
+

+ 140 - 0
khandy/boxes/boxes_transform_translate.py

@@ -0,0 +1,140 @@
+import numpy as np
+from .boxes_utils import assert_and_normalize_shape
+
+
+def translate_boxes(boxes, x_shift=0, y_shift=0, copy=True):
+    """scale boxes coordinates in x and y dimensions.
+    
+    Args:
+        boxes: (N, 4+K)
+        x_shift: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+            shift in x dimension
+        y_shift: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+            shift in y dimension
+        copy: bool
+        
+    References:
+        `datasets.pipelines.RandomCrop` in mmdetection
+    """
+    boxes = np.array(boxes, dtype=np.float32, copy=copy)
+    
+    x_shift = np.asarray(x_shift, np.float32)
+    y_shift = np.asarray(y_shift, np.float32)
+
+    x_shift = assert_and_normalize_shape(x_shift, boxes.shape[0])
+    y_shift = assert_and_normalize_shape(y_shift, boxes.shape[0])
+    
+    boxes[:, 0] += x_shift
+    boxes[:, 1] += y_shift
+    boxes[:, 2] += x_shift
+    boxes[:, 3] += y_shift
+    return boxes
+    
+    
+def adjust_boxes(boxes, x_min_shift, y_min_shift, x_max_shift, y_max_shift, copy=True):
+    """
+    Args:
+        boxes: (N, 4+K)
+        x_min_shift: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+            shift (x_min, y_min) in x dimension
+        y_min_shift: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+            shift (x_min, y_min) in y dimension
+        x_max_shift: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+            shift (x_max, y_max) in x dimension
+        y_max_shift: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+            shift (x_max, y_max) in y dimension
+        copy: bool
+    """
+    boxes = np.array(boxes, dtype=np.float32, copy=copy)
+
+    x_min_shift = np.asarray(x_min_shift, np.float32)
+    y_min_shift = np.asarray(y_min_shift, np.float32)
+    x_max_shift = np.asarray(x_max_shift, np.float32)
+    y_max_shift = np.asarray(y_max_shift, np.float32)
+
+    x_min_shift = assert_and_normalize_shape(x_min_shift, boxes.shape[0])
+    y_min_shift = assert_and_normalize_shape(y_min_shift, boxes.shape[0])
+    x_max_shift = assert_and_normalize_shape(x_max_shift, boxes.shape[0])
+    y_max_shift = assert_and_normalize_shape(y_max_shift, boxes.shape[0])
+    
+    boxes[:, 0] += x_min_shift
+    boxes[:, 1] += y_min_shift
+    boxes[:, 2] += x_max_shift
+    boxes[:, 3] += y_max_shift
+    return boxes
+    
+    
+def inflate_or_deflate_boxes(boxes, width_delta=0, height_delta=0, copy=True):
+    """
+    Args:
+        boxes: (N, 4+K)
+        width_delta: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        height_delta: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        copy: bool
+    """
+    boxes = np.array(boxes, dtype=np.float32, copy=copy)
+
+    width_delta = np.asarray(width_delta, np.float32)
+    height_delta = np.asarray(height_delta, np.float32)
+
+    width_delta = assert_and_normalize_shape(width_delta, boxes.shape[0])
+    height_delta = assert_and_normalize_shape(height_delta, boxes.shape[0])
+    
+    half_width_delta = width_delta * 0.5
+    half_height_delta = height_delta * 0.5
+    boxes[:, 0] -= half_width_delta
+    boxes[:, 1] -= half_height_delta
+    boxes[:, 2] += half_width_delta
+    boxes[:, 3] += half_height_delta
+    return boxes
+    
+
+def inflate_boxes_to_square(boxes, copy=True):
+    """Inflate boxes to square
+    Args:
+        boxes: (N, 4+K)
+        width_delta: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        height_delta: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        copy: bool
+    """
+    boxes = np.array(boxes, dtype=np.float32, copy=copy)
+
+    widths = boxes[:, 2] - boxes[:, 0]
+    heights = boxes[:, 3] - boxes[:, 1]
+    max_side_lengths = np.maximum(widths, heights)
+    
+    width_deltas = np.subtract(max_side_lengths, widths, widths)
+    height_deltas = np.subtract(max_side_lengths, heights, heights)
+    width_deltas *= 0.5
+    height_deltas *= 0.5
+    boxes[:, 0] -= width_deltas
+    boxes[:, 1] -= height_deltas
+    boxes[:, 2] += width_deltas
+    boxes[:, 3] += height_deltas
+    return boxes
+    
+
+def deflate_boxes_to_square(boxes, copy=True):
+    """Deflate boxes to square
+    Args:
+        boxes: (N, 4+K)
+        width_delta: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        height_delta: array-like whose shape is (), (1,), (N,), (1, 1) or (N, 1)
+        copy: bool
+    """
+    boxes = np.array(boxes, dtype=np.float32, copy=copy)
+
+    widths = boxes[:, 2] - boxes[:, 0]
+    heights = boxes[:, 3] - boxes[:, 1]
+    min_side_lengths = np.minimum(widths, heights)
+    
+    width_deltas = np.subtract(min_side_lengths, widths, widths)
+    height_deltas = np.subtract(min_side_lengths, heights, heights)
+    width_deltas *= 0.5
+    height_deltas *= 0.5
+    boxes[:, 0] -= width_deltas
+    boxes[:, 1] -= height_deltas
+    boxes[:, 2] += width_deltas
+    boxes[:, 3] += height_deltas
+    return boxes
+

+ 41 - 0
khandy/boxes/boxes_utils.py

@@ -0,0 +1,41 @@
+import numpy as np
+
+
+def assert_and_normalize_shape(x, length):
+    """
+    Args:
+        x: ndarray
+        length: int
+    """
+    if x.ndim == 0:
+        return x
+    elif x.ndim == 1:
+        if len(x) == 1:
+            return x
+        elif len(x) == length:
+            return x
+        else:
+            raise ValueError('Incompatible shape!')
+    elif x.ndim == 2:
+        if x.shape == (1, 1):
+            return np.squeeze(x, axis=-1)
+        elif x.shape == (length, 1):
+            return np.squeeze(x, axis=-1)
+        else:
+            raise ValueError('Incompatible shape!') 
+    else:
+        raise ValueError('Incompatible ndim!')
+        
+        
+def normalize_boxes(boxes, dtype=np.float32, copy=False, support_extra=True):
+    boxes = np.array(boxes, dtype=dtype, copy=copy)
+    assert boxes.ndim in [1, 2]
+    last_dimension = boxes.shape[-1]
+    if support_extra:
+        assert last_dimension >= 4
+    else:
+        assert last_dimension == 4
+        
+    if boxes.ndim == 1:
+        boxes = np.expand_dims(boxes, axis=0)
+    return boxes

+ 9 - 0
khandy/image/__init__.py

@@ -0,0 +1,9 @@
+from .align_and_crop import *
+from .crop_or_pad import *
+from .flip import *
+from .resize import *
+from .rotate import *
+from .translate import *
+
+from .misc import *
+

+ 67 - 0
khandy/image/align_and_crop.py

@@ -0,0 +1,67 @@
+import cv2
+import numpy as np
+
+from .crop_or_pad import crop_or_pad as _crop_or_pad
+
+
+def get_similarity_transform(src_pts, dst_pts):
+    """Get similarity transform matrix from src_pts to dst_pts
+    
+    Args:
+        src_pts: Kx2 np.array
+            source points matrix, each row is a pair of coordinates (x, y)
+        dst_pts: Kx2 np.array
+            destination points matrix, each row is a pair of coordinates (x, y)
+            
+    Returns:
+        xform_matrix: 3x3 np.array
+            transform matrix from src_pts to dst_pts
+    """
+    src_pts = np.asarray(src_pts)
+    dst_pts = np.asarray(dst_pts)
+    assert src_pts.ndim == 2
+    assert dst_pts.ndim == 2
+    assert src_pts.shape[-1] == 2
+    assert dst_pts.shape[-1] == 2
+    
+    npts = src_pts.shape[0]
+    A = np.empty((npts * 2, 4))
+    b = np.empty((npts * 2,))
+    for k in range(npts):
+        A[2 * k + 0] = [src_pts[k, 0], -src_pts[k, 1], 1, 0]
+        A[2 * k + 1] = [src_pts[k, 1], src_pts[k, 0], 0, 1]
+        b[2 * k + 0] = dst_pts[k, 0]
+        b[2 * k + 1] = dst_pts[k, 1]
+        
+    x = np.linalg.lstsq(A, b)[0]
+    xform_matrix = np.empty((3, 3))
+    xform_matrix[0] = [x[0], -x[1], x[2]]
+    xform_matrix[1] = [x[1], x[0], x[3]]
+    xform_matrix[2] = [0, 0, 1]
+    return xform_matrix
+    
+    
+def align_and_crop(image, landmarks, std_landmarks, align_size, 
+                   crop_size=None, crop_center=None,
+                   return_transform_matrix=False):
+    landmarks = np.asarray(landmarks)
+    std_landmarks = np.asarray(std_landmarks)
+    xform_matrix = get_similarity_transform(landmarks, std_landmarks)
+
+    landmarks_ex = np.pad(landmarks, ((0,0),(0,1)), mode='constant', constant_values=1)
+    dst_landmarks = np.dot(landmarks_ex, xform_matrix[:2,:].T)
+    dst_image = cv2.warpAffine(image, xform_matrix[:2,:], dsize=align_size)
+    if crop_size is not None:
+        crop_center_ex = (crop_center[0], crop_center[1], 1)
+        aligned_crop_center = np.dot(xform_matrix, crop_center_ex)
+        dst_image = _crop_or_pad(dst_image, crop_size, aligned_crop_center)
+        
+        crop_begin_x = int(round(aligned_crop_center[0] - crop_size[0] / 2.0))
+        crop_begin_y = int(round(aligned_crop_center[1] - crop_size[1] / 2.0))
+        dst_landmarks -= np.asarray([[crop_begin_x, crop_begin_y]])
+    if return_transform_matrix:
+        return dst_image, dst_landmarks, xform_matrix
+    else:
+        return dst_image, dst_landmarks
+        
+        

+ 78 - 0
khandy/image/crop_or_pad.py

@@ -0,0 +1,78 @@
+import cv2
+import numpy as np
+
+
+def crop_or_pad(image, crop_size, crop_center=None, pad_val=None):
+    """
+    References:
+        tf.image.resize_image_with_crop_or_pad
+    """
+    assert image.ndim in [2, 3]
+    
+    src_height, src_width = image.shape[:2]
+    channels = 1 if image.ndim == 2 else image.shape[2]
+    dst_height, dst_width = crop_size[1], crop_size[0]
+    if crop_center is None:
+        crop_center = [src_width // 2, src_height // 2]
+    if pad_val is not None:
+        if isinstance(pad_val, (int, float)):
+            pad_val = [pad_val for _ in range(channels)]
+        assert len(pad_val) == channels
+        
+    crop_begin_x = int(round(crop_center[0] - dst_width / 2.0))
+    crop_begin_y = int(round(crop_center[1] - dst_height / 2.0))
+    
+    src_begin_x = max(0, crop_begin_x)
+    src_begin_y = max(0, crop_begin_y)
+    src_end_x = min(src_width, crop_begin_x + dst_width)
+    src_end_y = min(src_height, crop_begin_y + dst_height)
+    dst_begin_x = max(0, -crop_begin_x)
+    dst_begin_y = max(0, -crop_begin_y)
+    dst_end_x = dst_begin_x + src_end_x - src_begin_x
+    dst_end_y = dst_begin_y + src_end_y - src_begin_y
+    
+    if image.ndim == 2: 
+        cropped_image_shape = (dst_height, dst_width)
+    else:
+        cropped_image_shape = (dst_height, dst_width, channels)
+    if pad_val is None:
+        cropped = np.zeros(cropped_image_shape, image.dtype)
+    else:
+        cropped = np.full(cropped_image_shape, pad_val, dtype=image.dtype)
+    if (src_end_x - src_begin_x <= 0) or (src_end_y - src_begin_y <= 0):
+        return cropped
+    else:
+        cropped[dst_begin_y: dst_end_y, dst_begin_x: dst_end_x, ...] = \
+            image[src_begin_y: src_end_y, src_begin_x: src_end_x, ...]
+        return cropped
+        
+        
+def crop_or_pad_coords(boxes, image_width, image_height):
+    """
+    References:
+        `mmcv.impad`
+        `pad` in https://github.com/kpzhang93/MTCNN_face_detection_alignment
+        `MtcnnDetector.pad` in https://github.com/AITTSMD/MTCNN-Tensorflow
+    """
+    x_min = boxes[:, 0]
+    y_min = boxes[:, 1]
+    x_max = boxes[:, 2]
+    y_max = boxes[:, 3]
+    
+    src_x_begin = np.maximum(x_min, 0)
+    src_y_begin = np.maximum(y_min, 0)
+    src_x_end = np.minimum(x_max + 1, image_width)
+    src_y_end = np.minimum(y_max + 1, image_height)
+    
+    dst_widths = x_max - x_min + 1
+    dst_heights = y_max - y_min + 1
+    dst_x_begin = np.maximum(-x_min, 0)
+    dst_y_begin = np.maximum(-y_min, 0)
+    dst_x_end = np.minimum(dst_widths, image_width - x_min)
+    dst_y_end = np.minimum(dst_heights, image_height - y_min)
+    
+    coords = np.stack([src_x_begin, src_y_begin, src_x_end, src_y_end,
+                       dst_x_begin, dst_y_begin, dst_x_end, dst_y_end], axis=1)
+    return coords
+    
+    

+ 67 - 0
khandy/image/flip.py

@@ -0,0 +1,67 @@
+import numpy as np
+
+
+def flip_image(image, direction='h', copy=True):
+    """
+    References:
+        np.flipud, np.fliplr, np.flip
+        cv2.flip
+        tf.image.flip_up_down
+        tf.image.flip_left_right
+    """
+    assert direction in ['x', 'h', 'horizontal',
+                         'y', 'v', 'vertical', 
+                         'o', 'b', 'both']
+    if copy:
+        image = image.copy()
+    if direction in ['o', 'b', 'both', 'x', 'h', 'horizontal']:
+        image = np.fliplr(image)
+    if direction in ['o', 'b', 'both', 'y', 'v', 'vertical']:
+        image = np.flipud(image)
+    return image
+    
+    
+def transpose_image(image, copy=True):
+    """Transpose image.
+    
+    References:
+        np.transpose
+        cv2.transpose
+        tf.image.transpose
+    """
+    if copy:
+        image = image.copy()
+    if image.ndim == 2:
+        transpose_axes = (1, 0)
+    else:
+        transpose_axes = (1, 0, 2)
+    image = np.transpose(image, transpose_axes)
+    return image
+
+    
+def rot90_image(image, n=1, copy=True):
+    """Rotate image counter-clockwise by 90 degrees.
+    
+    References:
+        np.rot90
+        tf.image.rot90
+    """
+    if copy:
+        image = image.copy()
+    if image.ndim == 2:
+        transpose_axes = (1, 0)
+    else:
+        transpose_axes = (1, 0, 2)
+        
+    n = n % 4
+    if n == 0:
+        return image
+    elif n == 1:
+        image = np.transpose(image, transpose_axes)
+        image = np.flipud(image)
+    elif n == 2:
+        image = np.fliplr(np.flipud(image))
+    else:
+        image = np.transpose(image, transpose_axes)
+        image = np.fliplr(image)
+    return image

+ 64 - 0
khandy/image/misc.py

@@ -0,0 +1,64 @@
+import cv2
+import numpy as np
+
+from ..utils_numpy import minmax_normalize as _minmax_normalize
+
+
+def normalize_image_dtype(image, keep_num_channels=False):
+    """Normalize image dtype to uint8 (usually for visualization).
+    
+    Args:
+        image : ndarray
+            Input image.
+        keep_num_channels : bool, optional
+            If this is set to True, the result is an array which has 
+            the same shape as input image, otherwise the result is 
+            an array whose channels number is 3.
+            
+    Returns:
+        out: ndarray
+            Image whose dtype is np.uint8.
+    """
+    assert (image.ndim == 3 and image.shape[-1] in [1, 3]) or (image.ndim == 2)
+
+    image = image.astype(np.float32)
+    image = _minmax_normalize(image, axis=None, copy=False)
+    image = np.array(image * 255, dtype=np.uint8)
+    
+    if not keep_num_channels:
+        if image.ndim == 2:
+            image = np.expand_dims(image, -1)
+        if image.shape[-1] == 1:
+            image = np.tile(image, (1,1,3))
+    return image
+    
+    
+def stack_image_list(image_list, dtype=np.float32):
+    """Join a sequence of image along a new axis before first axis.
+
+    References:
+        `im_list_to_blob` in `py-faster-rcnn-master/lib/utils/blob.py`
+    """
+    assert isinstance(image_list, (tuple, list))
+
+    max_dimension = np.array([image.ndim for image in image_list]).max()
+    assert max_dimension in [2, 3]
+    max_shape = np.array([image.shape[:2] for image in image_list]).max(axis=0)
+    
+    num_channels = []
+    for image in image_list:
+        if image.ndim == 2:
+            num_channels.append(1)
+        else:
+            num_channels.append(image.shape[-1])
+    assert len(set(num_channels) - set([1])) in [0, 1]
+    max_num_channels = np.max(num_channels)
+    
+    blob = np.empty((len(image_list), max_shape[0], max_shape[1], max_num_channels), dtype=dtype)
+    for k, image in enumerate(image_list):
+        blob[k, :image.shape[0], :image.shape[1], :] = np.atleast_3d(image).astype(dtype, copy=False)
+    if max_dimension == 2:
+        blob = np.squeeze(blob, axis=-1)
+    return blob
+    
+    

+ 173 - 0
khandy/image/resize.py

@@ -0,0 +1,173 @@
+import cv2
+import numpy as np
+
+
+interp_codes = {
+    'nearest': cv2.INTER_NEAREST,
+    'bilinear': cv2.INTER_LINEAR,
+    'bicubic': cv2.INTER_CUBIC,
+    'area': cv2.INTER_AREA,
+    'lanczos': cv2.INTER_LANCZOS4
+}
+
+
+def scale_image(image, x_scale, y_scale, interpolation='bilinear'):
+    """Scale image.
+    
+    Reference:
+        mmcv.imrescale
+    """
+    ori_height, ori_width = image.shape[:2]
+    
+    new_width = int(round(x_scale * ori_width))
+    new_height = int(round(y_scale * ori_height))
+    resized_image = cv2.resize(image, (new_width, new_height), 
+                               interpolation=interp_codes[interpolation])
+    return resized_image
+
+
+def resize_image(image, width, height, return_scale=False, interpolation='bilinear'):
+    """Resize image to a given size.
+
+    Args:
+        img (ndarray): The input image.
+        width (int): Target width.
+        height (int): Target height.
+        return_scale (bool): Whether to return `x_scale` and `y_scale`.
+        interpolation (str): Interpolation method, accepted values are
+            "nearest", "bilinear", "bicubic", "area", "lanczos".
+
+    Returns:
+        tuple or ndarray: (`resized_image`, `x_scale`, `y_scale`) or `resized_image`.
+        
+    Reference:
+        mmcv.imresize
+    """
+    ori_height, ori_width = image.shape[:2]
+    resized_image = cv2.resize(image, (width, height), 
+                               interpolation=interp_codes[interpolation])
+    if not return_scale:
+        return resized_image
+    else:
+        x_scale = width / float(ori_width)
+        y_scale = height / float(ori_height)
+        return resized_image, x_scale, y_scale
+    
+    
+def resize_image_short(image, size, return_scale=False, interpolation='bilinear'):
+    """Resize an image so that the length of shorter side is size while 
+    preserving the original aspect ratio.
+    
+    References:
+        `resize_min` in `https://github.com/pjreddie/darknet/blob/master/src/image.c`
+    """
+    ori_height, ori_width = image.shape[:2]
+    new_height, new_width = size, size
+    if ori_height > ori_width:
+        new_height = int(round(size * ori_height / float(ori_width)))
+    else:
+        new_width = int(round(size * ori_width / float(ori_height)))
+    
+    resized_image = cv2.resize(image, (new_width, new_height), 
+                               interpolation=interp_codes[interpolation])
+    if not return_scale:
+        return resized_image
+    else:
+        scale = new_width / float(ori_width)
+        return resized_image, scale
+    
+    
+def resize_image_long(image, size, return_scale=False, interpolation='bilinear'):
+    """Resize an image so that the length of longer side is size while 
+    preserving the original aspect ratio.
+    
+    References:
+        `resize_max` in `https://github.com/pjreddie/darknet/blob/master/src/image.c`
+    """
+    ori_height, ori_width = image.shape[:2]
+    new_height, new_width = size, size
+    if ori_height < ori_width:
+        new_height = int(round(size * ori_height / float(ori_width)))
+    else:
+        new_width = int(round(size * ori_width / float(ori_height)))
+    
+    resized_image = cv2.resize(image, (new_width, new_height), 
+                               interpolation=interp_codes[interpolation])
+    if not return_scale:
+        return resized_image
+    else:
+        scale = new_width / float(ori_width)
+        return resized_image, scale
+        
+        
+def letterbox_resize_image(image, new_width, new_height, pad_val=0,
+                           return_scale=False, interpolation='bilinear'):
+    """Resize an image preserving the original aspect ratio using padding.
+    
+    References:
+        `letterbox_image` in `https://github.com/pjreddie/darknet/blob/master/src/image.c`
+    """
+    ori_height, ori_width = image.shape[:2]
+
+    scale = min(new_width / float(ori_width), new_height / float(ori_height))
+    resize_w = int(round(scale * ori_width))
+    resize_h = int(round(scale * ori_height))
+
+    resized_image = cv2.resize(image, (resize_w, resize_h), 
+                       interpolation=interp_codes[interpolation])
+    padded_shape = list(resized_image.shape)
+    padded_shape[0] = new_height
+    padded_shape[1] = new_width
+    padded_image = np.full(padded_shape, pad_val, image.dtype)
+
+    dw = int(round((new_width - resize_w) / 2.0))
+    dh = int(round((new_height - resize_h) / 2.0))
+    padded_image[dh: resize_h + dh, dw: resize_w + dw, ...] = resized_image
+    
+    if not return_scale:
+        return padded_image
+    else:
+        return padded_image, scale, dw, dh
+        
+        
+def resize_image_to_range(image, min_length, max_length, return_scale=False, interpolation='bilinear'):
+    """Resizes an image so its dimensions are within the provided value.
+    
+    Rescale the shortest side of the image up to `min_length` pixels 
+    while keeping the largest side below `max_length` pixels without 
+    changing the aspect ratio. Often used in object detection (e.g. RCNN and SSH.)
+    
+    The output size can be described by two cases:
+    1. If the image can be rescaled so its shortest side is equal to the
+        `min_length` without the other side exceeding `max_length`, then do so.
+    2. Otherwise, resize so the longest side is equal to `max_length`.
+    
+    Returns:
+        resized_image: resized image (with bilinear interpolation) so that
+            min(new_height, new_width) == min_length or
+            max(new_height, new_width) == max_length.
+          
+    References:
+        `resize_to_range` in `models-master/research/object_detection/core/preprocessor.py`
+        `prep_im_for_blob` in `py-faster-rcnn-master/lib/utils/blob.py`
+        mmcv.imrescale
+    """
+    assert min_length < max_length
+    ori_height, ori_width = image.shape[:2]
+    
+    min_side_length = np.minimum(ori_width, ori_height)
+    max_side_length = np.maximum(ori_width, ori_height)
+    scale = float(min_length) / float(min_side_length)
+    if round(scale * max_side_length) > max_length:
+        scale = float(max_length) / float(max_side_length)
+        
+    new_width = int(round(scale * ori_width))
+    new_height = int(round(scale * ori_height))
+    resized_image = cv2.resize(image, (new_width, new_height), 
+                               interpolation=interp_codes[interpolation])
+    if not return_scale:
+        return resized_image
+    else:
+        return resized_image, scale
+        
+        

+ 70 - 0
khandy/image/rotate.py

@@ -0,0 +1,70 @@
+import cv2
+import numpy as np
+
+
+def get_2d_rotation_matrix(angle, cx=0, cy=0, scale=1, 
+                           degrees=True, dtype=np.float32):
+    """
+    References:
+        `cv2.getRotationMatrix2D` in OpenCV
+    """
+    if degrees:
+        angle = np.deg2rad(angle)
+    c = scale * np.cos(angle)
+    s = scale * np.sin(angle)
+
+    tx = cx - cx * c + cy * s
+    ty = cy - cx * s - cy * c
+    return np.array([[ c, -s, tx],
+                     [ s,  c, ty],
+                     [ 0,  0, 1]], dtype=dtype)
+    
+    
+def rotate_image(image, angle, scale=1.0, center=None, 
+                 degrees=True, border_value=0, auto_bound=False):
+    """Rotate an image.
+
+    Args:
+        image : ndarray
+            Image to be rotated.
+        angle : float
+            Rotation angle in degrees, positive values mean clockwise rotation.
+        center : tuple
+            Center of the rotation in the source image, by default
+            it is the center of the image.
+        scale : float
+            Isotropic scale factor.
+        degrees : bool
+        border_value : int
+            Border value.
+        auto_bound : bool
+            Whether to adjust the image size to cover the whole rotated image.
+
+    Returns:
+        ndarray: The rotated image.
+        
+    References:
+        mmcv.imrotate
+    """
+    image_height, image_width = image.shape[:2]
+    if auto_bound:
+        center = None
+    if center is None:
+        center = ((image_width - 1) * 0.5, (image_height - 1) * 0.5)
+    assert isinstance(center, tuple)
+
+    rotation_matrix = get_2d_rotation_matrix(angle, center[0], center[1], scale, degrees)
+    if auto_bound:
+        scale_cos = np.abs(rotation_matrix[0, 0])
+        scale_sin = np.abs(rotation_matrix[0, 1])
+        new_width = image_width * scale_cos + image_height * scale_sin
+        new_height = image_width * scale_sin + image_height * scale_cos
+        
+        rotation_matrix[0, 2] += (new_width - image_width) * 0.5
+        rotation_matrix[1, 2] += (new_height - image_height) * 0.5
+        
+        image_width = int(np.round(new_width))
+        image_height = int(np.round(new_height))
+    rotated = cv2.warpAffine(image, rotation_matrix[:2,:], (image_width, image_height), 
+                             borderValue=border_value)
+    return rotated

+ 34 - 0
khandy/image/translate.py

@@ -0,0 +1,34 @@
+import numpy as np
+
+
+def translate_image(image, x_shift, y_shift):
+    image_height, image_width = image.shape[:2]
+    assert abs(x_shift) < image_width
+    assert abs(y_shift) < image_height
+    
+    new_image = np.zeros_like(image)
+    if x_shift < 0:
+        src_x_start = -x_shift
+        src_x_end = image_width
+        dst_x_start = 0
+        dst_x_end = image_width + x_shift
+    else:
+        src_x_start = 0
+        src_x_end = image_width - x_shift
+        dst_x_start = x_shift
+        dst_x_end = image_width
+        
+    if y_shift < 0:
+        src_y_start = -y_shift
+        src_y_end = image_height
+        dst_y_start = 0
+        dst_y_end = image_height + y_shift
+    else:
+        src_y_start = 0
+        src_y_end = image_height - y_shift
+        dst_y_start = y_shift
+        dst_y_end = image_height
+        
+    new_image[dst_y_start:dst_y_end, dst_x_start:dst_x_end] = \
+        image[src_y_start:src_y_end, src_x_start:src_x_end]
+    return new_image

+ 162 - 0
khandy/utils_dict.py

@@ -0,0 +1,162 @@
+import random
+from collections import OrderedDict
+
+
+def get_dict_first_item(dict_obj):
+    for key in dict_obj:
+        return key, dict_obj[key]
+
+
+def sort_dict(dict_obj, key=None, reverse=False):
+    return OrderedDict(sorted(dict_obj.items(), key=key, reverse=reverse))
+
+
+def create_class_dict(name_list, label_list):
+    assert len(name_list) == len(label_list)
+    class_dict = {}
+    for name, label in zip(name_list, label_list):
+        class_dict.setdefault(label, []).append(name)
+    return class_dict
+    
+    
+def convert_class_dict_to_list(class_dict):
+    name_list, label_list = [], []
+    for key, value in class_dict.items():
+        name_list += value
+        label_list += [key] * len(value)
+    return name_list, label_list
+    
+    
+def convert_class_dict_to_records(class_dict, label_map=None, raise_if_key_error=True):
+    records = []
+    if label_map is None:
+        for label in class_dict:
+            for name in class_dict[label]:
+                records.append('{},{}'.format(name, label))
+    else:
+        for label in class_dict:
+            if raise_if_key_error:
+                mapped_label = label_map[label]
+            else:
+                mapped_label = label_map.get(label, label)
+            for name in class_dict[label]:
+                records.append('{},{}'.format(name, mapped_label))
+    return records
+    
+    
+def sample_class_dict(class_dict, num_classes, num_examples_per_class=None):
+    num_classes = min(num_classes, len(class_dict))
+    sub_keys = random.sample(list(class_dict), num_classes)
+    if num_examples_per_class is None:
+        sub_class_dict = {key: class_dict[key] for key in sub_keys}
+    else:
+        sub_class_dict = {}
+        for key in sub_keys:
+            num_examples_inner = min(num_examples_per_class, len(class_dict[key]))
+            sub_class_dict[key] = random.sample(class_dict[key], num_examples_inner)
+    return sub_class_dict
+    
+    
+def split_class_dict_on_key(class_dict, split_ratio, use_shuffle=False):
+    """Split class_dict on its key.
+    """
+    assert isinstance(class_dict, dict)
+    assert isinstance(split_ratio, (list, tuple))
+    
+    pdf = [k / float(sum(split_ratio)) for k in split_ratio]
+    cdf = [sum(pdf[:k]) for k in range(len(pdf) + 1)]
+    indices = [int(round(len(class_dict) * k)) for k in cdf]
+    dict_keys = list(class_dict)
+    if use_shuffle: 
+        random.shuffle(dict_keys)
+        
+    be_split_list = []
+    for i in range(len(split_ratio)):
+        #if indices[i] != indices[i + 1]:
+        part_keys = dict_keys[indices[i]: indices[i + 1]]
+        part_dict = dict([(key, class_dict[key]) for key in part_keys])
+        be_split_list.append(part_dict)
+    return be_split_list
+    
+    
+def split_class_dict_on_value(class_dict, split_ratio, use_shuffle=False):
+    """Split class_dict on its value.
+    """
+    assert isinstance(class_dict, dict)
+    assert isinstance(split_ratio, (list, tuple))
+    
+    pdf = [k / float(sum(split_ratio)) for k in split_ratio]
+    cdf = [sum(pdf[:k]) for k in range(len(pdf) + 1)]
+    be_split_list = [dict() for k in range(len(split_ratio))] 
+    for key, value in class_dict.items():
+        indices = [int(round(len(value) * k)) for k in cdf]
+        cloned = value[:]
+        if use_shuffle: 
+            random.shuffle(cloned)
+        for i in range(len(split_ratio)):
+            #if indices[i] != indices[i + 1]:
+            be_split_list[i][key] = cloned[indices[i]: indices[i + 1]]
+    return be_split_list
+    
+    
+def get_class_dict_info(class_dict, with_print=False, desc=None):
+    num_list = [len(val) for val in class_dict.values()]
+    num_classes = len(num_list)
+    num_examples = sum(num_list)
+    max_examples_per_class = max(num_list)
+    min_examples_per_class = min(num_list)
+    if num_classes == 0:
+        avg_examples_per_class = 0
+    else:
+        avg_examples_per_class = num_examples / num_classes
+    info = {
+        'num_classes': num_classes,
+        'num_examples': num_examples,
+        'max_examples_per_class': max_examples_per_class,
+        'min_examples_per_class': min_examples_per_class,
+        'avg_examples_per_class': avg_examples_per_class,
+    }
+    if with_print:
+        desc = desc or '<unknown>'
+        print('{} subject number:    {}'.format(desc, info['num_classes']))
+        print('{} example number:    {}'.format(desc, info['num_examples']))
+        print('{} max number per-id: {}'.format(desc, info['max_examples_per_class']))
+        print('{} min number per-id: {}'.format(desc, info['min_examples_per_class']))
+        print('{} avg number per-id: {:.2f}'.format(desc, info['avg_examples_per_class']))
+    return info
+    
+
+def filter_class_dict_by_number(class_dict, lower, upper=None):
+    if upper is None:
+        return {key: value for key, value in class_dict.items() 
+                if lower <= len(value) }
+    else:
+        assert lower <= upper, 'lower must not be greater than upper'
+        return {key: value for key, value in class_dict.items() 
+                if lower <= len(value) <= upper }
+        
+        
+def sort_class_dict_by_number(class_dict, num_classes_to_keep=None, reverse=True):
+    """
+    Args:
+        reverse: sort in ascending order when is True.
+    """
+    if num_classes_to_keep is None: 
+        num_classes_to_keep = len(class_dict)
+    else:
+        num_classes_to_keep = min(num_classes_to_keep, len(class_dict))
+    sorted_items = sorted(class_dict.items(), key=lambda x: len(x[1]), reverse=reverse)
+    filtered_dict = OrderedDict()
+    for i in range(num_classes_to_keep):
+        filtered_dict[sorted_items[i][0]] = sorted_items[i][1]
+    return filtered_dict
+
+    
+def merge_class_dict(*class_dicts):
+    merged_class_dict = {}
+    for item in class_dicts:
+        for key, value in item.items():
+            merged_class_dict.setdefault(key, []).extend(value)
+    return merged_class_dict
+    
+    

+ 65 - 0
khandy/utils_feature.py

@@ -0,0 +1,65 @@
+from collections import OrderedDict
+
+import numpy as np
+
+from .utils_dict import get_dict_first_item as _get_dict_first_item
+
+
+def convert_feature_dict_to_array(feature_dict):
+    key_list = []
+    one_feature = _get_dict_first_item(feature_dict)[1]
+    feature_array = np.empty((len(feature_dict), len(one_feature)), one_feature.dtype)
+    for k, (key, value) in enumerate(feature_dict.items()):
+        key_list.append(key)
+        feature_array[k] = value
+    return key_list, feature_array
+    
+    
+def convert_feature_array_to_dict(key_list, feature_array):
+    assert len(feature_array) == len(key_list)
+    feature_dict = OrderedDict()
+    for k, key in enumerate(key_list):
+        feature_dict[key] = feature_array[k]
+    return feature_dict
+    
+    
+def get_feature_array(feature_dict, keys):
+    one_feature = _get_dict_first_item(feature_dict)[1]
+    feature_array = np.empty((len(keys), len(one_feature)), one_feature.dtype)
+    for i, key in enumerate(keys):
+        feature_array[i, :] = feature_dict[key]
+    return feature_array
+
+
+def pairwise_distances(x, y, squared=True):
+    """Compute pairwise (squared) Euclidean distances.
+    
+    References:
+        [2016 CVPR] Deep Metric Learning via Lifted Structured Feature Embedding
+        `euclidean_distances` from sklearn
+    """
+    assert isinstance(x, np.ndarray) and x.ndim == 2
+    assert isinstance(y, np.ndarray) and y.ndim == 2
+    assert x.shape[1] == y.shape[1]
+    
+    x_square = np.expand_dims(np.einsum('ij,ij->i', x, x), axis=1)
+    if x is y:
+        y_square = x_square.T
+    else:
+        y_square = np.expand_dims(np.einsum('ij,ij->i', y, y), axis=0)
+    distances = np.dot(x, y.T)
+    # use inplace operation to accelerate
+    distances *= -2
+    distances += x_square
+    distances += y_square
+    # result maybe less than 0 due to floating point rounding errors.
+    np.maximum(distances, 0, distances)
+    if x is y:
+        # Ensure that distances between vectors and themselves are set to 0.0.
+        # This may not be the case due to floating point rounding errors.
+        distances.flat[::distances.shape[0] + 1] = 0.0
+    if not squared:
+        np.sqrt(distances, distances)
+    return distances
+    
+    

+ 42 - 0
khandy/utils_file_io.py

@@ -0,0 +1,42 @@
+import json
+from collections import OrderedDict
+
+
+def load_list(filename, encoding='utf-8', start=0, stop=None, step=1):
+    assert isinstance(start, int) and start >= 0
+    assert (stop is None) or (isinstance(stop, int) and stop > start)
+    assert isinstance(step, int) and step >= 1
+    
+    lines = []
+    with open(filename, 'r', encoding=encoding) as f:
+        for _ in range(start):
+            f.readline()
+        for k, line in enumerate(f):
+            if (stop is not None) and (k + start > stop):
+                break
+            if k % step == 0:
+                lines.append(line.rstrip())
+    return lines
+
+
+def save_list(filename, written_list, encoding='utf-8', append_break=True):
+    with open(filename, 'w', encoding=encoding) as f:
+        if append_break:
+            for item in written_list:
+                f.write(str(item) + '\n')
+        else:
+            for item in written_list:
+                f.write(str(item))
+
+
+def load_dict(filename, encoding='utf-8'):
+    with open(filename, 'r', encoding=encoding) as f:
+        dict_obj = json.load(f, object_pairs_hook=OrderedDict)
+    return dict_obj
+
+
+def save_dict(filename, dict_obj, encoding='utf-8', sort=False):
+    with open(filename, 'w', encoding=encoding) as f:
+        json.dump(dict_obj, f, indent=4, separators=(',',': '),
+                  ensure_ascii=False, sort_keys=sort)
+

+ 189 - 0
khandy/utils_fs.py

@@ -0,0 +1,189 @@
+
+import os
+import shutil
+
+
+def get_path_stem(path):
+    """
+    References:
+        `std::filesystem::path::stem` since C++17
+    """
+    return os.path.splitext(os.path.basename(path))[0]
+
+
+def replace_path_stem(path, new_stem):
+    dirname, basename = os.path.split(path)
+    stem, extname = os.path.splitext(basename)
+    if isinstance(new_stem, str):
+        return os.path.join(dirname, new_stem + extname)
+    elif hasattr(new_stem, '__call__'):
+        return os.path.join(dirname, new_stem(stem) + extname)
+    else:
+        raise ValueError('Unsupported Type!')
+        
+
+def get_path_extension(path):
+    """
+    References:
+        `std::filesystem::path::extension` since C++17
+        
+    Notes:
+        Not fully consistent with `std::filesystem::path::extension`
+    """
+    return os.path.splitext(os.path.basename(path))[1]
+    
+
+def replace_path_extension(path, new_extname=None):
+    """Replaces the extension with new_extname or removes it when the default value is used.
+    Firstly, if this path has an extension, it is removed. Then, a dot character is appended 
+    to the pathname, if new_extname is not empty or does not begin with a dot character.
+
+    References:
+        `std::filesystem::path::replace_extension` since C++17
+    """
+    filename_wo_ext = os.path.splitext(path)[0]
+    if new_extname == '' or new_extname is None:
+        return filename_wo_ext
+    elif new_extname.startswith('.'):
+        return ''.join([filename_wo_ext, new_extname]) 
+    else:
+        return '.'.join([filename_wo_ext, new_extname])
+
+
+def makedirs(name, mode=0o755):
+    """
+    References:
+        mmcv.mkdir_or_exist
+    """
+    if name == '':
+        return
+    name = os.path.expanduser(name)
+    os.makedirs(name, mode=mode, exist_ok=True)
+
+
+def listdirs(paths, path_sep=None, full_path=True):
+    """Enhancement on `os.listdir`
+    """
+    assert isinstance(paths, (str, tuple, list))
+    if isinstance(paths, str):
+        path_sep = path_sep or os.path.pathsep
+        paths = paths.split(path_sep)
+        
+    all_filenames = []
+    for path in paths:
+        path_ex = os.path.expanduser(path)
+        filenames = os.listdir(path_ex)
+        if full_path:
+            filenames = [os.path.join(path_ex, filename) for filename in filenames]
+        all_filenames.extend(filenames)
+    return all_filenames
+
+
+def get_all_filenames(dirname, extensions=None, is_valid_file=None):
+    if (extensions is not None) and (is_valid_file is not None):
+        raise ValueError("Both extensions and is_valid_file cannot "
+                         "be not None at the same time")
+    if is_valid_file is None:
+        if extensions is not None:
+            def is_valid_file(filename):
+                return filename.lower().endswith(extensions)
+        else:
+            def is_valid_file(filename):
+                return True
+
+    all_filenames = []
+    dirname = os.path.expanduser(dirname)
+    for root, _, filenames in sorted(os.walk(dirname, followlinks=True)):
+        for filename in sorted(filenames):
+            path = os.path.join(root, filename)
+            if is_valid_file(path):
+                all_filenames.append(path)
+    return all_filenames
+
+
+def copy_file(src, dst_dir, action_if_exist=None):
+    """
+    Args:
+        src: source file path
+        dst_dir: dest dir
+        action_if_exist: 
+            None: when dest file exists, no operation
+            overwritten: when dest file exists, overwritten
+            rename: when dest file exists, rename it
+            
+    Returns:
+        dest file basename
+    """
+    src_basename = os.path.basename(src)
+    dst_fullname = os.path.join(dst_dir, src_basename)
+    
+    if action_if_exist is None:
+        if not os.path.exists(dst_fullname):
+            makedirs(dst_dir)
+            shutil.copy(src, dst_dir)
+    elif action_if_exist.lower() == 'overwritten':
+        makedirs(dst_dir)
+        # shutil.copy
+        # If dst is a directory, a file with the same basename as src is 
+        # created (or overwritten) in the directory specified. 
+        shutil.copy(src, dst_dir)
+    elif action_if_exist.lower() == 'rename':
+        src_stem, src_extname = os.path.splitext(src_basename)
+        suffix = 2
+        while os.path.exists(dst_fullname):
+            dst_basename = '{} ({}){}'.format(src_stem, suffix, src_extname)
+            dst_fullname = os.path.join(dst_dir, dst_basename)
+            suffix += 1
+        else:
+            makedirs(dst_dir)
+            shutil.copy(src, dst_fullname)
+    else:
+        raise ValueError('Invalid action_if_exist, got {}.'.format(action_if_exist))
+        
+    return os.path.basename(dst_fullname)
+    
+    
+def move_file(src, dst_dir, action_if_exist=None):
+    """
+    Args:
+        src: source file path
+        dst_dir: dest dir
+        action_if_exist: 
+            None: when dest file exists, no operation
+            overwritten: when dest file exists, overwritten
+            rename: when dest file exists, rename it
+            
+    Returns:
+        dest file basename
+    """
+    src_basename = os.path.basename(src)
+    dst_fullname = os.path.join(dst_dir, src_basename)
+    
+    if action_if_exist is None:
+        if not os.path.exists(dst_fullname):
+            makedirs(dst_dir)
+            shutil.move(src, dst_dir)
+    elif action_if_exist.lower() == 'overwritten':
+        if os.path.exists(dst_fullname):
+            os.remove(dst_fullname)
+        makedirs(dst_dir)
+        # shutil.move
+        # If the destination already exists but is not a directory, 
+        # it may be overwritten depending on os.rename() semantics.
+        shutil.move(src, dst_dir)
+    elif action_if_exist.lower() == 'rename':
+        src_stem, src_extname = os.path.splitext(src_basename)
+        suffix = 2
+        while os.path.exists(dst_fullname):
+            dst_basename = '{} ({}){}'.format(src_stem, suffix, src_extname)
+            dst_fullname = os.path.join(dst_dir, dst_basename)
+            suffix += 1
+        else:
+            makedirs(dst_dir)
+            shutil.move(src, dst_fullname)
+    else:
+        raise ValueError('Invalid action_if_exist, got {}.'.format(action_if_exist))
+        
+    return os.path.basename(dst_fullname)
+    
+

+ 25 - 0
khandy/utils_hash.py

@@ -0,0 +1,25 @@
+import hashlib
+
+
+def calc_hash(content, hash_func=None):
+    hash_func = hash_func or hashlib.md5()
+    if isinstance(hash_func, str):
+        hash_func = hashlib.new(hash_func)
+    hash_func.update(content)
+    return hash_func.hexdigest()
+
+
+def calc_file_hash(filename, hash_func=None, chunk_size=1024 * 1024):
+    hash_func = hash_func or hashlib.md5()
+    if isinstance(hash_func, str):
+        hash_func = hashlib.new(hash_func)
+    
+    with open(filename, "rb") as f:
+        while True:
+            chunk = f.read(chunk_size)
+            if not chunk:
+                break
+            hash_func.update(chunk)
+    return hash_func.hexdigest()
+
+    

+ 52 - 0
khandy/utils_list.py

@@ -0,0 +1,52 @@
+import random
+
+
+def to_list(obj):
+    if obj is None:
+        return None
+    elif hasattr(obj, '__iter__') and not isinstance(obj, str):
+        try:
+            return list(obj)
+        except:
+            return [obj]
+    else:
+        return [obj]
+
+
+def convert_lists_to_record(*list_objs, delimiter=None):
+    assert len(list_objs) >= 1, 'list_objs length must >= 1.'
+    delimiter = delimiter or ','
+
+    assert isinstance(list_objs[0], (tuple, list))
+    number = len(list_objs[0])
+    for item in list_objs[1:]:
+        assert isinstance(item, (tuple, list))
+        assert len(item) == number, '{} != {}'.format(len(item), number)
+        
+    records = []
+    record_list = zip(*list_objs)
+    for record in record_list:
+        record_str = [str(item) for item in record]
+        records.append(delimiter.join(record_str))
+    return records
+
+
+def shuffle_table(*table):
+    """
+    Notes:
+        table can be seen as list of list which have equal items.
+    """
+    shuffled_list = list(zip(*table))
+    random.shuffle(shuffled_list)
+    tuple_list = zip(*shuffled_list)
+    return [list(item) for item in tuple_list]
+    
+    
+def transpose_table(table):
+    """
+    Notes:
+        table can be seen as list of list which have equal items.
+    """
+    m, n = len(table), len(table[0])
+    return [[table[i][j] for i in range(m)] for j in range(n)]
+

+ 91 - 0
khandy/utils_numpy.py

@@ -0,0 +1,91 @@
+import numpy as np
+
+
+def sigmoid(x):
+    return 1. / (1 + np.exp(-x))
+    
+    
+def softmax(x, axis=-1, copy=True):
+    """
+    Args:
+        copy: Copy x or not.
+        
+    Referneces:
+        `from sklearn.utils.extmath import softmax`
+    """
+    if copy:
+        x = np.copy(x)
+    max_val = np.max(x, axis=axis, keepdims=True)
+    x -= max_val
+    np.exp(x, x)
+    sum_exp = np.sum(x, axis=axis, keepdims=True)
+    x /= sum_exp
+    return x
+    
+    
+def log_sum_exp(x, axis=-1, keepdims=False):
+    """
+    References:
+        numpy.logaddexp
+        numpy.logaddexp2
+        scipy.misc.logsumexp
+    """
+    max_val = np.max(x, axis=axis, keepdims=True)
+    x -= max_val
+    np.exp(x, x)
+    sum_exp = np.sum(x, axis=axis, keepdims=keepdims)
+    lse = np.log(sum_exp, sum_exp)
+    if not keepdims:
+        max_val = np.squeeze(max_val, axis=axis)
+    return max_val + lse
+    
+    
+def l2_normalize(x, axis=0, epsilon=1e-12, copy=True):
+    """L2 normalize an array along an axis.
+    
+    Args:
+        x : array_like of floats
+            Input data.
+        axis : None or int or tuple of ints, optional
+            Axis or axes along which to operate.
+        epsilon: float, optional
+            A small value such as to avoid division by zero.
+        copy : bool, optional
+            Copy X or not.
+    """
+    if copy:
+        x = np.copy(x)
+    x /= np.maximum(np.linalg.norm(x, axis=axis, keepdims=True), epsilon)
+    return x
+    
+    
+def minmax_normalize(x, axis=0, copy=True):
+    """minmax normalize an array along a given axis.
+    
+    Args:
+        x : array_like of floats
+            Input data.
+        axis : None or int or tuple of ints, optional
+            Axis or axes along which to operate.
+        copy : bool, optional
+            Copy X or not.
+    """
+    if copy:
+        x = np.copy(x)
+    
+    minval = np.min(x, axis=axis, keepdims=True)
+    maxval = np.max(x, axis=axis, keepdims=True)
+    maxval -= minval
+    maxval = np.maximum(maxval, 1e-5)
+    
+    x -= minval
+    x /= maxval
+    return x
+
+    
+def get_order_of_magnitude(number):
+    number = np.where(number == 0, 1, number)
+    oom = np.floor(np.log10(np.abs(number)))
+    return oom.astype(np.int32)
+    
+    

+ 80 - 0
khandy/utils_others.py

@@ -0,0 +1,80 @@
+import time
+import socket
+import logging
+from collections import OrderedDict
+
+
+def print_with_no(obj):
+    if hasattr(obj, '__len__'):
+        for k, item in enumerate(obj):
+            print('[{}/{}] {}'.format(k+1, len(obj), item)) 
+    elif hasattr(obj, '__iter__'):
+        for k, item in enumerate(obj):
+            print('[{}] {}'.format(k+1, item)) 
+    else:
+        print('[1] {}'.format(obj))
+        
+      
+def get_file_line_count(filename):
+    line_count = 0
+    buffer_size = 1024 * 1024 * 8
+    with open(filename, 'r') as f:
+        while True:
+            data = f.read(buffer_size)
+            if not data:
+                break
+            line_count += data.count('\n')
+    return line_count
+
+    
+def get_host_ip():
+    try:
+        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+        s.connect(('8.8.8.8', 80))
+        ip = s.getsockname()[0]
+    finally:
+        s.close()
+    return ip
+    
+ 
+class ContextTimer(object):
+    """
+    References:
+        WithTimer in https://github.com/uber/ludwig/blob/master/ludwig/utils/time_utils.py
+    """
+    def __init__(self, name=None, use_log=True, quiet=False):
+        self.use_log = use_log
+        self.quiet = quiet
+        if name is None:
+            self.name = ''
+        else:
+            self.name = '{}, '.format(name.rstrip())
+                
+    def __enter__(self):
+        self.start_time = time.time()
+        if not self.quiet:
+            self._print_or_log('{}{} starts'.format(self.name, self._now_time_str))
+        return self
+    
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        if not self.quiet:
+            self._print_or_log('{}elapsed_time = {:.5}s'.format(self.name, self.get_eplased_time()))
+            self._print_or_log('{}{} ends'.format(self.name, self._now_time_str))
+            
+    @property
+    def _now_time_str(self):
+        return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())
+    
+    def _print_or_log(self, output_str):
+        if self.use_log is True:
+            logging.info(output_str)
+        else:
+            print(output_str)
+            
+    def get_eplased_time(self):
+        return time.time() - self.start_time
+        
+    def enter(self):
+        """Manually trigger enter"""
+        self.__enter__()
+        

+ 2 - 0
requirements.txt

@@ -0,0 +1,2 @@
+numpy>=1.11.1
+opencv-python

+ 24 - 0
setup.py

@@ -0,0 +1,24 @@
+import sys
+from setuptools import find_packages, setup
+
+install_requires = ['numpy>=1.11.1', 'opencv-python']
+
+setup(
+    name='KHandy',
+    version='0.1',
+    description='Handy Utilities for Computer Vision',
+    long_description='Handy Utilities for Computer Vision',
+    keywords='computer vision',
+    packages=find_packages(),
+    classifiers=[
+        'Development Status :: 4 - Beta',
+        'License :: OSI Approved :: Apache Software License',
+        'Operating System :: OS Independent',
+        'Topic :: Utilities',
+    ],
+    url='',
+    author='quarryman',
+    author_email='quarrying@qq.com',
+    license='GPLv3',
+    install_requires=install_requires,
+    zip_safe=False)