From 366b9071f80ae148ab7f32de4347da9579cb136e Mon Sep 17 00:00:00 2001
From: Oleksii Turkot <oleksii.turkot@xfel.eu>
Date: Tue, 22 Jun 2021 14:49:05 +0100
Subject: [PATCH 1/9] Bring in mask conversion code from EXtra-xwiz

https://git.xfel.eu/gitlab/dataAnalysis/crystfel_spb_workflow/tree/dev/extra_xwiz/mask_converter
Commit ddf45d59f03b7ff2ba7768e70c438baa43e335b5
---
 extra_geom/mask.py | 489 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 489 insertions(+)
 create mode 100644 extra_geom/mask.py

diff --git a/extra_geom/mask.py b/extra_geom/mask.py
new file mode 100644
index 00000000..35e4b47f
--- /dev/null
+++ b/extra_geom/mask.py
@@ -0,0 +1,489 @@
+"""Convert between
+"""
+
+import os
+import re
+import warnings
+
+import h5py
+import numpy as np
+
+
+def delta_method(mask):
+    """
+    Generalized delta-method algorithm to decompose two-dimensional mask
+    (boolean numpy array) into rectangles.
+
+    Based on paper:
+    http://library.utia.cas.cz/separaty/2012/ZOI
+    /suk-rectangular%20decomposition%20of%20binary%20images.pdf
+
+    Args:
+        mask (2D np.array, dtype=bool): input mask.
+
+    Raises:
+        ValueError: mask is not a 2D boolean numpy array.
+
+    Returns:
+        list: list of rectangles that represent masked regionsin in the form:
+            [((x_min, x_max),(y_min, y_max)), ...]
+    """
+
+    # Check input:
+    if (mask.ndim != 2
+            or mask.dtype != bool):
+        raise ValueError(f"Expected input - 2D boolean numpy array.")
+
+    A = np.copy(mask)
+    res = []
+
+    i_y = 0
+    while not np.array_equiv(A, False):
+
+        assert i_y < A.shape[0], (
+            "Decomposition GDM: algorithm did not convert the whole matrix.")
+
+        x_min = x_max = y_min = y_max = -1
+        for i_x in range(A.shape[1]):
+            if x_min < 0 and A[i_y, i_x]:
+                x_min = i_x
+                y_min = i_y
+                if i_x == A.shape[1] - 1:
+                    x_max = A.shape[1]
+            elif x_min >= 0 and not A[i_y, i_x]:
+                x_max = i_x
+            elif x_min >= 0 and i_x == A.shape[1] - 1:
+                x_max = A.shape[1]
+
+            if x_max >= 0:
+                sx = slice(x_min, x_max)
+                for i_y2 in range(i_y + 1, A.shape[0]):
+                    if not np.array_equal(A[i_y2, sx], A[i_y, sx]):
+                        y_max = i_y2
+                        break
+                else:
+                    y_max = A.shape[0]
+                res.append(((x_min, x_max), (y_min, y_max)))
+                A[y_min:y_max, sx] = False
+                x_min = x_max = y_min = y_max = -1
+
+        i_y += 1
+
+    return res
+
+
+class MaskConverter:
+
+    modes_avail = ('hd52geom', 'geom2hd5')
+    write_modes = ('replace', 'add')
+
+    def __init__(self, hd5file, geofile, run_mode, write_mode,
+                 hd5path, hd5entry, detector, data_type, invert):
+        """
+        Construct a mask converter with provided parameters.
+
+        Args:
+            hd5file (string): relative or absolute path to the HD5 file.
+            geofile (string): relative or absolute path to the geometry file.
+            run_mode (string): mode of the mask converter operation.
+            write_mode (string): mode of writing to the existing file.
+            hd5path (string): path to the mask in HD5 file.
+            hd5entry (int): entry number of the mask in HD5 file.
+            detector (string): name of the detector.
+            data_type (string): type of the detector data.
+            invert (bool): invert the mask after reading from of before
+                writing to the HD5 file.
+
+        Raises:
+            KeyError: unexpected run mode.
+            KeyError: unexpected write mode.
+            KeyError: detector name missing in the detector_info keys.
+            KeyError: data type missing in detector_info for specified
+                detector.
+        """
+
+        self._hd5file = hd5file
+        self._hd5path = hd5path
+        self._hd5entry = hd5entry
+        self._geofile = geofile
+        self._run_mode = run_mode
+        self._write_mode = write_mode
+        self._detector = detector
+        self._data_type = data_type
+        self._invert = invert
+
+        if self._run_mode not in self.modes_avail:
+            raise KeyError(
+                f"Unknown MaskConverter run mode: {self._run_mode}.")
+        if self._write_mode not in self.write_modes:
+            raise KeyError(
+                f"Unknown MaskConverter write mode: {self._write_mode}.")
+        if self._detector not in di.detector_info.keys():
+            raise KeyError(
+                f"Unknown detector: {self._detector}.")
+        if self._data_type not in di.detector_info[self._detector].keys():
+            raise KeyError(
+                f"Missing info on '{self._data_type}' data type for "
+                f"detector {self._detector}.")
+
+        self._det_info = di.detector_info[self._detector][self._data_type]
+
+        self._read_mask()
+
+    @property
+    def mask(self):
+        """
+        Provides a copy of the detector mask.
+
+        Returns:
+            np.array: Detector mask as a 2D boolean numpy array.
+        """
+        return np.copy(self.__mask)
+
+    def convert(self):
+        """
+        Convert detector mask and write to the output file.
+        """
+        self._convert_mask()
+        self._write_mask()
+
+    def _read_mask_hd5(self):
+        """
+        Read mask from the HD5 file to boolean numpy array.
+
+        Returns:
+            np.array: Detector mask as a 2D boolean numpy array.
+        """
+
+        res_mask = None
+        mask_shape = self._det_info['shape']
+
+        with h5py.File(self._hd5file, 'r') as f_hd5:
+            hd5mask = f_hd5.get(self._hd5path)
+
+            # Check shape of the HDF5 mask
+            mu.check_hd5mask(hd5mask, mask_shape, self._hd5entry)
+
+            # Remove 'n_data' dimension, convert to np.array
+            if hd5mask.ndim > len(mask_shape):
+                hd5mask = hd5mask[self._hd5entry]
+            else:
+                hd5mask = np.array(hd5mask)
+
+        # Convert mask values to True (masked) and False
+        res_mask = self._det_info['read_mask'](hd5mask)
+
+        if self._invert:
+            res_mask = np.logical_not(res_mask)
+
+        return res_mask
+
+    def _read_mask_geo(self):
+        """
+        Read mask from the geometry file to the dictionary of rectangles
+        (in the same format as in the geometry file).
+
+        Raises:
+            ValueError: mask panel description not expected for the
+                specified data type.
+            ValueError: mask panel description does not suit specified
+                detector and data type.
+
+        Returns:
+            dict: dictionary of rectangles (in the same format as in the
+            geometry file) representing masked regions.
+        """
+
+        res_dict = {}
+        bad_dict = {}
+
+        with open(self._geofile, 'r') as f_geo:
+            for line in f_geo:
+                # To ignore comments:
+                line = line.partition(';')[0].rstrip()
+
+                m_obj = re.search(r"bad_(.+)/(\S+)\s*=\s*(\S+)", line)
+                if m_obj is not None:
+                    area, var, val = m_obj.groups()
+                    if area not in bad_dict.keys():
+                        bad_dict[area] = {
+                            'min_fs': -1,
+                            'max_fs': -1,
+                            'min_ss': -1,
+                            'max_ss': -1,
+                            'panel': 'all'
+                        }
+                    if var == 'panel':
+                        bad_dict[area][var] = val
+
+                        # Check whether panel value is expected
+                        if len(self._det_info['shape']) != 3:
+                            raise ValueError(
+                                f"Panel description ({val}) not expected "
+                                f"for {self._data_type} data.")
+
+                        # Check <val> to be suitable description
+                        # of a panel and asic
+                        m_obj = re.match(r"(p\d+)(a\d+)", val)
+                        if (m_obj is None
+                            or m_obj.group(1) not in
+                                self._det_info['panel_names']
+                            or m_obj.group(2) not in
+                                self._det_info['asic_names']):
+                            raise ValueError(
+                                f"Not suitable panel description: {val}.")
+                    elif var in bad_dict[area].keys():
+                        bad_dict[area][var] = int(val)
+                    else:
+                        warnings.warn(
+                            f"Geometry file - unsupported mask variable: "
+                            f"{var} in bad_{area}.")
+
+        # Check if rectangle information is complete
+        i = 0
+        for area in bad_dict.keys():
+            if all([
+                bad_dict[area]['min_fs'] >= 0,
+                bad_dict[area]['max_fs'] >= 0,
+                bad_dict[area]['min_ss'] >= 0,
+                bad_dict[area]['max_ss'] >= 0
+            ]):
+                res_dict[i] = bad_dict[area]
+                i += 1
+            else:
+                warnings.warn(
+                    f"Geometry file - incomplete information for bad_{area}.")
+
+        return res_dict
+
+    def _read_mask(self):
+        """
+        Read mask from the HD5 or geometry file, depending on mode.
+        """
+
+        self.__mask = np.zeros(self._det_info['shape'], dtype=bool)
+        self.__rect = {}
+
+        if self._run_mode == "hd52geom":
+            self.__mask = self._read_mask_hd5()
+
+            # Reduce mask in case of write option 'add'
+            if self._write_mode == 'add' and os.path.exists(self._geofile):
+                self.__rect = self._read_mask_geo()
+                reduce_mask = self._convert_rectd2nparr()
+                self.__mask = np.logical_and(self.__mask,
+                                             np.logical_not(reduce_mask))
+                self.__rect = {}
+
+        elif self._run_mode == "geom2hd5":
+            self.__rect = self._read_mask_geo()
+
+            # Read also mask from HD5 in case of write option 'add'
+            if (self._write_mode == 'add'
+                    and os.path.exists(self._hd5file)):
+                self.__mask = self._read_mask_hd5()
+
+    def _convert_nparr2rectd(self):
+        """
+        Convert mask from the 2D boolean numpy array to the dictionary
+        of rectangles (in the same format as in the geometry file).
+
+        Returns:
+            dict: Dictionary of rectangles (in the same format as in the
+            geometry file) representing masked regions.
+        """
+
+        res_dict = {}
+
+        # First check for regions to be excluded in all panels:
+        if self.__mask.ndim == 3:
+            panel_all = self.mask[0]
+            for i in range(1, self.__mask.shape[0]):
+                panel_all = np.logical_and(panel_all, self.__mask[i])
+        else:
+            panel_all = self.mask
+
+        is_panel_all_empty = np.array_equiv(panel_all, False)
+        if not is_panel_all_empty:
+            res_dict.update(mu.rect2dict(delta_method(panel_all), 'all'))
+
+        if self.__mask.ndim == 3:
+            panels = self._det_info['panel_names']
+            asics = self._det_info['asic_names']
+            asic_range = self._det_info['asic_range']
+
+            # Loop over all panels:
+            for i in range(len(panels)):
+                if is_panel_all_empty:
+                    panel_i = self.__mask[i]
+                else:
+                    panel_i = np.copy(self.__mask[i])
+                    panel_i = np.logical_and(panel_i,
+                                             np.logical_not(panel_all))
+
+                # Loop over all asics in the panel:
+                for j in range(len(asics)):
+                    asic_j = np.zeros(panel_i.shape, dtype=bool)
+                    asic_j[asic_range[i][j]] = True
+                    panel_i_asic_j = np.logical_and(panel_i, asic_j)
+                    res_dict.update(
+                        mu.rect2dict(delta_method(panel_i_asic_j),
+                                     f"{panels[i]}{asics[j]}"))
+
+        return res_dict
+
+    def _convert_rectd2nparr(self):
+        """
+        Convert mask from the dictionary of rectangles (same format as
+        in the geometry file) to the 2D boolean numpy array.
+
+        Returns:
+            np.array: Detector mask as a 2D boolean numpy array.
+        """
+
+        shape = self._det_info['shape']
+        res_mask = np.zeros(shape, dtype=bool)
+
+        for area in self.__rect.keys():
+            slice_ss = slice(self.__rect[area]['min_ss'],
+                             self.__rect[area]['max_ss'] + 1)
+            slice_fs = slice(self.__rect[area]['min_fs'],
+                             self.__rect[area]['max_fs'] + 1)
+            if self.__rect[area]['panel'] == 'all':
+                if len(shape) == 3:
+                    res_mask[:, slice_ss, slice_fs] = True
+                else:
+                    res_mask[slice_ss, slice_fs] = True
+            else:
+                assert len(shape) == 3, (
+                    "Convert rectd2nparr: mask has to be dimensions 3 to "
+                    "apply rectangles per panel.")
+
+                panel, asic = re.match(
+                    r"(p\d+)(a\d+)", self.__rect[area]['panel']).groups()
+                panel_n = self._det_info['panel_names'].index(panel)
+                asic_n = self._det_info['asic_names'].index(asic)
+                asic_range = self._det_info['asic_range'][panel_n][asic_n]
+
+                slice_ss_in_asic = slice(
+                    max(slice_ss.start, asic_range[0].start),
+                    min(slice_ss.stop, asic_range[0].stop))
+                slice_fs_in_asic = slice(
+                    max(slice_fs.start, asic_range[1].start),
+                    min(slice_fs.stop, asic_range[1].stop))
+
+                res_mask[panel_n, slice_ss_in_asic, slice_fs_in_asic] = True
+
+        return res_mask
+
+    def _convert_mask(self):
+        """
+        Convert the mask, depending on mode.
+        """
+        if self._run_mode == "hd52geom":
+            self.__rect = self._convert_nparr2rectd()
+        elif self._run_mode == "geom2hd5":
+            rect_mask = self._convert_rectd2nparr()
+            self.__mask = np.logical_or(self.__mask, rect_mask)
+
+    def _write_mask_hd5(self):
+        """
+        Write converted mask to the HD5 file.
+        """
+
+        mask_shape = self.__mask.shape
+        mask_tmp = self.__mask
+        if self._invert:
+            mask_tmp = np.logical_not(mask_tmp)
+        mask_to_write = self._det_info['write_mask'](mask_tmp)
+
+        with h5py.File(self._hd5file, 'a') as f_hd5:
+            if self._hd5path in f_hd5:
+                hd5mask = f_hd5[self._hd5path]
+
+                # Check shape of the existing HDF5 mask
+                mu.check_hd5mask(hd5mask, mask_shape, self._hd5entry)
+
+                if hd5mask.ndim > len(mask_shape):
+                    hd5mask[self._hd5entry] = mask_to_write
+                else:
+                    hd5mask[...] = mask_to_write
+            else:
+                f_hd5.create_dataset(self._hd5path, data=mask_to_write)
+
+    def _write_mask_geo(self):
+        """
+        Write converted mask to the geometry file.
+        """
+
+        text_before = text_after = []
+        n_area_start = 0
+
+        # Store and process content of the existing geometry file
+        if os.path.exists(self._geofile):
+            with open(self._geofile, 'r') as f_geo:
+                contents = f_geo.readlines()
+
+            idx_write = len(contents)
+            for i, line in enumerate(contents):
+
+                # Search for the mask information
+                if "bad_" in line:
+                    idx_write = i + 1
+
+                    # Comment existing mask for the 'replace' mode
+                    if all([
+                        self._write_mode == 'replace',
+                        "bad_" in line.partition(';')[0]
+                    ]):
+                        contents[i] = "; " + line
+
+                    # Check existing numbered 'bad_area's
+                    sa_obj = re.search(r"bad_area(\d+)/", line)
+                    if sa_obj is not None:
+                        sa_num = int(sa_obj.group(1))
+                        n_area_start = max(n_area_start, sa_num + 1)
+
+                # Search for the 'rigid_group'
+                # (to put mask before it in case no 'bad_' area found)
+                if all([
+                    idx_write == len(contents),
+                    "rigid_group" in line
+                ]):
+                    idx_write = i
+
+            text_before = contents[:idx_write]
+            text_after = contents[idx_write:]
+
+        # Format mask as a list of text lines
+        text_mask = []
+        if (text_before
+                and text_before[-1].strip() != ""):
+            text_mask.append("\n")
+
+        for area in self.__rect:
+            for dim in ['min_fs', 'max_fs', 'min_ss', 'max_ss']:
+                text_mask.append(
+                    f"bad_area{n_area_start + area}/{dim} = "
+                    f"{self.__rect[area][dim]}\n")
+            if self.__rect[area]['panel'] != 'all':
+                text_mask.append(
+                    f"bad_area{n_area_start + area}/panel = "
+                    f"{self.__rect[area]['panel']}\n")
+            text_mask.append("\n")
+
+        text_write = "".join(text_before + text_mask + text_after)
+
+        with open(self._geofile, 'w') as f_geo:
+            f_geo.write(text_write)
+
+    def _write_mask(self):
+        """
+        Write converted mask to the HD5 or geometry file, depending on mode.
+        """
+        # Write the mask depending on mode.
+        if self._run_mode == "hd52geom":
+            self._write_mask_geo()
+        elif self._run_mode == "geom2hd5":
+            self._write_mask_hd5()
+

