changeset 6:22bb32eae6a1 draft default tip

planemo upload for repository https://github.com/BMCV/galaxy-image-analysis/tree/master/tools/points2labelimage/ commit edac062b00490276ef00d094e0594abdb3a3f23c
author imgteam
date Thu, 06 Nov 2025 09:59:34 +0000
parents 4a49f74a3c14
children
files REAME.md creators.xml points2label.py points2label.xml test-data/input5.tsv test-data/input6.tsv test-data/output2.tiff test-data/output4.tiff test-data/output5.tiff test-data/output6.tiff test-data/output7.tiff test-data/output8.tiff test-data/rois-illegal1.geojson test-data/rois-illegal2.geojson test-data/rois-noname.geojson test-data/rois.geojson test_utils.py
diffstat 17 files changed, 404 insertions(+), 333 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/REAME.md	Thu Nov 06 09:59:34 2025 +0000
@@ -0,0 +1,5 @@
+# points2labelimage
+
+Unit-testable functions are implemented in utils.py.
+
+Run `python -m unittest` for the unit tests (for regression testing).
--- a/creators.xml	Mon May 12 14:01:26 2025 +0000
+++ b/creators.xml	Thu Nov 06 09:59:34 2025 +0000
@@ -30,4 +30,9 @@
         <yield/>
     </xml>
 
+    <xml name="creators/tuncK">
+        <person givenName="Tunc" familyName="Kayikcioglu"/>
+        <yield/>
+    </xml>
+
 </macros>
--- a/points2label.py	Mon May 12 14:01:26 2025 +0000
+++ b/points2label.py	Thu Nov 06 09:59:34 2025 +0000
@@ -1,12 +1,11 @@
 import argparse
 import json
-import os
 import warnings
 from typing import (
+    Any,
     Dict,
-    List,
+    Optional,
     Tuple,
-    Union,
 )
 
 import giatools.pandas
@@ -14,219 +13,276 @@
 import numpy.typing as npt
 import pandas as pd
 import scipy.ndimage as ndi
+import skimage.draw
 import skimage.io
 import skimage.segmentation
 
 
-def is_rectangular(points: Union[List[Tuple[float, float]], npt.NDArray]) -> bool:
-    points = np.asarray(points)
-
-    # Rectangle must have 5 points, where first and last are identical
-    if len(points) != 5 or not (points[0] == points[-1]).all():
-        return False
-
-    # Check that all edges align with the axes
-    edges = points[1:] - points[:-1]
-    if any((edge == 0).sum() != 1 for edge in edges):
-        return False
-
-    # All checks have passed, the geometry is rectangular
-    return True
+def get_list_depth(nested_list: Any) -> int:
+    if isinstance(nested_list, list):
+        if len(nested_list) > 0:
+            return 1 + max(map(get_list_depth, nested_list))
+        else:
+            return 1
+    else:
+        return 0
 
 
-def geojson_to_tabular(geojson: Dict):
-    rows = []
-    labels = []
-    for feature in geojson['features']:
-        assert feature['geometry']['type'].lower() == 'polygon', (
-            f'Unsupported geometry type: "{feature["geometry"]["type"]}"'
-        )
-        coords = feature['geometry']['coordinates'][0]
+class AutoLabel:
+    """
+    Creates a sequence of unique labels (non-negative values).
+    """
 
-        # Properties and name (label) are optional
-        try:
-            label = feature['properties']['name']
-        except KeyError:
-            label = max(labels, default=0) + 1
-        labels.append(label)
+    def __init__(self, reserved_labels):
+        self.reserved_labels = reserved_labels
+        self.next_autolabel = 0
 
-        # Read geometry
-        xs = [pt[0] for pt in coords]
-        ys = [pt[1] for pt in coords]
+    def next(self):
+        """
+        Retrieve the next auto-label (post-increment).
+        """
+        # Fast-forward `next_autolabel` to the first free label
+        while self.next_autolabel in self.reserved_labels:
+            self.next_autolabel += 1
 
-        x = min(xs)
-        y = min(ys)
+        # Return the free label, then advance `next_autolabel`
+        try:
+            return self.next_autolabel
+        finally:
+            self.next_autolabel += 1
 
-        width = max(xs) + 1 - x
-        height = max(ys) + 1 - y
-
-        # Validate geometry (must be rectangular)
-        assert is_rectangular(list(zip(xs, ys)))
 
