Repository: deepc-health/nekton (language: python)

Issue:
[Feat] check the slice thickness is uniform or variable and trigger different workflows.
- [x] From Dicom header check the slice thickness type
- [x] If uniform thickness go with processing
- [x] else for now raise not implemented error


Discussion / hints:

Fix patch:
diff --git a/nekton/base.py b/nekton/base.py
new file mode 100644
index 0000000..d256a6d
--- /dev/null
+++ b/nekton/base.py
@@ -0,0 +1,25 @@
+from utils.json_helpers import write_json
+import os
+
+
+class BaseConverter:
+    def __init__(self):
+        pass
+
+    @staticmethod
+    def write_dict_json(directory: str, data_dict: dict, name: str) -> str:
+        """write a dictionary type to json file
+
+        Args:
+            directory (str): directory where the json has to be stored
+            data_dict (dict): dictionary to be made json
+            name (str): name of the json file
+
+        Returns:
+            str: the path of the stored json
+        """
+        if ".json" not in name:
+            name += ".json"
+        output_filepath = os.path.join(directory, name)
+        write_json(data_dict, output_filepath)
+        return output_filepath
diff --git a/nekton/dcm2nii.py b/nekton/dcm2nii.py
new file mode 100644
index 0000000..cbff2dc
--- /dev/null
+++ b/nekton/dcm2nii.py
@@ -0,0 +1,87 @@
+import glob
+import os
+from typing import List
+
+import pydicom
+
+from utils.dicom import is_file_a_dicom
+from base import BaseConverter
+
+
+class Dcm2Nii(BaseConverter):
+    def __init__(self):
+        super().__init__()
+
+    @staticmethod
+    def get_all_dicoms(dicom_directory: str) -> List[str]:
+        """Class method to read all dicoms in adirectory
+
+        Args:
+            dicom_directory (str): the directory where
+
+        Raises:
+            NameError: when the directory does not contain a single dicom
+            NameError: when the direcotry does not exist
+
+        Returns:
+            List[str]: list of all dicom-paths in the directory
+        """
+
+        if not os.path.exists(dicom_directory):
+            raise NameError(f"directory: '{dicom_directory}' not found!")
+
+        # all the files in the directory
+        file_path_list = [
+            file_path
+            for file_path in glob.glob(os.path.join(dicom_directory, "**"))
+            if os.path.isfile(file_path)
+        ]
+
+        # check for dicoms only
+        dicom_path_list = [
+            file_path for file_path in file_path_list if is_file_a_dicom(file_path)
+        ]
+
+        if len(dicom_path_list) == 0:
+            raise NameError("Incorrect Path. No Dicoms found!")
+        return dicom_path_list
+
+    @staticmethod
+    def check_slice_thickness_variable(all_dcm_paths: List[str]) -> bool:
+        """read file header slice thickness to determine if uniform thickness or variable
+
+        Args:
+            all_dcm_paths (List[str]): list of path to DICOMs
+
+        Returns:
+            bool: True if variable slice thickness else False
+        """
+        all_slice_thickness = set(
+            [pydicom.read_file(path).SliceThickness for path in all_dcm_paths]
+        )
+        return False if len(all_slice_thickness) == 1 else True
+
+    def _run_conv_variable(self, all_dcm_paths: List[str], name: str) -> str:
+        raise NotImplementedError(
+            "DICOM with variable slice thickness cannot to be handled yet!!"
+        )
+        return ""
+
+    def _run_conv_uniform(self, all_dcm_paths: List[str], name: str) -> str:
+        return ""
+
+    def run(self, dicom_directory: str, name: str) -> str:
+        try:
+            all_dcm_paths = self.get_all_dicoms(dicom_directory)
+        except Exception as err:
+            raise RuntimeError(f"Error parsing dicoms: {err}")
+
+        try:
+            if self.check_slice_thickness_variable(all_dcm_paths):
+                converted_file_path = self._run_conv_variable(all_dcm_paths, name)
+            else:
+                converted_file_path = self._run_conv_uniform(all_dcm_paths, name)
+        except Exception as err:
+            raise RuntimeError(f"Error converting DCM to NifTi: {err}")
+
+        return converted_file_path
diff --git a/nekton/utils/dicom.py b/nekton/utils/dicom.py
new file mode 100644
index 0000000..0a50d5f
--- /dev/null
+++ b/nekton/utils/dicom.py
@@ -0,0 +1,19 @@
+import pydicom
+from pydicom.errors import InvalidDicomError
+
+
+def is_file_a_dicom(file: str) -> bool:
+    """function to check if a given file is a DICOM or not
+
+    Args:
+        file (str): path to a file
+
+    Returns:
+        bool: returns True if a file is a DICOM else False
+    """
+
+    try:
+        pydicom.read_file(file)
+    except InvalidDicomError:
+        return False
+    return True
diff --git a/nekton/utils/json_helpers.py b/nekton/utils/json_helpers.py
new file mode 100644
index 0000000..c10f8fd
--- /dev/null
+++ b/nekton/utils/json_helpers.py
@@ -0,0 +1,39 @@
+import json
+
+
+def write_json(dictionary: dict, path: str) -> str:
+    """write a dictionary as json
+
+    Args:
+        dictionary (dict): a dictionary that has to be converted to json
+        path (str): path to output json file
+
+    Returns:
+        str: path to output json file
+    """
+    try:
+        json_object = json.dumps(dictionary, indent=4)
+        with open(path, "w") as outfile:
+            outfile.write(json_object)
+    except Exception as err:
+        raise RuntimeError(f"Unable to write json: {err}")
+
+    return path
+
+
+def read_json(path: str) -> dict:
+    """read a json as a dictionary
+
+    Args:
+        path (str): path to a json file
+
+    Returns:
+        dict: the dictionary read from a json file
+    """
+    try:
+        with open(path, "r") as openfile:
+            json_object = json.load(openfile)
+    except Exception as err:
+        raise RuntimeError(f"Unable to read json: {err}")
+
+    return json_object
diff --git a/requirements.txt b/requirements.txt
index 04443b8..0b09f54 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,34 +1,63 @@
 argcomplete==1.12.3
 attrs==21.2.0
 backports.entry-points-selectable==1.1.1