From fc4c37bd36297e39831fec26e5f677d0a5a64c9a Mon Sep 17 00:00:00 2001
From: Thomas Kluyver <thomas@kluyver.me.uk>
Date: Wed, 23 Jun 2021 14:28:24 +0100
Subject: [PATCH 2/9] Refactoring mask conversion code

---
 extra_geom/crystfel_fmt.py |  43 +++-
 extra_geom/mask.py         | 508 +++++++++++++------------------------
 2 files changed, 214 insertions(+), 337 deletions(-)

diff --git a/extra_geom/crystfel_fmt.py b/extra_geom/crystfel_fmt.py
index 540875d8..cbfb8af4 100644
--- a/extra_geom/crystfel_fmt.py
+++ b/extra_geom/crystfel_fmt.py
@@ -1,4 +1,4 @@
-"""Write geometry in CrystFEL format.
+"""Write & process geometry in CrystFEL format.
 """
 from itertools import product
 
@@ -190,3 +190,44 @@ def get_rigid_groups(geom, nquads=4):
         ]
 
     return '\n'.join(lines)
+
+
+def panel_modno(panel_info, pname):
+    """Find the module number (counting from 0) of the given panel
+
+    Returns None if the geometry describes a 2D input array rather than a 3D
+    array with a stack of modules.
+    """
+    dims = panel_info['dim_structure']
+    ix_dims = [i for i in dims if isinstance(i, int)]
+    if len(ix_dims) > 1:
+        raise ValueError(f"Too many index dimensions for {pname}: {dims}")
+
+    return ix_dims[0] if ix_dims else None
+
+
+def data_shape(panels_dict):
+    """Make a 2- or 3-tuple representing the expected data shape
+
+    panels_dict is the structure given by cfelpytuils::
+
+        load_crystfel_geometry(f)['panels']
+    """
+    nmodules = slow_scan_px = fast_scan_px = 0
+
+    for pname, info in panels_dict.items():
+        modno = panel_modno(info, pname)
+        if modno is not None:
+            nmodules = max(nmodules, modno + 1)
+        slow_scan_px = max(slow_scan_px, info['max_ss'] + 1)
+        fast_scan_px = max(fast_scan_px, info['max_fs'] + 1)
+
+    if nmodules == 0:
+        shape = (slow_scan_px, fast_scan_px)
+    else:
+        shape = (nmodules, slow_scan_px, fast_scan_px)
+
+    if min(shape) <= 0:
+        raise ValueError("Could not find detector data shape from .geom file")
+
+    return shape
diff --git a/extra_geom/mask.py b/extra_geom/mask.py
index 35e4b47f..e01297c4 100644
--- a/extra_geom/mask.py
+++ b/extra_geom/mask.py
@@ -1,13 +1,15 @@
-"""Convert between
+"""Convert between masks as arrays and as a set of rectangular regions
 """
 
 import os
 import re
 import warnings
 