-        # Append the rectangle
-        rows.append({
-            'pos_x': x,
-            'pos_y': y,
-            'width': width,
-            'height': height,
-            'label': label,
-        })
-    df = pd.DataFrame(rows)
-    point_file = './point_file.tabular'
-    df.to_csv(point_file, sep='\t', index=False)
-    return point_file
+def get_feature_label(feature: Dict) -> Optional[int]:
+    """
+    Get the label of a GeoJSON feature, or `None` if there is no proper label.
+    """
+    label = feature.get('properties', {}).get('name', None)
+    if label is None:
+        return None
+
+    # If the `label` is given as a string, try to parse as integer
+    if isinstance(label, str):
+        try:
+            label = int(label)
+        except ValueError:
+            pass
+
+    # Finally, if `label` is an integer, only use it if it is non-negative
+    if isinstance(label, int) and label >= 0:
+        return label
+    else:
+        return None
 
 
-def rasterize(point_file, out_file, shape, has_header=False, swap_xy=False, bg_value=0, fg_value=None):
+def rasterize(
+    geojson: Dict,
+    shape: Tuple[int, int],
+    bg_value: int = 0,
+    fg_value: Optional[int] = None,
+) -> npt.NDArray:
+    """
+    Rasterize GeoJSON into a pixel image, that is returned as a NumPy array.
+    """
 
-    img = np.full(shape, dtype=np.uint16, fill_value=bg_value)
-    if os.path.exists(point_file) and os.path.getsize(point_file) > 0:
+    # Determine which labels are reserved (not used by auto-label)
+    reserved_labels = [bg_value]
+    if fg_value is None:
+        for feature in geojson['features']:
+            label = get_feature_label(feature)
+            if label is not None:
+                reserved_labels.append(label)
 
-        # Read the tabular file with information from the header
-        if has_header:
-            df = pd.read_csv(point_file, delimiter='\t')
+    # Convert `reserved_labels` into a `set` for faster look-ups
+    reserved_labels = frozenset(reserved_labels)
+
+    # Define routine to retrieve the next auto-label
+    autolabel = AutoLabel(reserved_labels)
 
-            pos_x_column = giatools.pandas.find_column(df, ['pos_x', 'POS_X'])
-            pos_y_column = giatools.pandas.find_column(df, ['pos_y', 'POS_Y'])
-            pos_x_list = df[pos_x_column].round().astype(int)
-            pos_y_list = df[pos_y_column].round().astype(int)
-            assert len(pos_x_list) == len(pos_y_list)
+    # Rasterize the image
+    img = np.full(shape, dtype=np.uint16, fill_value=bg_value)
+    for feature in geojson['features']:
+        geom_type = feature['geometry']['type'].lower()
+        coords = feature['geometry']['coordinates']
+
+        # Rasterize a `mask` separately for each feature
+        if geom_type == 'polygon':
+
+            # Normalization: Let there always be a list of polygons
+            if get_list_depth(coords) == 2:
+                coords = [coords]
 
-            try:
-                radius_column = giatools.pandas.find_column(df, ['radius', 'RADIUS'])
-                radius_list = df[radius_column]
-                assert len(pos_x_list) == len(radius_list)
-            except KeyError:
-                radius_list = [0] * len(pos_x_list)
+            # Rasterize each polygon separately, then join via XOR
+            mask = np.zeros(shape, dtype=bool)
+            for polygon_coords in coords:
+                polygon_mask = skimage.draw.polygon2mask(
+                    shape,
+                    [point[::-1] for point in polygon_coords],
+                )
+                mask = np.logical_xor(mask, polygon_mask)
+
+        elif geom_type == 'point':
+            mask = np.zeros(shape, dtype=bool)
+            mask[coords[1], coords[0]] = True
+            radius = feature.get('properties', {}).get('radius', 0)
+            if radius > 0:
+                mask = (ndi.distance_transform_edt(~mask) <= radius)
+
+        else:
+            raise ValueError(
+                f'Unsupported geometry type: "{feature["geometry"]["type"]}"',
+            )
 
-            try:
-                width_column = giatools.pandas.find_column(df, ['width', 'WIDTH'])
-                height_column = giatools.pandas.find_column(df, ['height', 'HEIGHT'])
-                width_list = df[width_column]
-                height_list = df[height_column]
-                assert len(pos_x_list) == len(width_list)
-                assert len(pos_x_list) == len(height_list)
-            except KeyError:
-                width_list = [0] * len(pos_x_list)
-                height_list = [0] * len(pos_x_list)
+        # Determine the `label` for the current `mask`
+        if fg_value is None:
+            label = get_feature_label(feature)
+            if label is None:
+                label = autolabel.next()
+        else:
+            label = fg_value
+
+        # Blend the current `mask` with the rasterized image
+        img[mask] = label
+
+    # Return the rasterized image
+    return img
+
+
+def convert_tabular_to_geojson(
+    tabular_file: str,
+    has_header: bool,
+) -> dict:
+    """
+    Read a tabular file and convert it to GeoJSON.
+
+    The GeoJSON data is returned as a dictionary.
+    """
+
+    # Read the tabular file with information from the header
+    if has_header:
+        df = pd.read_csv(tabular_file, delimiter='\t')
 
