File size: 14,499 Bytes
8eb0b3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
import math
import numpy as np
import cv2
import uuid

def is_number(n):
    is_number = True
    try:
        num = float(n)
        # check for "nan" floats
        is_number = num == num   # or use `math.isnan(num)`
    except ValueError:
        is_number = False
    return is_number

class Point:
    def __init__(self, x, y):
        self.x = int(x) # Ensure integer coordinates if they represent pixels
        self.y = int(y)

        self.proximity_node = None # Placeholder for proximity node assignment
        self.is_arrow = False # Placeholder for entry point assignment

    def get_distance_between_points(self, other_point):
        """Calculate Euclidean distance between this point and another point."""
        return math.sqrt((self.x - other_point.x) ** 2 + (self.y - other_point.y) ** 2)
    
    def is_inside_contour(self, contour):
        """Check if this point is inside a given contour using cv2.pointPolygonTest"""
        # Note: This requires cv2, which might be better placed in a different module
        point_tuple = (float(self.x), float(self.y)) # pointPolygonTest needs float tuple
        # Ensure contour is in the correct format (e.g., Nx1x2 or Nx2)
        try:
            # >= 0 means inside or on the boundary
            return cv2.pointPolygonTest(contour, point_tuple, False) >= 0 
        except Exception as e:
            print(f"Error during pointPolygonTest: {e}")
            return False
        
    def get_numpy_array(self):
        """Returns the point as a numpy array."""
        return np.array([self.x, self.y], dtype=np.int32)

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return self.x == other.x and self.y == other.y

    def __hash__(self):
        """Allows Point objects to be added to sets or used as dictionary keys."""
        return hash((self.x, self.y))

class Line:
    def __init__(self, start_point: Point, end_point: Point, angle=None, length=None):
        """
        Initializes a Line object.
        If angle and length are not provided, they are calculated.
        """
        self.point1 = start_point
        self.point2 = end_point

        # Assign self to the points for back-reference if needed later
        # self.point1.part_of = self 
        # self.point2.part_of = self

        if angle is None or length is None:
            dx = self.point2.x - self.point1.x
            dy = self.point2.y - self.point1.y
            # Calculate angle in degrees
            self.angle = math.degrees(math.atan2(dy, dx)) if not (dx == 0 and dy == 0) else 0.0
            # Calculate length
            self.length = self.point1.get_distance_between_points(self.point2)
        else:
            self.angle = angle
            self.length = length

    def get_other_point(self, point: Point) -> Point:
        """Given one point of the line, returns the other point."""
        if point == self.point1:
            return self.point2
        elif point == self.point2:
            return self.point1
        else:
            # This case should ideally not be reached if logic is correct
            raise ValueError("Point is not part of this line.")

    def get_vector(self, start_point: Point = None, end_point: Point = None) -> np.ndarray:
        """
        Returns the vector of the line.
        If start_point and end_point are provided, computes vector from start to end.
        Otherwise, defaults to point1 -> point2.
        """
        if start_point and end_point:
            return np.array([end_point.x - start_point.x, end_point.y - start_point.y])
        return np.array([self.point2.x - self.point1.x, self.point2.y - self.point1.y])

    def get_normalized_vector(self, start_point: Point = None, end_point: Point = None) -> np.ndarray:
        """Returns the normalized (unit) vector of the line."""
        vec = self.get_vector(start_point, end_point)
        norm = np.linalg.norm(vec)
        if norm == 0:
            return np.array([0, 0]) # Represents a zero-length line segment
        return vec / norm

    def distance_point_to_infinite_line(self, point: Point) -> float:
        """
        Calculates the perpendicular distance from a point to the infinite line
        defined by this line segment.
        """
        p1_np = np.array([self.point1.x, self.point1.y])
        p2_np = np.array([self.point2.x, self.point2.y])
        p3_np = np.array([point.x, point.y])

        if np.array_equal(p1_np, p2_np): # If the line is just a point
            return np.linalg.norm(p3_np - p1_np)

        numerator = np.abs(np.cross(p2_np - p1_np, p1_np - p3_np))
        denominator = np.linalg.norm(p2_np - p1_np)
        if denominator == 0:
            return np.linalg.norm(p3_np - p1_np) # Distance to the single point
        return numerator / denominator
    
    def distance_point_to_segment(self, point: Point) -> float:
        """
        Calculates the shortest distance from a query point to this line segment.
        """
        # Convert query point and segment endpoints to numpy arrays
        p_np = point.get_numpy_array().astype(float)
        a_np = self.point1.get_numpy_array().astype(float) # Segment start (self.point1)
        b_np = self.point2.get_numpy_array().astype(float) # Segment end (self.point2)

        # If the segment is essentially a point (point1 and point2 are the same)
        if self.point1 == self.point2: # Relies on Point.__eq__
            return point.get_distance_between_points(self.point1)

        # Vector from A to B (segment vector)
        vec_ab = b_np - a_np
        # Vector from A to P (point relative to segment start)
        vec_ap = p_np - a_np

        t = np.dot(vec_ap, vec_ab) / np.dot(vec_ab, vec_ab)

        if 0.0 <= t <= 1.0:
            # The projection falls on the segment AB.
            # The shortest distance is the perpendicular distance from P to the line AB.
            # This can be calculated by self.distance_point_to_infinite_line(point).
            return self.distance_point_to_infinite_line(point)
        elif t < 0.0:
            # The projection falls outside the segment, on the side of A.
            # The closest point on the segment to P is A (self.point1).
            return point.get_distance_between_points(self.point1)
        else: # t > 1.0
            return point.get_distance_between_points(self.point2)

    def __repr__(self):
        return f"Line(start={self.point1}, end={self.point2}, angle={self.angle:.2f}, length={self.length:.2f})"

    def __eq__(self, other):
        if not isinstance(other, Line):
            return NotImplemented
        # A line is considered equal if its endpoints are the same, regardless of order.
        return (self.point1 == other.point1 and self.point2 == other.point2) or \
               (self.point1 == other.point2 and self.point2 == other.point1)

    def __hash__(self):
        """Allows Line objects to be added to sets. The hash is order-invariant for points."""
        # Hash the tuple of sorted point hashes
        return hash(tuple(sorted((hash(self.point1), hash(self.point2)))))