-import h5py
+from cfelpyutils.crystfel_utils import load_crystfel_geometry
 import numpy as np
 
+from . import crystfel_fmt
+
 
 def delta_method(mask):
     """
@@ -71,357 +73,193 @@ def delta_method(mask):
 
     return res
 
+class RegionRect:
+    """One masked region within a 3D array of data (modules stacked)
 
-class MaskConverter:
+    This can apply to a single module (modno=5) or all modules (modno=None).
+    """
+    def __init__(self, modno, start_ss, stop_ss, start_fs, stop_fs):
+        self.modno = modno  # None -> applies to all modules
+        self.start_ss = start_ss
+        self.stop_ss = stop_ss
+        self.start_fs = start_fs
+        self.stop_fs = stop_fs
 
-    modes_avail = ('hd52geom', 'geom2hd5')
-    write_modes = ('replace', 'add')
+    def _tuple(self):
+        return self.modno, self.start_ss, self.stop_ss, self.start_fs, self.stop_fs
 
-    def __init__(self, hd5file, geofile, run_mode, write_mode,
-                 hd5path, hd5entry, detector, data_type, invert):
-        """
-        Construct a mask converter with provided parameters.
-
-        Args:
-            hd5file (string): relative or absolute path to the HD5 file.
-            geofile (string): relative or absolute path to the geometry file.
-            run_mode (string): mode of the mask converter operation.
-            write_mode (string): mode of writing to the existing file.
-            hd5path (string): path to the mask in HD5 file.
-            hd5entry (int): entry number of the mask in HD5 file.
-            detector (string): name of the detector.
-            data_type (string): type of the detector data.
-            invert (bool): invert the mask after reading from of before
-                writing to the HD5 file.
-
-        Raises:
-            KeyError: unexpected run mode.
-            KeyError: unexpected write mode.
-            KeyError: detector name missing in the detector_info keys.
-            KeyError: data type missing in detector_info for specified
-                detector.
-        """
+    def __hash__(self):
+        return hash(self._tuple())
 
-        self._hd5file = hd5file
-        self._hd5path = hd5path
-        self._hd5entry = hd5entry
-        self._geofile = geofile
-        self._run_mode = run_mode
-        self._write_mode = write_mode
-        self._detector = detector
-        self._data_type = data_type
-        self._invert = invert
-
-        if self._run_mode not in self.modes_avail:
-            raise KeyError(
-                f"Unknown MaskConverter run mode: {self._run_mode}.")
-        if self._write_mode not in self.write_modes:
-            raise KeyError(
-                f"Unknown MaskConverter write mode: {self._write_mode}.")
-        if self._detector not in di.detector_info.keys():
-            raise KeyError(
-                f"Unknown detector: {self._detector}.")
-        if self._data_type not in di.detector_info[self._detector].keys():
-            raise KeyError(
-                f"Missing info on '{self._data_type}' data type for "
-                f"detector {self._detector}.")
-
-        self._det_info = di.detector_info[self._detector][self._data_type]
-
-        self._read_mask()
+    def __eq__(self, other):
+        return isinstance(other, RegionRect) and self._tuple() == other._tuple()
 
     @property
-    def mask(self):
-        """
-        Provides a copy of the detector mask.
+    def array_slice(self):
+        """Get a tuple to use for slicing an array"""
+        mod = np.s_[:] if (self.modno is None) else self.modno
+        return np.s_[mod, self.start_ss:self.stop_ss, self.start_fs:self.stop_fs]
 