-            try:
-                label_column = giatools.pandas.find_column(df, ['label', 'LABEL'])
-                label_list = df[label_column]
-                assert len(pos_x_list) == len(label_list)
-            except KeyError:
-                label_list = list(range(1, len(pos_x_list) + 1))
+        pos_x_column = giatools.pandas.find_column(df, ['pos_x', 'POS_X'])
+        pos_y_column = giatools.pandas.find_column(df, ['pos_y', 'POS_Y'])
+        pos_x_list = df[pos_x_column].round().astype(int)
+        pos_y_list = df[pos_y_column].round().astype(int)
+        assert len(pos_x_list) == len(pos_y_list)
+
+        try:
+            radius_column = giatools.pandas.find_column(df, ['radius', 'RADIUS'])
+            radius_list = df[radius_column]
+            assert len(pos_x_list) == len(radius_list)
+        except KeyError:
+            radius_list = [0] * len(pos_x_list)
 
-        # Read the tabular file without header
-        else:
-            df = pd.read_csv(point_file, header=None, delimiter='\t')
-            pos_x_list = df[0].round().astype(int)
-            pos_y_list = df[1].round().astype(int)
-            assert len(pos_x_list) == len(pos_y_list)
-            radius_list, width_list, height_list = [[0] * len(pos_x_list)] * 3
+        try:
+            width_column = giatools.pandas.find_column(df, ['width', 'WIDTH'])
+            height_column = giatools.pandas.find_column(df, ['height', 'HEIGHT'])
+            width_list = df[width_column]
+            height_list = df[height_column]
+            assert len(pos_x_list) == len(width_list)
+            assert len(pos_x_list) == len(height_list)
+        except KeyError:
+            width_list = [0] * len(pos_x_list)
+            height_list = [0] * len(pos_x_list)
+
+        try:
+            label_column = giatools.pandas.find_column(df, ['label', 'LABEL'])
+            label_list = df[label_column]
+            assert len(pos_x_list) == len(label_list)
+        except KeyError:
             label_list = list(range(1, len(pos_x_list) + 1))
 
-        # Optionally swap the coordinates
-        if swap_xy:
-            pos_x_list, pos_y_list = pos_y_list, pos_x_list
-
-        # Perform the rasterization
-        for y, x, radius, width, height, label in zip(
-            pos_y_list, pos_x_list, radius_list, width_list, height_list, label_list,
-        ):
-            if fg_value is not None:
-                label = fg_value
-
-            if y < 0 or x < 0 or y >= shape[0] or x >= shape[1]:
-                raise IndexError(f'The point x={x}, y={y} exceeds the bounds of the image (width: {shape[1]}, height: {shape[0]})')
+    # Read the tabular file without header
+    else:
+        df = pd.read_csv(tabular_file, header=None, delimiter='\t')
+        pos_x_list = df[0].round().astype(int)
+        pos_y_list = df[1].round().astype(int)
+        assert len(pos_x_list) == len(pos_y_list)
+        radius_list, width_list, height_list = [[0] * len(pos_x_list)] * 3
+        label_list = list(range(1, len(pos_x_list) + 1))
 
-            # Rasterize circle and distribute overlapping image area
-            # Rasterize primitive geometry
-            if radius > 0 or (width > 0 and height > 0):
-
-                # Rasterize circle
-                if radius > 0:
-                    mask = np.ones(shape, dtype=bool)
-                    mask[y, x] = False
-                    mask = (ndi.distance_transform_edt(mask) <= radius)
-                else:
-                    mask = np.zeros(shape, dtype=bool)
-
-                # Rasterize rectangle
-                if width > 0 and height > 0:
-                    mask[
-                        y:min(shape[0], y + width),
-                        x:min(shape[1], x + height)
-                    ] = True
+    # Convert to GeoJSON
+    features = []
+    geojson = {
+        'type': 'FeatureCollection',
+        'features': features,
+    }
+    for y, x, radius, width, height, label in zip(
+        pos_y_list, pos_x_list, radius_list, width_list, height_list, label_list,
+    ):
+        if radius > 0 and width > 0 and height > 0:
+            raise ValueError('Ambiguous shape type (circle or rectangle)')
 
