Repository: thodan/bop_toolkit

Issue:
Path type error
Path is passed as PosixPath and fails here:
https://github.com/thodan/bop_toolkit/blob/1282b0da40c73c4aaba9cf010fa804815a52ec7c/bop_toolkit_lib/inout.py#L81

Discussion:

Solution patch:
diff --git a/bop_toolkit_lib/inout.py b/bop_toolkit_lib/inout.py
index d860395..d7949f9 100644
--- a/bop_toolkit_lib/inout.py
+++ b/bop_toolkit_lib/inout.py
@@ -3,59 +3,59 @@
 
 """I/O functions."""
 
-import os
 import gzip
 import struct
 import numpy as np
-import imageio
+import numpy.typing as npt
+import imageio.v2 as iio
 import png
 import json
 from collections import defaultdict
+from pathlib import Path
+from typing import Union
 
 from bop_toolkit_lib import misc
 
 
-def load_im(path):
+def load_im(path: Union[str,Path]):
     """Loads an image from a file.
 
     :param path: Path to the image file to load.
     :return: ndarray with the loaded image.
     """
-    im = imageio.imread(path)
+    im = iio.imread(path)
     return im
 
 
-def save_im(path, im, jpg_quality=95):
+def save_im(path: Union[str,Path], im: npt.NDArray, jpg_quality: int =95):
     """Saves an image to a file.
 
     :param path: Path to the output image file.
     :param im: ndarray with the image to save.
     :param jpg_quality: Quality of the saved image (applies only to JPEG).
     """
-    ext = os.path.splitext(path)[1][1:]
-    if ext.lower() in ["jpg", "jpeg"]:
-        imageio.imwrite(path, im, quality=jpg_quality)
+    if Path(path).suffix.lower() in ["jpg", "jpeg"]:
+        iio.imwrite(path, im, quality=jpg_quality)
     else:
-        imageio.imwrite(path, im, compression=3)
+        iio.imwrite(path, im, compression=3)
 
 
-def load_depth(path):
+def load_depth(path: Union[str,Path]):
     """Loads a depth image from a file.
 
     :param path: Path to the depth image file to load.
     :return: ndarray with the loaded depth image.
     """
-    d = imageio.imread(path)
-    return d.astype(np.float32)
+    return iio.imread(path).astype(np.float32)
 
 
-def save_depth(path, im):
+def save_depth(path: Union[str,Path], im: npt.NDArray):
     """Saves a depth image (16-bit) to a PNG file.
 
     :param path: Path to the output depth image file.
     :param im: ndarray with the depth image to save.
     """
-    if path.split(".")[-1].lower() != "png":
+    if Path(path).suffix.lower() != ".png":
         raise ValueError("Only PNG format is currently supported.")
 
     im_uint16 = np.round(im).astype(np.uint16)
@@ -66,19 +66,21 @@ def save_depth(path, im):
         w_depth.write(f, np.reshape(im_uint16, (-1, im.shape[1])))
 
 
-def load_json(path, keys_to_int=False):
+def load_json(path: Union[str,Path], keys_to_int=False):
     """Loads content of a JSON file.
 
     :param path: Path to the JSON file. If ".json.gz" extension, opens with gzip.
     :return: Content of the loaded JSON file.
     """
+    path = Path(path)
+    assert path.as_posix().endswith(('.json', '.json.gz')), f"{path} should end with .json or .json.gz extension"
 
     # Keys to integers.
     def convert_keys_to_int(x):
         return {int(k) if k.lstrip("-").isdigit() else k: v for k, v in x.items()}
     
     # Open+decompress with gzip if ".json.gz" file extension
-    if path.endswith('.json.gz'):
+    if path.as_posix().endswith('.json.gz'):
         f = gzip.open(path, "rt", encoding="utf8")
     else:
         f = open(path, "r")
@@ -92,15 +94,19 @@ def load_json(path, keys_to_int=False):
     return content
 
 
-def save_json(path, content, compress=False, verbose=False):
+def save_json(path: Union[str,Path], content: dict, compress=False, verbose=False):
     """Saves the provided content to a JSON file.
 
     :param path: Path to the output JSON file.
     :param content: Dictionary/list to save.
     :param compress: Saves as a gzip archive, appends ".gz" extension to filepath.
     """
+    path = Path(path)
+    assert path.as_posix().endswith(('.json', '.json.gz')), f"{path} should end with .json or .json.gz extension"
+    
     if compress:
-        path += ".gz"
+        if path.suffix == '.json':
+            path = path.parent / (path.stem + ".json.gz")
         f = gzip.open(path, "wt", encoding="utf8")
     else:
         f = open(path, "w")
@@ -132,7 +138,7 @@ def save_json(path, content, compress=False, verbose=False):
         misc.log(f"Saved {path}")
 
 
-def load_cam_params(path):
+def load_cam_params(path: Union[str,Path]):
     """Loads camera parameters from a JSON file.
 
     :param path: Path to the JSON file.
@@ -156,7 +162,7 @@ def load_cam_params(path):
     return cam
 
 
-def _camera_as_numpy(camera):
+def _camera_as_numpy(camera: dict):
     """Convert fields from scene camera from native python to numpy.
 
     See docs/bop_datasets_format.md for details.
@@ -182,7 +188,7 @@ def _camera_as_numpy(camera):
     return camera
 
 
-def _camera_as_json(camera):
+def _camera_as_json(camera: dict):
     """Convert fields from scene camera from numpy to native python.
 
     See docs/bop_datasets_format.md for details.
@@ -208,7 +214,7 @@ def _camera_as_json(camera):
     return camera
 
 
-def load_scene_camera(path):
+def load_scene_camera(path: Union[str,Path]):
     """Loads content of a JSON file with information about the scene camera.
 
     See docs/bop_datasets_format.md for details.
@@ -223,7 +229,7 @@ def load_scene_camera(path):
     return scene_camera
 
 
-def save_scene_camera(path, scene_camera):
+def save_scene_camera(path: Union[str,Path], scene_camera: dict):
     """Saves information about the scene camera to a JSON file.
 
     See docs/bop_datasets_format.md for details.
@@ -236,7 +242,7 @@ def save_scene_camera(path, scene_camera):
     save_json(path, scene_camera)
 
 
-def _gt_as_numpy(gt):
+def _gt_as_numpy(gt: dict):
     if "cam_R_m2c" in gt.keys():
         gt["cam_R_m2c"] = np.array(gt["cam_R_m2c"], np.float64).reshape((3, 3))
     if "cam_t_m2c" in gt.keys():
@@ -244,7 +250,7 @@ def _gt_as_numpy(gt):
     return gt
 
 
-def _gt_as_json(gt):
+def _gt_as_json(gt: dict):
     if "cam_R_m2c" in gt.keys():
         gt["cam_R_m2c"] = gt["cam_R_m2c"].flatten().tolist()
     if "cam_t_m2c" in gt.keys():
@@ -254,7 +260,7 @@ def _gt_as_json(gt):
     return gt
 
 
-def load_scene_gt(path):
+def load_scene_gt(path: Union[str,Path]):
     """Loads content of a JSON file with ground-truth annotations.
 
     See docs/bop_datasets_format.md for details.
@@ -273,7 +279,7 @@ def load_scene_gt(path):
     return scene_gt
 
 
-def save_scene_gt(path, scene_gt):
+def save_scene_gt(path: Union[str,Path], scene_gt: dict):
     """Saves ground-truth annotations to a JSON file.
 
     See docs/bop_datasets_format.md for details.
@@ -293,7 +299,7 @@ def save_scene_gt(path, scene_gt):
     save_json(path, scene_gt)
 
 
-def load_bop_results(path, version="bop19", max_num_estimates_per_image=None):
+def load_bop_results(path: Union[str,Path], version="bop19", max_num_estimates_per_image=None):
     """Loads 6D object pose estimates from a file.
 
     :param path: Path to a file with pose estimates.
@@ -359,7 +365,7 @@ def load_bop_results(path, version="bop19", max_num_estimates_per_image=None):
     return results
 
 
-def save_bop_results(path, results, version="bop19"):
+def save_bop_results(path: Union[str,Path], results: list[dict], version="bop19"):
     """Saves 6D object pose estimates to a file.
 
     :param path: Path to the output file.
@@ -394,7 +400,7 @@ def save_bop_results(path, results, version="bop19"):
         raise ValueError("Unknown version of BOP results.")
 
 
-def check_bop_results(path, version="bop19"):
+def check_bop_results(path: Union[str,Path], version="bop19"):
     """Checks if the format of BOP results is correct.
 
     :param result_filenames: Path to a file with pose estimates.
@@ -433,7 +439,7 @@ def check_bop_results(path, version="bop19"):
     return check_passed, check_msg
 
 