-        Returns:
-            np.array: Detector mask as a 2D boolean numpy array.
-        """
-        return np.copy(self.__mask)
+    def intersection(self, other: 'RegionRect'):
+        """Find the intersection of this and another RegionRect
 
-    def convert(self):
+        Returns (overlap, intersection). The first value is True if the two
+        regions overlap, and the second is a RegionRect for their intersection.
         """
-        Convert detector mask and write to the output file.
+        modno = self.modno if (self.modno is not None) else other.modno
+        if other.modno in (modno, None):
+            # On the same module, or one of self/other is for all modules
+            r = RegionRect(
+                modno,
+                max(self.start_ss, other.start_ss),
+                min(self.stop_ss, other.stop_ss),
+                max(self.start_fs, other.start_fs),
+                min(self.stop_fs, other.stop_fs),
+            )
+            overlap = (r.start_ss < r.stop_ss) and (r.start_fs < r.stop_fs)
+            return overlap, r
+
+        return False, RegionRect(0, 0, 0, 0, 0)
+
+
+class MaskRegions:
+    """A set of rectangular regions masked in a stack of module images"""
+    def __init__(self, shape, regions=()):
+        assert len(shape) == 3
+        self.shape = shape
+        self.regions = regions  # [RegionRect]
+
+    @classmethod
+    def from_crystfel_geom(cls, filename):
+        """Read mask from a CrystFEL format geometry file.
         """
-        self._convert_mask()
-        self._write_mask()
-
-    def _read_mask_hd5(self):
-        """
-        Read mask from the HD5 file to boolean numpy array.
-
-        Returns:
-            np.array: Detector mask as a 2D boolean numpy array.
-        """
-
-        res_mask = None
-        mask_shape = self._det_info['shape']
-
-        with h5py.File(self._hd5file, 'r') as f_hd5:
-            hd5mask = f_hd5.get(self._hd5path)
-
-            # Check shape of the HDF5 mask
-            mu.check_hd5mask(hd5mask, mask_shape, self._hd5entry)
-
-            # Remove 'n_data' dimension, convert to np.array
-            if hd5mask.ndim > len(mask_shape):
-                hd5mask = hd5mask[self._hd5entry]
+        geom_dict = load_crystfel_geometry(filename)
+        arr_shape = crystfel_fmt.data_shape(geom_dict['panels'])
+        if len(arr_shape) == 2:
+            arr_shape = (1,) + arr_shape
+
+        regions = []
+        for name, info in geom_dict['bad'].items():
+            if not info['is_fsss']:
+                # Regions can also be defined with x & y coordinates, but these
+                # are not handled yet.
+                continue
+
+            min_ss, max_ss = info['min_ss'], info['max_ss']
+            min_fs, max_fs = info['min_fs'], info['max_fs']
+            if (max_ss < min_ss) or (max_fs < min_fs):
+                warnings.warn(
+                    f"Geometry file {filename} - incomplete information for {name}."
+                )
+            if 'panel' in info:
+                panel_info = geom_dict['panels'][info['panel']]
+                modno = crystfel_fmt.panel_modno(panel_info, info['panel'])
+                modno = modno or 0  # None -> 0
+
+                # Clip the region to the panel limits
+                min_ss = max(min_ss, panel_info['min_ss'])
+                max_ss = min(max_ss, panel_info['max_ss'])
+                min_fs = max(min_fs, panel_info['min_fs'])
+                max_fs = min(max_fs, panel_info['max_fs'])
             else:
-                hd5mask = np.array(hd5mask)
+                modno = None  # Applies to all panels
 
-        # Convert mask values to True (masked) and False
-        res_mask = self._det_info['read_mask'](hd5mask)
+            regions.append(RegionRect(
+                modno, min_ss, max_ss + 1, min_fs, max_fs + 1,
+            ))
 
-        if self._invert:
-            res_mask = np.logical_not(res_mask)
+        return cls(arr_shape, regions)
 
-        return res_mask
+    @classmethod
+    def from_mask_array(cls, arr):
+        """Convert mask from a 2D or 3D boolean numpy array to rectangular regions.
 
-    def _read_mask_geo(self):
-        """
-        Read mask from the geometry file to the dictionary of rectangles
-        (in the same format as in the geometry file).
-
-        Raises:
-            ValueError: mask panel description not expected for the
-                specified data type.
-            ValueError: mask panel description does not suit specified
-                detector and data type.
-
-        Returns:
-            dict: dictionary of rectangles (in the same format as in the
-            geometry file) representing masked regions.
+        The masked/selected regions will be where the array contains True.
         """
+        if arr.ndim not in {2, 3}:
+            raise TypeError(f"Array must be 2D or 3D (got {arr.ndim} dims)")
 
