Repository: Quasars/orange-spectroscopy
Pull request #792: [FIX] NeaImageGSF fix: works without channel in filename
Author: login: ngergihun
Created: 2025-02-25T15:57:44Z | Merged: 2025-03-18T11:13:16Z

Previously the NeaImageGSF reader crashed if the filename did not contain the proper data channel string pattern. With the fix, instead of crashing, it reads the file and considers it a Topography type measurement.


Diff:
diff --git a/orangecontrib/spectroscopy/io/neaspec.py b/orangecontrib/spectroscopy/io/neaspec.py
index 401cf9c73..b28d2e8d2 100644
--- a/orangecontrib/spectroscopy/io/neaspec.py
+++ b/orangecontrib/spectroscopy/io/neaspec.py
@@ -345,25 +345,34 @@ class NeaImageGSF(FileFormat, SpectralFileFormat):
     EXTENSIONS = (".gsf",)
     DESCRIPTION = 'NeaSPEC single image'
 
-    def read_spectra(self):
+    @staticmethod
+    def detect_signal_type(filename):
 
         channel_strings = ['M(.?)A', 'M(.?)P', 'O(.?)A', 'O(.?)P', 'Z C', 'Z raw']
+        channel_name = None
+
         for pattern in channel_strings:
-            if re.search(pattern, self.filename) is not None:
-                channel_name = re.search(pattern, self.filename)[0]
+            if re.search(pattern, filename) is not None:
+                channel_name = re.search(pattern, filename)[0]
 
-        if 'P' in channel_name:
+        if channel_name is None:
+            signal_type = 'Topography'
+        elif 'P' in channel_name:
             signal_type = "Phase"
         elif 'A' in channel_name:
             signal_type = "Amplitude"
-        elif 'Z' in channel_name:
-            signal_type = "Topography"
         else:
-            signal_type = "None"
+            signal_type = "Topography"
+
+        return signal_type
+
+    def read_spectra(self):
 
         X, XRr, YRr = reader_gsf(self.filename)
         features, final_data, meta_data = _spectra_from_image(X, np.array([1]), XRr, YRr)
 
+        signal_type = self.detect_signal_type(self.filename)
+
         meta_data.attributes["measurement.signaltype"] = signal_type
         # TODO add all the meta info here from the Gwyddion header
 
diff --git a/orangecontrib/spectroscopy/tests/test_readers.py b/orangecontrib/spectroscopy/tests/test_readers.py
index 3e926ff1a..7b112e343 100644
--- a/orangecontrib/spectroscopy/tests/test_readers.py
+++ b/orangecontrib/spectroscopy/tests/test_readers.py
@@ -9,7 +9,7 @@
 from Orange.tests import named_file
 from Orange.widgets.data.owfile import OWFile
 from orangecontrib.spectroscopy.data import getx, build_spec_table
-from orangecontrib.spectroscopy.io.neaspec import NeaReader, NeaReaderGSF, NeaReaderMultiChannel
+from orangecontrib.spectroscopy.io.neaspec import NeaReader, NeaReaderGSF, NeaReaderMultiChannel, NeaImageGSF
 from orangecontrib.spectroscopy.io.util import ConstantBytesVisibleImage
 from orangecontrib.spectroscopy.io.soleil import SelectColumnReader, HDF5Reader_HERMES
 from orangecontrib.spectroscopy.preprocess import features_with_interpolation
@@ -468,6 +468,57 @@ def test_read(self):
         self.assertEqual(data.attributes['Calculated Datapoint Spacing (Δx)'][0], '[cm]')
         check_attributes(data)
 