-def check_coco_results(path, version="bop22", ann_type="segm", enforce_no_segm_if_bbox=False):
+def check_coco_results(path: Union[str,Path], version="bop22", ann_type="segm", enforce_no_segm_if_bbox=False):
     """Checks if the format of extended COCO results is correct.
 
     :param path: Path to a file with coco estimates. If ".json.gz" extension, opens with gzip.
@@ -487,7 +493,7 @@ def check_coco_results(path, version="bop22", ann_type="segm", enforce_no_segm_i
     return check_passed, check_msg
 
 
-def save_coco_results(path, results, version="bop22", compress=False):
+def save_coco_results(path: Union[str,Path], results: list[dict], version="bop22", compress=False):
     """Saves detections/instance segmentations for each scene in coco format.
 
     "bbox" should be [x,y,w,h] in pixels
@@ -519,7 +525,7 @@ def save_coco_results(path, results, version="bop22", compress=False):
         raise ValueError("Unknown version of BOP detection results.")
 
 
-def load_ply(path):
+def load_ply(path: Union[str,Path]):
     """Loads a 3D mesh model from a PLY file.
 
     :param path: Path to a PLY file.
@@ -716,7 +722,7 @@ def load_ply(path):
     return model
 
 
-def save_ply(path, model, extra_header_comments=None):
+def save_ply(path: Union[str,Path], model: dict, extra_header_comments=None):
     """Saves a 3D mesh model to a PLY file.
 
     :param path: Path to a PLY file.
