diff --git a/README.md b/README.md index d386c4d..dbe9f24 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ [![PyPI version](https://badge.fury.io/py/pybx.svg)](https://badge.fury.io/py/pybx) [![Open In Collab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/thatgeeman/pybx/blob/master/nbs/pybx_walkthrough.ipynb) -*WIP* - A simple python package to generate anchor (aka default/prior) boxes for object detection tasks. Calculated anchor boxes are in `pascal_voc` format by default. @@ -67,13 +65,12 @@ to [Visualising anchor boxes](data/README.md). - [x] Companion notebook - [x] Update with new Class methods - [x] Integrate MultiBx into anchor.bx() -- [ ] IOU check (return best overlap boxes) -- [ ] Return masks +- [x] IOU calcultaion - [x] Unit tests - [x] Specific tests - [x] `feature_sz` of different aspect ratios - [x] `image_sz` of different aspect ratios -- [ ] Move to setup.py -- [ ] Generate docs +- [ ] Generate docs `sphinx` +- [ ] clean docstrings diff --git a/data/annots_iou.json b/data/annots_iou.json new file mode 100644 index 0000000..0370d1a --- /dev/null +++ b/data/annots_iou.json @@ -0,0 +1,23 @@ +[ + { + "x_min": 20.0, + "y_min": 10.0, + "x_max": 70.0, + "y_max": 80.0, + "label": "b1" + }, + { + "x_min": 50.0, + "y_min": 60.0, + "x_max": 120.0, + "y_max": 150.0, + "label": "b2" + }, + { + "x_min": 50.0, + "y_min": 60.0, + "x_max": 70.0, + "y_max": 80.0, + "label": "int" + } +] \ No newline at end of file diff --git a/data/annots_rand.json b/data/annots_rand.json index bb5f539..ffd5c7a 100644 --- a/data/annots_rand.json +++ b/data/annots_rand.json @@ -2,15 +2,15 @@ { "x_min": 50.0, "y_min": 70.0, - "y_max": 100.0, "x_max": 120.0, + "y_max": 100.0, "label": "rand1" }, { "x_min": 150.0, "y_min": 200.0, - "y_max": 240.0, "x_max": 250.0, + "y_max": 240.0, "label": "rand2" } ] \ No newline at end of file diff --git a/src/pybx/anchor.py b/src/pybx/anchor.py index 39f7820..361056d 100644 --- a/src/pybx/anchor.py +++ b/src/pybx/anchor.py @@ -4,8 +4,6 @@ from .ops import __ops__, get_op, named_idx -voc_keys = ['x_min', 'y_min', 'x_max', 'y_max', 'label'] - def get_edges(image_sz: tuple, feature_sz: tuple, op='noop'): """ diff --git a/src/pybx/basics.py b/src/pybx/basics.py index 797faf9..9d6c775 100644 --- a/src/pybx/basics.py +++ b/src/pybx/basics.py @@ -1,10 +1,20 @@ import numpy as np from fastcore.basics import concat, store_attr -from .anchor import voc_keys -from .ops import mul, sub +from .ops import mul, sub, intersection_box, make_array, NoIntersection, voc_keys -__all__ = ['mbx', 'MultiBx', 'BaseBx', 'JsonBx', 'ListBx'] +__all__ = ['bbx', 'mbx', 'MultiBx', 'BaseBx', 'JsonBx', 'ListBx'] + + +def bbx(coords=None, labels=None): + """ + interface to the BaseBx class and all of its attributes + MultiBx wraps the coordinates and labels exposing many validation methods + :param coords: coordinates in list/array/json format + :param labels: labels in list format or keep intentionally None (also None for json) + :return: BaseBx object + """ + return BaseBx.basebx(coords, labels) def mbx(coords=None, labels=None): @@ -21,19 +31,29 @@ def mbx(coords=None, labels=None): class BaseBx: def __init__(self, coords, label=''): store_attr('coords, label') - coords_ = coords[::-1] # reverse - self.w = sub(*coords_[::2]) - self.h = sub(*coords_[1::2]) + self.w = sub(*coords[::2][::-1]) + self.h = sub(*coords[1::2][::-1]) def area(self): return abs(mul(self.w, self.h)) + def iou(self, other): + if not isinstance(other, BaseBx): + other = BaseBx.basebx(other) + if self.valid(): + try: + int_box = BaseBx.basebx(intersection_box(self.coords, other.coords)) + except NoIntersection: + return 0.0 + int_area = int_box.area() + union_area = other.area() + self.area() - int_area + return int_area / union_area + return 0.0 + def valid(self): - # TODO: more validations here v_area = bool(self.area()) # False if 0 - # TODO: v_ratio - v_all = [v_area] - return False if False in v_all else True + v_all = np.array([v_area]) + return True if v_all.all() else False def values(self): return [*self.coords, self.label] @@ -49,6 +69,15 @@ def make_2d(self): labels = [self.label] return coords, labels + @classmethod + def basebx(cls, coords, label: list = None): + if not isinstance(coords, np.ndarray): + try: + coords, label = make_array(coords) + except ValueError: + coords = make_array(coords) + return cls(coords, label) + class MultiBx: def __init__(self, coords, label: list = None): @@ -129,72 +158,9 @@ def jsonbx(cls, coords, label=None): r = [] for i, c in enumerate(coords): assert isinstance(c, dict), f'expected b of type dict, got {type(c)}' - c_ = list(c.values()) + c_ = [c[k] for k in voc_keys] # read in order l_ = c_[-1] if len(c_) > 4 else '' if label is None else label[i] l.append(l_) r.append(c_[:-1] if len(c_) > 4 else c_) coords = np.array(r) return cls(coords, label=l) - - -# deprecated -class BxIter: - def __init__(self, coords: np.ndarray, x_max=-1.0, y_max=-1.0, clip_only=False): - """ - returns an iterator that validates the coordinates calculated. - :param coords: ndarray of box coordinates - :param x_max: max dimension along x - :param y_max: max dimension along y - :param clip_only: whether to apply only np.clip with validate - clip_only cuts boxes that bleed outside limits - and forgo other validation ops - """ - if not isinstance(coords, np.ndarray): - coords = np.array(coords) - self.coords = coords.clip(0, max(x_max, y_max)) - # clip_only cuts boxes that bleed outside limits - store_attr('x_max, y_max, clip_only') - - def __iter__(self): - self.index = 0 - return self - - def __next__(self): - try: - c = self.coords[self.index] - if not self.clip_only: - self.validate_edge(c) - except IndexError: - raise StopIteration - self.index += 1 - return c - - def validate_edge(self, c): - """ - return next only if the x_min and y_min - # TODO: more tests, check if asp ratio changed as an indicator - does not flow outside the image, but: - - while might keep point (1,1,1,1) or line (0,0,1,0) | (0,0,0,1) boxes! - either maybe undesirable. - :param c: pass a box - :return: call for next iterator if conditions not met - """ - x1, y1 = c[:2] - if (x1 >= self.x_max) or (y1 >= self.y_max): - self.index += 1 - return self.__next__() - - def to_array(self, cast_fn=np.asarray): - """ - return all validated coords as np.ndarray - :return: array of coordinates, specify get_as torch.tensor for Tensor - """ - # TODO: fix UserWarning directly casting a numpy to tensor is too slow (torch>10) - return cast_fn([c for c in self.coords]) - - def to_records(self, cast_fn=list): - """ - return all validated coords as records (list of dicts) - :return: array of coordinates, specify get_as dict for json - """ - return cast_fn(dict(zip(voc_keys, [*c, f'a{i}'])) for i, c in enumerate(self.coords)) diff --git a/src/pybx/ops.py b/src/pybx/ops.py index 12fe952..b4da2a3 100644 --- a/src/pybx/ops.py +++ b/src/pybx/ops.py @@ -1,7 +1,8 @@ import numpy as np from fastcore.foundation import L -__ops__ = ['add', 'sub', 'noop'] +__ops__ = ['add', 'sub', 'mul', 'noop'] +voc_keys = ['x_min', 'y_min', 'x_max', 'y_max', 'label'] def add(x, y): @@ -30,6 +31,24 @@ def get_op(op: str): return eval(op, globals()) +def make_array(x): + if isinstance(x, dict): + try: + x = [x[k] for k in voc_keys] + except TypeError: + x = [x[k] for k in voc_keys[:-1]] + # now dict made into a list too + if isinstance(x, list): + if len(x) > 4: + return np.asarray(x[:4]), x[-1] + else: + return np.asarray(x) + elif isinstance(x, np.ndarray): + return x + else: + raise NotImplementedError + + def named_idx(x: np.ndarray, sfx: str): """ return a list of string indices matching the array @@ -40,3 +59,23 @@ def named_idx(x: np.ndarray, sfx: str): """ idx = np.arange(0, x.shape[0]).tolist() return L([sfx + i.__str__() for i in idx]) + + +def intersection_box(b1: np.ndarray, b2: np.ndarray): + """ + return the intersection box given two boxes + :param b1: + :param b2: + :return: + """ + if not isinstance(b1, np.ndarray): + raise TypeError('expected ndarrays') + top_edge = np.max(np.vstack([b1, b2]), axis=0)[:2] + bot_edge = np.min(np.vstack([b1, b2]), axis=0)[2:] + if (bot_edge > top_edge).all(): + return np.hstack([top_edge, bot_edge]) + raise NoIntersection + + +class NoIntersection(Exception): + pass diff --git a/tests/test_anchor.py b/tests/test_anchor.py new file mode 100644 index 0000000..e70fa93 --- /dev/null +++ b/tests/test_anchor.py @@ -0,0 +1,43 @@ +import json +import unittest + +import numpy as np + +from pybx import anchor + +np.random.seed(1) + +params = { + "feature_szs": [(2, 2), (3, 3), (4, 4)], + "asp_ratios": [1 / 2., 1., 2.], + "feature_sz": (2, 2), + "asp_ratio": 1 / 2., + "image_sz": (10, 10, 3), + "data_dir": '../data', +} + +results = { + "bx_b": 236.8933982822018, + "bx_l": 'a_2x2_0.5_8', + "bxs_b": 3703.086279536432, + "bxs_l": 'a_4x4_2.0_24', + "scaled_ans": (9.0, 6.0), +} + + +class AnchorTestCase(unittest.TestCase): + def test_bx(self): + b, l_ = anchor.bx(params["image_sz"], params["feature_sz"], params["asp_ratio"]) + self.assertIn(results["bx_l"], l_, 'label not matching') + self.assertEqual(len(b), len(l_)) + self.assertEqual(b.sum(), results["bx_b"], 'sum not matching') # add assertion here + + def test_bxs(self): + b, l_ = anchor.bxs(params["image_sz"], params["feature_szs"], params["asp_ratios"]) + self.assertIn(results["bxs_l"], l_, 'label not matching') + self.assertEqual(len(b), len(l_)) + self.assertEqual(b.sum(), results["bxs_b"], 'sum not matching') # add assertion here + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_basics.py b/tests/test_basics.py new file mode 100644 index 0000000..a64851d --- /dev/null +++ b/tests/test_basics.py @@ -0,0 +1,70 @@ +import json +import unittest + +import numpy as np + +from pybx.basics import mbx, bbx, MultiBx + +np.random.seed(1) + +params = { + "annots_rand_file": '../data/annots_rand.json', + "annots_iou_file": '../data/annots_iou.json', + "annots_l": [[50., 70., 120., 100., 'rand1'], [150., 200., 250., 240., 'rand2']], + "annots_a": np.random.randn(10, 4) +} + +results = { + "mbx_json": (120.0, 'rand2'), + "mbx_list": (50.0, 'rand1'), + "mbx_arr": -0.08959797456887511, + "iou": 0.0425531914893617, + "xywh": np.array([50.0, 70.0, 70.0, 30.0]), +} + + +class BasicsTestCase(unittest.TestCase): + def test_mbx_json(self): + with open(params["annots_rand_file"]) as f: + annots = json.load(f) + b = mbx(annots) + r = b.coords[0][2], b.label[1] + self.assertIsInstance(b, MultiBx, 'b is not MultiBx') + self.assertEqual(r, results["mbx_json"]) + + def test_mbx_list(self): + annots = params["annots_l"] + b = mbx(annots) + r = b.coords[0][2], b.label[1] + self.assertIsInstance(b, MultiBx, 'b is not MultiBx') + self.assertEqual(r, results["mbx_json"]) + + def test_mbx_array(self): + annots = params["annots_a"] + b = mbx(annots) + r = b.coords.mean() + self.assertIsInstance(b, MultiBx, 'b is not MultiBx') + self.assertEqual(r, results["mbx_arr"]) + + def test_iou(self): + with open(params["annots_iou_file"]) as f: + annots = json.load(f) + b0 = bbx(annots[0]) + b1 = bbx(annots[1]) + b2 = bbx(annots[2]) # intersecting box + iou = b0.iou(b1) # calculated iou + iou_ = b2.area() / (b0.area() + b1.area() - b2.area()) + self.assertEqual(iou, iou_) + self.assertEqual(iou, results["iou"]) + + def test_xywh(self): + with open(params["annots_rand_file"]) as f: + annots = json.load(f) + b = bbx(annots[0]) + self.assertTrue((b.xywh() == results["xywh"]).all(), True) + self.assertGreaterEqual(b.xywh()[-1], 0) + self.assertGreaterEqual(b.xywh()[-2], 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_bx.py b/tests/test_bx.py deleted file mode 100644 index d84dbda..0000000 --- a/tests/test_bx.py +++ /dev/null @@ -1,73 +0,0 @@ -import json -import unittest -from pybx import anchor -import numpy as np -from pybx.basics import mbx, MultiBx -from pybx.sample import get_example - -np.random.seed(1) - -params = { - "feature_szs": [(2, 2), (3, 3), (4, 4)], - "asp_ratios": [1 / 2., 1., 2.], - "feature_sz": (2, 2), - "asp_ratio": 1 / 2., - "image_sz": (10, 10, 3), - "annots_l": [[50, 70, 100, 120, 'rand1'], [150, 200, 240, 250, 'rand2']], - "annots_a": np.random.randn(10, 4) -} - -results = { - "test_bx_b": 236.8933982822018, - "test_bx_l": 'a_2x2_0.5_8', - "test_bxs_b": 3703.086279536432, - "test_bxs_l": 'a_4x4_2.0_24', - "test_basics_mbx_json": (100.0, 'rand2'), - "test_basics_mbx_list": (50.0, 'rand1'), - "test_basics_mbx_arr": -0.08959797456887511, - "scaled_ans": (9.0, 6.0), -} - - -class MyTestCase(unittest.TestCase): - def test_anchor_bx(self): - b, l_ = anchor.bx(params["image_sz"], params["feature_sz"], params["asp_ratio"]) - self.assertIn(results["test_bx_l"], l_, 'label not matching') - self.assertEqual(b.sum(), results["test_bx_b"], 'sum not matching') # add assertion here - - def test_anchor_bxs(self): - b, l_ = anchor.bxs(params["image_sz"], params["feature_szs"], params["asp_ratios"]) - self.assertIn(results["test_bxs_l"], l_, 'label not matching') - self.assertEqual(b.sum(), results["test_bxs_b"], 'sum not matching') # add assertion here - - def test_basics_mbx_json(self): - with open('../data/annots_rand.json') as f: - annots = json.load(f) - b = mbx(annots) - r = b.coords[0][2], b.label[1] - self.assertIsInstance(b, MultiBx, 'b is not MultiBx') - self.assertEqual(r, results["test_basics_mbx_json"]) - - def test_basics_mbx_list(self): - annots = params["annots_l"] - b = mbx(annots) - r = b.coords[0][2], b.label[1] - self.assertIsInstance(b, MultiBx, 'b is not MultiBx') - self.assertEqual(r, results["test_basics_mbx_json"]) - - def test_basics_mbx_array(self): - annots = params["annots_a"] - b = mbx(annots) - r = b.coords.mean() - self.assertIsInstance(b, MultiBx, 'b is not MultiBx') - self.assertEqual(r, results["test_basics_mbx_arr"]) - - def test_sample_ex(self): - im, ann, _, _ = get_example(params["image_sz"], pth='../data') - self.assertEqual(im.shape, params["image_sz"]) - r = ann[0]['x_max'], ann[1]['y_min'] - self.assertEqual(r, results['scaled_ans']) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_ops.py b/tests/test_ops.py new file mode 100644 index 0000000..8ce2c05 --- /dev/null +++ b/tests/test_ops.py @@ -0,0 +1,66 @@ +import json +import unittest + +import numpy as np + +from pybx import anchor, ops +from pybx.ops import NoIntersection + +np.random.seed(1) + +params = { + "data_dir": '../data', + "annots_iou_file": '../data/annots_iou.json', + "annots_rand_file": '../data/annots_rand.json', +} + +results = { + "add": 18, + "sub": 2, + "noop": 10, + "mul": 80, + "namedidx": 'a3' +} + + +class OpsTestCase(unittest.TestCase): + def test_get_op(self): + for o in ops.__ops__: + o_ = ops.get_op(o) + self.assertEqual(o_(10, 8), results[o], 'op results not matching') + + def test_make_array(self): + with open(params["annots_iou_file"]) as f: + annots = json.load(f) + array, label = ops.make_array(annots[0]) + self.assertIsInstance(array, np.ndarray) + self.assertIsInstance(label, str) + + def test_named_idx(self): + with open(params["annots_iou_file"]) as f: + annots = json.load(f) + array, label = ops.make_array(annots[1]) + namedidx = ops.named_idx(array, 'a') + self.assertEqual(namedidx[-1], results["namedidx"]) + + def test_intersection_box(self): + with open(params["annots_iou_file"]) as f: + annots = json.load(f) + a0, _ = ops.make_array(annots[0]) + a1, _ = ops.make_array(annots[1]) + a2, _ = ops.make_array(annots[2]) + int_box_array = ops.intersection_box(a1, a2) + self.assertTrue((a2 == int_box_array).sum()) + + def test_intersection_box_noint(self): + with open(params["annots_iou_file"]) as f: + annots0 = json.load(f) + with open(params["annots_rand_file"]) as f: + annots1 = json.load(f) + a1, _ = ops.make_array(annots0[0]) + a2, _ = ops.make_array(annots1[1]) + self.assertRaises(NoIntersection, ops.intersection_box, b1=a1, b2=a2) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_sample.py b/tests/test_sample.py new file mode 100644 index 0000000..fd8d79e --- /dev/null +++ b/tests/test_sample.py @@ -0,0 +1,30 @@ +import unittest +import numpy as np +from pybx.sample import get_example + +np.random.seed(1) +params = { + "feature_szs": [(2, 2), (3, 3), (4, 4)], + "feature_sz": (2, 2), + "asp_ratio": 1 / 2., + "image_sz": (10, 10, 3), + "data_dir": '../data', +} + +results = { + "scaled_ans": (9.0, 6.0), +} + + +class SampleTestCase(unittest.TestCase): + def test_example(self): + im, ann, lgts, _ = get_example(params["image_sz"], feature_sz=params["feature_sz"], logits=True, + pth=params["data_dir"]) + self.assertEqual(im.shape, params["image_sz"]) + r = ann[0]['x_max'], ann[1]['y_min'] + self.assertEqual(r, results["scaled_ans"]) + self.assertEqual(lgts.shape, params["feature_sz"]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_vis.py b/tests/test_vis.py new file mode 100644 index 0000000..0ec2517 --- /dev/null +++ b/tests/test_vis.py @@ -0,0 +1,54 @@ +import json +import unittest + +from pybx.basics import bbx, BaseBx +from pybx.vis import VisBx + +params = { + "data_dir": '../data', + "annots_iou_file": '../data/annots_iou.json', + "annots_rand_file": '../data/annots_rand.json', + "annots_l": [[50., 70., 120., 100., 'rand1'], [150., 200., 250., 240., 'rand2']], + "feature_sz": (2, 2), + "image_sz": (10, 10, 3), +} + + +class VisTestCase(unittest.TestCase): + def __init__(self, args): + super(VisTestCase, self).__init__(args) + self.v = VisBx(params["image_sz"], feature_sz=params["feature_sz"], logits=True, pth=params["data_dir"]) + + def test_vis_bx(self): + with open(params["annots_rand_file"]) as f: + annots = json.load(f) + ax = self.v.show(annots) + self.assertTrue(ax) + + def test_vis_jsonbx(self): + with open(params["annots_rand_file"]) as f: + annots = json.load(f) + ax = self.v.show(annots) + self.assertTrue(ax) + + def test_vis_listbx(self): + ax = self.v.show(params["annots_l"]) + self.assertTrue(ax) + + def test_vis_bbx_list(self): + b = bbx(params["annots_l"][0]) + ax = self.v.show(b) + self.assertTrue(ax) + self.assertIsInstance(b, BaseBx) + + def test_vis_bbx_json(self): + with open(params["annots_rand_file"]) as f: + annots = json.load(f) + b = bbx(annots[0]) + ax = self.v.show(b) + self.assertTrue(ax) + self.assertIsInstance(b, BaseBx) + + +if __name__ == '__main__': + unittest.main()