-                # Compute the overlap (pretend there is none if the rasterization is binary)
-                if fg_value is None:
-                    overlap = np.logical_and(img > 0, mask)
-                else:
-                    overlap = np.zeros(shape, dtype=bool)
+        # Create a rectangle
+        if width > 0 and height > 0:
+            geom_type = 'Polygon'
+            coords = [
+                [x, y],
+                [x + width - 1, y],
+                [x + width - 1, y + height - 1],
+                [x, y + height - 1],
+            ]
 
-                # Rasterize the part of the circle which is disjoint from other foreground.
-                #
-                # In the current implementation, the result depends on the order of the rasterized circles if somewhere
-                # more than two circles overlap. This is probably negligable for most applications. To achieve results
-                # that are invariant to the order, first all circles would need to be rasterized independently, and
-                # then blended together. This, however, would either strongly increase the memory consumption, or
-                # require a more complex implementation which exploits the sparsity of the rasterized masks.
-                #
-                disjoint_mask = np.logical_xor(mask, overlap)
-                if disjoint_mask.any():
-                    img[disjoint_mask] = label
+        # Create a point or circle
+        else:
+            geom_type = 'Point'
+            coords = [x, y]
 
-                    # Distribute the remaining part of the circle
-                    if overlap.any():
-                        dist = ndi.distance_transform_edt(overlap)
-                        foreground = (img > 0)
-                        img[overlap] = 0
-                        img = skimage.segmentation.watershed(dist, img, mask=foreground)
+        # Create a GeoJSON feature
+        feature = {
+            'type': 'Feature',
+            'geometry': {
+                'type': geom_type,
+                'coordinates': coords,
+            },
+            'properties': {
+                'name': label,
+            },
+        }
+        if radius > 0:
+            feature['properties']['radius'] = radius
+            feature['properties']['subType'] = 'Circle'
+        features.append(feature)
 
-            # Rasterize point (there is no overlapping area to be distributed)
-            else:
-                img[y, x] = label
-
-    else:
-        raise Exception('{} is empty or does not exist.'.format(point_file))  # appropriate built-in error?
-
-    with warnings.catch_warnings():
-        warnings.simplefilter("ignore")
-        skimage.io.imsave(out_file, img, plugin='tifffile')  # otherwise we get problems with the .dat extension
+    # Return the GeoJSON object
+    return geojson
 
 
 if __name__ == '__main__':
     parser = argparse.ArgumentParser()
-    parser.add_argument('in_file', type=argparse.FileType('r'), help='Input point file or GeoJSON file')
-    parser.add_argument('out_file', type=str, help='out file (TIFF)')
-    parser.add_argument('shapex', type=int, help='shapex')
-    parser.add_argument('shapey', type=int, help='shapey')
-    parser.add_argument('--has_header', dest='has_header', default=False, help='set True if point file has header')
+    parser.add_argument('in_ext', type=str, help='Input file format')
+    parser.add_argument('in_file', type=str, help='Input file path (tabular or GeoJSON)')
+    parser.add_argument('out_file', type=str, help='Output file path (TIFF)')
+    parser.add_argument('shapex', type=int, help='Output image width')
+    parser.add_argument('shapey', type=int, help='Output image height')
+    parser.add_argument('--has_header', dest='has_header', default=False, help='Set True if tabular file has a header')
     parser.add_argument('--swap_xy', dest='swap_xy', default=False, help='Swap X and Y coordinates')
     parser.add_argument('--binary', dest='binary', default=False, help='Produce binary image')
-
     args = parser.parse_args()
 
-    point_file = args.in_file.name
-    has_header = args.has_header
+    # Validate command-line arguments
+    assert args.in_ext in ('tabular', 'geojson'), (
+        f'Unexpected input file format: {args.in_ext}'
+    )
 
-    try:
-        with open(args.in_file.name, 'r') as f:
-            content = json.load(f)
-            if isinstance(content, dict) and content.get('type') == 'FeatureCollection' and isinstance(content.get('features'), list):
-                point_file = geojson_to_tabular(content)
-                has_header = True  # header included in the converted file
-            else:
-                raise ValueError('Input is a JSON file but not a valid GeoJSON file')
-    except json.JSONDecodeError:
-        print('Input is not a valid JSON file. Assuming it a tabular file.')
+    # Load the GeoJSON data (if the input file is tabular, convert to GeoJSON)
+    if args.in_ext == 'tabular':
+        geojson = convert_tabular_to_geojson(args.in_file, args.has_header)
+    else:
+        with open(args.in_file) as f:
+            geojson = json.load(f)
 