+bleach==4.1.0
+certifi==2021.10.8
+cffi==1.15.0
 cfgv==3.3.1
+charset-normalizer==2.0.9
 colorama==0.4.4
 commitizen==2.20.0
+coverage==6.2
+cryptography==36.0.0
 decli==0.5.2
 distlib==0.3.3
+docutils==0.18.1
+fastcore==1.3.27
+fastrelease==0.1.12
 filelock==3.4.0
+ghapi==0.1.19
 identify==2.4.0
+idna==3.3
+importlib-metadata==4.8.2
 iniconfig==1.1.1
+jeepney==0.7.1
 Jinja2==3.0.3
+keyring==23.4.0
 MarkupSafe==2.0.1
 mypy==0.910
 mypy-extensions==0.4.3
 nodeenv==1.6.0
 packaging==21.3
+pkginfo==1.8.2
 platformdirs==2.4.0
 pluggy==1.0.0
 pre-commit==2.16.0
 prompt-toolkit==3.0.23
 py==1.11.0
+pycparser==2.21
+pydicom==2.2.2
+Pygments==2.10.0
 pyparsing==3.0.6
 pytest==6.2.5
 PyYAML==6.0
 questionary==1.10.0
+readme-renderer==30.0
+requests==2.26.0
+requests-toolbelt==0.9.1
+rfc3986==1.5.0
+SecretStorage==3.3.1
 six==1.16.0
 termcolor==1.1.0
 toml==0.10.2
+tomli==1.2.2
 tomlkit==0.7.2
+tqdm==4.62.3
+twine==3.7.1
 types-setuptools==57.4.4
 typing_extensions==4.0.1
+urllib3==1.26.7
 virtualenv==20.10.0
 wcwidth==0.2.5
+webencodings==0.5.1
+zipp==3.6.0


Test patch:
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index 1d3d7f4..a0372be 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -15,7 +15,7 @@ jobs:
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
-        pip install flake8 pytest
+        pip install flake8 pytest pytest-cov
         pip install -r requirements.txt
     - name: Lint with flake8
       run: |