#####################################################################
#####################################################################
class Place:
    def __init__(
        self,
        circle: tuple[int, int, int], # (x, y, radius)
        original_detection_data=None, # Placeholder for any original detection data
    ):
        self.id = str(uuid.uuid4())
        self.center = Point(circle[0], circle[1])
        self.radius = circle[2]
        self.center.part_of = self # Link back to the Place object

        self.text = [] # Placeholder for any text associated with this place
        self.original_detection_data = original_detection_data 

        self.markers = 0 # Placeholder for markers associated with this place

    @classmethod
    def from_contour(cls, contour: np.ndarray):
        (x, y), radius = cv2.minEnclosingCircle(contour)
        return cls((x, y, radius), original_detection_data= contour)
    
    def update_markers_from_text(self):
        """
        Recalculates and updates self.markers by summing numeric values
        from associated Text objects in self.text.
        Only text values that consist purely of digits after stripping whitespace
        are considered numeric.
        """
        current_sum_of_markers = 0
        for text_obj in self.text: # self.text is a list of Text objects
            value_str = text_obj.value.strip()
            if is_number(value_str):
                try:
                    num_val = float(value_str)
                    
                    if num_val != float('inf') and num_val != float('-inf'):
                        current_sum_of_markers += int(num_val)
                        self.text.remove(text_obj) ## Remove the text object from the list
                    
                except ValueError:
                    pass 
        self.markers = current_sum_of_markers

    def get_name(self):
        if len(self.text) > 0:
            return " ".join(text.value for text in self.text if text.value.strip() != "")
        else:
            return ""

    def __repr__(self):
        return f"Place(center={self.center}, radius={self.radius})"
    
    def __eq__(self, other):
        if not isinstance(other, Place):
            return NotImplemented
        return (self.center == other.center and self.radius == other.radius)
    
    def __hash__(self):
        return hash((self.center, self.radius))
    