-    rasterize(
-        point_file,
-        args.out_file,
-        (args.shapey, args.shapex),
-        has_header=has_header,
-        swap_xy=args.swap_xy,
+    # Rasterize the image from GeoJSON
+    shape = (args.shapey, args.shapex)
+    img = rasterize(
+        geojson,
+        shape if not args.swap_xy else shape[::-1],
         fg_value=0xffff if args.binary else None,
     )
+    if args.swap_xy:
+        img = img.T
+
+    # Write the rasterized image as TIFF
+    with warnings.catch_warnings():
+        warnings.simplefilter('ignore')
+        skimage.io.imsave(args.out_file, img, plugin='tifffile')  # otherwise we get problems with the .dat extension
--- a/points2label.xml	Mon May 12 14:01:26 2025 +0000
+++ b/points2label.xml	Thu Nov 06 09:59:34 2025 +0000
@@ -1,10 +1,9 @@
-<tool id="ip_points_to_label" name="Convert coordinates to label map" version="@TOOL_VERSION@+galaxy@VERSION_SUFFIX@" profile="20.05">
+<tool id="ip_points_to_label" name="Convert coordinates to label map" version="@TOOL_VERSION@" profile="20.05">
     <description></description>
     <macros>
         <import>creators.xml</import>
         <import>tests.xml</import>
-        <token name="@TOOL_VERSION@">0.4.1</token>
-        <token name="@VERSION_SUFFIX@">1</token>
+        <token name="@TOOL_VERSION@">0.5.0</token>
     </macros>
     <creator>
         <expand macro="creators/bmcv" />
@@ -16,15 +15,16 @@
         <xref type="bio.tools">galaxy_image_analysis</xref>
     </xrefs>
     <requirements>
-        <requirement type="package" version="0.21">scikit-image</requirement>
-        <requirement type="package" version="1.26.4">numpy</requirement>
-        <requirement type="package" version="1.2.4">pandas</requirement>
-        <requirement type="package" version="2024.6.18">tifffile</requirement>
+        <requirement type="package" version="0.25.2">scikit-image</requirement>
+        <requirement type="package" version="2.3.4">numpy</requirement>
+        <requirement type="package" version="2.3.3">pandas</requirement>
+        <requirement type="package" version="2025.10.16">tifffile</requirement>
         <requirement type="package" version="0.3.1">giatools</requirement>
     </requirements>
     <command detect_errors="aggressive"><![CDATA[
 
         python '$__tool_directory__/points2label.py'
+        '$input.file_ext'
         '$input'
         '$output'
         $shapex
@@ -35,12 +35,12 @@
 
     ]]></command>
     <inputs>
-        <param name="input" type="data" format="tabular,geojson" label="List of points in tabular or geojson format"/>
-        <param name="shapex" type="integer" value="500" min="1" label="Width of output image" />
-        <param name="shapey" type="integer" value="500" min="1" label="Height of output image" />
-        <param name="has_header" type="boolean" checked="true" truevalue="--has_header True" falsevalue="" optional="true" label="Tabular list of points has header" help="Turning this off will ignore the first row and assume that the X and Y coordinates correspond to the first and second column, respectively. Ignored, if GeoJSON is used for input." />
-        <param name="swap_xy" type="boolean" checked="false" falsevalue="" truevalue="--swap_xy True" optional="true" label="Swap X and Y coordinates" help="Swap the X and Y coordinates, regardless of whether the tabular list has a header or not." />
-        <param name="binary" type="boolean" checked="false" truevalue="--binary True" falsevalue="" optional="true" label="Produce binary image" help="Use the same label for all points (65535)." />
+        <param name="input" type="data" format="tabular,geojson" label="Shapes to be rasterized"/>
+        <param name="shapex" type="integer" value="500" min="1" label="Width of the output image" />
+        <param name="shapey" type="integer" value="500" min="1" label="Height of the output image" />
+        <param name="has_header" type="boolean" checked="true" truevalue="--has_header True" falsevalue="" optional="true" label="Tabular list of shapes has header" help="Only used if the input is a tabular file (ignored for GeoJSON). Turning this off will interpret the tabular file as a list of points, where the X and Y coordinates correspond to the first and second column, respectively." />
+        <param name="swap_xy" type="boolean" checked="false" falsevalue="" truevalue="--swap_xy True" optional="true" label="Swap X and Y coordinates" help="Swap the X and Y coordinates before rasterization. The width and height of the output image is not affected." />
+        <param name="binary" type="boolean" checked="false" truevalue="--binary True" falsevalue="" optional="true" label="Produce binary image" help="Use the same label for all shapes (65535)." />
     </inputs>
     <outputs>
         <data name="output" format="tiff" />
@@ -106,7 +106,29 @@
             <param name="binary" value="false" />
             <expand macro="tests/label_image_diff" name="output" value="output4.tiff" ftype="tiff" />
         </test>
-        <!-- Labeled / ROI / Rectangles -->
+        <!-- Labeled / TSV with header / TSV with labels / Circles + Rectangles -->
+        <test>
+            <param name="input" value="input5.tsv" />
+            <param name="shapex" value="200" />
+            <param name="shapey" value="100" />
+            <param name="has_header" value="true" />
+            <param name="swap_xy" value="false" />
+            <param name="binary" value="false" />
+            <expand macro="tests/label_image_diff" name="output" value="output8.tiff" ftype="tiff" />
+        </test>
+        <test expect_failure="true">
+            <!-- The test should fail because simultaneous usage of radius and width/height on a row is forbidden -->
+            <param name="input" value="input6.tsv" />
+            <param name="shapex" value="200" />
+            <param name="shapey" value="100" />
+            <param name="has_header" value="true" />
+            <param name="swap_xy" value="false" />
+            <param name="binary" value="false" />
+            <assert_stderr>
+                <has_text text="Ambiguous shape type (circle or rectangle)" />
+            </assert_stderr>
+        </test>
+        <!-- Labeled / GeoJSON / mixed name and no-name -->
         <test>
             <param name="input" value="rois.geojson" />
             <param name="shapex" value="300" />
@@ -117,15 +139,6 @@
             <expand macro="tests/label_image_diff" name="output" value="output5.tiff" ftype="tiff" />
         </test>
         <test>
-            <param name="input" value="rois-noname.geojson" />
-            <param name="shapex" value="300" />
-            <param name="shapey" value="300" />
-            <param name="has_header" value="false" />
-            <param name="swap_xy" value="false" />
-            <param name="binary" value="false" />
-            <expand macro="tests/label_image_diff" name="output" value="output5.tiff" ftype="tiff" />
-        </test>
-        <test>
             <param name="input" value="rois.geojson" />
             <param name="shapex" value="300" />
             <param name="shapey" value="300" />
@@ -134,48 +147,57 @@
             <param name="binary" value="false" />
             <expand macro="tests/label_image_diff" name="output" value="output6.tiff" ftype="tiff" />
         </test>
-        <test expect_failure="true">
-            <!-- The test should fail because the GeoJSON is not rectangular -->
-            <param name="input" value="rois-illegal1.geojson" />
+        <!-- Labeled / GeoJSON / no-name -->
+        <test>
+            <param name="input" value="rois-noname.geojson" />
             <param name="shapex" value="300" />
             <param name="shapey" value="300" />
             <param name="has_header" value="false" />
             <param name="swap_xy" value="false" />
             <param name="binary" value="false" />
+            <expand macro="tests/label_image_diff" name="output" value="output7.tiff" ftype="tiff" />
         </test>
         <test expect_failure="true">
-            <!-- The test should fail because the GeoJSON is not of `Polygon` type -->
+            <!-- The test should fail because the GeoJSON uses the `LineString` primitive type that is not supported yet -->
             <param name="input" value="rois-illegal2.geojson" />
             <param name="shapex" value="300" />
             <param name="shapey" value="300" />
             <param name="has_header" value="false" />
             <param name="swap_xy" value="false" />
             <param name="binary" value="false" />
+            <assert_stderr>
+                <has_text text='Unsupported geometry type: "LineString"' />
+            </assert_stderr>
         </test>
     </tests>
     <help>
 
-        **Converts a list of points to a label map by rasterizing the coordinates.**
+        **Converts a list of shapes to a label map via rasterization.**
 
-        The created image is a single-channel image with 16 bits per pixel (unsigned integer). The points are
-        rasterized with unique labels, or the value 65535 (white) for binary image output. Pixels not corresponding to
-        any points in the tabular file are assigned the value 0 (black).
+        The created image is a single-channel image with 16 bits per pixel (unsigned integers). The shapes are
+        rasterized with unique labels, explicitly given labels (custom), or the value 65535 (white) for binary image
+        output. Pixels not corresponding to any shapes are assigned the value 0 (black).
 
-        **Using a tabular input file:** The tabular list of points can either be header-less. In this case, the first
-        and second columns are expected to be the X and Y coordinates, respectively. Otherwise, if a header is present,
-        it is searched for the following column names:
+        **Using a GeoJSON input file (recommended).** Only features (shape specifications) of `Polygon` and `Point`
+        type are supported. In conjunction with the `radius` property, a `Point` type feature can also be used to
+        represent circles. Custom labels can be encoded in the `name` property (must be numeric and integer), and
+        different features are allowed to use the same labels.
 
-        - ``pos_x`` or ``POS_X``: This column corresponds to the X coordinates.
-        - ``pos_y`` or ``POS_Y``: This column corresponds to the Y coordinates.
-        - If a ``radius`` or ``RADIUS`` column is present, then the points will be rasterized as circles of the
-          corresponding radii.
-        - If ``width`` or ``WIDTH`` and ``height`` or ``HEIGHT`` columns are present, then the points will be rasterized
-          as rectangles of the corresponding size.
+        **Using a tabular input file (deprecated).** The tabular list of points can either be header-less. In this
+        case, the first and second columns are expected to be the X and Y coordinates, respectively, and each row
+        corresponds to a single point. Otherwise, if a header is present, it is searched for the following column
+        names:
+
+        - Column ``pos_x`` or ``POS_X`` is mandatory and corresponds to the X coordinates.
+        - Column ``pos_y`` or ``POS_Y`` is mandatory and corresponds to the Y coordinates.
+        - If a ``radius`` or ``RADIUS`` column is present and the value in a row is positive, then the row will be
+          rasterized as a circle of the corresponding size, centered at the given X and Y coordinates.
+        - If ``width`` or ``WIDTH`` and ``height`` or ``HEIGHT`` columns are present and the values in a row are
+          positive, then the rows will be rasterized as rectangles of the corresponding size, with the upper-left
+          corner given by the X and Y coordinates.
         - If a ``label`` or ``LABEL`` column is present, then the corresponding labels will be used for rasterization
-          (unless "Produce binary image" is activated). Different points are allowed to use the same label. If used, the
-          label must be numeric and integer.
-
-        **Using a GeoJSON input file:** Only rectangular specifications of `Polygon` type geometry is supported.
+          (unless "Produce binary image" is activated). Different rows are allowed to use the same label. If used,
+          the label must be numeric and integer.
 
     </help>
     <citations>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/test-data/input5.tsv	Thu Nov 06 09:59:34 2025 +0000
@@ -0,0 +1,4 @@
+pos_x	pos_y	radius	width	height	label
+20	20	10	0	0	1
+50	50	0	30	40	2
+150	50	0	60	40	3
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/test-data/input6.tsv	Thu Nov 06 09:59:34 2025 +0000
@@ -0,0 +1,4 @@
+pos_x	pos_y	radius	width	height	label
+20	20	10	0	0	1
+50	50	10	30	40	2
+150	50	0	60	40	3
Binary file test-data/output2.tiff has changed
Binary file test-data/output4.tiff has changed
Binary file test-data/output5.tiff has changed
Binary file test-data/output6.tiff has changed
Binary file test-data/output7.tiff has changed
Binary file test-data/output8.tiff has changed
--- a/test-data/rois-illegal1.geojson	Mon May 12 14:01:26 2025 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,40 +0,0 @@
-{
-    "type": "FeatureCollection",
-    "features": [
-        {
-            "type": "Feature",
-            "id": "a5d9de43-1a4a-4194-b06d-a6c6d0f81f91",
-            "geometry": {
-                "type": "Polygon",
-                "coordinates": [
-                    [
-                        [
-                            201,
-                            48
-                        ],
-                        [
-                            292,
-                            48
-                        ],
-                        [
-                            292,
-                            184
-                        ],
-                        [
-                            201,
-                            48
-                        ],
-                        [
-                            201,
-                            48
-                        ]
-                    ]
-                ]
-            },
-            "properties": {
-                "objectType": "annotation",
-                "name": "1"
-            }
-        }
-    ]
-}
--- a/test-data/rois-illegal2.geojson	Mon May 12 14:01:26 2025 +0000
+++ b/test-data/rois-illegal2.geojson	Thu Nov 06 09:59:34 2025 +0000
@@ -5,29 +5,27 @@
             "type": "Feature",
             "id": "a5d9de43-1a4a-4194-b06d-a6c6d0f81f91",
             "geometry": {
-                "type": "Point",
+                "type": "LineString",
                 "coordinates": [
                     [
-                        [
-                            201,
-                            48
-                        ],
-                        [
-                            292,
-                            48
-                        ],
-                        [
-                            292,
-                            184
-                        ],
-                        [
-                            201,
-                            184
-                        ],
-                        [
-                            201,
-                            48
-                        ]
+                        201,
+                        48
+                    ],
+                    [
+                        292,
+                        48
+                    ],
+                    [
+                        292,
+                        184
+                    ],
+                    [
+                        201,
+                        184
+                    ],
+                    [
+                        201,
+                        48
                     ]
                 ]
             },
--- a/test-data/rois-noname.geojson	Mon May 12 14:01:26 2025 +0000
+++ b/test-data/rois-noname.geojson	Thu Nov 06 09:59:34 2025 +0000
@@ -3,6 +3,18 @@
     "features": [
         {
             "type": "Feature",
+            "id": "9ef0487b-29a5-4e53-8eca-5c9dbf7bfc81",
+            "geometry": {
+                "type": "Point",
+                "coordinates": [100, 50]
+            },
+            "properties": {
+                "objectType": "annotation",
+                "radius": 100
+            }
+        },
+        {
+            "type": "Feature",
             "id": "9ef0487b-29a5-4e53-8eca-5c9dbf7bfc80",
             "geometry": {
                 "type": "Polygon",
@@ -17,11 +29,11 @@
                             149
                         ],
                         [
-                            183,
+                            163,
                             275
                         ],
                         [
-                            124,
+                            104,
                             275
                         ],
                         [
@@ -64,6 +76,9 @@
                         ]
                     ]
                 ]
+            },
+            "properties": {
+                "objectType": "annotation"
             }
         },
         {
@@ -73,31 +88,18 @@
                 "type": "Polygon",
                 "coordinates": [
                     [
-                        [
-                            151,
-                            95
-                        ],
-                        [
-                            260,
-                            95
-                        ],
-                        [
-                            260,
-                            162
-                        ],
-                        [
-                            151,
-                            162
-                        ],
-                        [
-                            151,
-                            95
-                        ]
+                        [151, 95],
+                        [260, 95],
+                        [260, 162],
+                        [151, 162]
+                    ],
+                    [
+                        [156, 100],
+                        [255, 100],
+                        [255, 157],
+                        [156, 157]
                     ]
                 ]
-            },
-            "properties": {
-                "objectType": "annotation"
             }
         }
     ]