-        res_dict = {}
-        bad_dict = {}
-
-        with open(self._geofile, 'r') as f_geo:
-            for line in f_geo:
-                # To ignore comments:
-                line = line.partition(';')[0].rstrip()
-
-                m_obj = re.search(r"bad_(.+)/(\S+)\s*=\s*(\S+)", line)
-                if m_obj is not None:
-                    area, var, val = m_obj.groups()
-                    if area not in bad_dict.keys():
-                        bad_dict[area] = {
-                            'min_fs': -1,
-                            'max_fs': -1,
-                            'min_ss': -1,
-                            'max_ss': -1,
-                            'panel': 'all'
-                        }
-                    if var == 'panel':
-                        bad_dict[area][var] = val
-
-                        # Check whether panel value is expected
-                        if len(self._det_info['shape']) != 3:
-                            raise ValueError(
-                                f"Panel description ({val}) not expected "
-                                f"for {self._data_type} data.")
-
-                        # Check <val> to be suitable description
-                        # of a panel and asic
-                        m_obj = re.match(r"(p\d+)(a\d+)", val)
-                        if (m_obj is None
-                            or m_obj.group(1) not in
-                                self._det_info['panel_names']
-                            or m_obj.group(2) not in
-                                self._det_info['asic_names']):
-                            raise ValueError(
-                                f"Not suitable panel description: {val}.")
-                    elif var in bad_dict[area].keys():
-                        bad_dict[area][var] = int(val)
-                    else:
-                        warnings.warn(
-                            f"Geometry file - unsupported mask variable: "
-                            f"{var} in bad_{area}.")
-
-        # Check if rectangle information is complete
-        i = 0
-        for area in bad_dict.keys():
-            if all([
-                bad_dict[area]['min_fs'] >= 0,
-                bad_dict[area]['max_fs'] >= 0,
-                bad_dict[area]['min_ss'] >= 0,
-                bad_dict[area]['max_ss'] >= 0
-            ]):
-                res_dict[i] = bad_dict[area]
-                i += 1
-            else:
-                warnings.warn(
-                    f"Geometry file - incomplete information for bad_{area}.")
+        def find_regions(arr_2d, modno):
+            return [RegionRect(
+                modno, min_ss, max_ss + 1, min_fs, max_fs + 1
+            ) for ((min_fs, max_fs), (min_ss, max_ss)) in delta_method(arr_2d)]
 
-        return res_dict
+        if arr.ndim == 2:
+            return cls((1,) + arr.shape, find_regions(arr, modno=np.s_[:]))
 
-    def _read_mask(self):
-        """
-        Read mask from the HD5 or geometry file, depending on mode.
-        """
-
-        self.__mask = np.zeros(self._det_info['shape'], dtype=bool)
-        self.__rect = {}
+        # 3D array (modno, slow_scan, fast_scan)
 
-        if self._run_mode == "hd52geom":
-            self.__mask = self._read_mask_hd5()
+        # First check for regions to be excluded in all panels:
+        panel_all = np.logical_and.reduce(arr, axis=0)
+        regions = find_regions(panel_all, modno=np.s_[:])
+        is_panel_all_empty = not np.any(panel_all)
 
-            # Reduce mask in case of write option 'add'
-            if self._write_mode == 'add' and os.path.exists(self._geofile):
-                self.__rect = self._read_mask_geo()
-                reduce_mask = self._convert_rectd2nparr()
-                self.__mask = np.logical_and(self.__mask,
-                                             np.logical_not(reduce_mask))
-                self.__rect = {}
+        # Loop over all panels:
+        for i, panel_arr in enumerate(arr):
+            if not is_panel_all_empty:
+                panel_arr = np.copy(panel_arr) & ~panel_all
 
-        elif self._run_mode == "geom2hd5":
-            self.__rect = self._read_mask_geo()
+            regions.extend(find_regions(panel_arr, modno=i))
 
-            # Read also mask from HD5 in case of write option 'add'
-            if (self._write_mode == 'add'
-                    and os.path.exists(self._hd5file)):
-                self.__mask = self._read_mask_hd5()
+        return cls(arr.shape, regions)
 
-    def _convert_nparr2rectd(self):
-        """
-        Convert mask from the 2D boolean numpy array to the dictionary
-        of rectangles (in the same format as in the geometry file).
+    def to_mask_array(self):
+        """Convert the mask rectangles to a 3D boolean numpy array.
 
-        Returns:
-            dict: Dictionary of rectangles (in the same format as in the
-            geometry file) representing masked regions.
+        The selected regions will be True in the array.
         """
+        res_mask = np.zeros(self.shape, dtype=np.bool_)
 
-        res_dict = {}
-
-        # First check for regions to be excluded in all panels:
-        if self.__mask.ndim == 3:
-            panel_all = self.mask[0]
-            for i in range(1, self.__mask.shape[0]):
-                panel_all = np.logical_and(panel_all, self.__mask[i])
-        else:
-            panel_all = self.mask
-
-        is_panel_all_empty = np.array_equiv(panel_all, False)
-        if not is_panel_all_empty:
-            res_dict.update(mu.rect2dict(delta_method(panel_all), 'all'))
-
-        if self.__mask.ndim == 3:
-            panels = self._det_info['panel_names']
-            asics = self._det_info['asic_names']
-            asic_range = self._det_info['asic_range']
-
-            # Loop over all panels:
-            for i in range(len(panels)):
-                if is_panel_all_empty:
-                    panel_i = self.__mask[i]
-                else:
-                    panel_i = np.copy(self.__mask[i])
-                    panel_i = np.logical_and(panel_i,
-                                             np.logical_not(panel_all))
-
-                # Loop over all asics in the panel:
-                for j in range(len(asics)):
-                    asic_j = np.zeros(panel_i.shape, dtype=bool)
-                    asic_j[asic_range[i][j]] = True
-                    panel_i_asic_j = np.logical_and(panel_i, asic_j)
-                    res_dict.update(
-                        mu.rect2dict(delta_method(panel_i_asic_j),
-                                     f"{panels[i]}{asics[j]}"))
-
-        return res_dict
-
-    def _convert_rectd2nparr(self):
-        """
-        Convert mask from the dictionary of rectangles (same format as
-        in the geometry file) to the 2D boolean numpy array.
-
-        Returns:
-            np.array: Detector mask as a 2D boolean numpy array.
-        """
-
-        shape = self._det_info['shape']
-        res_mask = np.zeros(shape, dtype=bool)
-
-        for area in self.__rect.keys():
-            slice_ss = slice(self.__rect[area]['min_ss'],
-                             self.__rect[area]['max_ss'] + 1)
-            slice_fs = slice(self.__rect[area]['min_fs'],
-                             self.__rect[area]['max_fs'] + 1)
-            if self.__rect[area]['panel'] == 'all':
-                if len(shape) == 3:
-                    res_mask[:, slice_ss, slice_fs] = True
-                else:
-                    res_mask[slice_ss, slice_fs] = True
-            else:
-                assert len(shape) == 3, (
-                    "Convert rectd2nparr: mask has to be dimensions 3 to "
-                    "apply rectangles per panel.")
-
-                panel, asic = re.match(
-                    r"(p\d+)(a\d+)", self.__rect[area]['panel']).groups()
-                panel_n = self._det_info['panel_names'].index(panel)
-                asic_n = self._det_info['asic_names'].index(asic)
-                asic_range = self._det_info['asic_range'][panel_n][asic_n]
-
-                slice_ss_in_asic = slice(
-                    max(slice_ss.start, asic_range[0].start),
-                    min(slice_ss.stop, asic_range[0].stop))
-                slice_fs_in_asic = slice(
-                    max(slice_fs.start, asic_range[1].start),
-                    min(slice_fs.stop, asic_range[1].stop))
-
-                res_mask[panel_n, slice_ss_in_asic, slice_fs_in_asic] = True
+        for region in self.regions:
+            res_mask[region.array_slice] = True
 
         return res_mask
 
-    def _convert_mask(self):
-        """
-        Convert the mask, depending on mode.
-        """
-        if self._run_mode == "hd52geom":
-            self.__rect = self._convert_nparr2rectd()
-        elif self._run_mode == "geom2hd5":
-            rect_mask = self._convert_rectd2nparr()
-            self.__mask = np.logical_or(self.__mask, rect_mask)
+    def make_crystfel_bad_regions(self, panels_dict):
+        modno_to_panels = {}
+        for pname, pinfo in panels_dict:
+            modno = crystfel_fmt.panel_modno(pinfo, pname)
+            modno_to_panels.setdefault(modno, []).append((pname, pinfo))
 
-    def _write_mask_hd5(self):
-        """
-        Write converted mask to the HD5 file.
-        """
-
-        mask_shape = self.__mask.shape
-        mask_tmp = self.__mask
-        if self._invert:
-            mask_tmp = np.logical_not(mask_tmp)
-        mask_to_write = self._det_info['write_mask'](mask_tmp)
-
-        with h5py.File(self._hd5file, 'a') as f_hd5:
-            if self._hd5path in f_hd5:
-                hd5mask = f_hd5[self._hd5path]
+        def to_dict(region: RegionRect):
+            return {
+                'min_ss': region.start_ss, 'max_ss': region.stop_ss - 1,
+                'min_fs': region.start_fs, 'max_fs': region.stop_fs - 1,
+            }
 
-                # Check shape of the existing HDF5 mask
-                mu.check_hd5mask(hd5mask, mask_shape, self._hd5entry)
+        res = []
+        for mask_region in self.regions:
+            if mask_region.modno is None:
+                res.append(to_dict(mask_region))  # Mask for all panels
 
-                if hd5mask.ndim > len(mask_shape):
-                    hd5mask[self._hd5entry] = mask_to_write
-                else:
-                    hd5mask[...] = mask_to_write
             else:
-                f_hd5.create_dataset(self._hd5path, data=mask_to_write)
-
-    def _write_mask_geo(self):
-        """
-        Write converted mask to the geometry file.
+                for pname, pinfo in modno_to_panels[mask_region.modno]:
+                    panel_region = RegionRect(
+                        mask_region.modno,
+                        pinfo['min_ss'], pinfo['max_ss'] + 1,
+                        pinfo['min_fs'], pinfo['max_fs'] + 1,
+                    )
+                    overlap, matching = mask_region.intersection(panel_region)
+                    if overlap:
+                        d = to_dict(matching)
+                        d['panel'] = pname
+                        res.append(d)
+
+        return res
+
+    def write_crystfel_geom(self, filename, write_mode):
+        """Write the mask regions to a CrystFEL format geometry file.
+
+        *filename* needs to exist already; it will be read to identify the
+        panels which the mask applies to.
         """
 
         text_before = text_after = []
         n_area_start = 0
 
         # Store and process content of the existing geometry file
