-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathbase.py
More file actions
359 lines (296 loc) · 11.8 KB
/
Copy pathbase.py
File metadata and controls
359 lines (296 loc) · 11.8 KB
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 json
from typing import Union
import numpy as np
import sqlalchemy
import torch
import torch.utils.data
import torchvision.transforms
from sentry_sdk import start_transaction
from trapdata import logger
from trapdata.common.schemas import FilePath
from trapdata.common.utils import slugify
from trapdata.db.models.queue import QueueManager
from trapdata.ml.utils import StopWatch, get_device, get_or_download_file
class BatchEmptyException(Exception):
pass
def zero_okay_collate(batch):
"""
If the queue is cleared or shortened before the original batch count is complete
then the dataloader will crash. This catches the empty batch more gracefully.
@TODO switch to streaming IterableDataset type.
"""
if any(not item for item in batch):
logger.debug(f"There's a None in the batch of len {len(batch)}")
return None
else:
return torch.utils.data.default_collate(batch)
imagenet_normalization = torchvision.transforms.Normalize(
# "torch preprocessing"
mean=[0.485, 0.456, 0.406], # RGB
std=[0.229, 0.224, 0.225], # RGB
)
tensorflow_normalization = torchvision.transforms.Normalize(
# -1 to 1
mean=[0.5, 0.5, 0.5], # RGB
std=[0.5, 0.5, 0.5], # RGB
)
generic_normalization = torchvision.transforms.Normalize(
# 0 to 1
mean=[0.5, 0.5, 0.5], # RGB
std=[0.5, 0.5, 0.5], # RGB
)
class InferenceBaseClass:
"""
Base class for all batch-inference models.
This outlines a common interface for all classifiers and object detectors.
Generic methods like `get_weights_from_url` are defined here, but
methods that return NotImplementedError must be overridden in a subclass
that is specific to each inference model.
See examples in `classification.py` and `localization.py`
"""
db_path: Union[str, sqlalchemy.engine.URL]
image_base_path: FilePath
name = "Unknown Inference Model"
description = str()
model_type = None
device = None
weights_path = None
weights = None
labels_path = None
category_map = {}
class_masking_list = None
num_classes: Union[int, None] = None # Will use len(category_map) if None
lookup_gbif_names: bool = False
default_taxon_rank: str = "SPECIES"
model: torch.nn.Module
normalization = tensorflow_normalization
transforms: torchvision.transforms.Compose
batch_size = 4
num_workers = 1
user_data_path = None
type = "unknown"
stage = 0
single = True
queue: QueueManager
dataset: torch.utils.data.Dataset
dataloader: torch.utils.data.DataLoader
def __init__(
self,
db_path: Union[str, sqlalchemy.engine.URL],
image_base_path: FilePath,
**kwargs,
):
self.db_path = db_path
self.image_base_path = image_base_path
for k, v in kwargs.items():
setattr(self, k, v)
logger.info(f"Initializing inference class {self.name}")
self.device = self.device or get_device()
self.category_map = self.get_labels(self.labels_path)
self.num_classes = self.num_classes or len(self.category_map)
self.weights = self.get_weights(self.weights_path)
self.transforms = self.get_transforms()
self.queue = self.get_queue()
self.dataset = self.get_dataset()
self.dataloader = self.get_dataloader()
logger.info(
f"Loading {self.type} model (stage: {self.stage}) for {self.name} with {len(self.category_map or [])} categories"
)
self.model = self.get_model()
self.class_masking_list = self.get_class_masking_list()
@classmethod
def get_key(cls):
if hasattr(cls, "key") and cls.key: # type: ignore
return cls.key # type: ignore
else:
return slugify(cls.name)
def get_weights(self, weights_path):
if weights_path:
return get_or_download_file(
weights_path,
self.user_data_path or torch.hub.get_dir(),
prefix="models",
)
else:
logger.warn(f"No weights specified for model {self.name}")
def get_labels(self, labels_path) -> dict[int, str]:
if labels_path:
local_path = get_or_download_file(
labels_path,
self.user_data_path or torch.hub.get_dir(),
prefix="models",
)
with open(local_path) as f:
labels = json.load(f)
if self.lookup_gbif_names:
"""
Use this if you want to store name strings instead of taxon IDs.
Taxon IDs are helpful for looking up additional information about the species
such as the genus and family.
"""
import concurrent.futures
from trapdata.ml.utils import replace_gbif_id_with_name
def fetch_gbif_ids(labels):
string_labels = {}
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = []
for label, _index in labels.items():
future = executor.submit(replace_gbif_id_with_name, label)
futures.append(future)
for future, (_label, index) in zip(futures, labels.items()):
string_label = future.result()
string_labels[string_label] = index
return string_labels
string_labels = fetch_gbif_ids(labels)
logger.info(f"Replacing GBIF IDs with names in {local_path}")
# Backup the original file
local_path.rename(local_path.with_suffix(".bak"))
with open(local_path, "w") as f:
json.dump(string_labels, f)
# @TODO would this be faster as a list? especially when getting the labels of multiple
# indexes in one prediction
index_to_label = {index: label for label, index in labels.items()}
return index_to_label
else:
return {}
def get_class_masking_list(self) -> list[str]:
"""
This must be implemented by a subclass
"""
raise NotImplementedError
def _mask_classes(self, predictions: torch.Tensor):
"""Class mask function to include specific output classes and exclude the rest"""
# Create a mask for the classes to prune
mask = torch.zeros(
predictions.size(1), dtype=torch.bool, device=predictions.device
)
# Get species keys that needs to be removed
for taxon_to_keep in self.class_masking_list:
id_to_keep = self.name_to_id_map[taxon_to_keep]
mask[id_to_keep] = True
# Apply the mask to zero out unwanted nodes
predictions[:, ~mask] = float("-inf") # Set to -inf to ignore during softmax
return predictions
def get_model(self) -> torch.nn.Module:
"""
This method must be implemented by a subclass.
Example:
model = torch.nn.Module()
checkpoint = torch.load(self.weights, map_location=self.device)
model.load_state_dict(checkpoint["model_state_dict"])
model = model.to(self.device)
model.eval()
return model
"""
raise NotImplementedError
def get_transforms(self) -> torchvision.transforms.Compose:
"""
This method must be implemented by a subclass.
Example:
transforms = torchvision.transforms.Compose(
[
torchvision.transforms.ToTensor(),
]
)
return transforms
"""
raise NotImplementedError
def get_queue(self) -> QueueManager:
"""
This method must be implemented by a subclass.
Example:
from trapdata.db.models.queue import DetectedObjectQueue
def get_queue(self):
return DetectedObjectQueue(self.db_path, self.image_base_path)
"""
raise NotImplementedError
def get_dataset(self) -> torch.utils.data.Dataset:
"""
This method must be implemented by a subclass.
Example:
dataset = torch.utils.data.Dataset()
return dataset
"""
raise NotImplementedError
def get_dataloader(self):
"""
Prepare dataloader for streaming/iterable datasets from database
"""
if self.single:
logger.info(
f"Preparing dataloader with batch size of {self.batch_size} in single worker mode."
)
else:
logger.info(
f"Preparing dataloader with batch size of {self.batch_size} and {self.num_workers} workers."
)
dataloader_args = {
"num_workers": 0 if self.single else self.num_workers,
"persistent_workers": False if self.single else True,
"shuffle": False,
"pin_memory": False if self.single else True, # @TODO review this
}
if isinstance(self.dataset, torch.utils.data.IterableDataset):
# Batch size and sample should be None for streaming datasets
dataloader_args.update(
{
"batch_size": None,
"batch_sampler": None,
}
)
else:
dataloader_args.update(
{
"batch_size": self.batch_size,
}
)
self.dataloader = torch.utils.data.DataLoader(self.dataset, **dataloader_args)
return self.dataloader
def predict_batch(self, batch):
batch_input = batch.to(
self.device,
non_blocking=True, # Block while in development, are we already in a background process?
)
batch_output = self.model(batch_input)
return batch_output
def post_process_single(self, item):
return item
def post_process_batch(self, batch_output):
return [self.post_process_single(item) for item in batch_output]
# Had problems with this generator and multiprocessing
# for item in batch_output:
# yield self.post_process_single(item)
def save_results(
self, item_ids, batch_output, seconds_per_item: float | None = None
):
logger.warn("No save method configured for model. Doing nothing with results")
return None
@torch.no_grad()
def run(self):
torch.cuda.empty_cache()
for i, batch in enumerate(self.dataloader):
if not batch:
# @TODO review this once we switch to streaming IterableDataset
logger.info(f"Batch {i+1} is empty, skipping")
continue
item_ids, batch_input = batch
logger.info(
f"Processing batch {i+1}, about {len(self.dataloader)} remaining"
)
# @TODO the StopWatch doesn't seem to work when there are multiple workers,
# it always returns 0 seconds.
with StopWatch() as batch_time:
with start_transaction(op="inference_batch", name=self.name):
batch_output = self.predict_batch(batch_input)
seconds_per_item = batch_time.duration / len(batch_output)
logger.info(
f"Inference time for batch: {batch_time}, "
f"Seconds per item: {round(seconds_per_item, 2)}"
)
batch_output = list(self.post_process_batch(batch_output))
if isinstance(item_ids, (np.ndarray, torch.Tensor)):
item_ids = item_ids.tolist()
logger.info(f"Saving results from {len(item_ids)} items")
self.save_results(item_ids, batch_output, seconds_per_item=seconds_per_item)
logger.info(f"{self.name} Batch -- Done")
logger.info(f"{self.name} -- Done")