@@ -859,7 +865,7 @@ def save_ply2(
     f.close()
 
 
-def get_im_targets(im_gt, im_gt_info, visib_gt_min, eval_mode="localization"):
+def get_im_targets(im_gt: dict, im_gt_info: dict, visib_gt_min: float, eval_mode="localization"):
     """
     From an image gt and gt info, given a minimum visibility, get valid object evaluation targets.
 
diff --git a/bop_toolkit_lib/pycoco_utils.py b/bop_toolkit_lib/pycoco_utils.py
index be18ef2..e53436a 100644
--- a/bop_toolkit_lib/pycoco_utils.py
+++ b/bop_toolkit_lib/pycoco_utils.py
@@ -5,11 +5,7 @@
 import datetime
 import numpy as np
 import datetime
-import time
-from collections import defaultdict
-import copy
 from skimage import measure
-from itertools import groupby
 from bop_toolkit_lib import misc
 
 
diff --git a/scripts/count_visib_instances.py b/scripts/count_visib_instances.py
index 9db0ac9..9d0d842 100644
--- a/scripts/count_visib_instances.py
+++ b/scripts/count_visib_instances.py
@@ -6,8 +6,6 @@
 from bop_toolkit_lib import config
 from bop_toolkit_lib import dataset_params
 from bop_toolkit_lib import inout
-import os
-import zipfile
 from tqdm import tqdm
 # PARAMETERS.
 ################################################################################
diff --git a/scripts/subsample_dataset.py b/scripts/subsample_dataset.py
index c888ab7..c79ed6b 100644
--- a/scripts/subsample_dataset.py
+++ b/scripts/subsample_dataset.py
@@ -11,9 +11,7 @@ import numpy as np
 import math
 from scipy.cluster.vq import kmeans
 
-from bop_toolkit_lib import config
 from bop_toolkit_lib import inout
-from bop_toolkit_lib import misc
 from bop_toolkit_lib import transform
 
 # Get the base name of the file without the .py extension


Test patch:
diff --git a/bop_toolkit_lib/tests/test_inout.py b/bop_toolkit_lib/tests/test_inout.py
index c9b506f..169bade 100644
--- a/bop_toolkit_lib/tests/test_inout.py
+++ b/bop_toolkit_lib/tests/test_inout.py
@@ -1,5 +1,9 @@
 import os
 import unittest
+from pathlib import Path
+import numpy as np
+from numpy.testing import assert_almost_equal, assert_equal
+
 
 from bop_toolkit_lib import inout
 
@@ -7,15 +11,42 @@ from bop_toolkit_lib import inout
 class TestInout(unittest.TestCase):
 
     def setUp(self) -> None:
-        self.csv_6d_path = 'bop_toolkit_lib/tests/data/cnos-fastsammegapose_icbin-test_7c9f443f-b900-41bb-af01-09b8eddfc2c4.csv'
-        self.json_coco_path = 'bop_toolkit_lib/tests/data/zebraposesat-effnetb4_ycbv-test_5ed0eecc-96f8-498b-9438-d586d4d92528.json'
+        results_dir = Path(__file__).parent / 'data/results_sub'
+        self.csv_6d_path = results_dir / 'cnos-fastsammegapose_icbin-test_7c9f443f-b900-41bb-af01-09b8eddfc2c4.csv'
+        self.json_coco_path = results_dir / 'zebraposesat-effnetb4_ycbv-test_5ed0eecc-96f8-498b-9438-d586d4d92528.json'
+    
+    def test_load_save_operations(self):
+        tmp = Path(__file__).parent / "tmp"
+        tmp.mkdir(exist_ok=True)
 
-    def test_coco_json(self):
-        """
-        Check json loading/savings functions.
-        """
-        json_coco_bis_path = self.json_coco_path + '_bis'
-        json_coco_gz_path = self.json_coco_path + '.gz'
+        # im
+        im_path_png = tmp / "test_im.png"
+        im_path_jpg = tmp / "test_im.jpg"
+        im_path_jpeg = tmp / "test_im.jpeg"
+        im = np.ones((10,10), dtype=np.uint8)
+        inout.save_im(im_path_png, im)
+        inout.save_im(im_path_png.as_posix(), im)
+        inout.save_im(im_path_jpg.as_posix(), im)
+        inout.save_im(im_path_jpeg.as_posix(), im)
+        im_path_png_loaded = inout.load_im(im_path_png)
+        im_path_jpg_loaded = inout.load_im(im_path_jpg)
+        im_path_jpeg_loaded = inout.load_im(im_path_jpeg)
+        assert_almost_equal(im, im_path_png_loaded)
+        assert_almost_equal(im, im_path_jpg_loaded)
+        assert_almost_equal(im, im_path_jpeg_loaded)
+
+        # depth
+        depth_path = tmp / "test_depth.png"
+        depth = np.ones((10,10), dtype=np.float64) + 0.1
+        inout.save_depth(depth_path, depth)
+        inout.save_depth(depth_path.as_posix(), depth)
+        inout.load_depth(depth_path)
+        depth_loaded = inout.load_depth(depth_path.as_posix())
+        assert_almost_equal(np.round(depth).astype(np.uint16), depth_loaded)
+
+        # json
+        json_coco_bis_path = self.json_coco_path.parent / (self.json_coco_path.stem + '_bis.json')
+        json_coco_gz_path = self.json_coco_path.parent / (self.json_coco_path.stem + '.json.gz')
 
         # load example json coco submission
         coco_res = inout.load_json(self.json_coco_path)
@@ -28,14 +59,27 @@ class TestInout(unittest.TestCase):
         # load the compressed copy
         coco_res_from_gz = inout.load_json(json_coco_gz_path)
 
-        # A few checks to assess if format/values are kept the same
         self.assertTrue(len(coco_res) == len(coco_res_from_bis) == len(coco_res_from_gz))
         self.assertTrue(coco_res[0].keys() == coco_res_from_bis[0].keys() == coco_res_from_gz[0].keys())
         self.assertTrue(coco_res[0]['time'] == coco_res_from_bis[0]['time'] == coco_res_from_gz[0]['time'])
         self.assertTrue(coco_res[0]['score'] == coco_res_from_bis[0]['score'] == coco_res_from_gz[0]['score'])
         self.assertTrue(coco_res[0]['bbox'] == coco_res_from_bis[0]['bbox'] == coco_res_from_gz[0]['bbox'])
+
+
+        # cleanup
+        os.remove(im_path_png)
+        os.remove(im_path_jpg)
+        os.remove(im_path_jpeg)
+        os.remove(depth_path)
+        os.remove(json_coco_bis_path)
+        os.remove(json_coco_gz_path)
+
+
+    def test_coco_json(self):
+        """
+        Check json loading/savings functions.
+        """
         inout.check_coco_results(self.json_coco_path)
-        inout.check_coco_results(json_coco_gz_path)
 
         # ISSUE: save_coco_results and check_coco_results not compatible
         # # create fake coco results with save_coco_results format 
@@ -51,10 +95,6 @@ class TestInout(unittest.TestCase):
         # inout.save_coco_results(self.json_coco_path, coco_res_remap, compress=True)
         # inout.check_coco_results(json_coco_gz_path)
 
-        # cleanup
-        os.remove(json_coco_bis_path)
-        os.remove(json_coco_gz_path)
-