-        if os.path.exists(self._geofile):
-            with open(self._geofile, 'r') as f_geo:
+        if os.path.exists(filename):
+            with open(filename, 'r') as f_geo:
                 contents = f_geo.readlines()
 
             idx_write = len(contents)
@@ -433,7 +271,7 @@ def _write_mask_geo(self):
 
                     # Comment existing mask for the 'replace' mode
                     if all([
-                        self._write_mode == 'replace',
+                        write_mode == 'replace',
                         "bad_" in line.partition(';')[0]
                     ]):
                         contents[i] = "; " + line
@@ -457,33 +295,31 @@ def _write_mask_geo(self):
 
         # Format mask as a list of text lines
         text_mask = []
-        if (text_before
-                and text_before[-1].strip() != ""):
+        if text_before and text_before[-1].strip() != "":
             text_mask.append("\n")
 
-        for area in self.__rect:
-            for dim in ['min_fs', 'max_fs', 'min_ss', 'max_ss']:
-                text_mask.append(
-                    f"bad_area{n_area_start + area}/{dim} = "
-                    f"{self.__rect[area][dim]}\n")
-            if self.__rect[area]['panel'] != 'all':
-                text_mask.append(
-                    f"bad_area{n_area_start + area}/panel = "
-                    f"{self.__rect[area]['panel']}\n")
-            text_mask.append("\n")
+        geom_dict = load_crystfel_geometry(filename)
+
+        new_bad_regions = self.make_crystfel_bad_regions(geom_dict['panels'])
+        for i, bad_d in enumerate(new_bad_regions, start=n_area_start):
+            text_mask.extend([
+                f'bad_area{i}/{k} = {v}' for (k, v) in bad_d.items()
+            ] + ['\n'])
 
         text_write = "".join(text_before + text_mask + text_after)
 
-        with open(self._geofile, 'w') as f_geo:
+        with open(filename, 'w') as f_geo:
             f_geo.write(text_write)
 
-    def _write_mask(self):
-        """
-        Write converted mask to the HD5 or geometry file, depending on mode.
-        """
-        # Write the mask depending on mode.
-        if self._run_mode == "hd52geom":
-            self._write_mask_geo()
-        elif self._run_mode == "geom2hd5":
-            self._write_mask_hd5()
+    def __eq__(self, other):
+        if not (isinstance(other, MaskRegions) and (self.shape == other.shape)):
+            return False
+
+        if set(self.regions) == set(other.regions):
+            return True
+
+        # Two equivalent masks could have regions described in different ways.
+        # Converting them both to arrays is the easiest way to normalise them,
+        # though probably not the most efficient.
+        return np.array_equal(self.to_mask_array(), other.to_mask_array())
 

From fbeccf7e26ee5b96df44c9653b2f403e7c14cc4d Mon Sep 17 00:00:00 2001
From: Thomas Kluyver <thomas@kluyver.me.uk>
Date: Wed, 23 Jun 2021 17:36:04 +0100
Subject: [PATCH 3/9] Fix some leftover uses of np.s_[:]

---
 extra_geom/mask.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/extra_geom/mask.py b/extra_geom/mask.py
index e01297c4..ff9b4931 100644
--- a/extra_geom/mask.py
+++ b/extra_geom/mask.py
@@ -185,13 +185,13 @@ def find_regions(arr_2d, modno):
             ) for ((min_fs, max_fs), (min_ss, max_ss)) in delta_method(arr_2d)]
 
         if arr.ndim == 2:
-            return cls((1,) + arr.shape, find_regions(arr, modno=np.s_[:]))
+            return cls((1,) + arr.shape, find_regions(arr, modno=None))
 
         # 3D array (modno, slow_scan, fast_scan)
 
         # First check for regions to be excluded in all panels:
         panel_all = np.logical_and.reduce(arr, axis=0)
-        regions = find_regions(panel_all, modno=np.s_[:])
+        regions = find_regions(panel_all, modno=None)
         is_panel_all_empty = not np.any(panel_all)
 
         # Loop over all panels:

From 4dc579a78d7b7c921788027268e87edab08c94c7 Mon Sep 17 00:00:00 2001
From: Oleksii Turkot <oleksii.turkot@xfel.eu>
Date: Thu, 24 Jun 2021 11:22:49 +0100
Subject: [PATCH 4/9] Add test for delta_method()

---
 extra_geom/tests/test_mask.py | 22 ++++++++++++++++++++++
 1 file changed, 22 insertions(+)
 create mode 100644 extra_geom/tests/test_mask.py

