File size: 4,941 Bytes
79b7634
 
 
 
cdce371
 
 
79b7634
cdce371
79b7634
 
 
 
 
 
 
cdce371
79b7634
 
 
 
 
 
 
 
 
 
 
 
cdce371
79b7634
 
 
 
 
cdce371
79b7634
 
 
cdce371
 
79b7634
 
 
 
 
cdce371
 
 
 
 
 
 
 
 
 
 
 
 
79b7634
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cdce371
79b7634
 
 
 
 
 
 
 
 
 
cdce371
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import cv2
import numpy as np


from typing import List, Union

def mask_to_svg_path(mask: np.ndarray, epsilon_factor: float = 0.005) -> Union[str, List[str]]:
    """
    Converts a binary mask to SVG path strings.

    Args:
        mask (np.ndarray): The 2D binary mask.
        epsilon_factor (float): The factor for approximating the contour with Ramer-Douglas-Peucker algorithm.
            A higher value means more simplification (fewer points, smaller SVG size).

    Returns:
        List[str]: A list of SVG path data strings (`M x,y L x,y Z ...`), one for each external contour.
    """
    if not isinstance(mask, np.ndarray) or mask.ndim != 2:
        raise ValueError("Mask must be a 2D numpy array.")
    # 1. Extract Contours
    # RETR_CCOMP retrieves all of the contours and organizes them into a two-level hierarchy.
    # At the top level, there are external boundaries of the components.
    # At the second level, there are boundaries of the holes.
    contours, hierarchy = cv2.findContours(
        mask, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE
    )

    if contours is None or len(contours) == 0:
        return []

    # 2. Iterate through contours and hierarchy to build the path
    # The hierarchy array has shape (1, num_contours, 4)
    # The 4 elements are: [Next, Previous, First_Child, Parent]
    if hierarchy is None:
        return []

    hierarchy = hierarchy[0]

    paths = []

    for i, contour in enumerate(contours):
        # We only want to process the contours if it has at least 3 points
        if len(contour) < 3:
            continue

        # Check if it's an external contour or a hole
        # We can group holes into the same path as their parent external contour
        parent_idx = hierarchy[i][3]
        if parent_idx != -1:
            # It's a hole, it will be added to the parent's path if we are combining them.
            # However, to avoid a bounding box issue with multiple non-connected geometries,
            # we should separate out the geometries.
            # In RETR_CCOMP, external contours have parent == -1.
            # Wait, if we separate them into different paths, holes will be filled if they are
            # in a separate <path> tag. To keep holes as holes, they MUST be in the SAME <path> d string
            # as their bounding external contour.
            pass

        # 3. Simplify Contour
        # Calculate epsilon based on the contour's arc length
        epsilon = epsilon_factor * cv2.arcLength(contour, True)
        approx = cv2.approxPolyDP(contour, epsilon, True)

        # We want to skip highly simplified contours that are just points or lines
        if len(approx) < 3:
            continue

        # 4. Format to SVG path
        # M = moveto (start point)
        # L = lineto (subsequent points)
        # Z = closepath (return to start)
        pts = approx.reshape(-1, 2)

        path_data = []
        # Add the M command for the first point
        path_data.append(f"M {pts[0][0]},{pts[0][1]}")

        # Add the L commands for the rest
        for x, y in pts[1:]:
            path_data.append(f"L {x},{y}")

        # Close the contour
        path_data.append("Z")

        # Keep track of paths
        # Actually, let's group holes with their parent external boundaries.
        # But for now, to fix the bounding box issue, returning a list of all geometries
        # as separate strings might cause holes to be filled.
        # Let's rebuild the hierarchy correctly: each external contour + its holes is one path string!

        # We'll just do that below.
        pass

    # A better approach to group holes with parents:
    paths_grouped = []

    for i, contour in enumerate(contours):
        parent_idx = hierarchy[i][3]

        # If it's an external contour
        if parent_idx == -1:
            # Gather this contour and all its immediate holes
            component_contours = [contour]

            # Find holes that have this contour as their parent
            for j, hole_contour in enumerate(contours):
                if hierarchy[j][3] == i:
                    component_contours.append(hole_contour)

            # Build the path string for this component
            component_path_data = []
            for c in component_contours:
                if len(c) < 3:
                    continue
                epsilon = epsilon_factor * cv2.arcLength(c, True)
                approx = cv2.approxPolyDP(c, epsilon, True)
                if len(approx) < 3:
                    continue
                pts = approx.reshape(-1, 2)
                component_path_data.append(f"M {pts[0][0]},{pts[0][1]}")
                for x, y in pts[1:]:
                    component_path_data.append(f"L {x},{y}")
                component_path_data.append("Z")

            if component_path_data:
                paths_grouped.append(" ".join(component_path_data))

    return paths_grouped