@@ -23,6 +23,19 @@ jobs:
         flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
         # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
         flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+    
     - name: Test with pytest
       run: |
-        pytest tests/
\ No newline at end of file
+        pytest tests/  --cov-report=xml --cov=nekton --junitxml=./coverage.xml
+
+    - name: Code Coverage Summary Report
+      run: |
+       pip install coverage
+       coverage combine && coverage report --fail-under=90 -m
+       coverage html 
+
+    - name: Archive code coverage html report
+      uses: actions/upload-artifact@v2
+      with:
+        name: code-coverage-report
+        path: htmlcov/index.html
diff --git a/tests/conftest.py b/tests/conftest.py
index 50592a6..9929684 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,15 @@
 # add root-dir to sys path for tests
 import sys
+import site
+import pytest
+
 from os.path import abspath
 from os.path import dirname as d
 
 parent_dir = f"{d(d(abspath(__file__)))}"
 sys.path.append(f"{parent_dir}/nekton")
+
+
+@pytest.fixture
+def site_package_path():
+    yield site.getsitepackages()[0]
diff --git a/tests/pytest.ini b/tests/pytest.ini
index d4c41e6..ce99ee7 100644
--- a/tests/pytest.ini
+++ b/tests/pytest.ini
@@ -6,4 +6,5 @@ junit_family=xunit2
 ; log_cli_format = %(asctime)s [%(name)s:%(levelname)s] %(message)s (%(filename)s:%(lineno)s)
 ; log_cli_date_format=%Y-%m-%d %H:%M:%S
 markers =