class Transition:
    def __init__(
        self,
        center_coords: tuple[int, int], # (x, y)
        height: int,
        width: int,
        angle: float = 0.0, # Default angle
        original_detection_data=None, 
    ):
        self.id = str(uuid.uuid4())
        self.center = Point(center_coords[0], center_coords[1])
        self.center.part_of = self

        self.height = height
        self.width = width
        self.angle = angle # Angle in degrees

        self.box_points = cv2.boxPoints(((self.center.x, self.center.y), (self.height, self.width), angle))

        self.points = [Point(int(pt[0]), int(pt[1])) for pt in self.box_points]
        for point in self.points:
            point.part_of = self

        self.text = [] 

        self.original_detection_data = original_detection_data 

    @classmethod
    def from_contour(cls, contour: np.ndarray):
        min_area_rect = cv2.minAreaRect(contour)
        return cls(min_area_rect[0], min_area_rect[1][0], min_area_rect[1][1], min_area_rect[2], original_detection_data=contour)
    
    def __repr__(self):
        return f"Transition(center={self.center}, height={self.height}, width={self.width}, angle={self.angle})"
    
    def get_name(self):
        if len(self.text) > 0:
            return " ".join(text.value for text in self.text if text.value.strip() != "")
        else:
            return ""
        
    def __eq__(self, other):
        if not isinstance(other, Transition):
            return NotImplemented
        return (self.center == other.center and self.height == other.height and self.width == other.width and self.angle == other.angle)
        
    def __hash__(self):
        return hash((self.center, self.height, self.width, self.angle))

### Potentially add an Arc class later if needed to represent the final connections
class Arc:
    def __init__(self, source, target, start_point, end_point, points=None, lines=None):
        self.id = str(uuid.uuid4())
        self.source = source # Place or Transition object
        self.target = target # Place or Transition object
        self.start_point = start_point # Point object
        self.end_point = end_point # Point object
        self.points = points # Optional: Ordered list of points forming the arc geometry
        self.lines = lines   # Optional: List of Line segments forming the arc geometry

        self.text = [] # Placeholder for any text associated with this place
        self.weight = 1

    def update_weight_from_text(self):
        """
        Recalculates and updates self.weight by summing numeric values
        from associated Text objects in self.text.
        Only text values that consist purely of digits after stripping whitespace
        are considered numeric.
        """
        current_sum_of_weight = 1
        for text_obj in self.text: # self.text is a list of Text objects
            value_str = text_obj.value.strip()
            if is_number(value_str):
                try:
                    num_val = float(value_str)
                    if num_val != float('inf') and num_val != float('-inf'):
                        current_sum_of_weight = int(num_val)
                        self.text.remove(text_obj) ## Remove the text object from the list
                except ValueError:
                    pass 
        self.weight = current_sum_of_weight

    def get_name(self):
        if len(self.text) > 0:
            return " ".join(text.value for text in self.text if text.value.strip() != "")
        else:
            return ""

    def __repr__(self):
        return f"Arc(source={self.source}, target={self.target})"
    
    def __eq__(self, other):
        if not isinstance(other, Arc):
            return NotImplemented
        return (self.source == other.source and self.target == other.target)
    
    def __hash__(self):
        return hash((self.source, self.target))

class Text:
    """Represents a detected text element with its content and bounding box."""
    # Store geometry as absolute integer coordinates
    def __init__(self, value: str, geometry_abs: tuple[tuple[int, int], tuple[int, int]], confidence: float):
        """
        Args:
            value: The recognized text string.
            geometry_abs: Bounding box absolute coordinates ((xmin, ymin), (xmax, ymax)).
            confidence: The recognition confidence score.
        """
        self.value = value
        self.pt1 = Point(geometry_abs[0][0], geometry_abs[0][1])
        self.pt2 = Point(geometry_abs[1][0], geometry_abs[1][1])
        self.center = Point(
            (self.pt1.x + self.pt2.x) // 2,
            (self.pt1.y + self.pt2.y) // 2
        )
        self.confidence = confidence

    def __repr__(self):
        return f"Text(value='{self.value}', box=({self.pt1.x},{self.pt1.y})-({self.pt2.x},{self.pt2.y}), conf={self.confidence:.2f})"