-
Notifications
You must be signed in to change notification settings - Fork 0
/
subject.py
241 lines (213 loc) · 13 KB
/
subject.py
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
import cv2
import numpy
import math
class Subject:
""" The Subject class captures individual areas of activity within a single frame - essentially, objects / subjects.
Subject has required_for Subject.setup() and Subject.setup_dimensions_numpy() methods which must be called before
the first Subject instance is created, in order to set key parameters. Subject is then initialised with a
contour representing the subject.
Subject has no project-specific dependencies.
"""
#
# ##### CLASS ATTRIBUTES #####
#
_is_setup = False
_bounds_padding = None
_annotate_line_colour = None
_absolute_intensity_threshold = None
_min_difference_area_percent = None
_min_difference_area_pixels = None
_dilate_pixels = None
_is_setup_dimensions_numpy = False
_dimensions_numpy = None
# findContours returns (image, contours, hierarchy) in OpenCV 3, but (contours, hierarchy) in OpenCV 2 and OpenCV 4
_contours_return_index = 1 if cv2.__version__.startswith('3.') else 0
#
# ##### SETUP METHODS #####
#
@staticmethod
def setup(bounds_padding, annotate_line_colour, absolute_intensity_threshold, min_difference_area_percent,
min_difference_area_pixels, dilate_pixels):
""" Subject.setup() must be called prior to creating a Subject instance.
Typically this would be at the top of the main file.
:param bounds_padding:
:param annotate_line_colour: A tuple in BGR format i.e. (b, g, r), with each value 0-255
:param absolute_intensity_threshold: In range 0-255, as min threshold for considering a pixel as different
:param min_difference_area_percent: Fraction (range 0-1) for % of difference in subject area to be active
:param min_difference_area_pixels: Absolute pixels for difference in subject area to be considered active
:param dilate_pixels: Number of pixels by which to dilate the subject contour mask
"""
Subject._bounds_padding = bounds_padding
Subject._annotate_line_colour = annotate_line_colour
Subject._absolute_intensity_threshold = absolute_intensity_threshold
Subject._min_difference_area_percent = min_difference_area_percent
Subject._min_difference_area_pixels = min_difference_area_pixels
Subject._dilate_pixels = dilate_pixels
Subject._is_setup = True
@staticmethod
def setup_dimensions_numpy(dimensions_numpy):
""" setup_dimensions_numpy() must be called prior to creating a Subject instance.
Typically this would be called as part of the setup of Frame, when _dimensions will be first known.
:param dimensions_numpy:
"""
Subject._dimensions_numpy = dimensions_numpy
Subject._is_setup_dimensions_numpy = True
#
# ##### INIT METHOD #####
#
def __init__(self, contour):
""" Initialises the key parameters of a new subject, as defined by a contour.
Provides methods and attributes which can be accessed later, providing other subject-related features.
:param contour: should be in valid OpenCV (i.e. findContours) format.
"""
# Check here that Subject class attributes are set
if not Subject._is_setup:
raise Exception('Must call Subject.setup before creating a Subject instance')
if not Subject._is_setup_dimensions_numpy:
raise Exception('Must call Subject.setup_dimensions_numpy before creating a Subject instance')
# Save / initialise key values for use later
self.contour = contour
self.contour_dilated = self.contour_dilate()
contour_moments = cv2.moments(contour)
self.contour_center = (int(contour_moments['m10']/contour_moments['m00']),
int(contour_moments['m01']/contour_moments['m00']))
self.contour_area = cv2.contourArea(contour) # Returns the enclosed area of the contour, in pixels
self.bounds = cv2.boundingRect(contour) # Produces [x, y, w, h]
# Note crop_params uses format [y1, y2, x1, x2] - note usage for cropping images: image[y1: y2, x1: x2]
self.crop_params = [max(self.bounds[1] - Subject._bounds_padding, 0),
min(self.bounds[1] + self.bounds[3] + Subject._bounds_padding,
Subject._dimensions_numpy[0]),
max(self.bounds[0] - Subject._bounds_padding, 0),
min(self.bounds[0] + self.bounds[2] + Subject._bounds_padding,
Subject._dimensions_numpy[1])]
self._is_active = None
self.is_tracked = False # Set externally if added to a Track class
self.is_used = False # Set externally if used within a Composite image
self.audit = {} # Stores interim steps of calculations, images, etc for debug / explainability
#
# ##### PUBLIC PROPERTIES #####
#
@property
def is_active(self):
""" Returns True if subject is moving compared to previous frame; otherwise False - but must be explicitly
calculated using Subject.test_if_active.
Simple property which will raise an exception if it is called prior to being set.
"""
if self._is_active is None:
raise EOFError('Subject.is_active can only be called after Subject.test_is_active.')
return self._is_active
#
# ##### OTHER PUBLIC METHODS #####
#
def get_cropped_img(self, img, annotate=False):
""" Generates and returns a copy of the supplied image, cropped to just include this subject (plus padding).
:param img: Should be a 'large' size image; can be colour or greyscale, but note annotation will be grey if
the supplied image is greyscale.
:param annotate: If set to true, will also draw the contour onto the cropped image before it is returned.
:return: Returns a cropped version of the image.
"""
if not (img.shape[0] == Subject._dimensions_numpy[0] and img.shape[1] == Subject._dimensions_numpy[1]):
raise Exception('Image provided to Subject.get_cropped_img is the incorrect size. Should be large.')
cropped_img = img[self.crop_params[0]: self.crop_params[1],
self.crop_params[2]: self.crop_params[3]].copy() # Create a copy to prevent over-writing
if annotate:
cv2.drawContours(cropped_img, [self.contour], -1, Subject._annotate_line_colour, 1,
offset=(-self.crop_params[2], -self.crop_params[0]))
return cropped_img
def test_if_active(self, prev_img_greyblur, this_img_greyblur):
""" Compares the previous frame to current frame, only within the region of the subject, to determine activity.
Note that a subject is considered 'active' if the difference between the previous and current frame, just
within the subject contour boundary, is more than x% of the total area, or an area of at least ypx.
:param prev_img_greyblur: Must be the 'large' size greyblur image for the previous frame.
:param this_img_greyblur: Must be the 'large' size greyblur image for the current frame.
:return: Returns boolean, describing whether the subject is considered active.
"""
# Get just the cropped areas of each image, then get a difference _retain_mask of them
self.audit['prev_cropped_img'] = self.get_cropped_img(prev_img_greyblur)
self.audit['this_cropped_img'] = self.get_cropped_img(this_img_greyblur)
self.audit['prev_basic'] = cv2.absdiff(self.audit['prev_cropped_img'],
self.audit['this_cropped_img'])
self.audit['prev_absolute'] = cv2.threshold(self.audit['prev_basic'],
Subject._absolute_intensity_threshold,
255,
cv2.THRESH_BINARY)[1] # Note [1] returns just the array / image
# Create a _retain_mask for just the subject area, and apply this to the difference _retain_mask to ensure only
# differences within the subject contour are considered
self.audit['subject_mask'] = self.get_subject_mask(crop=True)
self.audit['prev_masked_absolute'] = numpy.bitwise_and(self.audit['prev_absolute'],
self.audit['subject_mask'])
# Calculate the number of pixels which have changed sufficiently, then check if that exceeds defined threshold
self.audit['difference_area'] = numpy.count_nonzero(self.audit['prev_masked_absolute'])
if (self.audit['difference_area'] / self.contour_area > Subject._min_difference_area_percent
or self.audit['difference_area'] > Subject._min_difference_area_pixels):
# More than x% or ypx of previous difference contour_area is still different, hence object still moving
self._is_active = True
else:
self._is_active = False
return self._is_active
def get_subject_mask(self, dilated=False, crop=False, invert=False):
""" Creates an 'include _retain_mask' for the subject, i.e. non-zero (255) where the subject is.
The default is for the background to be zeros, with the subject as 255's (white / non-zero).
:param dilated: Boolean; if true, returns a dilated contour, rather than the original.
:param crop: Boolean; if true crops to the subject contour + padding boundary.
:param invert: Boolean; if true the subject is 0's, whilst the background is 255's.
:return: The selected _retain_mask, as a numpy array.
"""
# Prepare _dimensions, offset values, base _retain_mask and fill colour depending on options chosen.
if crop:
dimensions_numpy = (self.crop_params[1] - self.crop_params[0],
self.crop_params[3] - self.crop_params[2])
offset = (-self.crop_params[2],
-self.crop_params[0])
else:
dimensions_numpy = Subject._dimensions_numpy
offset = (0, 0)
if invert:
subject_mask = numpy.ones(dimensions_numpy, numpy.uint8) * 255
fill_colour = (0, 0, 0)
else:
subject_mask = numpy.zeros(dimensions_numpy, numpy.uint8)
fill_colour = (255, 255, 255)
# Draw the subject on the _retain_mask, using the selected options, and return it.
if dilated:
selected_contour = self.contour_dilated
else:
selected_contour = self.contour
cv2.drawContours(subject_mask, [selected_contour], -1, fill_colour, cv2.FILLED, offset=offset)
return subject_mask
def contour_dilate(self):
""" Generates and returns a dilated version of the contour, to give cleaner edges to subjects when composited.
Amount of dilation is set within Subject.setup(). The mask itself is not currently saved; just the contour.
:return: Returns a single dilated contour.
"""
subject_mask = numpy.zeros(Subject._dimensions_numpy, numpy.uint8)
cv2.drawContours(subject_mask, [self.contour], -1, (255, 255, 255), cv2.FILLED)
subject_mask = cv2.morphologyEx(subject_mask,
cv2.MORPH_DILATE,
numpy.ones((Subject._dilate_pixels, Subject._dilate_pixels), numpy.uint8))
contour_dilate = cv2.findContours(subject_mask,
cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)[self._contours_return_index]
# As we start with only one contour, and are dilating it, we're guaranteed to still only have one - hence [0]
return contour_dilate[0]
def dist_from_point(self, point_xy):
""" Calculate and return the distance (in pixels) of the centre of the subject from the specified point.
This is calculated with simple trigonometry = sqrt(x^2 + y^2)
:param point_xy: A tuple specifying the point from which to measure, as (x, y)
:return: Returns the distance between the points, in pixels
"""
return math.sqrt((abs(point_xy[0] - self.contour_center[0]) ** 2) +
(abs(point_xy[1] - self.contour_center[1]) ** 2))
# NEW:
def within_trigger_zone(self, trigger_zones):
results = {}
for trigger_zone in trigger_zones:
if trigger_zone['type'] == 'contour':
contour = numpy.array(trigger_zone['value'], dtype=numpy.int32)
tz_mask = numpy.zeros(Subject._dimensions_numpy, numpy.uint8)
cv2.drawContours(tz_mask, [contour], -1, (255, 255, 255), cv2.FILLED)
if tz_mask[self.contour_center[1], self.contour_center[0]] == 255:
results[trigger_zone['label']] = True
else:
results[trigger_zone['label']] = False
return results