--- a/test-data/rois.geojson	Mon May 12 14:01:26 2025 +0000
+++ b/test-data/rois.geojson	Thu Nov 06 09:59:34 2025 +0000
@@ -3,6 +3,18 @@
     "features": [
         {
             "type": "Feature",
+            "id": "9ef0487b-29a5-4e53-8eca-5c9dbf7bfc81",
+            "geometry": {
+                "type": "Point",
+                "coordinates": [100, 50]
+            },
+            "properties": {
+                "objectType": "annotation",
+                "radius": 100
+            }
+        },
+        {
+            "type": "Feature",
             "id": "9ef0487b-29a5-4e53-8eca-5c9dbf7bfc80",
             "geometry": {
                 "type": "Polygon",
@@ -17,11 +29,11 @@
                             149
                         ],
                         [
-                            183,
+                            163,
                             275
                         ],
                         [
-                            124,
+                            104,
                             275
                         ],
                         [
@@ -78,26 +90,16 @@
                 "type": "Polygon",
                 "coordinates": [
                     [
-                        [
-                            151,
-                            95
-                        ],
-                        [
-                            260,
-                            95
-                        ],
-                        [
-                            260,
-                            162
-                        ],
-                        [
-                            151,
-                            162
-                        ],
-                        [
-                            151,
-                            95
-                        ]
+                        [151, 95],
+                        [260, 95],
+                        [260, 162],
+                        [151, 162]
+                    ],
+                    [
+                        [156, 100],
+                        [255, 100],
+                        [255, 157],
+                        [156, 157]
                     ]
                 ]
             },
@@ -107,4 +109,4 @@
             }
         }
     ]
-}
\ No newline at end of file
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/test_utils.py	Thu Nov 06 09:59:34 2025 +0000
@@ -0,0 +1,13 @@
+import unittest
+
+import points2label
+
+
+class get_list_depth(unittest.TestCase):
+
+    def test(self):
+        self.assertEqual(points2label.get_list_depth(1234), 0)
+        self.assertEqual(points2label.get_list_depth([]), 1)
+        self.assertEqual(points2label.get_list_depth([1, 2, 3]), 1)
+        self.assertEqual(points2label.get_list_depth([1, [2, 3]]), 2)
+        self.assertEqual(points2label.get_list_depth([[1], [2, 3]]), 2)