diff --git a/extra_geom/tests/test_mask.py b/extra_geom/tests/test_mask.py
new file mode 100644
index 00000000..20317541
--- /dev/null
+++ b/extra_geom/tests/test_mask.py
@@ -0,0 +1,22 @@
+import numpy as np
+
+from extra_geom import mask
+
+def test_delta_method():
+    A = np.zeros((3, 4), dtype=bool)
+    A[1:2, 1:3] = True
+    B = np.copy(A)
+    assert mask.delta_method(A) == [((1, 3), (1, 2))], "Simple rectangle test."
+    assert np.array_equal(A, B), (
+        "Make sure function does not change the original matrix.")
+
+    C = np.zeros((5, 5), dtype=bool)
+    C[0:3, 1:3] = C[1:3, 0:4] = C[2:4, 2:5] = C[4:5, 1:4] = True
+    exp_res_C = [((1, 3), (0, 3)), ((0, 1), (1, 3)), ((3, 4), (1, 5)),
+                 ((4, 5), (2, 4)), ((2, 3), (3, 5)), ((1, 2), (4, 5))]
+    assert mask.delta_method(C) == exp_res_C, "Complex shape test."
+
+    D = np.zeros((5, 5), dtype=bool)
+    D[0:1, 0:1] = D[2:3, 2:3] = True
+    exp_res_D = [((0, 1), (0, 1)), ((2, 3), (2, 3))]
+    assert mask.delta_method(D) == exp_res_D, "Test on separate pixels."

From efea9450a316d41bad9f675cecb848e3f0cb8974 Mon Sep 17 00:00:00 2001
From: turkot <oleksii.turkot@xfel.eu>
Date: Fri, 25 Jun 2021 13:21:39 +0200
Subject: [PATCH 5/9] Fix few small bugs with writing a mask to the geometry
 file and using delta_method.

---
 extra_geom/mask.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/extra_geom/mask.py b/extra_geom/mask.py
index ff9b4931..08c4e7d2 100644
--- a/extra_geom/mask.py
+++ b/extra_geom/mask.py
@@ -181,7 +181,7 @@ def from_mask_array(cls, arr):
 
         def find_regions(arr_2d, modno):
             return [RegionRect(
-                modno, min_ss, max_ss + 1, min_fs, max_fs + 1
+                modno, min_ss, max_ss, min_fs, max_fs
             ) for ((min_fs, max_fs), (min_ss, max_ss)) in delta_method(arr_2d)]
 
         if arr.ndim == 2:
@@ -217,7 +217,7 @@ def to_mask_array(self):
 
     def make_crystfel_bad_regions(self, panels_dict):
         modno_to_panels = {}
-        for pname, pinfo in panels_dict:
+        for pname, pinfo in panels_dict.items():
             modno = crystfel_fmt.panel_modno(pinfo, pname)
             modno_to_panels.setdefault(modno, []).append((pname, pinfo))
 
@@ -303,7 +303,7 @@ def write_crystfel_geom(self, filename, write_mode):
         new_bad_regions = self.make_crystfel_bad_regions(geom_dict['panels'])
         for i, bad_d in enumerate(new_bad_regions, start=n_area_start):
             text_mask.extend([
-                f'bad_area{i}/{k} = {v}' for (k, v) in bad_d.items()
+                f'bad_area{i}/{k} = {v}\n' for (k, v) in bad_d.items()
             ] + ['\n'])
 
         text_write = "".join(text_before + text_mask + text_after)

From 4840021c267eaebd5eba1016e04c724dce56911c Mon Sep 17 00:00:00 2001
From: Thomas Kluyver <thomas@kluyver.me.uk>
Date: Fri, 25 Jun 2021 17:31:04 +0100
Subject: [PATCH 6/9] Give mask objects more helpful reprs

---
 extra_geom/mask.py | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/extra_geom/mask.py b/extra_geom/mask.py
index 08c4e7d2..9839cad1 100644
--- a/extra_geom/mask.py
+++ b/extra_geom/mask.py
@@ -88,6 +88,9 @@ def __init__(self, modno, start_ss, stop_ss, start_fs, stop_fs):
     def _tuple(self):
         return self.modno, self.start_ss, self.stop_ss, self.start_fs, self.stop_fs
 
+    def __repr__(self):
+        return f'RegionRect{self._tuple()}'
+
     def __hash__(self):
         return hash(self._tuple())
 
@@ -129,6 +132,11 @@ def __init__(self, shape, regions=()):
         self.shape = shape
         self.regions = regions  # [RegionRect]
 
+    def __repr__(self):
+        npx = self.to_mask_array().sum()
+        return (f"<MaskRegions for {self.shape} array: "
+                f"{len(self.regions)} covering {npx} pixels>")
+
     @classmethod
     def from_crystfel_geom(cls, filename):
         """Read mask from a CrystFEL format geometry file.

From 4f8dee87857781556d0486062419e3f9ecce40de Mon Sep 17 00:00:00 2001
From: Thomas Kluyver <thomas@kluyver.me.uk>
Date: Fri, 25 Jun 2021 17:33:59 +0100
Subject: [PATCH 7/9] Clearer repr for MaskRegions

---
 extra_geom/mask.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/extra_geom/mask.py b/extra_geom/mask.py
index 9839cad1..57d9c070 100644
--- a/extra_geom/mask.py
+++ b/extra_geom/mask.py
@@ -135,7 +135,7 @@ def __init__(self, shape, regions=()):
     def __repr__(self):
         npx = self.to_mask_array().sum()
         return (f"<MaskRegions for {self.shape} array: "
-                f"{len(self.regions)} covering {npx} pixels>")
+                f"{len(self.regions)} regions covering {npx} pixels>")
 
     @classmethod
     def from_crystfel_geom(cls, filename):

From 7101fc1ad03e5ea06578b18e9a5e967725c36c33 Mon Sep 17 00:00:00 2001
From: Thomas Kluyver <thomas@kluyver.me.uk>
Date: Fri, 25 Jun 2021 18:06:13 +0100
Subject: [PATCH 8/9] Add test for mask -> array and vice versa

---
 extra_geom/tests/test_mask.py | 24 ++++++++++++++++++++++++
 1 file changed, 24 insertions(+)

diff --git a/extra_geom/tests/test_mask.py b/extra_geom/tests/test_mask.py
index 20317541..d26af5ec 100644
--- a/extra_geom/tests/test_mask.py
+++ b/extra_geom/tests/test_mask.py
@@ -20,3 +20,27 @@ def test_delta_method():
     D[0:1, 0:1] = D[2:3, 2:3] = True
     exp_res_D = [((0, 1), (0, 1)), ((2, 3), (2, 3))]
     assert mask.delta_method(D) == exp_res_D, "Test on separate pixels."
+
+
+def simple_mask():
+    return mask.MaskRegions((2, 4, 5), [
+        mask.RegionRect(None, 0, 1, 0, 2),
+        mask.RegionRect(0, 2, 4, 0, 1),
+        mask.RegionRect(1, 3, 4, 0, 5),
+    ])
+
+def test_roundtrip_array():
+    m1 = simple_mask()
+    arr = m1.to_mask_array()
+    np.testing.assert_array_equal(arr, np.array([
+        [[1, 1, 0, 0, 0],
+         [0, 0, 0, 0, 0],
+         [1, 0, 0, 0, 0],
+         [1, 0, 0, 0, 0]],
+        [[1, 1, 0, 0, 0],
+         [0, 0, 0, 0, 0],
+         [0, 0, 0, 0, 0],
+         [1, 1, 1, 1, 1]],
+    ], dtype=np.bool_))
+    m2 = mask.MaskRegions.from_mask_array(arr)
+    assert m2 == m1

From c4b9a3c8e5cbe3d4ec9afe5cdb9b14e5f444c3c5 Mon Sep 17 00:00:00 2001
From: Thomas Kluyver <thomas@kluyver.me.uk>
Date: Thu, 15 Jul 2021 15:30:44 +0100
Subject: [PATCH 9/9] Allow writing geometry object plus mask to .geom file

---
 extra_geom/crystfel_fmt.py | 19 +++++++++++-
 extra_geom/mask.py         | 59 ++++++++++++++++++++++++++------------
 2 files changed, 58 insertions(+), 20 deletions(-)

diff --git a/extra_geom/crystfel_fmt.py b/extra_geom/crystfel_fmt.py
index cbfb8af4..dc9bd34f 100644
--- a/extra_geom/crystfel_fmt.py
+++ b/extra_geom/crystfel_fmt.py
@@ -4,6 +4,8 @@
 
 import numpy as np
 
+from .mask import RegionRect
+
 HEADER_TEMPLATE = """\
 ; {detector} geometry file written by EXtra-geom {version}
 ; You may need to edit this file to add:
@@ -79,7 +81,8 @@ def frag_to_crystfel(fragment, p, a, ss_slice, fs_slice, dims, pixel_size):
 
 def write_crystfel_geom(self, filename, *,
                         data_path='/entry_1/instrument_1/detector_1/data',
-                        mask_path=None, dims=('frame', 'modno', 'ss', 'fs'),
+                        mask_path=None, mask_regions=None,
+                        dims=('frame', 'modno', 'ss', 'fs'),
                         nquads=4, adu_per_ev=None, clen=None,
                         photon_energy=None):
     """Write this geometry to a CrystFEL format (.geom) geometry file.
@@ -116,9 +119,13 @@ def write_crystfel_geom(self, filename, *,
         raise ValueError('No frame dimension given')
 
     panel_chunks = []
+    panel_rects = {}
     for p, module in enumerate(self.modules):
         for a, fragment in enumerate(module):
             ss_slice, fs_slice = self._tile_slice(a)
+            panel_rects[f'p{p}a{a}'] = RegionRect(
+                p, ss_slice.start, ss_slice.stop, fs_slice.start, fs_slice.stop
+            )
             if 'modno' not in dims:
                 # If we don't have a modno dimension, assume modules are
                 # concatenated along the slow-scan dim, e.g. AGIPD (8192, 128)
@@ -139,6 +146,15 @@ def write_crystfel_geom(self, filename, *,
         paths['mask'] = mask_path
     path_str = '\n'.join('{} = {} ;'.format(i, j) for i, j in paths.items())
 
+    mask_lines = []
+    if mask_regions is not None:
+        for i, bad_d in enumerate(mask_regions.make_crystfel_bad_regions(
+            panel_rects, modules_stacked=('modno' in dims)
+        )):
+            mask_lines.extend([
+                f'bad_area{i}/{k} = {v}' for (k, v) in bad_d.items()
+            ] + [''])
+
     with open(filename, 'w') as f:
         f.write(HEADER_TEMPLATE.format(
             detector=self.detector_type_name,
@@ -152,6 +168,7 @@ def write_crystfel_geom(self, filename, *,
         ))
         rigid_groups = get_rigid_groups(self, nquads=nquads)
         f.write(rigid_groups)
+        f.write('\n'.join(mask_lines))
         for chunk in panel_chunks:
             f.write(chunk)
 
diff --git a/extra_geom/mask.py b/extra_geom/mask.py
index 57d9c070..ac9c8f0e 100644
--- a/extra_geom/mask.py
+++ b/extra_geom/mask.py
@@ -97,6 +97,13 @@ def __hash__(self):
     def __eq__(self, other):
         return isinstance(other, RegionRect) and self._tuple() == other._tuple()
 
+    def replace(self, **kwargs):
+        new_kwargs = {
+            k: kwargs.get(k, getattr(self, k))
+            for k in ('modno', 'start_ss', 'stop_ss', 'start_fs', 'stop_fs')
+        }
+        return RegionRect(**new_kwargs)
+
     @property
     def array_slice(self):
         """Get a tuple to use for slicing an array"""
@@ -223,13 +230,18 @@ def to_mask_array(self):
 
         return res_mask
 
-    def make_crystfel_bad_regions(self, panels_dict):
-        modno_to_panels = {}
-        for pname, pinfo in panels_dict.items():
-            modno = crystfel_fmt.panel_modno(pinfo, pname)
-            modno_to_panels.setdefault(modno, []).append((pname, pinfo))
+    def make_crystfel_bad_regions(self, panel_rects, modules_stacked=True):
+        panel_rects_by_modno = {i: {} for i in range(self.shape[0])}
+        for pname, region in panel_rects.items():
+            panel_rects_by_modno[region.modno][pname] = region
 
         def to_dict(region: RegionRect):
+            if (region.modno is not None) and not modules_stacked:
+                mod_offset = region.modno * self.shape[1]
+                region = region.replace(
+                    start_ss=region.start_ss + mod_offset,
+                    stop_ss=region.stop_ss + mod_offset,
+                )
             return {
                 'min_ss': region.start_ss, 'max_ss': region.stop_ss - 1,
                 'min_fs': region.start_fs, 'max_fs': region.stop_fs - 1,
@@ -237,21 +249,25 @@ def to_dict(region: RegionRect):
 
         res = []
         for mask_region in self.regions:
-            if mask_region.modno is None:
-                res.append(to_dict(mask_region))  # Mask for all panels
+            if modules_stacked:
+                if mask_region.modno is None:
+                    yield to_dict(mask_region)  # Mask for all panels
+                else:
+                    module_panels = panel_rects_by_modno[mask_region.modno]
+                    for pname, panel_region in module_panels.items():
+                        overlap, matching = mask_region.intersection(panel_region)
+                        if overlap:
+                            d = to_dict(matching)
+                            d['panel'] = pname
+                            res.append(d)
+
 
             else:
-                for pname, pinfo in modno_to_panels[mask_region.modno]:
-                    panel_region = RegionRect(
-                        mask_region.modno,
-                        pinfo['min_ss'], pinfo['max_ss'] + 1,
-                        pinfo['min_fs'], pinfo['max_fs'] + 1,
-                    )
-                    overlap, matching = mask_region.intersection(panel_region)
-                    if overlap:
-                        d = to_dict(matching)
-                        d['panel'] = pname
-                        res.append(d)
+                if mask_region.modno is None:
+                    for i in range(self.shape[0]):
+                        yield to_dict(mask_region.replace(modno=i))
+                else:
+                    yield to_dict(mask_region)
 
         return res
 
@@ -308,7 +324,12 @@ def write_crystfel_geom(self, filename, write_mode):
 
         geom_dict = load_crystfel_geometry(filename)
 
-        new_bad_regions = self.make_crystfel_bad_regions(geom_dict['panels'])
+        panel_rects = {pname: RegionRect(
+            crystfel_fmt.panel_modno(pinfo, pname),
+            pinfo['min_ss'], pinfo['max_ss'] + 1,
+            pinfo['min_fs'], pinfo['max_fs'] + 1,
+        ) for (pname, pinfo) in geom_dict['panels'].items()}
+        new_bad_regions = self.make_crystfel_bad_regions(panel_rects)
         for i, bad_d in enumerate(new_bad_regions, start=n_area_start):
             text_mask.extend([
                 f'bad_area{i}/{k} = {v}\n' for (k, v) in bad_d.items()