+class TestNeaImageGSF(unittest.TestCase):
+
+    def test_type_detect(self):
+        # For Phase
+        fn = 'NeaReaderGSF_test O2P raw.gsf'
+        signaltype = NeaImageGSF.detect_signal_type(fn)
+        self.assertEqual(signaltype, 'Phase')
+        # For Amplitude
+        fn = 'NeaReaderGSF_test O2A raw.gsf'
+        signaltype = NeaImageGSF.detect_signal_type(fn)
+        self.assertEqual(signaltype, 'Amplitude')
+        # Topography
+        fn = 'NeaReaderGSF_test Z raw.gsf'
+        signaltype = NeaImageGSF.detect_signal_type(fn)
+        self.assertEqual(signaltype, 'Topography')
+        # No correct channelname
+        fn = 'NeaReaderGSF_test.gsf'
+        signaltype = NeaImageGSF.detect_signal_type(fn)
+        self.assertEqual(signaltype, 'Topography')
+        # For backward scan naming
+        fn = 'NeaReaderGSF_test R-Z raw.gsf'
+        signaltype = NeaImageGSF.detect_signal_type(fn)
+        self.assertEqual(signaltype, 'Topography')
+        # Backward name amplitude
+        fn = 'NeaReaderGSF_test R-O2A raw.gsf'
+        signaltype = NeaImageGSF.detect_signal_type(fn)
+        self.assertEqual(signaltype, 'Amplitude')
+        # total bullshit
+        fn = 'mittudomenmiaezanev asdha 2A dk raw.gsf'
+        signaltype = NeaImageGSF.detect_signal_type(fn)
+        self.assertEqual(signaltype, 'Topography')
+
+    def test_read(self):
+        fn = "whitelight.gsf"
+        absolute_filename = FileFormat.locate(fn, dataset_dirs)
+        data = NeaImageGSF(absolute_filename).read()
+        self.assertEqual(data.attributes["measurement.signaltype"],'Topography')
+        self.assertEqual(data.X.shape, (20000, 1))
+        # check some pixel vaules
+        self.assertAlmostEqual(data.X[235,0], 1.2788502, 7)
+        np.testing.assert_almost_equal(data.metas[235],
+                                       [53.2443, 30.6984], decimal=3)
+
+        self.assertAlmostEqual(data.X[1235,0], 1.2770579, 7)
+        np.testing.assert_almost_equal(data.metas[1235],
+                                       [53.2443, 30.6484], decimal=3)
+
+        self.assertAlmostEqual(data.X[11235,0], 1.2476133, 7)
+        np.testing.assert_almost_equal(data.metas[11235],
+                                       [53.2443, 30.1484], decimal=3)
+
 class TestNeaMultiChannel(unittest.TestCase):
     def test_read(self):
         fn = 'NeaReaderMultichannel_test/Test_Au_Fourier_Scan_Synchrotron.txt'
@@ -636,3 +687,6 @@ def test_read(self):
         self.assertEqual(len(data), 100)
         self.assertAlmostEqual(float(data.domain.attributes[0].name), 698)
         self.assertAlmostEqual(float(data.domain.attributes[-1].name), 700)
+
+if __name__ == "__main__":
+    unittest.main()


Reviews:
user: login: markotoplak
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-17T13:11:30Z
user: login: markotoplak
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-17T13:13:04Z
user: login: markotoplak
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-17T13:15:29Z
user: login: ngergihun
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T07:55:29Z
user: login: ngergihun
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T07:55:33Z
user: login: markotoplak
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-18T09:19:32Z
user: login: markotoplak
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-18T09:20:34Z

Review comments:
diff_hunk: @@ -351,15 +351,20 @@ def read_spectra(self):
         for pattern in channel_strings:
             if re.search(pattern, self.filename) is not None:
                 channel_name = re.search(pattern, self.filename)[0]
-
-        if 'P' in channel_name:
-            signal_type = "Phase"
-        elif 'A' in channel_name:
-            signal_type = "Amplitude"
-        elif 'Z' in channel_name:
-            signal_type = "Topography"
+            else:
+                channel_name = None
+
+        if type(channel_name)==str:
path: orangecontrib/spectroscopy/io/neaspec.py
user: login: markotoplak
body: `if channel_name is not None` (preferred here), alternatively also `isinstance(channel_name, str)`. `type` and comparisons are discouraged.
created_at: 2025-03-17T13:11:30Z
author_association: COLLABORATOR
diff_hunk: @@ -351,15 +351,20 @@ def read_spectra(self):
         for pattern in channel_strings:
             if re.search(pattern, self.filename) is not None:
                 channel_name = re.search(pattern, self.filename)[0]
-
-        if 'P' in channel_name:
-            signal_type = "Phase"
-        elif 'A' in channel_name:
-            signal_type = "Amplitude"
-        elif 'Z' in channel_name:
-            signal_type = "Topography"
+            else:
+                channel_name = None
+
+        if type(channel_name)==str:
path: orangecontrib/spectroscopy/io/neaspec.py
user: login: markotoplak
body: I would actually also flatten this out, you can start wtih `if channel_name is None`, say it is topography, and then continue with elifs.
created_at: 2025-03-17T13:13:04Z
author_association: COLLABORATOR
diff_hunk: @@ -351,15 +351,20 @@ def read_spectra(self):
         for pattern in channel_strings:
             if re.search(pattern, self.filename) is not None:
                 channel_name = re.search(pattern, self.filename)[0]
-
-        if 'P' in channel_name:
-            signal_type = "Phase"
-        elif 'A' in channel_name:
-            signal_type = "Amplitude"
-        elif 'Z' in channel_name:
-            signal_type = "Topography"
+            else:
+                channel_name = None
path: orangecontrib/spectroscopy/io/neaspec.py
user: login: markotoplak
body: So what does this mean? This sets channel_name to None if only the last pattern is not found. Is this the correct logic?

