Repository: microsoft/InnerEye-DICOM-RT (language: python)

Issue:
Timezone information is displayed wrong in Raystation
When viewing segmentation results in RayStation, the displayed time is about 1h off. This could mean either that the time encoded by IE-DicomRT is not correctly localized, or that RayStation is not displaying timezone info correctly. 
We should go through the code here and ensure that IE-DicomRT is correctly storing its processing time in UTC.


Discussion / hints:
https://github.com/microsoft/InnerEye-DICOM-RT/blob/11bdeaeaa740b4954764f3f1f6dc443930981917/RTConvert/Microsoft.RTConvert.MedIO/RT/DicomRTSeries.cs#L60 writes a plain string from `UtcNow`. However no information is attached about the timezone offset for that.

Possible solution: 
- Use the TimezoneOffset tag as described [here](https://dicom.innolitics.com/ciods/ct-image/sop-common/00080201)
- Encode the timezone offset as described in [table 6.2-1 of the standard](https://dicom.nema.org/medical/dicom/current/output/html/part05.html#sect_6.2)

Fix patch:
diff --git a/README.md b/README.md
index aa1a9bb..01ca3fa 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,34 @@
-# Introduction 
+# Introduction
 
-InnerEye-DICOM-RT contains tools to convert medical datasets in NIFTI format to DICOM-RT. Datasets converted using 
+InnerEye-DICOM-RT contains tools to convert medical datasets in NIFTI format to DICOM-RT. Datasets converted using
 this tool can be consumed directly by [InnerEye-DeepLearning](https://github.com/microsoft/InnerEye-DeepLearning).
 
 Most of the work is done by a .NET Core 2.1 project in RTConvert, written in C#. There is a very lightweight wrapper
 around this so that it can be consumed from Python. The wrapper relies on the PyPI package https://pypi.org/project/dotnetcore2/ which wraps up .NET Core 2.1.
 
-# Installing
+## Installing
 
-## Git for Windows
+### Git for Windows
 
 Get the installer from [Git for Windows](https://git-scm.com/download/win)
 
- The installer will prompt you to "Select Components". Make sure that you tick 
+ The installer will prompt you to "Select Components". Make sure that you tick
+
 * Git LFS (Large File Support)
 * Git Credential Manager for Windows
 
 After the installation, open a command prompt or the Git Bash:
-- Run `git lfs install` to set up the hooks in git
-- Run `git config --global core.autocrlf true` to ensure that line endings are working as expected
+
+* Run `git lfs install` to set up the hooks in git
+* Run `git config --global core.autocrlf true` to ensure that line endings are working as expected
 
 Clone the InnerEye-DICOM-RT repository on your machine: Run `git lfs clone --recursive https://github.com/microsoft/InnerEye-DICOM-RT`
 
-## Visual Studio / .NET Core
+### Visual Studio / .NET Core
 
 The C# components can be built with the .NET Core SDK. We use version 2.1 for compatibility with the PyPI package `dotnetcore2`.
- Installation instructions are here: https://docs.microsoft.com/en-us/dotnet/core/install/. 
-Visual Studio is not required to build, but if you wish to use it then for .Net Core 2.1 you need at least: 
+ Installation instructions are here: https://docs.microsoft.com/en-us/dotnet/core/install/.
+Visual Studio is not required to build, but if you wish to use it then for .Net Core 2.1 you need at least:
 [Visual Studio 2017 version 15.7](https://visualstudio.microsoft.com/vs/?utm_medium=microsoft&utm_source=docs.microsoft.com&utm_campaign=inline+link).
 
 ### RTConvert
@@ -55,12 +57,12 @@ Finally, for consumption by the Python wrapper, this solution must be published:
 dotnet publish RTConvert --configuration Release -p:Platform=x64
 ```
 
-This should create a folder with all the requirements for RTConvert at: 
+This should create a folder with all the requirements for RTConvert at:
 `RTConvert/Microsoft.RTConvert.Console/bin/x64/Release/netcoreapp2.1/publish/*`
 
 ### Echo
 
-Echo is a very simple application that takes 1 or 2 arguments. The first is echoed to `stdout`, and if a 
+Echo is a very simple application that takes 1 or 2 arguments. The first is echoed to `stdout`, and if a
 second argument is supplied then it is echoed to `stderr`. This is only required for units tests to establish
 that a .NET Core application can be called.
 
@@ -95,6 +97,7 @@ The Python package is created with:
 ```bash
 python setup.py sdist bdist_wheel
 ```
+
 which builds a source distribution and wheel to the `dist` folder.
 
 To run the Python tests:
@@ -132,6 +135,7 @@ To call RTConvert:
 ```
 
 where:
+
 * `in_file` is the path to the input Nifti file. This file is a 3D volume in [Nifti format](https://nifti.nimh.nih.gov/).
 * `reference_series` is the path to the input folder containing the reference DICOM series;
 * `out_file` is the path to the output DICOM-RT file;
@@ -153,6 +157,12 @@ where:
 * `interpreter` Interpreter for the DICOM-RT (check DICOM-RT documentation)
 * `roi_interpreted_types` is a list of ROIInterpretedType. Possible values (None, CTV, ORGAN, EXTERNAL).
 
+### Segmentation Timestamps
+
+In the process of converting from NIFTI masks to DICOM, InnerEye-DICOM-RT adds a timestamp tag to the series. This timestamp will be set to the ***local time of the compute resource that `rtconvert()` is run on***. This is as some DICOM readers do not handle timezones / UTC offsets from these tags and will result in erroneous times being displayed. Therefore, if your compute resource is running in a different timezone to the timezone you are viewing the DICOM files in, the timestamps may not be what you are expecting.
+
+## License
+
 [MIT License](LICENSE)
 
 **You are responsible for the performance, the necessary testing, and if needed any regulatory clearance for
@@ -162,7 +172,7 @@ where:
 
 This project welcomes contributions and suggestions.  Most contributions require you to agree to a
 Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
-the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.
+the rights to use your contribution. For details, visit the [Micrsoft CLA page](https://cla.opensource.microsoft.com).
 
 When you submit a pull request, a CLA bot will automatically determine whether you need to provide
 a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions
@@ -174,8 +184,8 @@ contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additio
 
 ## Trademarks
 
-This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft 
-trademarks or logos is subject to and must follow 
+This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
+trademarks or logos is subject to and must follow
 [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general).
 Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship.
 Any use of third-party trademarks or logos are subject to those third-party's policies.
diff --git a/RTConvert/Microsoft.RTConvert.MedIO/RT/DicomRTSeries.cs b/RTConvert/Microsoft.RTConvert.MedIO/RT/DicomRTSeries.cs
index f844c67..bd7a8cc 100644
--- a/RTConvert/Microsoft.RTConvert.MedIO/RT/DicomRTSeries.cs
+++ b/RTConvert/Microsoft.RTConvert.MedIO/RT/DicomRTSeries.cs
@@ -55,7 +55,9 @@ public static void Write(DicomDataset ds, DicomRTSeries series)
             ds.Add(DicomTag.SeriesNumber, string.Empty);
 
             // Type 3 tags - optional but useful
-            var now = DateTime.UtcNow;
+
+            //  Using DateTime.Now as some DICOM readers do not handle UTC offests. Local time is best guess.
+            var now = DateTime.Now;
             var date = now.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
             var time = now.ToString("HHmmss", CultureInfo.InvariantCulture);
             ds.Add(DicomTag.SeriesDate, date);
@@ -63,4 +65,4 @@ public static void Write(DicomDataset ds, DicomRTSeries series)
         }
     }
 
-}
\ No newline at end of file
+}
diff --git a/build_requirements.txt b/build_requirements.txt
index 64cd902..a99956f 100644
--- a/build_requirements.txt
+++ b/build_requirements.txt
@@ -1,5 +1,5 @@
 dotnetcore2==2.1.23
-flake8==3.8.4
+flake8==5.0.4
 mypy==0.800
 pydicom==2.1.2
 pytest-cov==2.11.1


Test patch:
diff --git a/tests/test_nifti_to_dicom_rt_converter.py b/tests/test_nifti_to_dicom_rt_converter.py
index f1f5721..e48ed18 100644
--- a/tests/test_nifti_to_dicom_rt_converter.py
+++ b/tests/test_nifti_to_dicom_rt_converter.py
@@ -1,183 +1,179 @@
-#  ------------------------------------------------------------------------------------------
-#  Copyright (c) Microsoft Corporation. All rights reserved.
-#  Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
-#  ------------------------------------------------------------------------------------------
-
-"""
-Tests for nifti_to_dicom_rt_converter.
-"""
-
-import logging
-from pathlib import Path
-from typing import List
-from pydicom import dcmread
-
-try:
-    from InnerEye_DICOM_RT.nifti_to_dicom_rt_converter import echo, get_version, rtconvert  # type: ignore
-except ImportError:
-    logging.info("using local src")
-    from src.InnerEye_DICOM_RT.nifti_to_dicom_rt_converter import echo, get_version, rtconvert  # type: ignore
-
-logger = logging.getLogger('test_rtconvert')
-logger.setLevel(logging.DEBUG)
-
-
-def test_get_version() -> None:
-    """
-    Test that .dotnet core can be called --info and that it is
-    running version 2.1.
-    """
-    (stdout, stderr) = get_version()
-
-    logger.debug("stdout: %s", stdout)
-    logger.debug("stderr: %s", stderr)
-
-    assert stderr == ''
-    assert 'Microsoft.NETCore.App 2.1.' in stdout
-
-
-def test_echo() -> None:
-    """
-    Test that the test Echo dll can be called and returns the test string and no error.
-    """
-    test_string = "hello world2!"
-    (stdout, stderr) = echo(test_string)
-
-    logger.debug("stdout: %s", stdout)
-    logger.debug("stderr: %s", stderr)
-
-    assert stderr == ''
-    assert stdout == test_string + '\n'
-
-
-def test_echo_err() -> None:
-    """
-    Test that the test Echo dll can be called and returns the test and error strings.
-    """
-    test_string = "hello world2!"
-    test_error = "Test error."
-    (stdout, stderr) = echo(test_string, test_error)
-
-    logger.debug("stdout: %s", stdout)
-    logger.debug("stderr: %s", stderr)
-
-    assert stderr == test_error + '\n'
-    assert stdout == test_string + '\n'
-
-
-# The directory containing this file.
-THIS_DIR: Path = Path(__file__).parent.resolve()
-# The TestData directory.
-TEST_DATA_DIR: Path = THIS_DIR / "TestData"
-# Test Nifti file.
-TestNiftiSegmentationLocation: Path = TEST_DATA_DIR / "hnsegmentation.nii.gz"
-# Test reference series.
-TestDicomVolumeLocation: Path = TEST_DATA_DIR / "HN"
-# Target test output file.
-TestOutputFile: Path = THIS_DIR / "test.dcm"
-
-# Test fill holes.
-FillHoles: List[bool] = \
-    [
-        True, True, True, True,
-        False, False, True, True,
-        True, True, False, True,
-        True, True, True, False,
-        True, False, True, True,
-        False, True
-    ]
-
-# Test ROIInterpretedType.
-ROIInterpretedTypes: List[str] = \
-    [
-        "ORGAN", "None", "CTV", "EXTERNAL",
-        "ORGAN", "None", "CTV", "EXTERNAL",
-        "ORGAN", "None", "CTV", "EXTERNAL",
-        "ORGAN", "None", "CTV", "EXTERNAL",
-        "ORGAN", "None", "CTV", "EXTERNAL",
-        "ORGAN", "None"
-    ]
-
-# Test structure colors.
-StructureColors: List[str] = \
-    [
-        "FF0001", "FF0002", "FF0003", "FF0004",
-        "FF0101", "FF0102", "FF0103", "FF0103",
-        "FF0201", "FF02FF", "FF0203", "FF0204",
-        "FF0301", "FF0302", "01FF03", "FF0304",
-        "FF0401", "00FFFF", "FF0403", "FF0404",
-        "FF0501", "FF0502"
-    ]
-
-# Test structure names.
-StructureNames: List[str] = \
-    [
-        "External", "parotid_l", "parotid_r", "smg_l",
-        "smg_r", "spinal_cord", "brainstem", "globe_l",
-        "Globe_r", "mandible", "spc_muscle", "mpc_muscle",
-        "Cochlea_l", "cochlea_r", "lens_l", "lens_r",
-        "optic_chiasm", "optic_nerve_l", "optic_nerve_r", "pituitary_gland",
-        "lacrimal_gland_l", "lacrimal_gland_r"
-    ]
-
-Manufacturer = "Contosos"
-Interpreter = "Ai"
-ModelId = "XYZ:12"
-
-def test_rtconvert() -> None:
-    """
-    Test calling RTConvert for the test data.
-    """
-    (stdout, stderr) = rtconvert(
-        in_file=TestNiftiSegmentationLocation,
-        reference_series=TestDicomVolumeLocation,
-        out_file=TestOutputFile,
-        struct_names=StructureNames,
-        struct_colors=StructureColors,
-        fill_holes=FillHoles,
-        roi_interpreted_types=ROIInterpretedTypes,
-        manufacturer=Manufacturer,
-        interpreter=Interpreter,
-        modelId=ModelId
-    )
-
-    logger.debug("stdout: %s", stdout)
-    logger.debug("stderr: %s", stderr)
-
-    assert stderr == ''
-    assert "Successfully written" in stdout
-
-    assert TestOutputFile.is_file()
-
-    with open(TestOutputFile, 'rb') as infile:
-        ds = dcmread(infile)
-
-        assert ds is not None
-
-        # Check the modality
-        assert ds.Modality == 'RTSTRUCT'
-
-        assert ds.Manufacturer == Manufacturer
-        assert ds.SoftwareVersions == ModelId
-
-        assert len(ds.StructureSetROISequence) == len(StructureNames)
-
-        for i, item in enumerate(StructureNames):
-            assert ds.StructureSetROISequence[i].ROINumber == i + 1
-            assert ds.StructureSetROISequence[i].ROIName == item
-            assert Interpreter in ds.RTROIObservationsSequence[i].ROIInterpreter
-            assert ds.RTROIObservationsSequence[i].RTROIInterpretedType == ('' if ROIInterpretedTypes[i] == 'None' else ROIInterpretedTypes[i])
-
-        assert len(ds.ROIContourSequence) == len(StructureNames)
-
-        for i, item in enumerate(StructureNames):
-            assert ds.ROIContourSequence[i].ReferencedROINumber == i + 1
-            assert ds.ROIContourSequence[i].ROIDisplayColor == _parse_rgb(StructureColors[i])
-
-def _parse_rgb(rgb: str) -> List[int]:
-    """
-    Convert the string representation of RGB color to an int list
-    :param rgb: Color string
-    :return: List of [R, G, B] components.
-    """
-    return [int(rgb[i:i+2], 16) for i in (0, 2, 4)]
+#  ------------------------------------------------------------------------------------------
+#  Copyright (c) Microsoft Corporation. All rights reserved.
+#  Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
+#  ------------------------------------------------------------------------------------------
+
+"""
+Tests for nifti_to_dicom_rt_converter.
+"""
+
+import logging
+from pathlib import Path
+from typing import List
+from pydicom import dcmread
+
+try:
+    from InnerEye_DICOM_RT.nifti_to_dicom_rt_converter import echo, get_version, rtconvert  # type: ignore
+except ImportError:
+    logging.info("using local src")
+    from src.InnerEye_DICOM_RT.nifti_to_dicom_rt_converter import echo, get_version, rtconvert  # type: ignore
+
+logger = logging.getLogger('test_rtconvert')
+logger.setLevel(logging.DEBUG)
+
+
+def test_get_version() -> None:
+    """
+    Test that .dotnet core can be called --info and that it is
+    running version 2.1.
+    """
+    (stdout, stderr) = get_version()
+
+    logger.debug("stdout: %s", stdout)
+    logger.debug("stderr: %s", stderr)
+
+    assert stderr == ''
+    assert 'Microsoft.NETCore.App 2.1.' in stdout
+
+
+def test_echo() -> None:
+    """
+    Test that the test Echo dll can be called and returns the test string and no error.
+    """
+    test_string = "hello world2!"
+    (stdout, stderr) = echo(test_string)
+
+    logger.debug("stdout: %s", stdout)
+    logger.debug("stderr: %s", stderr)
+
+    assert stderr == ''
+    assert stdout == test_string + '\n'
+
+
+def test_echo_err() -> None:
+    """
+    Test that the test Echo dll can be called and returns the test and error strings.
+    """
+    test_string = "hello world2!"
+    test_error = "Test error."
+    (stdout, stderr) = echo(test_string, test_error)
+
+    logger.debug("stdout: %s", stdout)
+    logger.debug("stderr: %s", stderr)
+
+    assert stderr == test_error + '\n'
+    assert stdout == test_string + '\n'
+
+
+# The directory containing this file.
+THIS_DIR: Path = Path(__file__).parent.resolve()
+# The TestData directory.
+TEST_DATA_DIR: Path = THIS_DIR / "TestData"
+# Test Nifti file.
+TestNiftiSegmentationLocation: Path = TEST_DATA_DIR / "hnsegmentation.nii.gz"
+# Test reference series.
+TestDicomVolumeLocation: Path = TEST_DATA_DIR / "HN"
+# Target test output file.
+TestOutputFile: Path = THIS_DIR / "test.dcm"
+
+# Test fill holes.
+FillHoles: List[bool] = [
+        True, True, True, True,
+        False, False, True, True,
+        True, True, False, True,
+        True, True, True, False,
+        True, False, True, True,
+        False, True
+]
+
+# Test ROIInterpretedType.
+ROIInterpretedTypes: List[str] = [
+        "ORGAN", "None", "CTV", "EXTERNAL",
+        "ORGAN", "None", "CTV", "EXTERNAL",
+        "ORGAN", "None", "CTV", "EXTERNAL",
+        "ORGAN", "None", "CTV", "EXTERNAL",
+        "ORGAN", "None", "CTV", "EXTERNAL",
+        "ORGAN", "None"
+]
+
+# Test structure colors.
+StructureColors: List[str] = [
+        "FF0001", "FF0002", "FF0003", "FF0004",
+        "FF0101", "FF0102", "FF0103", "FF0103",
+        "FF0201", "FF02FF", "FF0203", "FF0204",
+        "FF0301", "FF0302", "01FF03", "FF0304",
+        "FF0401", "00FFFF", "FF0403", "FF0404",
+        "FF0501", "FF0502"
+]
+
+# Test structure names.
+StructureNames: List[str] = [
+        "External", "parotid_l", "parotid_r", "smg_l",
+        "smg_r", "spinal_cord", "brainstem", "globe_l",
+        "Globe_r", "mandible", "spc_muscle", "mpc_muscle",
+        "Cochlea_l", "cochlea_r", "lens_l", "lens_r",
+        "optic_chiasm", "optic_nerve_l", "optic_nerve_r", "pituitary_gland",
+        "lacrimal_gland_l", "lacrimal_gland_r"
+]
+
+Manufacturer = "Contosos"
+Interpreter = "Ai"
+ModelId = "XYZ:12"
+
+def test_rtconvert() -> None:
+    """
+    Test calling RTConvert for the test data.
+    """
+    (stdout, stderr) = rtconvert(
+        in_file=TestNiftiSegmentationLocation,
+        reference_series=TestDicomVolumeLocation,
+        out_file=TestOutputFile,
+        struct_names=StructureNames,
+        struct_colors=StructureColors,
+        fill_holes=FillHoles,
+        roi_interpreted_types=ROIInterpretedTypes,
+        manufacturer=Manufacturer,
+        interpreter=Interpreter,
+        modelId=ModelId
+    )
+
+    logger.debug("stdout: %s", stdout)
+    logger.debug("stderr: %s", stderr)
+
+    assert stderr == ''
+    assert "Successfully written" in stdout
+
+    assert TestOutputFile.is_file()
+
+    with open(TestOutputFile, 'rb') as infile:
+        ds = dcmread(infile)
+
+        assert ds is not None
+
+        # Check the modality
+        assert ds.Modality == 'RTSTRUCT'
+
+        assert ds.Manufacturer == Manufacturer
+        assert ds.SoftwareVersions == ModelId
+
+        assert len(ds.StructureSetROISequence) == len(StructureNames)
+
+        for i, item in enumerate(StructureNames):
+            assert ds.StructureSetROISequence[i].ROINumber == i + 1
+            assert ds.StructureSetROISequence[i].ROIName == item
+            assert Interpreter in ds.RTROIObservationsSequence[i].ROIInterpreter
+            assert ds.RTROIObservationsSequence[i].RTROIInterpretedType == ('' if ROIInterpretedTypes[i] == 'None' else ROIInterpretedTypes[i])
+
+        assert len(ds.ROIContourSequence) == len(StructureNames)
+
+        for i, item in enumerate(StructureNames):
+            assert ds.ROIContourSequence[i].ReferencedROINumber == i + 1
+            assert ds.ROIContourSequence[i].ROIDisplayColor == _parse_rgb(StructureColors[i])
+
+def _parse_rgb(rgb: str) -> List[int]:
+    """
+    Convert the string representation of RGB color to an int list
+    :param rgb: Color string
+    :return: List of [R, G, B] components.
+    """
+    return [int(rgb[i:i+2], 16) for i in (0, 2, 4)]