-    devtest: only for dev testing not ci (deselect with '-m "not devtest"')
\ No newline at end of file
+    utilstest: only for dev testing not ci (deselect with '-m "not utilstest"')
+    dcm2nii: all tests for DICOM to NII (deselect with '-m "not dcm2nii"')
\ No newline at end of file
diff --git a/tests/test_0_sample.py b/tests/test_0_sample.py
deleted file mode 100644
index 93a734d..0000000
--- a/tests/test_0_sample.py
+++ /dev/null
@@ -1,6 +0,0 @@
-import pytest
-
-
-@pytest.mark.devtest
-def test_0_1_dummy():
-    pass
diff --git a/tests/test_0_utils.py b/tests/test_0_utils.py
new file mode 100644
index 0000000..92e2015
--- /dev/null
+++ b/tests/test_0_utils.py
@@ -0,0 +1,54 @@
+import pytest
+import os
+import json
+
+from utils.json_helpers import read_json, write_json
+from utils.dicom import is_file_a_dicom
+
+
+@pytest.mark.utilstest
+def test_0_1_readjson(site_package_path):
+    # read a proper json
+    path_json = os.path.join(site_package_path, "pydicom/data/test_files/test1.json")
+    dict_from_json = read_json(path_json)
+    assert type(dict_from_json) is dict
+    assert dict_from_json["00080016"]["vr"] == "UI"
+
+    # read a non-json file
+    with pytest.raises(RuntimeError):
+        read_json("./")
+
+
+@pytest.mark.utilstest
+def test_0_2_readjson():
+    def is_json(myjson):
+        try:
+            with open(myjson, "r") as openfile:
+                json.load(openfile)
+        except Exception:
+            return False
+        return True
+
+    # writing a dict to json file
+    dict_data = {"key1": 1, "key2": "a"}
+    outpath = write_json(dict_data, "./test.json")
+    assert os.path.exists(outpath)
+    assert is_json(outpath)
+    os.remove(outpath)
+
+    # passing a non-dict type
+    with pytest.raises(RuntimeError):
+        write_json({None}, "./test.json")
+
+
+@pytest.mark.utilstest
+def test_0_3_check_dicom(site_package_path):
+    non_dicom_file = os.path.join(
+        site_package_path, "pydicom/data/test_files/test1.json"
+    )
+    assert is_file_a_dicom(non_dicom_file) is False
+
+    dicom_file = os.path.join(
+        site_package_path, "pydicom/data/test_files/JPEG-lossy.dcm"
+    )
+    assert is_file_a_dicom(dicom_file)
diff --git a/tests/test_1_slice_thickness_check.py b/tests/test_1_slice_thickness_check.py
new file mode 100644
index 0000000..d70a999
--- /dev/null
+++ b/tests/test_1_slice_thickness_check.py
@@ -0,0 +1,63 @@
+import pytest
+import os
+import glob
+
+from dcm2nii import Dcm2Nii
+
+
+@pytest.fixture
+def converter_nii():
+    yield Dcm2Nii()
+
+
+@pytest.mark.dcm2nii
+def test_1_1_check_read_dicom(converter_nii, site_package_path):
+    # when directory does not exist
+    with pytest.raises(NameError, match="directory: './non-existant-dir' not found!"):
+        converter_nii.get_all_dicoms("./non-existant-dir")
+
+    # when no dicoms found
+    with pytest.raises(NameError, match="Incorrect Path. No Dicoms found!"):
+        converter_nii.get_all_dicoms("./")
+
+    # when dicom without extension are found in a directory
+    path_dcms = os.path.join(
+        site_package_path, "pydicom/data/test_files/dicomdirtests/77654033/CR1/"
+    )
+    assert len(converter_nii.get_all_dicoms(path_dcms)) == 1
+
+    # when  mulitple dicoms are found in a directory
+    path_dcms = os.path.join(
+        site_package_path, "pydicom/data/test_files/dicomdirtests/98892001/CT5N/"
+    )
+    assert len(converter_nii.get_all_dicoms(path_dcms)) == 5
+
+
+@pytest.mark.dcm2nii
+def test_1_2_check_slice_thickness_variable(converter_nii, site_package_path):
+
+    # samples with uniform slice thickness
+    path_dcms = os.path.join(
+        site_package_path, "pydicom/data/test_files/dicomdirtests/98892001/CT5N/"
+    )
+    path_dcms_list = glob.glob(os.path.join(path_dcms, "*"))
+    assert converter_nii.check_slice_thickness_variable(path_dcms_list) is False
+
+    # samples with variable slice thickness
+    path_dcms_list = glob.glob(
+        os.path.join("tests/test_data/variable_SliceThickness/*")
+    )
+    assert converter_nii.check_slice_thickness_variable(path_dcms_list)
+
+
+@pytest.mark.dcm2nii
+def test_1_3_check_end2end_variable(converter_nii):
+    with pytest.raises(NotImplementedError):
+        converter_nii._run_conv_variable([], name="")
+    with pytest.raises(RuntimeError):
+        converter_nii.run("tests/test_data/variable_SliceThickness", name="")
+
+
+@pytest.mark.dcm2nii
+def test_1_3_check_end2end_uniform(converter_nii):
+    pass
diff --git a/tests/test_data/placeholder b/tests/test_data/placeholder
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/test_data/variable_SliceThickness/9999.162769995481703652708081065963548998351.dcm b/tests/test_data/variable_SliceThickness/9999.162769995481703652708081065963548998351.dcm
new file mode 100644
index 0000000..0bd02e0
Binary files /dev/null and b/tests/test_data/variable_SliceThickness/9999.162769995481703652708081065963548998351.dcm differ
diff --git a/tests/test_data/variable_SliceThickness/9999.210283585444337834545498032216456143001.dcm b/tests/test_data/variable_SliceThickness/9999.210283585444337834545498032216456143001.dcm
new file mode 100644
index 0000000..bb170bd
Binary files /dev/null and b/tests/test_data/variable_SliceThickness/9999.210283585444337834545498032216456143001.dcm differ
diff --git a/tests/test_data/variable_SliceThickness/9999.277010260948439493115553981478824923910.dcm b/tests/test_data/variable_SliceThickness/9999.277010260948439493115553981478824923910.dcm
new file mode 100644
index 0000000..ed758df
Binary files /dev/null and b/tests/test_data/variable_SliceThickness/9999.277010260948439493115553981478824923910.dcm differ
diff --git a/tests/test_data/variable_SliceThickness/9999.31513324048902488534813472554080260207.dcm b/tests/test_data/variable_SliceThickness/9999.31513324048902488534813472554080260207.dcm
new file mode 100644
index 0000000..3cc7b41
Binary files /dev/null and b/tests/test_data/variable_SliceThickness/9999.31513324048902488534813472554080260207.dcm differ