Skip to content

Commit 9863c9b

Browse files
committed
remove pandas + hit as tuple + more type hints
1 parent 6d9c2d1 commit 9863c9b

File tree

5 files changed

+106
-91
lines changed

5 files changed

+106
-91
lines changed

Diff for: MTM/NMS.py

+48-40
Original file line numberDiff line numberDiff line change
@@ -12,81 +12,89 @@
1212
1313
@author: Laurent Thomas
1414
"""
15-
1615
import cv2
16+
from typing import Tuple, List, Sequence
1717

18+
Hit = Tuple[str, Tuple[int, int, int, int], float]
1819

19-
def NMS(tableHit, scoreThreshold=0.5, sortAscending=False, N_object=float("inf"), maxOverlap=0.5):
20+
def NMS(listHit:Sequence[Hit], scoreThreshold:float=0.5, sortAscending:bool=False, N_object=float("inf"), maxOverlap:float=0.5) -> List[Hit]:
2021
"""
21-
Perform Non-Maxima Supression (NMS).
22+
Perform Non-Maxima Supression (NMS)
2223
2324
it compares the hits after maxima/minima detection, and removes detections overlapping above the maxOverlap
2425
Also removes detections that do not satisfy a minimum score
2526
26-
INPUT
27-
- tableHit : Pandas DataFrame
28-
List of potential detections as returned by MTM.findMatches.
29-
Each row is a hit, with columns "TemplateName"(String),"BBox"(x,y,width,height),"Score"(float).
27+
Parameters
28+
----------
29+
- listHit
30+
A list (or equivalent) of potential detections in the form (label, (x,y,width,height), score) as returned by MTM.findMatches.
3031
31-
- scoreThreshold : Float, used to remove low score detections.
32-
If sortAscending=False, ie when best detections have high scores (correlation method), the detections with score below the threshold are discarded.
33-
If sortAscending=True, ie when best detections have low scores (difference method), the detections with score above the threshold are discarded.
32+
- scoreThreshold, float, default 0.5
33+
used to remove low score detections.
34+
If sortAscending=False, ie when best detections have high scores (correlation method), the detections with score below the threshold are discarded.
35+
If sortAscending=True, ie when best detections have low scores (difference method), the detections with score above the threshold are discarded.
3436
35-
- sortAscending : Boolean
36-
use True when low score means better prediction (Difference-based score), False otherwise (Correlation score).
37+
- sortAscending, bool
38+
use True when low score means better prediction (Difference-based score), False otherwise (Correlation score).
3739
38-
- N_object : int or infinity/float("inf")
39-
maximum number of best detections to return, to use when the number of object is known.
40-
Otherwise Default=infinity, ie return all detections passing NMS.
40+
- N_object, int or infinity/float("inf")
41+
maximum number of best detections to return, to use when the number of object is known.
42+
Otherwise Default=infinity, ie return all detections passing NMS.
4143
42-
- maxOverlap : float between 0 and 1
43-
the maximal overlap (IoU: Intersection over Union of the areas) authorised between 2 bounding boxes.
44-
Above this value, the bounding box of lower score is deleted.
44+
- maxOverlap, float between 0 and 1
45+
the maximal overlap (IoU: Intersection over Union of the areas) authorised between 2 bounding boxes.
46+
Above this value, the bounding box of lower score is deleted.
4547
4648
4749
Returns
4850
-------
49-
Panda DataFrame with best detection after NMS, it contains max N detections (but potentially less)
51+
A list of hit with no overlapping detection (not above the maxOverlap)
5052
"""
51-
listBoxes = tableHit["BBox"].to_list()
52-
listScores = tableHit["Score"].to_list()
53+
nHits = len(listHit)
54+
if nHits <= 1:
55+
return listHit[:] # same just making a copy to avoid side effects
56+
57+
# Get separate lists for the bounding boxe coordinates and their score
58+
listBoxes = [None] * nHits # list of (x,y,width,height)
59+
listScores = [None] * nHits # list of associated scores
5360

54-
if N_object==1:
61+
for i, hit in enumerate(listHit): # single iteration through the list instead of using 2 list comprehensions
62+
listBoxes[i] = hit[1]
63+
listScores[i] = hit[2]
64+
65+
if N_object == 1:
5566

56-
# Get row with highest or lower score
67+
# Get hit with highest or lower score
5768
if sortAscending:
58-
outTable = tableHit[tableHit.Score == tableHit.Score.min()]
69+
bestHit = min(listHit, key = lambda hit: hit[2])
5970
else:
60-
outTable = tableHit[tableHit.Score == tableHit.Score.max()]
71+
bestHit = max(listHit, key = lambda hit: hit[2])
6172

62-
return outTable
73+
return [bestHit] # wrap it into a list so the function always returns a list
6374

6475

6576
# N object > 1 -> do NMS
6677
if sortAscending: # invert score to have always high-score for bets prediction
6778
listScores = [1-score for score in listScores] # NMS expect high-score for good predictions
6879
scoreThreshold = 1-scoreThreshold
6980

70-
# Do NMS
81+
# Do NMS, it returns a list of the positional indexes of the hits in the original list that pass the NMS test
7182
indexes = cv2.dnn.NMSBoxes(listBoxes, listScores, scoreThreshold, maxOverlap)
7283

73-
# Get N best hit
84+
# Eventually take only up to n hit if provided
7485
if N_object != float("inf"):
7586
indexes = indexes[:N_object]
7687

77-
outTable = tableHit.iloc[indexes]
78-
79-
return outTable
80-
88+
return [listHit[x] for x in indexes]
8189

8290
if __name__ == "__main__":
83-
import pandas as pd
84-
listHit =[
85-
{'TemplateName':1,'BBox':(780, 350, 700, 480), 'Score':0.8},
86-
{'TemplateName':1,'BBox':(806, 416, 716, 442), 'Score':0.6},
87-
{'TemplateName':1,'BBox':(1074, 530, 680, 390), 'Score':0.4}
88-
]
91+
import numpy as np
92+
93+
listHit = [("1", (780, 350, 700, 480), 0.8),
94+
("1", (806, 416, 716, 442), 0.6),
95+
("1", (1074, 530, 680, 390), 0.4)]
96+
8997

90-
finalHits = NMS( pd.DataFrame(listHit), scoreThreshold=0.61, sortAscending=False, maxOverlap=0.8, N_object=1 )
98+
finalHits = NMS(listHit, scoreThreshold=0.3, sortAscending=False, maxOverlap=0.5, N_object=2)
9199

92-
print(finalHits)
100+
print(np.array(finalHits, dtype=object)) # does not work if not specifying explicitely dtype=object

Diff for: MTM/__init__.py

+50-42
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,27 @@
22
import os
33
import warnings
44
from concurrent.futures import ThreadPoolExecutor, as_completed
5+
from typing import Tuple, List, Sequence, Optional
56

67
import cv2
78
import numpy as np
8-
import pandas as pd
99
from scipy.signal import find_peaks
1010
from skimage.feature import peak_local_max
1111

12-
from .NMS import NMS
12+
from .NMS import NMS, Hit
1313
from .version import __version__
1414

1515
__all__ = ['NMS']
1616

17+
# Define custom "types" for type hints
18+
BBox = Tuple[int, int, int, int] # bounding box in the form (x,y,width,height) with x,y top left corner
19+
1720
def _findLocalMax_(corrMap, score_threshold=0.6):
1821
"""Get coordinates of the local maximas with values above a threshold in the image of the correlation map."""
19-
# IF depending on the shape of the correlation map
22+
# If depending on the shape of the correlation map
2023
if corrMap.shape == (1,1): ## Template size = Image size -> Correlation map is a single digit')
2124

22-
if corrMap[0,0]>=score_threshold:
25+
if corrMap[0,0] >= score_threshold:
2326
peaks = np.array([[0,0]])
2427
else:
2528
peaks = []
@@ -48,7 +51,7 @@ def _findLocalMin_(corrMap, score_threshold=0.4):
4851
return _findLocalMax_(-corrMap, -score_threshold)
4952

5053

51-
def computeScoreMap(template, image, method=cv2.TM_CCOEFF_NORMED, mask=None):
54+
def computeScoreMap(template, image, method:int = cv2.TM_CCOEFF_NORMED, mask=None):
5255
"""
5356
Compute score map provided numpy array for template and image (automatically converts images if necessary).
5457
The template must be smaller or as large as the image.
@@ -87,10 +90,9 @@ def computeScoreMap(template, image, method=cv2.TM_CCOEFF_NORMED, mask=None):
8790
return cv2.matchTemplate(image, template, method, mask=mask)
8891