If it should be None if it was not found in any of the channel_strings, then `channel_name = None` should got in front of the for loop.
created_at: 2025-03-17T13:15:28Z
author_association: COLLABORATOR
diff_hunk: @@ -351,15 +351,20 @@ def read_spectra(self):
         for pattern in channel_strings:
             if re.search(pattern, self.filename) is not None:
                 channel_name = re.search(pattern, self.filename)[0]
-
-        if 'P' in channel_name:
-            signal_type = "Phase"
-        elif 'A' in channel_name:
-            signal_type = "Amplitude"
-        elif 'Z' in channel_name:
-            signal_type = "Topography"
+            else:
+                channel_name = None
path: orangecontrib/spectroscopy/io/neaspec.py
user: login: ngergihun
body: Yes, true it is the logic. Sure, if it is before the loop, it does not have to be set, just ones to None. 
created_at: 2025-03-18T07:55:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -351,15 +351,20 @@ def read_spectra(self):
         for pattern in channel_strings:
             if re.search(pattern, self.filename) is not None:
                 channel_name = re.search(pattern, self.filename)[0]
-
-        if 'P' in channel_name:
-            signal_type = "Phase"
-        elif 'A' in channel_name:
-            signal_type = "Amplitude"
-        elif 'Z' in channel_name:
-            signal_type = "Topography"
+            else:
+                channel_name = None
+
+        if type(channel_name)==str:
path: orangecontrib/spectroscopy/io/neaspec.py
user: login: ngergihun
body: Yes, makes sense, thx for the suggestion. Just a dumb question: Why are type comparisons not favored?
created_at: 2025-03-18T07:55:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -351,15 +351,20 @@ def read_spectra(self):
         for pattern in channel_strings:
             if re.search(pattern, self.filename) is not None:
                 channel_name = re.search(pattern, self.filename)[0]
-
-        if 'P' in channel_name:
-            signal_type = "Phase"
-        elif 'A' in channel_name:
-            signal_type = "Amplitude"
-        elif 'Z' in channel_name:
-            signal_type = "Topography"
+            else:
+                channel_name = None
+
+        if type(channel_name)==str:
path: orangecontrib/spectroscopy/io/neaspec.py
user: login: markotoplak
body: `type` does not handle subclasses, `isinstance` does.

It is also what I am used seeing in most codebases, and directly tells the reader that there is type comparison there without even needing to parse the expression.
created_at: 2025-03-18T09:19:32Z
author_association: COLLABORATOR
diff_hunk: @@ -348,13 +348,15 @@ class NeaImageGSF(FileFormat, SpectralFileFormat):
     def read_spectra(self):
 
         channel_strings = ['M(.?)A', 'M(.?)P', 'O(.?)A', 'O(.?)P', 'Z C', 'Z raw']
+        channel_name = None
+
         for pattern in channel_strings:
             if re.search(pattern, self.filename) is not None:
                 channel_name = re.search(pattern, self.filename)[0]
-            else:
-                channel_name = None
 
-        if type(channel_name)==str:
+        if channel_name is None:
+            signal_type = 'Topography'
+        else:
path: orangecontrib/spectroscopy/io/neaspec.py
user: login: markotoplak
body: Thiis else: and then if could be just elif.
created_at: 2025-03-18T09:20:34Z
author_association: COLLABORATOR

PR comments:
user: login: markotoplak
created_at: 2025-03-18T09:36:11Z
body: Thanks for the fixes, now it seems simpler. But I see that the behavior of this functionality can be quite complex (even your first commit had an error). So I suggest having tests. For that:

1) Make this a separate function, perhaps `detect_signal_type(filename)`.
2) Test it well, with carefully made-up or real filenames.

The tests then define what the behavior should be (and will be when someone edits the code further).

![If you like it, put a test on it](https://wadetregaskis.com/wp-content/uploads/2022/03/If-you-liked-it-you-shoulda-put-a-test-on-it.jpg)
author_association: COLLABORATOR
user: login: ngergihun
created_at: 2025-03-18T09:48:17Z
body: Hi Marko! Sure, should the test run with the Reader tests? Or should the function go to io utils and the test to the corresponding tests?
author_association: CONTRIBUTOR
user: login: markotoplak
created_at: 2025-03-18T09:53:47Z
body: With the reader. That function should also be part of the reader class.
author_association: COLLABORATOR
user: login: ngergihun
created_at: 2025-03-18T10:05:32Z
body: OK. In the meanwhile, I realized that we don't even have a test for this reader at all.
author_association: CONTRIBUTOR
user: login: markotoplak
created_at: 2025-03-18T11:13:08Z
body: Looks good, thanks!
author_association: COLLABORATOR