8992

90-
def findMatches(listTemplates, image, method=cv2.TM_CCOEFF_NORMED, N_object=float("inf"), score_threshold=0.5, searchBox=None):
93+
def findMatches(listTemplates, image, method:int = cv2.TM_CCOEFF_NORMED, N_object=float("inf"), score_threshold:float=0.5, searchBox:Optional[BBox] = None) -> List[Hit]:
9194
"""
9295
Find all possible templates locations satisfying the score threshold provided a list of templates to search and an image.
93-
Returns a pandas dataframe with one row per detection.
9496
9597
Parameters
9698
----------
@@ -117,7 +119,10 @@ def findMatches(listTemplates, image, method=cv2.TM_CCOEFF_NORMED, N_object=floa
117119
118120
Returns
119121
-------
120-
- Pandas DataFrame with 1 row per hit and column "TemplateName"(string), "BBox":(X, Y, Width, Height), "Score":float
122+
A list of hit where each hit is a tuple as following ["TemplateName", (x, y, width, height), score]
123+
where template name is the name (or label) of the matching template
124+
(x, y, width, height) is a tuple of the bounding box coordinates in pixels, with xy the coordinates for the top left corner
125+
score (float) for the confidence of the detection
121126
"""
122127
if N_object != float("inf") and not isinstance(N_object, int):
123128
raise TypeError("N_object must be an integer")
@@ -132,9 +137,9 @@ def findMatches(listTemplates, image, method=cv2.TM_CCOEFF_NORMED, N_object=floa
132137
## Crop image to search region if provided
133138
if searchBox is not None:
134139
xOffset, yOffset, searchWidth, searchHeight = searchBox
135-
image = image[yOffset : yOffset+searchHeight, xOffset : xOffset+searchWidth]
140+
image = image[yOffset : yOffset + searchHeight, xOffset : xOffset + searchWidth]
136141
else:
137-
xOffset=yOffset=0
142+
xOffset = yOffset = 0
138143

139144
# Check that the template are all smaller are equal to the image (original, or cropped if there is a search region)
140145
for index, tempTuple in enumerate(listTemplates):
@@ -160,22 +165,19 @@ def findMatches(listTemplates, image, method=cv2.TM_CCOEFF_NORMED, N_object=floa
160165
raise ValueError("Template '{}' at index {} in the list of templates is larger than {}.".format(tempName, index, fitIn) )
161166

162167
listHit = []
163-
## Use multi-threading to iterate through all templates, using half the number of cpu cores available.
168+
# Use multi-threading to iterate through all templates, using half the number of cpu cores available.
169+
# i.e parallelize the search with the individual templates, in the same image
164170
with ThreadPoolExecutor(max_workers=round(os.cpu_count()*.5)) as executor:
165171
futures = [executor.submit(_multi_compute, tempTuple, image, method, N_object, score_threshold, xOffset, yOffset, listHit) for tempTuple in listTemplates]
166172
for future in as_completed(futures):
167173
_ = future.result()
168174

169-
if listHit:
170-
return pd.DataFrame(listHit) # All possible hits before Non-Maxima Supression
171-
else:
172-
return pd.DataFrame(columns=["TemplateName", "BBox", "Score"])
173-
175+
return listHit # All possible hits before Non-Maxima Supression
174176

175-
def _multi_compute(tempTuple, image, method, N_object, score_threshold, xOffset, yOffset, listHit):
177+
def _multi_compute(tempTuple, image, method:int, N_object:int, score_threshold:float, xOffset:int, yOffset:int, listHit:Sequence[Hit]):
176178
"""
177179
Find all possible template locations satisfying the score threshold provided a template to search and an image.
178-
Add the hits in the list of hits.
180+
Add the hits found to the provided listHit, this function is running in parallel each instance for a different templates.
179181
180182
Parameters
181183
----------
@@ -203,8 +205,7 @@ def _multi_compute(tempTuple, image, method, N_object, score_threshold, xOffset,
203205
- yOffset : int
204206
optional the y offset if the search area is provided
205207
206-
- listHit : the list of hits which we want to add the discovered hit
207-
expected array of hits
208+
- listHit : New hits are added to this list
208209
"""
209210
templateName, template = tempTuple[:2]
210211
mask = None
@@ -232,19 +233,16 @@ def _multi_compute(tempTuple, image, method, N_object, score_threshold, xOffset,
232233
peaks = _findLocalMax_(corrMap, score_threshold)
233234

234235
#print('Initially found',len(peaks),'hit with this template')
235-
236-
# Once every peak was detected for this given template
237-
## Create a dictionnary for each hit with {'TemplateName':, 'BBox': (x,y,Width, Height), 'Score':coeff}
238-
239236
height, width = template.shape[0:2] # slicing make sure it works for RGB too
240237

241-
for peak in peaks :
242-
# append to list of potential hit before Non maxima suppression
243-
# no need to lock the list, append is thread-safe
244-
listHit.append({'TemplateName':templateName, 'BBox': ( int(peak[1])+xOffset, int(peak[0])+yOffset, width, height ) , 'Score':corrMap[tuple(peak)]}) # empty df with correct column header
238+
# For each peak create a hit as a list [templateName, (x,y,width,height), score] and add this hit into a bigger list
239+
newHits = [ (templateName, (int(peak[1]) + xOffset, int(peak[0]) + yOffset, width, height), corrMap[tuple(peak)] ) for peak in peaks]
240+
241+
# Finally add these new hits to the original list of hits
242+
listHit.extend(newHits)
245243

246244

247-
def matchTemplates(listTemplates, image, method=cv2.TM_CCOEFF_NORMED, N_object=float("inf"), score_threshold=0.5, maxOverlap=0.25, searchBox=None):
245+
def matchTemplates(listTemplates, image, method:int = cv2.TM_CCOEFF_NORMED, N_object = float("inf"), score_threshold:float = 0.5, maxOverlap:float = 0.25, searchBox:Optional[BBox] = None) -> List[Hit]:
248246
"""
249247
Search each template in the image, and return the best N_object locations which offer the best score and which do not overlap above the maxOverlap threshold.
250248
@@ -278,23 +276,25 @@ def matchTemplates(listTemplates, image, method=cv2.TM_CCOEFF_NORMED, N_object=f
278276
279277
Returns
280278
-------
281-
Pandas DataFrame with 1 row per hit and column "TemplateName"(string), "BBox":(X, Y, Width, Height), "Score":float
279+
A list of hit, where each hit is a tuple in the form (label, (x, y, width, height), score)
282280
if N=1, return the best matches independently of the score_threshold
283281
if N<inf, returns up to N best matches that passed the NMS
284282
if N=inf, returns all matches that passed the NMS
285283
"""
286-
if maxOverlap<0 or maxOverlap>1:
284+
if maxOverlap < 0 or maxOverlap > 1:
287285
raise ValueError("Maximal overlap between bounding box is in range [0-1]")
288286

289-
tableHit = findMatches(listTemplates, image, method, N_object, score_threshold, searchBox)
287+
listHits = findMatches(listTemplates, image, method, N_object, score_threshold, searchBox)
290288

291-
if method == 0: raise ValueError("The method TM_SQDIFF is not supported. Use TM_SQDIFF_NORMED instead.")
289+
if method == 0:
290+
raise ValueError("The method TM_SQDIFF is not supported. Use TM_SQDIFF_NORMED instead.")
291+
292292
sortAscending = (method==1)
293293

294-
return NMS(tableHit, score_threshold, sortAscending, N_object, maxOverlap)
294+
return NMS(listHits, score_threshold, sortAscending, N_object, maxOverlap)
295295

296296

297-
def drawBoxesOnRGB(image, tableHit, boxThickness=2, boxColor=(255, 255, 00), showLabel=False, labelColor=(255, 255, 0), labelScale=0.5 ):
297+
def drawBoxesOnRGB(image, listHit:Sequence[Hit], boxThickness:int=2, boxColor:Tuple[int,int,int] = (255, 255, 00), showLabel:bool=False, labelColor=(255, 255, 0), labelScale=0.5 ):
298298
"""
299299
Return a copy of the image with predicted template locations as bounding boxes overlaid on the image
300300
The name of the template can also be displayed on top of the bounding box with showLabel=True
@@ -303,7 +303,7 @@ def drawBoxesOnRGB(image, tableHit, boxThickness=2, boxColor=(255, 255, 00), sho
303303
----------
304304
- image : image in which the search was performed
305305
306-
- tableHit: list of hit as returned by matchTemplates or findMatches
306+
- listHit: list of hit as returned by matchTemplates or findMatches
307307
308308
- boxThickness: int
309309
thickness of bounding box contour in pixels
@@ -322,13 +322,21 @@ def drawBoxesOnRGB(image, tableHit, boxThickness=2, boxColor=(255, 255, 00), sho
322322
original image with predicted template locations depicted as bounding boxes
323323
"""
324324
# Convert Grayscale to RGB to be able to see the color bboxes
325-
if image.ndim == 2: outImage = cv2.cvtColor(image, cv2.COLOR_GRAY2RGB) # convert to RGB to be able to show detections as color box on grayscale image
326-
else: outImage = image.copy()
325+
outImage = cv2.cvtColor(image, cv2.COLOR_GRAY2RGB) if image.ndim == 2 else image.copy
327326

328-
for _, row in tableHit.iterrows():
329-
x,y,w,h = row['BBox']
330-
cv2.rectangle(outImage, (x, y), (x+w, y+h), color=boxColor, thickness=boxThickness)
331-
if showLabel: cv2.putText(outImage, text=row['TemplateName'], org=(x, y), fontFace=cv2.FONT_HERSHEY_SIMPLEX, fontScale=labelScale, color=labelColor, lineType=cv2.LINE_AA)
327+
for label, bbox, _ in listHit:
328+
329+
x,y,w,h = bbox
330+
cv2.rectangle(outImage, (x, y), (x + w, y + h), color = boxColor, thickness = boxThickness)
331+
332+
if showLabel:
333+
cv2.putText(outImage,
334+
text=label,
335+
org=(x, y),
336+
fontFace=cv2.FONT_HERSHEY_SIMPLEX,
337+
fontScale=labelScale,
338+
color=labelColor,
339+
lineType=cv2.LINE_AA)
332340

333341
return outImage
334342

Diff for: old_requirements.txt

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
numpy==1.22.0
22
opencv-python-headless==4.2.0.32
33
scikit-image==0.15.0
4-
scipy==1.10.0
5-
pandas==0.25.0
4+
scipy==1.10.0

Diff for: setup.py

-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
'opencv-python-headless>=4.5.4',
2424
'scikit-image',
2525
'scipy',
26-
'pandas'
2726
],
2827
classifiers=[
2928
"Programming Language :: Python :: 3",

Diff for: test.py

+7-6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
44
Make sure the active directory is the directory of the repo when running the test in a IDE
55
"""
6+
#%%
67
from skimage.data import coins
78
import matplotlib.pyplot as plt
89
import MTM, cv2
@@ -20,15 +21,15 @@
2021

2122

2223
#%% Perform matching
23-
tableHit = MTM.matchTemplates(listTemplates, image, score_threshold=0.3, method=cv2.TM_CCOEFF_NORMED, maxOverlap=0) # Correlation-score
24+
listHit = MTM.matchTemplates(listTemplates, image, score_threshold=0.3, method=cv2.TM_CCOEFF_NORMED, maxOverlap=0) # Correlation-score
2425
#tableHit = MTM.matchTemplates(listTemplates, image, score_threshold=0.4, method=cv2.TM_SQDIFF_NORMED, maxOverlap=0) # Difference-score
2526

26-
print("Found {} coins".format(len(tableHit)))
27-
print(tableHit)
27+
print("Found {} coins".format(len(listHit)))
28+
print(np.array(listHit, dtype=object))
2829

2930

3031
#%% Display matches
31-
Overlay = MTM.drawBoxesOnRGB(image, tableHit, showLabel=True)
32+
Overlay = MTM.drawBoxesOnRGB(image, listHit, showLabel=True)
3233
plt.figure()
3334
plt.imshow(Overlay)
3435

@@ -46,9 +47,9 @@
4647
import gluoncv as gcv
4748

4849
# Convert from x,y,w,h to xmin, ymin, xmax, ymax
49-
BBoxes_xywh = np.array( tableHit["BBox"].tolist() )
50+
BBoxes_xywh = np.array( [hit[1] for hit in listHit] )
5051
BBoxes_xyxy = gcv.utils.bbox.bbox_xywh_to_xyxy(BBoxes_xywh)
5152

52-
Overlay2 = gcv.utils.viz.cv_plot_bbox(cv2.cvtColor(image, cv2.COLOR_GRAY2RGB), BBoxes_xyxy.astype("float64"), scores=tableHit["Score"].to_numpy(), thresh=0 )
53+
Overlay2 = gcv.utils.viz.cv_plot_bbox(cv2.cvtColor(image, cv2.COLOR_GRAY2RGB), BBoxes_xyxy.astype("float64"), scores=[hit[2] for hit in listHit], thresh=0 )
5354
plt.figure()
5455
plt.imshow(Overlay2)

0 commit comments

Comments
 (0)