learning-loop-node 0.13.6__py3-none-any.whl → 0.14.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of learning-loop-node might be problematic. Click here for more details.

@@ -143,7 +143,7 @@ class DataExchanger():
143
143
  logging.info('Downloading model data for uuid %s from the loop to %s..', model_uuid, target_folder)
144
144
 
145
145
  path = f'/{context.organization}/projects/{context.project}/models/{model_uuid}/{model_format}/file'
146
- response = await self.loop_communicator.get(path, requires_login=False)
146
+ response = await self.loop_communicator.get(path, requires_login=False, timeout=60*10)
147
147
  if response.status_code != 200:
148
148
  decoded_content = response.content.decode('utf-8')
149
149
  logging.error('could not download loop/%s: %s, content: %s', path,
@@ -2,8 +2,6 @@ import logging
2
2
  from abc import abstractmethod
3
3
  from typing import List, Optional
4
4
 
5
- import numpy as np
6
-
7
5
  from ..data_classes import ImageMetadata, ModelInformation
8
6
  from ..globals import GLOBALS
9
7
  from .exceptions import NodeNeedsRestartError
@@ -44,13 +42,13 @@ class DetectorLogic():
44
42
  def init(self):
45
43
  """Called when a (new) model was loaded. Initialize the model. Model information available via `self.model_info`"""
46
44
 
47
- def evaluate_with_all_info(self, image: np.ndarray, tags: List[str], source: Optional[str] = None, creation_date: Optional[str] = None) -> ImageMetadata: # pylint: disable=unused-argument
45
+ def evaluate_with_all_info(self, image: bytes, tags: List[str], source: Optional[str] = None, creation_date: Optional[str] = None) -> ImageMetadata: # pylint: disable=unused-argument
48
46
  """Called by the detector node when an image should be evaluated (REST or SocketIO).
49
47
  Tags, source come from the caller and may be used in this function.
50
48
  By default, this function simply calls `evaluate`"""
51
49
  return self.evaluate(image)
52
50
 
53
51
  @abstractmethod
54
- def evaluate(self, image: np.ndarray) -> ImageMetadata:
52
+ def evaluate(self, image: bytes) -> ImageMetadata:
55
53
  """Evaluate the image and return the detections.
56
54
  The object should return empty detections if it is not initialized"""
@@ -1,14 +1,12 @@
1
1
  import asyncio
2
2
  import contextlib
3
- import math
4
3
  import os
5
4
  import shutil
6
5
  import subprocess
7
6
  import sys
8
7
  from dataclasses import asdict
9
8
  from datetime import datetime
10
- from threading import Thread
11
- from typing import Dict, List, Optional
9
+ from typing import Dict, List, Optional, cast
12
10
 
13
11
  import numpy as np
14
12
  import socketio
@@ -30,7 +28,7 @@ from ..data_classes.socket_response import SocketResponse
30
28
  from ..data_exchanger import DataExchanger, DownloadError
31
29
  from ..enums import OperationMode, VersionMode
32
30
  from ..globals import GLOBALS
33
- from ..helpers import environment_reader
31
+ from ..helpers import background_tasks, environment_reader, run
34
32
  from ..node import Node
35
33
  from .detector_logic import DetectorLogic
36
34
  from .exceptions import NodeNeedsRestartError
@@ -227,7 +225,7 @@ class DetectorNode(Node):
227
225
  async def detect(sid, data: Dict) -> Dict:
228
226
  try:
229
227
  det = await self.get_detections(
230
- raw_image=np.frombuffer(data['image'], np.uint8),
228
+ raw_image=data['image'],
231
229
  camera_id=data.get('camera-id', None) or data.get('mac', None),
232
230
  tags=data.get('tags', []),
233
231
  source=data.get('source', None),
@@ -279,9 +277,10 @@ class DetectorNode(Node):
279
277
  return {'error': str(e)}
280
278
 
281
279
  @self.sio.event
282
- async def upload(sid, data: Dict) -> Optional[Dict]:
283
- '''upload an image with detections'''
280
+ async def upload(sid, data: Dict) -> Dict:
281
+ """Upload an image with detections"""
284
282
 
283
+ self.log.debug('Processing upload via socketio.')
285
284
  detection_data = data.get('detections', {})
286
285
  if detection_data and self.detector_logic.model_info is not None:
287
286
  try:
@@ -293,22 +292,19 @@ class DetectorNode(Node):
293
292
  else:
294
293
  image_metadata = ImageMetadata()
295
294
 
296
- tags = data.get('tags', [])
297
- tags.append('picked_by_system')
298
-
299
- source = data.get('source', None)
300
- creation_date = data.get('creation_date', None)
301
-
302
- self.log.debug('running upload via socketio. tags: %s, source: %s, creation_date: %s',
303
- tags, source, creation_date)
304
-
305
- loop = asyncio.get_event_loop()
306
295
  try:
307
- await loop.run_in_executor(None, self.outbox.save, data['image'], image_metadata, tags, source, creation_date)
296
+ await self.upload_images(
297
+ images=[data['image']],
298
+ image_metadata=image_metadata,
299
+ tags=data.get('tags', []),
300
+ source=data.get('source', None),
301
+ creation_date=data.get('creation_date', None),
302
+ upload_priority=data.get('upload_priority', False)
303
+ )
308
304
  except Exception as e:
309
305
  self.log.exception('could not upload via socketio')
310
306
  return {'error': str(e)}
311
- return None
307
+ return {'status': 'OK'}
312
308
 
313
309
  @self.sio.event
314
310
  def connect(sid, environ, auth) -> None:
@@ -344,16 +340,21 @@ class DetectorNode(Node):
344
340
  with step_into(GLOBALS.data_folder):
345
341
  model_symlink = 'model'
346
342
  target_model_folder = f'models/{self.target_model.version}'
347
- if not os.path.exists(target_model_folder):
348
- os.makedirs(target_model_folder)
349
- await self.data_exchanger.download_model(target_model_folder,
350
- Context(organization=self.organization,
351
- project=self.project),
352
- self.target_model.id,
353
- self.detector_logic.model_format)
354
- self.log.info('Downloaded model %s', self.target_model.version)
355
- else:
343
+ if os.path.exists(target_model_folder) and len(os.listdir(target_model_folder)) > 0:
356
344
  self.log.info('No need to download model %s (already exists)', self.target_model.version)
345
+ else:
346
+ os.makedirs(target_model_folder, exist_ok=True)
347
+ try:
348
+ await self.data_exchanger.download_model(target_model_folder,
349
+ Context(organization=self.organization,
350
+ project=self.project),
351
+ self.target_model.id,
352
+ self.detector_logic.model_format)
353
+ self.log.info('Downloaded model %s', self.target_model.version)
354
+ except Exception:
355
+ self.log.exception('Could not download model %s', self.target_model.version)
356
+ shutil.rmtree(target_model_folder, ignore_errors=True)
357
+ return
357
358
  try:
358
359
  os.unlink(model_symlink)
359
360
  os.remove(model_symlink)
@@ -422,16 +423,23 @@ class DetectorNode(Node):
422
423
  self.log_status_on_change(status.state or 'None', status)
423
424
 
424
425
  # NOTE: sending organization and project is no longer required!
425
- response = await self.sio_client.call('update_detector', (self.organization, self.project, jsonable_encoder(asdict(status))))
426
+ try:
427
+ response = await self.sio_client.call('update_detector', (self.organization, self.project, jsonable_encoder(asdict(status))))
428
+ except TimeoutError:
429
+ self.socket_connection_broken = True
430
+ self.log.exception('TimeoutError for sending status update (will try to reconnect):')
431
+ raise Exception('Status update failed due to timeout') from None
432
+
426
433
  if not response:
427
434
  self.socket_connection_broken = True
428
- return
435
+ self.log.error('Status update failed (will try to reconnect): %s', response)
436
+ raise Exception('Status update failed: Did not receive a response from the learning loop')
429
437
 
430
438
  socket_response = from_dict(data_class=SocketResponse, data=response)
431
439
  if not socket_response.success:
432
440
  self.socket_connection_broken = True
433
- self.log.error('Statusupdate failed: %s', response)
434
- raise Exception(f'Statusupdate failed: {response}')
441
+ self.log.error('Status update failed (will try to reconnect): %s', response)
442
+ raise Exception(f'Status update failed. Response from learning loop: {response}')
435
443
 
436
444
  assert socket_response.payload is not None
437
445
 
@@ -457,7 +465,7 @@ class DetectorNode(Node):
457
465
  self.log.warning('Operation mode set to %s, but sync failed: %s', mode, e)
458
466
 
459
467
  def reload(self, reason: str):
460
- '''provide a cause for the reload'''
468
+ """provide a cause for the reload"""
461
469
 
462
470
  self.log.info('########## reloading app because %s', reason)
463
471
  if os.path.isfile('/app/app_code/restart/restart.py'):
@@ -470,7 +478,7 @@ class DetectorNode(Node):
470
478
  self.log.error('could not reload app')
471
479
 
472
480
  async def get_detections(self,
473
- raw_image: np.ndarray,
481
+ raw_image: bytes,
474
482
  camera_id: Optional[str],
475
483
  tags: List[str],
476
484
  source: Optional[str] = None,
@@ -482,8 +490,7 @@ class DetectorNode(Node):
482
490
  It can be converted e.g. using cv2.imdecode(raw_image, cv2.IMREAD_COLOR)"""
483
491
 
484
492
  await self.detection_lock.acquire()
485
- loop = asyncio.get_event_loop()
486
- detections = await loop.run_in_executor(None, self.detector_logic.evaluate_with_all_info, raw_image, tags, source, creation_date)
493
+ detections = await run.io_bound(self.detector_logic.evaluate_with_all_info, raw_image, tags, source, creation_date)
487
494
  self.detection_lock.release()
488
495
 
489
496
  fix_shape_detections(detections)
@@ -491,21 +498,40 @@ class DetectorNode(Node):
491
498
  n_po, n_se = len(detections.point_detections), len(detections.segmentation_detections)
492
499
  self.log.debug('Detected: %d boxes, %d points, %d segs, %d classes', n_bo, n_po, n_se, n_cl)
493
500
 
494
- if autoupload is None or autoupload == 'filtered': # NOTE default is filtered
495
- Thread(target=self.relevance_filter.may_upload_detections,
496
- args=(detections, camera_id, raw_image, tags, source, creation_date)).start()
501
+ autoupload = autoupload or 'filtered'
502
+ if autoupload == 'filtered' and camera_id is not None:
503
+ background_tasks.create(self.relevance_filter.may_upload_detections(
504
+ detections, camera_id, raw_image, tags, source, creation_date
505
+ ))
497
506
  elif autoupload == 'all':
498
- Thread(target=self.outbox.save, args=(raw_image, detections, tags, source, creation_date)).start()
507
+ background_tasks.create(self.outbox.save(raw_image, detections, tags, source, creation_date))
499
508
  elif autoupload == 'disabled':
500
509
  pass
501
510
  else:
502
511
  self.log.error('unknown autoupload value %s', autoupload)
503
512
  return detections
504
513
 
505
- async def upload_images(self, images: List[bytes], source: Optional[str], creation_date: Optional[str]):
506
- loop = asyncio.get_event_loop()
514
+ async def upload_images(
515
+ self, *,
516
+ images: List[bytes],
517
+ image_metadata: Optional[ImageMetadata] = None,
518
+ tags: Optional[List[str]] = None,
519
+ source: Optional[str],
520
+ creation_date: Optional[str],
521
+ upload_priority: bool = False
522
+ ) -> None:
523
+ """Save images to the outbox using an asyncio executor.
524
+ Used by SIO and REST upload endpoints."""
525
+
526
+ if image_metadata is None:
527
+ image_metadata = ImageMetadata()
528
+ if tags is None:
529
+ tags = []
530
+
531
+ tags.append('picked_by_system')
532
+
507
533
  for image in images:
508
- await loop.run_in_executor(None, self.outbox.save, image, ImageMetadata(), ['picked_by_system'], source, creation_date)
534
+ await self.outbox.save(image, image_metadata, tags, source, creation_date, upload_priority)
509
535
 
510
536
  def add_category_id_to_detections(self, model_info: ModelInformation, image_metadata: ImageMetadata):
511
537
  def find_category_id_by_name(categories: List[Category], category_name: str):
@@ -11,14 +11,16 @@ class RelevanceFilter():
11
11
  self.cam_histories: Dict[str, CamObservationHistory] = {}
12
12
  self.outbox: Outbox = outbox
13
13
 
14
- def may_upload_detections(self,
15
- image_metadata: ImageMetadata,
16
- cam_id: str,
17
- raw_image: bytes,
18
- tags: List[str],
19
- source: Optional[str] = None,
20
- creation_date: Optional[str] = None
21
- ) -> List[str]:
14
+ async def may_upload_detections(self,
15
+ image_metadata: ImageMetadata,
16
+ cam_id: str,
17
+ raw_image: bytes,
18
+ tags: List[str],
19
+ source: Optional[str] = None,
20
+ creation_date: Optional[str] = None) -> List[str]:
21
+ """Check if the detection should be uploaded to the outbox.
22
+ If so, upload it and return the list of causes for the upload.
23
+ """
22
24
  for group in self.cam_histories.values():
23
25
  group.forget_old_detections()
24
26
 
@@ -30,5 +32,5 @@ class RelevanceFilter():
30
32
  if len(causes) > 0:
31
33
  tags = tags if tags is not None else []
32
34
  tags.extend(causes)
33
- self.outbox.save(raw_image, image_metadata, tags, source, creation_date)
35
+ await self.outbox.save(raw_image, image_metadata, tags, source, creation_date)
34
36
  return causes
@@ -5,13 +5,15 @@ import logging
5
5
  import os
6
6
  import shutil
7
7
  from asyncio import Task
8
+ from collections import deque
8
9
  from dataclasses import asdict
9
10
  from datetime import datetime
10
11
  from glob import glob
11
12
  from io import BufferedReader, TextIOWrapper
12
13
  from multiprocessing import Event
13
14
  from multiprocessing.synchronize import Event as SyncEvent
14
- from typing import List, Optional, Tuple, Union
15
+ from threading import Lock
16
+ from typing import List, Optional, Tuple, TypeVar, Union
15
17
 
16
18
  import aiohttp
17
19
  import PIL
@@ -21,14 +23,27 @@ from fastapi.encoders import jsonable_encoder
21
23
  from ..data_classes import ImageMetadata
22
24
  from ..enums import OutboxMode
23
25
  from ..globals import GLOBALS
24
- from ..helpers import environment_reader
26
+ from ..helpers import environment_reader, run
27
+
28
+ T = TypeVar('T')
25
29
 
26
30
 
27
31
  class Outbox():
32
+ """
33
+ Outbox is a class that handles the uploading of images to the learning loop.
34
+ It uploads images from an internal queue (lifo) in batches of 20 every 5 seconds.
35
+ It handles upload failures by splitting the upload into two smaller batches until the problematic image is identified - and removed.
36
+ Any image can be saved to the normal or the priority queue.
37
+ Images in the priority queue are uploaded first.
38
+ The total queue length is limited to 1000 images.
39
+ """
40
+
28
41
  def __init__(self) -> None:
29
42
  self.log = logging.getLogger()
30
43
  self.path = f'{GLOBALS.data_folder}/outbox'
31
44
  os.makedirs(self.path, exist_ok=True)
45
+ os.makedirs(f'{self.path}/priority', exist_ok=True)
46
+ os.makedirs(f'{self.path}/normal', exist_ok=True)
32
47
 
33
48
  self.log = logging.getLogger()
34
49
  host = environment_reader.host()
@@ -42,6 +57,8 @@ class Outbox():
42
57
  self.log.info('Outbox initialized with target_uri: %s', self.target_uri)
43
58
 
44
59
  self.BATCH_SIZE = 20
60
+ self.MAX_UPLOAD_LENGTH = 1000 # only affects the `upload_folders` list
61
+ self.UPLOAD_INTERVAL_S = 5
45
62
  self.UPLOAD_TIMEOUT_S = 30
46
63
 
47
64
  self.shutdown_event: SyncEvent = Event()
@@ -49,15 +66,24 @@ class Outbox():
49
66
 
50
67
  self.upload_counter = 0
51
68
 
52
- def save(self,
53
- image: bytes,
54
- image_metadata: Optional[ImageMetadata] = None,
55
- tags: Optional[List[str]] = None,
56
- source: Optional[str] = None,
57
- creation_date: Optional[str] = None
58
- ) -> None:
69
+ self.priority_upload_folders: List[str] = []
70
+ self.upload_folders: deque[str] = deque()
71
+ self.folders_lock = Lock()
72
+
73
+ for file in glob(f'{self.path}/priority/*'):
74
+ self.priority_upload_folders.append(file)
75
+ for file in glob(f'{self.path}/normal/*'):
76
+ self.upload_folders.append(file)
59
77
 
60
- if not self._is_valid_jpg(image):
78
+ async def save(self,
79
+ image: bytes,
80
+ image_metadata: Optional[ImageMetadata] = None,
81
+ tags: Optional[List[str]] = None,
82
+ source: Optional[str] = None,
83
+ creation_date: Optional[str] = None,
84
+ upload_priority: bool = False) -> None:
85
+
86
+ if not await run.io_bound(self._is_valid_jpg, image):
61
87
  self.log.error('Invalid jpg image')
62
88
  return
63
89
 
@@ -66,9 +92,33 @@ class Outbox():
66
92
  if not tags:
67
93
  tags = []
68
94
  identifier = datetime.now().isoformat(sep='_', timespec='microseconds')
69
- if os.path.exists(self.path + '/' + identifier):
70
- self.log.error('Directory with identifier %s already exists', identifier)
95
+
96
+ try:
97
+ await run.io_bound(self._save_files_to_disk, identifier, image, image_metadata, tags, source, creation_date, upload_priority)
98
+ except Exception as e:
99
+ self.log.error('Failed to save files for image %s: %s', identifier, e)
71
100
  return
101
+
102
+ if upload_priority:
103
+ self.priority_upload_folders.append(f'{self.path}/priority/{identifier}')
104
+ else:
105
+ self.upload_folders.appendleft(f'{self.path}/normal/{identifier}')
106
+
107
+ await self._trim_upload_queue()
108
+
109
+ def _save_files_to_disk(self,
110
+ identifier: str,
111
+ image: bytes,
112
+ image_metadata: ImageMetadata,
113
+ tags: List[str],
114
+ source: Optional[str],
115
+ creation_date: Optional[str],
116
+ upload_priority: bool) -> None:
117
+ subpath = 'priority' if upload_priority else 'normal'
118
+ full_path = f'{self.path}/{subpath}/{identifier}'
119
+ if os.path.exists(full_path):
120
+ raise FileExistsError(f'Directory with identifier {identifier} already exists')
121
+
72
122
  tmp = f'{GLOBALS.data_folder}/tmp/{identifier}'
73
123
  image_metadata.tags = tags
74
124
  if self._is_valid_isoformat(creation_date):
@@ -77,6 +127,7 @@ class Outbox():
77
127
  image_metadata.created = identifier
78
128
 
79
129
  image_metadata.source = source or 'unknown'
130
+
80
131
  os.makedirs(tmp, exist_ok=True)
81
132
 
82
133
  with open(tmp + f'/image_{identifier}.json', 'w') as f:
@@ -85,10 +136,34 @@ class Outbox():
85
136
  with open(tmp + f'/image_{identifier}.jpg', 'wb') as f:
86
137
  f.write(image)
87
138
 
88
- if os.path.exists(tmp):
89
- os.rename(tmp, self.path + '/' + identifier) # NOTE rename is atomic so upload can run in parallel
90
- else:
91
- self.log.error('Could not rename %s to %s', tmp, self.path + '/' + identifier)
139
+ if not os.path.exists(tmp):
140
+ self.log.error('Could not rename %s to %s', tmp, full_path)
141
+ raise FileNotFoundError(f'Could not rename {tmp} to {full_path}')
142
+ os.rename(tmp, full_path)
143
+
144
+ async def _trim_upload_queue(self) -> None:
145
+ if len(self.upload_folders) > self.MAX_UPLOAD_LENGTH:
146
+ excess = len(self.upload_folders) - self.MAX_UPLOAD_LENGTH
147
+ self.log.info('Dropping %s images from upload list', excess)
148
+
149
+ folders_to_delete = []
150
+ for _ in range(excess):
151
+ if self.upload_folders:
152
+ try:
153
+ folder = self.upload_folders.pop()
154
+ folders_to_delete.append(folder)
155
+ except Exception:
156
+ self.log.exception('Failed to get item from upload_folders')
157
+
158
+ await run.io_bound(self._delete_folders, folders_to_delete)
159
+
160
+ def _delete_folders(self, folders_to_delete: List[str]) -> None:
161
+ for folder in folders_to_delete:
162
+ try:
163
+ shutil.rmtree(folder)
164
+ self.log.debug('Deleted %s', folder)
165
+ except Exception:
166
+ self.log.exception('Failed to delete %s', folder)
92
167
 
93
168
  def _is_valid_isoformat(self, date: Optional[str]) -> bool:
94
169
  if date is None:
@@ -99,10 +174,11 @@ class Outbox():
99
174
  except Exception:
100
175
  return False
101
176
 
102
- def get_data_files(self):
103
- return glob(f'{self.path}/*')
177
+ def get_upload_folders(self) -> List[str]:
178
+ with self.folders_lock:
179
+ return self.priority_upload_folders + list(self.upload_folders)
104
180
 
105
- def ensure_continuous_upload(self):
181
+ def ensure_continuous_upload(self) -> None:
106
182
  self.log.debug('start_continuous_upload')
107
183
  if self._upload_process_alive():
108
184
  self.log.debug('Upload thread already running')
@@ -111,44 +187,58 @@ class Outbox():
111
187
  self.shutdown_event.clear()
112
188
  self.upload_task = asyncio.create_task(self._continuous_upload())
113
189
 
114
- async def _continuous_upload(self):
190
+ async def _continuous_upload(self) -> None:
115
191
  self.log.info('continuous upload started')
116
192
  assert self.shutdown_event is not None
117
193
  while not self.shutdown_event.is_set():
118
194
  await self.upload()
119
- await asyncio.sleep(5)
195
+ await asyncio.sleep(self.UPLOAD_INTERVAL_S)
120
196
  self.log.info('continuous upload ended')
121
197
 
122
- async def upload(self):
123
- items = self.get_data_files()
198
+ async def upload(self) -> None:
199
+ items = self.get_upload_folders()
124
200
  if not items:
125
201
  self.log.debug('No images found to upload')
126
202
  return
127
203
 
128
204
  self.log.info('Found %s images to upload', len(items))
129
- for i in range(0, len(items), self.BATCH_SIZE):
130
- batch_items = items[i:i+self.BATCH_SIZE]
131
- if self.shutdown_event.is_set():
132
- break
133
- try:
134
- await self._upload_batch(batch_items)
135
- except Exception:
136
- self.log.exception('Could not upload files')
137
205
 
138
- async def _upload_batch(self, items: List[str]):
206
+ batch_items = items[:self.BATCH_SIZE]
207
+ try:
208
+ await self._upload_batch(batch_items)
209
+ except Exception:
210
+ self.log.exception('Could not upload files')
139
211
 
140
- # NOTE: keys are not relevant for the server, but using a fixed key like 'files'
141
- # results in a post failure on the first run of the test in a docker environment (WTF)
212
+ async def _clear_item(self, item: str) -> None:
213
+ try:
214
+ if item in self.upload_folders:
215
+ self.upload_folders.remove(item)
216
+ if item in self.priority_upload_folders:
217
+ self.priority_upload_folders.remove(item)
218
+ await run.io_bound(shutil.rmtree, item, ignore_errors=True)
219
+ self.log.debug('Deleted %s', item)
220
+ except Exception:
221
+ self.log.exception('Failed to delete %s', item)
222
+
223
+ async def _upload_batch(self, items: List[str]) -> None:
224
+ """
225
+ Uploads a batch of images to the server.
226
+ :param items: List of folders to upload (each folder contains an image and a metadata file)
227
+ """
142
228
 
143
229
  data: List[Tuple[str, Union[TextIOWrapper, BufferedReader]]] = []
144
230
  for item in items:
231
+ if not os.path.exists(item):
232
+ await self._clear_item(item)
233
+ continue
145
234
  identifier = os.path.basename(item)
146
235
  data.append(('files', open(f'{item}/image_{identifier}.json', 'r')))
147
236
  data.append(('files', open(f'{item}/image_{identifier}.jpg', 'rb')))
148
237
 
149
238
  try:
150
239
  async with aiohttp.ClientSession() as session:
151
- response = await session.post(self.target_uri, data=data, timeout=self.UPLOAD_TIMEOUT_S)
240
+ response = await session.post(self.target_uri, data=data, timeout=aiohttp.ClientTimeout(total=self.UPLOAD_TIMEOUT_S))
241
+ await response.read()
152
242
  except Exception:
153
243
  self.log.exception('Could not upload images')
154
244
  return
@@ -159,23 +249,23 @@ class Outbox():
159
249
 
160
250
  if response.status == 200:
161
251
  self.upload_counter += len(items)
252
+ self.log.debug('Uploaded %s images', len(items))
162
253
  for item in items:
163
- try:
164
- shutil.rmtree(item)
165
- self.log.debug('Deleted %s', item)
166
- except Exception:
167
- self.log.exception('Failed to delete %s', item)
168
- self.log.info('Uploaded %s images successfully', len(items))
169
-
170
- elif response.status == 422:
254
+ await self._clear_item(item)
255
+ self.log.debug('Cleared %s images', len(items))
256
+ return
257
+
258
+ if response.status == 422:
171
259
  if len(items) == 1:
172
260
  self.log.error('Broken content in image: %s\n Skipping.', items[0])
173
- shutil.rmtree(items[0], ignore_errors=True)
261
+ await self._clear_item(items[0])
174
262
  return
175
263
 
176
264
  self.log.exception('Broken content in batch. Splitting and retrying')
177
265
  await self._upload_batch(items[:len(items)//2])
178
266
  await self._upload_batch(items[len(items)//2:])
267
+ elif response.status == 429:
268
+ self.log.warning('Too many requests: %s', response.content)
179
269
  else:
180
270
  self.log.error('Could not upload images: %s', response.content)
181
271
 
@@ -1,7 +1,6 @@
1
1
  import logging
2
2
  from typing import TYPE_CHECKING, Optional
3
3
 
4
- import numpy as np
5
4
  from fastapi import APIRouter, File, Header, Request, UploadFile
6
5
 
7
6
  from ...data_classes.image_metadata import ImageMetadata
@@ -35,14 +34,15 @@ async def http_detect(
35
34
 
36
35
  """
37
36
  try:
38
- np_image = np.fromfile(file.file, np.uint8)
37
+ # Read file directly to bytes instead of using numpy
38
+ file_bytes = file.file.read()
39
39
  except Exception as exc:
40
40
  logging.exception('Error during reading of image %s.', file.filename)
41
41
  raise Exception(f'Uploaded file {file.filename} is no image file.') from exc
42
42
 
43
43
  try:
44
44
  app: 'DetectorNode' = request.app
45
- detections = await app.get_detections(raw_image=np_image,
45
+ detections = await app.get_detections(raw_image=file_bytes,
46
46
  camera_id=camera_id or mac or None,
47
47
  tags=tags.split(',') if tags else [],
48
48
  source=source,
@@ -12,7 +12,8 @@ router = APIRouter()
12
12
  async def upload_image(request: Request,
13
13
  files: List[UploadFile] = File(...),
14
14
  source: Optional[str] = Query(None, description='Source of the image'),
15
- creation_date: Optional[str] = Query(None, description='Creation date of the image')):
15
+ creation_date: Optional[str] = Query(None, description='Creation date of the image'),
16
+ upload_priority: bool = Query(False, description='Upload the image with priority')):
16
17
  """
17
18
  Upload an image or multiple images to the learning loop.
18
19
 
@@ -21,9 +22,9 @@ async def upload_image(request: Request,
21
22
 
22
23
  Example Usage
23
24
 
24
- curl -X POST -F 'files=@test.jpg' "http://localhost:/upload?source=test&creation_date=2024-01-01T00:00:00"
25
+ curl -X POST -F 'files=@test.jpg' "http://localhost:/upload?source=test&creation_date=2024-01-01T00:00:00&upload_priority=true"
25
26
  """
26
27
  raw_files = [await file.read() for file in files]
27
28
  node: DetectorNode = request.app
28
- await node.upload_images(raw_files, source, creation_date)
29
+ await node.upload_images(images=raw_files, source=source, creation_date=creation_date, upload_priority=upload_priority)
29
30
  return 200, "OK"
@@ -0,0 +1,78 @@
1
+ # Copy of Nicegui background_tasks.py
2
+ # MIT License
3
+
4
+ # Copyright (c) 2021 Zauberzeug GmbH
5
+
6
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ # of this software and associated documentation files (the "Software"), to deal
8
+ # in the Software without restriction, including without limitation the rights
9
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ # copies of the Software, and to permit persons to whom the Software is
11
+ # furnished to do so, subject to the following conditions:
12
+
13
+ # The above copyright notice and this permission notice shall be included in all
14
+ # copies or substantial portions of the Software.
15
+
16
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ # SOFTWARE.
23
+
24
+ """inspired from https://quantlane.com/blog/ensure-asyncio-task-exceptions-get-logged/"""
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import logging
29
+ from typing import Awaitable, Dict, Set
30
+
31
+ running_tasks: Set[asyncio.Task] = set()
32
+ lazy_tasks_running: Dict[str, asyncio.Task] = {}
33
+ lazy_tasks_waiting: Dict[str, Awaitable] = {}
34
+
35
+
36
+ def create(coroutine: Awaitable, *, name: str = 'unnamed task') -> asyncio.Task:
37
+ """Wraps a loop.create_task call and ensures there is an exception handler added to the task.
38
+
39
+ If the task raises an exception, it is logged and handled by the global exception handlers.
40
+ Also a reference to the task is kept until it is done, so that the task is not garbage collected mid-execution.
41
+ See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task.
42
+ """
43
+ loop = asyncio.get_event_loop()
44
+ coroutine = coroutine if asyncio.iscoroutine(coroutine) else asyncio.wait_for(coroutine, None)
45
+ task: asyncio.Task = loop.create_task(coroutine, name=name)
46
+ task.add_done_callback(_handle_task_result)
47
+ running_tasks.add(task)
48
+ task.add_done_callback(running_tasks.discard)
49
+ return task
50
+
51
+
52
+ def create_lazy(coroutine: Awaitable, *, name: str) -> None:
53
+ """Wraps a create call and ensures a second task with the same name is delayed until the first one is done.
54
+
55
+ If a third task with the same name is created while the first one is still running, the second one is discarded.
56
+ """
57
+ if name in lazy_tasks_running:
58
+ if name in lazy_tasks_waiting:
59
+ asyncio.Task(lazy_tasks_waiting[name]).cancel()
60
+ lazy_tasks_waiting[name] = coroutine
61
+ return
62
+
63
+ def finalize(name: str) -> None:
64
+ lazy_tasks_running.pop(name)
65
+ if name in lazy_tasks_waiting:
66
+ create_lazy(lazy_tasks_waiting.pop(name), name=name)
67
+ task = create(coroutine, name=name)
68
+ lazy_tasks_running[name] = task
69
+ task.add_done_callback(lambda _: finalize(name))
70
+
71
+
72
+ def _handle_task_result(task: asyncio.Task) -> None:
73
+ try:
74
+ task.result()
75
+ except asyncio.CancelledError:
76
+ pass
77
+ except Exception:
78
+ logging.exception('Background task %s raised an exception', task.get_name())
@@ -0,0 +1,21 @@
1
+ import asyncio
2
+ import sys
3
+ from typing import Any, Callable, TypeVar
4
+
5
+ T = TypeVar('T')
6
+
7
+ if sys.version_info >= (3, 10):
8
+ from typing import ParamSpec
9
+ P = ParamSpec('P')
10
+
11
+ async def io_bound(func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
12
+ """Run a blocking function in a thread pool executor.
13
+ This is useful for disk I/O operations that would block the event loop."""
14
+ loop = asyncio.get_event_loop()
15
+ return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
16
+ else:
17
+ async def io_bound(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
18
+ """Run a blocking function in a thread pool executor.
19
+ This is useful for disk I/O operations that would block the event loop."""
20
+ loop = asyncio.get_event_loop()
21
+ return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
@@ -100,31 +100,31 @@ class LoopCommunicator():
100
100
  raise TimeoutError('Backend not ready within timeout')
101
101
  await asyncio.sleep(10)
102
102
 
103
- async def retry_on_401(self, func: Callable[..., Awaitable[httpx.Response]], *args, **kwargs) -> httpx.Response:
103
+ async def _retry_on_401(self, func: Callable[..., Awaitable[httpx.Response]], *args, **kwargs) -> httpx.Response:
104
104
  response = await func(*args, **kwargs)
105
105
  if response.status_code == 401:
106
106
  await self.ensure_login(relogin=True)
107
107
  response = await func(*args, **kwargs)
108
108
  return response
109
109
 
110
- async def get(self, path: str, requires_login: bool = True, api_prefix: str = '/api') -> httpx.Response:
110
+ async def get(self, path: str, requires_login: bool = True, api_prefix: str = '/api', timeout: int = 60) -> httpx.Response:
111
111
  if requires_login:
112
112
  await self.ensure_login()
113
- return await self.retry_on_401(self._get, path, api_prefix)
113
+ return await self._retry_on_401(self._get, path, api_prefix, timeout)
114
114
  return await self._get(path, api_prefix)
115
115
 
116
116
  @retry_on_429
117
- async def _get(self, path: str, api_prefix: str) -> httpx.Response:
118
- return await self.async_client.get(api_prefix+path)
117
+ async def _get(self, path: str, api_prefix: str, timeout: int = 60) -> httpx.Response:
118
+ return await self.async_client.get(api_prefix+path, timeout=timeout)
119
119
 
120
- async def put(self, path: str, files: Optional[List[str]] = None, requires_login: bool = True, api_prefix: str = '/api', **kwargs) -> httpx.Response:
120
+ async def put(self, path: str, files: Optional[List[str]] = None, requires_login: bool = True, api_prefix: str = '/api', timeout: int = 60, **kwargs) -> httpx.Response:
121
121
  if requires_login:
122
122
  await self.ensure_login()
123
- return await self.retry_on_401(self._put, path, files, api_prefix, **kwargs)
124
- return await self._put(path, files, api_prefix, **kwargs)
123
+ return await self._retry_on_401(self._put, path, files, api_prefix, timeout, **kwargs)
124
+ return await self._put(path, files, api_prefix, timeout, **kwargs)
125
125
 
126
126
  @retry_on_429
127
- async def _put(self, path: str, files: Optional[List[str]], api_prefix: str, **kwargs) -> httpx.Response:
127
+ async def _put(self, path: str, files: Optional[List[str]], api_prefix: str, timeout: int = 60, **kwargs) -> httpx.Response:
128
128
  if files is None:
129
129
  return await self.async_client.put(api_prefix+path, **kwargs)
130
130
 
@@ -139,29 +139,29 @@ class LoopCommunicator():
139
139
 
140
140
  try:
141
141
  file_list = [('files', fh) for fh in file_handles] # Use file handles
142
- response = await self.async_client.put(api_prefix+path, files=file_list)
142
+ response = await self.async_client.put(api_prefix+path, files=file_list, timeout=timeout)
143
143
  finally:
144
144
  for fh in file_handles:
145
145
  fh.close() # Ensure all files are closed
146
146
 
147
147
  return response
148
148
 
149
- async def post(self, path: str, requires_login: bool = True, api_prefix: str = '/api', **kwargs) -> httpx.Response:
149
+ async def post(self, path: str, requires_login: bool = True, api_prefix: str = '/api', timeout: int = 60, **kwargs) -> httpx.Response:
150
150
  if requires_login:
151
151
  await self.ensure_login()
152
- return await self.retry_on_401(self._post, path, api_prefix, **kwargs)
152
+ return await self._retry_on_401(self._post, path, api_prefix, timeout, **kwargs)
153
153
  return await self._post(path, api_prefix, **kwargs)
154
154
 
155
155
  @retry_on_429
156
- async def _post(self, path, api_prefix='/api', **kwargs) -> httpx.Response:
157
- return await self.async_client.post(api_prefix+path, **kwargs)
156
+ async def _post(self, path, api_prefix='/api', timeout: int = 60, **kwargs) -> httpx.Response:
157
+ return await self.async_client.post(api_prefix+path, timeout=timeout, **kwargs)
158
158
 
159
- async def delete(self, path: str, requires_login: bool = True, api_prefix: str = '/api', **kwargs) -> httpx.Response:
159
+ async def delete(self, path: str, requires_login: bool = True, api_prefix: str = '/api', timeout: int = 60, **kwargs) -> httpx.Response:
160
160
  if requires_login:
161
161
  await self.ensure_login()
162
- return await self.retry_on_401(self._delete, path, api_prefix, **kwargs)
162
+ return await self._retry_on_401(self._delete, path, api_prefix, timeout, **kwargs)
163
163
  return await self._delete(path, api_prefix, **kwargs)
164
164
 
165
165
  @retry_on_429
166
- async def _delete(self, path, api_prefix, **kwargs) -> httpx.Response:
167
- return await self.async_client.delete(api_prefix+path, **kwargs)
166
+ async def _delete(self, path, api_prefix, timeout: int = 60, **kwargs) -> httpx.Response:
167
+ return await self.async_client.delete(api_prefix+path, timeout=timeout, **kwargs)
@@ -76,6 +76,8 @@ class Node(FastAPI):
76
76
  self.previous_state: Optional[str] = None
77
77
  self.repeat_loop_cycle_sec = 5
78
78
 
79
+ self._client_session: Optional[aiohttp.ClientSession] = None
80
+
79
81
  def log_status_on_change(self, current_state_str: str, full_status: Any):
80
82
  if self.previous_state != current_state_str:
81
83
  self.previous_state = current_state_str
@@ -127,6 +129,8 @@ class Node(FastAPI):
127
129
  await self.loop_communicator.shutdown()
128
130
  if self._sio_client is not None:
129
131
  await self._sio_client.disconnect()
132
+ if self._client_session is not None:
133
+ await self._client_session.close()
130
134
  self.log.info('successfully disconnected from loop.')
131
135
  await self.on_shutdown()
132
136
 
@@ -205,12 +209,15 @@ class Node(FastAPI):
205
209
  ssl_context.verify_mode = ssl.CERT_REQUIRED
206
210
  connector = TCPConnector(ssl=ssl_context)
207
211
 
212
+ if self._client_session is not None:
213
+ await self._client_session.close()
214
+
208
215
  if self.needs_login:
209
- self._sio_client = AsyncClient(request_timeout=20, http_session=aiohttp.ClientSession(
210
- cookies=cookies, connector=connector))
216
+ self._client_session = aiohttp.ClientSession(cookies=cookies, connector=connector)
211
217
  else:
212
- self._sio_client = AsyncClient(request_timeout=20, http_session=aiohttp.ClientSession(
213
- connector=connector))
218
+ self._client_session = aiohttp.ClientSession(connector=connector)
219
+
220
+ self._sio_client = AsyncClient(request_timeout=20, http_session=self._client_session)
214
221
 
215
222
  # pylint: disable=protected-access
216
223
  self._sio_client._trigger_event = ensure_socket_response(self._sio_client._trigger_event)
@@ -3,18 +3,23 @@ import logging
3
3
  import os
4
4
  import shutil
5
5
 
6
+ # ====================================== REDUNDANT FIXTURES IN ALL CONFTESTS ! ======================================
7
+ import sys
8
+
6
9
  import pytest
7
10
 
8
11
  from ...globals import GLOBALS
9
12
  from ...loop_communication import LoopCommunicator
10
13
 
11
- # ====================================== REDUNDANT FIXTURES IN ALL CONFTESTS ! ======================================
12
-
13
14
 
14
15
  @pytest.fixture()
15
16
  async def setup_test_project(): # pylint: disable=redefined-outer-name
16
17
  loop_communicator = LoopCommunicator()
17
- await loop_communicator.delete("/zauberzeug/projects/pytest_nodelib_annotator?keep_images=true")
18
+ try:
19
+ await loop_communicator.delete("/zauberzeug/projects/pytest_nodelib_annotator?keep_images=true", timeout=10)
20
+ except Exception:
21
+ logging.warning("Failed to delete project pytest_nodelib_annotator")
22
+ sys.exit(1)
18
23
  await asyncio.sleep(1)
19
24
  project_conf = {
20
25
  'project_name': 'pytest_nodelib_annotator', 'inbox': 0, 'annotate': 0, 'review': 0, 'complete': 3, 'image_style': 'beautiful',
@@ -22,7 +27,7 @@ async def setup_test_project(): # pylint: disable=redefined-outer-name
22
27
  'trainings': 1, 'box_detections': 3, 'box_annotations': 0}
23
28
  assert (await loop_communicator.post("/zauberzeug/projects/generator", json=project_conf)).status_code == 200
24
29
  yield
25
- await loop_communicator.delete("/zauberzeug/projects/pytest_nodelib_annotator?keep_images=true")
30
+ await loop_communicator.delete("/zauberzeug/projects/pytest_nodelib_annotator?keep_images=true", timeout=10)
26
31
  await loop_communicator.shutdown()
27
32
 
28
33
 
@@ -7,7 +7,14 @@ from fastapi.encoders import jsonable_encoder
7
7
 
8
8
  from ...annotation.annotator_logic import AnnotatorLogic
9
9
  from ...annotation.annotator_node import AnnotatorNode
10
- from ...data_classes import AnnotationData, Category, Context, Point, ToolOutput, UserInput
10
+ from ...data_classes import (
11
+ AnnotationData,
12
+ Category,
13
+ Context,
14
+ Point,
15
+ ToolOutput,
16
+ UserInput,
17
+ )
11
18
  from ...enums import AnnotationEventType, CategoryType
12
19
 
13
20
 
@@ -37,7 +44,8 @@ def default_user_input() -> UserInput:
37
44
 
38
45
 
39
46
  @pytest.mark.asyncio
40
- async def test_image_download(setup_test_project): # pylint: disable=unused-argument
47
+ @pytest.mark.usefixtures('setup_test_project')
48
+ async def test_image_download():
41
49
  image_folder = '/tmp/learning_loop_lib_data/zauberzeug/pytest_nodelib_annotator/images'
42
50
 
43
51
  assert os.path.exists(image_folder) is False or len(os.listdir(image_folder)) == 0
@@ -24,10 +24,11 @@ l_conf_point_det = PointDetection(category_name='point', x=100, y=100,
24
24
  ['uncertain', 'unexpected_observations_count']),
25
25
  (ImageMetadata(box_detections=[h_conf_box_det], point_detections=[l_conf_point_det]),
26
26
  ['uncertain'])])
27
- def test_unexpected_observations_count(detections: ImageMetadata, reason: List[str]):
27
+ @pytest.mark.asyncio
28
+ async def test_unexpected_observations_count(detections: ImageMetadata, reason: List[str]):
28
29
  os.environ['LOOP_ORGANIZATION'] = 'zauberzeug'
29
30
  os.environ['LOOP_PROJECT'] = 'demo'
30
31
  outbox = Outbox()
31
32
 
32
- r_filter = RelevanceFilter(outbox)
33
- assert r_filter.may_upload_detections(detections, raw_image=b'', cam_id='0:0:0:0', tags=[]) == reason
33
+ relevance_filter = RelevanceFilter(outbox)
34
+ assert await relevance_filter.may_upload_detections(detections, raw_image=b'', cam_id='0:0:0:0', tags=[]) == reason
@@ -84,7 +84,7 @@ async def test_sio_upload(test_detector_node: DetectorNode, sio_client):
84
84
  with open(test_image_path, 'rb') as f:
85
85
  image_bytes = f.read()
86
86
  result = await sio_client.call('upload', {'image': image_bytes})
87
- assert result is None
87
+ assert result.get('status') == 'OK'
88
88
  assert len(get_outbox_files(test_detector_node.outbox)) == 2, 'There should be one image and one .json file.'
89
89
 
90
90
 
@@ -175,25 +175,3 @@ async def test_rest_outbox_mode(test_detector_node: DetectorNode):
175
175
  check_switch_to_mode('stopped')
176
176
  check_switch_to_mode('continuous_upload')
177
177
  check_switch_to_mode('stopped')
178
-
179
-
180
- async def test_api_responsive_during_large_upload(test_detector_node: DetectorNode):
181
- assert len(get_outbox_files(test_detector_node.outbox)) == 0
182
-
183
- with open(test_image_path, 'rb') as f:
184
- image_bytes = f.read()
185
-
186
- for _ in range(200):
187
- test_detector_node.outbox.save(image_bytes)
188
-
189
- outbox_size_early = len(get_outbox_files(test_detector_node.outbox))
190
- await asyncio.sleep(5) # NOTE: we wait 5 seconds because the continuous upload is running every 5 seconds
191
-
192
- # check if api is still responsive
193
- response = requests.get(f'http://localhost:{GLOBALS.detector_port}/outbox_mode', timeout=2)
194
- assert response.status_code == 200, response.content
195
-
196
- await asyncio.sleep(5)
197
- outbox_size_late = len(get_outbox_files(test_detector_node.outbox))
198
- assert outbox_size_late > 0, 'The outbox should not be fully cleared, maybe the node was too fast.'
199
- assert outbox_size_early > outbox_size_late, 'The outbox should have been partially emptied.'
@@ -6,8 +6,6 @@ import shutil
6
6
  import pytest
7
7
  from PIL import Image
8
8
 
9
- from ...data_classes import ImageMetadata
10
- from ...detector.detector_node import DetectorNode
11
9
  from ...detector.outbox import Outbox
12
10
  from ...globals import GLOBALS
13
11
 
@@ -26,31 +24,24 @@ async def test_outbox():
26
24
  shutil.rmtree(test_outbox.path, ignore_errors=True)
27
25
 
28
26
 
29
- @pytest.mark.asyncio
30
- async def test_files_are_automatically_uploaded_by_node(test_detector_node: DetectorNode):
31
- test_detector_node.outbox.save(get_test_image_binary(), ImageMetadata())
32
- assert await wait_for_outbox_count(test_detector_node.outbox, 1)
33
- assert await wait_for_outbox_count(test_detector_node.outbox, 0)
34
-
35
-
36
27
  @pytest.mark.asyncio
37
28
  async def test_set_outbox_mode(test_outbox: Outbox):
38
29
  await test_outbox.set_mode('stopped')
39
- test_outbox.save(get_test_image_binary())
30
+ await test_outbox.save(get_test_image_binary())
40
31
  assert await wait_for_outbox_count(test_outbox, 1)
41
32
  await asyncio.sleep(6)
42
33
  assert await wait_for_outbox_count(test_outbox, 1), 'File was cleared even though outbox should be stopped'
43
34
 
44
35
  await test_outbox.set_mode('continuous_upload')
45
- assert await wait_for_outbox_count(test_outbox, 0), 'File was not cleared even though outbox should be in continuous_upload'
36
+ assert await wait_for_outbox_count(test_outbox, 0, timeout=15), 'File was not cleared even though outbox should be in continuous_upload'
46
37
  assert test_outbox.upload_counter == 1
47
38
 
48
39
 
49
40
  @pytest.mark.asyncio
50
41
  async def test_outbox_upload_is_successful(test_outbox: Outbox):
51
- test_outbox.save(get_test_image_binary())
42
+ await test_outbox.save(get_test_image_binary())
52
43
  await asyncio.sleep(1)
53
- test_outbox.save(get_test_image_binary())
44
+ await test_outbox.save(get_test_image_binary())
54
45
  assert await wait_for_outbox_count(test_outbox, 2)
55
46
  await test_outbox.upload()
56
47
  assert await wait_for_outbox_count(test_outbox, 0)
@@ -60,8 +51,8 @@ async def test_outbox_upload_is_successful(test_outbox: Outbox):
60
51
  @pytest.mark.asyncio
61
52
  async def test_invalid_jpg_is_not_saved(test_outbox: Outbox):
62
53
  invalid_bytes = b'invalid jpg'
63
- test_outbox.save(invalid_bytes)
64
- assert len(test_outbox.get_data_files()) == 0
54
+ await test_outbox.save(invalid_bytes)
55
+ assert len(test_outbox.get_upload_folders()) == 0
65
56
 
66
57
 
67
58
  # ------------------------------ Helper functions --------------------------------------
@@ -90,7 +81,7 @@ def get_test_image_binary():
90
81
 
91
82
  async def wait_for_outbox_count(outbox: Outbox, count: int, timeout: int = 10) -> bool:
92
83
  for _ in range(timeout):
93
- if len(outbox.get_data_files()) == count:
84
+ if len(outbox.get_upload_folders()) == count:
94
85
  return True
95
86
  await asyncio.sleep(1)
96
87
  return False
@@ -2,6 +2,7 @@ import asyncio
2
2
  import logging
3
3
  import os
4
4
  import shutil
5
+ import sys
5
6
 
6
7
  import pytest
7
8
 
@@ -15,7 +16,12 @@ from ...loop_communication import LoopCommunicator
15
16
  async def create_project_for_module():
16
17
 
17
18
  loop_communicator = LoopCommunicator()
18
- await loop_communicator.delete("/zauberzeug/projects/pytest_nodelib_general?keep_images=true")
19
+ try:
20
+ await loop_communicator.delete("/zauberzeug/projects/pytest_nodelib_general", timeout=10)
21
+ except Exception:
22
+ logging.warning("Failed to delete project pytest_nodelib_general")
23
+ sys.exit(1)
24
+
19
25
  await asyncio.sleep(1)
20
26
  project_configuration = {
21
27
  'project_name': 'pytest_nodelib_general', 'inbox': 0, 'annotate': 0, 'review': 0, 'complete': 3, 'image_style': 'beautiful',
@@ -23,7 +29,7 @@ async def create_project_for_module():
23
29
  'trainings': 1, 'box_detections': 3, 'box_annotations': 0}
24
30
  assert (await loop_communicator.post("/zauberzeug/projects/generator", json=project_configuration)).status_code == 200
25
31
  yield
26
- await loop_communicator.delete("/zauberzeug/projects/pytest_nodelib_general?keep_images=true")
32
+ await loop_communicator.delete("/zauberzeug/projects/pytest_nodelib_general", timeout=10)
27
33
  await loop_communicator.shutdown()
28
34
 
29
35
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: learning-loop-node
3
- Version: 0.13.6
3
+ Version: 0.14.0
4
4
  Summary: Python Library for Nodes which connect to the Zauberzeug Learning Loop
5
5
  Home-page: https://github.com/zauberzeug/learning_loop_node
6
6
  License: MIT
@@ -63,8 +63,8 @@ You can configure connection to our Learning Loop by specifying the following en
63
63
  | LOOP_USERNAME | USERNAME | Learning Loop user name | all besides Detector |
64
64
  | LOOP_PASSWORD | PASSWORD | Learning Loop password | all besides Detector |
65
65
  | LOOP_SSL_CERT_PATH | - | Path to the SSL certificate | all (opt.) |
66
- | LOOP_ORGANIZATION | ORGANIZATION | Organization name | Detector |
67
- | LOOP_PROJECT | PROJECT | Project name | Detector (opt.) |
66
+ | LOOP_ORGANIZATION | ORGANIZATION | Organization ID | Detector |
67
+ | LOOP_PROJECT | PROJECT | Project ID | Detector (opt.) |
68
68
  | MIN_UNCERTAIN_THRESHOLD | - | smallest confidence (float) at which auto-upload will happen | Detector (opt.) |
69
69
  | MAX_UNCERTAIN_THRESHOLD | - | largest confidence (float) at which auto-upload will happen | Detector (opt.) |
70
70
  | INFERENCE_BATCH_SIZE | - | Batch size of trainer when calculating detections | Trainer (opt.) |
@@ -73,6 +73,8 @@ You can configure connection to our Learning Loop by specifying the following en
73
73
  | TRAINER_IDLE_TIMEOUT_SEC | - | Automatically shutdown trainer after timeout (in seconds) | Trainer (opt.) |
74
74
  | USE_BACKDOOR_CONTROLS | - | Always enable backdoor controls (set to 1) | Trainer / Detector (opt.) |
75
75
 
76
+ Note that organization and project IDs are always lower case and may differ from the names in the Learning Loop which can have uppercase letters.
77
+
76
78
  #### Testing
77
79
 
78
80
  We use github actions for CI. Tests can also be executed locally by running
@@ -103,6 +105,9 @@ The detector also has a sio **upload endpoint** that can be used to upload image
103
105
  - `image`: the image data in jpg format
104
106
  - `tags`: a list of strings. If not provided the tag is `picked_by_system`
105
107
  - `detections`: a dictionary representing the detections. UUIDs for the classes are automatically determined based on the category names. This field is optional. If not provided, no detections are uploaded.
108
+ - `source`: optional source identifier for the image
109
+ - `creation_date`: optional creation date for the image
110
+ - `upload_priority`: boolean flag to prioritize the upload (defaults to False)
106
111
 
107
112
  The endpoint returns None if the upload was successful and an error message otherwise.
108
113
 
@@ -185,58 +190,52 @@ Upload a model with
185
190
  The model should now be available for the format 'format_a'
186
191
  `curl "https://learning-loop.ai/api/zauberzeug/projects/demo/models?format=format_a"`
187
192
 
188
- ````
189
-
193
+ ```json
190
194
  {
191
- "models": [
192
- {
193
- "id": "3c20d807-f71c-40dc-a996-8a8968aa5431",
194
- "version": "4.0",
195
- "formats": [
196
- "format_a"
197
- ],
198
- "created": "2021-06-01T06:28:21.289092",
199
- "comment": "uploaded at 2021-06-01 06:28:21.288442",
200
- ...
195
+ "models": [
196
+ {
197
+ "id": "3c20d807-f71c-40dc-a996-8a8968aa5431",
198
+ "version": "4.0",
199
+ "formats": [
200
+ "format_a"
201
+ ],
202
+ "created": "2021-06-01T06:28:21.289092",
203
+ "comment": "uploaded at 2021-06-01 06:28:21.288442",
204
+ ...
205
+ }
206
+ ]
201
207
  }
202
- ]
203
- }
204
-
205
208
  ```
206
209
 
207
210
  but not in the format_b
208
211
  `curl "https://learning-loop.ai/api/zauberzeug/projects/demo/models?format=format_b"`
209
212
 
210
- ```
211
-
213
+ ```json
212
214
  {
213
- "models": []
215
+ "models": []
214
216
  }
215
-
216
217
  ```
217
218
 
218
219
  Connect the Node to the Learning Loop by simply starting the container.
219
220
  After a short time the converted model should be available as well.
220
221
  `curl https://learning-loop.ai/api/zauberzeug/projects/demo/models?format=format_b`
221
222
 
222
- ```
223
-
224
- {
225
- "models": [
223
+ ```json
226
224
  {
227
- "id": "3c20d807-f71c-40dc-a996-8a8968aa5431",
228
- "version": "4.0",
229
- "formats": [
230
- "format_a",
231
- "format_b",
232
- ],
233
- "created": "2021-06-01T06:28:21.289092",
234
- "comment": "uploaded at 2021-06-01 06:28:21.288442",
235
- ...
236
- }
237
- ]
225
+ "models": [
226
+ {
227
+ "id": "3c20d807-f71c-40dc-a996-8a8968aa5431",
228
+ "version": "4.0",
229
+ "formats": [
230
+ "format_a",
231
+ "format_b",
232
+ ],
233
+ "created": "2021-06-01T06:28:21.289092",
234
+ "comment": "uploaded at 2021-06-01 06:28:21.288442",
235
+ ...
236
+ }
237
+ ]
238
238
  }
239
-
240
239
  ```
241
240
 
242
241
  ## About Models (the currency between Nodes)
@@ -255,6 +254,4 @@ After a short time the converted model should be available as well.
255
254
  - Nodes add properties to `model.json`, which contains all the information which are needed by subsequent nodes. These are typically the properties:
256
255
  - `resolution`: resolution in which the model expects images (as `int`, since the resolution is mostly square - later, ` resolution_x`` resolution_y ` would also be conceivable or `resolutions` to give a list of possible resolutions)
257
256
  - `categories`: list of categories with name, id, (later also type), in the order in which they are used by the model -- this is neccessary to be robust about renamings
258
- ```
259
- ````
260
257
 
@@ -9,23 +9,23 @@ learning_loop_node/data_classes/general.py,sha256=r7fVfuQvbo8qOTT7zylgfM45TbIvYu
9
9
  learning_loop_node/data_classes/image_metadata.py,sha256=56nNSf_7aMlvKsJOG8vKCzJHcqKGHVRoULp85pJ2imA,1598
10
10
  learning_loop_node/data_classes/socket_response.py,sha256=tIdt-oYf6ULoJIDYQCecNM9OtWR6_wJ9tL0Ksu83Vko,655
11
11
  learning_loop_node/data_classes/training.py,sha256=FFPsr2AA7ynYz39MLZaFJ0sF_9Axll5HHbAA8nnirp0,5726
12
- learning_loop_node/data_exchanger.py,sha256=IG5ki3f3IsVuXbyw6q_gUIakgv-GMT6e9nhOhzjKgW4,9055
12
+ learning_loop_node/data_exchanger.py,sha256=2gV2epi24NQm8MgZKhi-sUNAP8CmcFLwihLagHxzKgA,9070
13
13
  learning_loop_node/detector/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- learning_loop_node/detector/detector_logic.py,sha256=s1EFLrk_SFvLJOsIj9b0lp-Oq0DgfVWxT6Q34Vmi_JE,2243
15
- learning_loop_node/detector/detector_node.py,sha256=6MUdR_WzzcubwUAiaUo7TDF2xs-VkEvB_y1sNsrONYI,25461
14
+ learning_loop_node/detector/detector_logic.py,sha256=AnXnAWzZfPMxRwKImNy2uiffnTacE3ArE4IxwxspgBU,2213
15
+ learning_loop_node/detector/detector_node.py,sha256=TrBJlx9QEcyMYy4szVfw-g0xp9Yu5fdKgGWPJVGb4YQ,26629
16
16
  learning_loop_node/detector/exceptions.py,sha256=C6KbNPlSbtfgDrZx2Hbhm7Suk9jVoR3fMRCO0CkrMsQ,196
17
17
  learning_loop_node/detector/inbox_filter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
18
  learning_loop_node/detector/inbox_filter/cam_observation_history.py,sha256=1PHgXRrhSQ34HSFw7mdX8ndRxHf_i1aP5nXXnrZxhAY,3312
19
- learning_loop_node/detector/inbox_filter/relevance_filter.py,sha256=NPEmrAtuGjIWCtHS0B3zDmnYWkhVFCLbd_7RUp08_AM,1372
20
- learning_loop_node/detector/outbox.py,sha256=i12X28FJka8HMm4iqE7SCODT1uCEtM53tIBul3uxKFw,8828
19
+ learning_loop_node/detector/inbox_filter/relevance_filter.py,sha256=rI46jL9ZuI0hiDVxWCfXllB8DlQyyewNs6oZ6MnglMc,1540
20
+ learning_loop_node/detector/outbox.py,sha256=KjQ2C8OokFtXtSOUKiYihADGI4QgkBX8QVRV109Bdr0,12716
21
21
  learning_loop_node/detector/rest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  learning_loop_node/detector/rest/about.py,sha256=evHJ2svUZY_DFz0FSef5u9c5KW4Uc3GL7EbPinG9-dg,583
23
23
  learning_loop_node/detector/rest/backdoor_controls.py,sha256=ZNaFOvC0OLWNtcLiG-NIqS_y1kkLP4csgk3CHhp8Gis,885
24
- learning_loop_node/detector/rest/detect.py,sha256=ofJ3ysTarbCpiH1YAD6gSJbrDOzAcsLRuGxhr57dtk0,2503
24
+ learning_loop_node/detector/rest/detect.py,sha256=wYf9cCgtImMgnHbrcE6GMXE2aBopdZciKvGmc92ZCGw,2533
25
25
  learning_loop_node/detector/rest/model_version_control.py,sha256=P4FOG0U9HT6QtCoNt-1s1pT6drtgdVjGZWEuCAyuNmA,1370
26
26
  learning_loop_node/detector/rest/operation_mode.py,sha256=1_xfutA_6nzdb4Q_jZiHQ5m_wA83bcG5jSIy-sfNIvk,1575
27
27
  learning_loop_node/detector/rest/outbox_mode.py,sha256=H8coDNbgLGEfXmKQrhtXWeUHBAHpnrdZktuHXQz0xis,1148
28
- learning_loop_node/detector/rest/upload.py,sha256=5YWY0Ku4duZqKd6tjyJzq-Ga83o2UYb1VmzuxBIgo0w,1061
28
+ learning_loop_node/detector/rest/upload.py,sha256=GMDKyN3UNfzsKq5GtBBlv828lht0bztgqRqT_PQHkZM,1250
29
29
  learning_loop_node/enums/__init__.py,sha256=tjSrhztIQ8W656_QuXfTbbVNtH_wDXP5hpYZgzfgRhc,285
30
30
  learning_loop_node/enums/annotator.py,sha256=mtTAw-8LJIrHcYkBjYHCZuhYEEHS6QzSK8k6BhLusvQ,285
31
31
  learning_loop_node/enums/detector.py,sha256=Qvm5LWWR9BfsDxHEQ8YzaPaUuSmp4BescYuV4X4ikwE,512
@@ -34,34 +34,36 @@ learning_loop_node/enums/trainer.py,sha256=VaD63guLO4aKgVfXT0EryPlXKQGegSET3Cp4R
34
34
  learning_loop_node/examples/novelty_score_updater.py,sha256=1DRgM9lxjFV-q2JvGDDsNLz_ic_rhEZ9wc6ZdjcxwPE,2038
35
35
  learning_loop_node/globals.py,sha256=tgw_8RYOipPV9aYlyUhYtXfUxvJKRvfUk6u-qVAtZmY,174
36
36
  learning_loop_node/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
+ learning_loop_node/helpers/background_tasks.py,sha256=sNKyHyk9J5vNn-0GG1OzNJbB-F7GXGcbCWKE3MbRrno,3346
37
38
  learning_loop_node/helpers/environment_reader.py,sha256=6DxDJecLHxiGczByhyVa_JssAwwft7vuNCGaEzoSY2I,1662
38
39
  learning_loop_node/helpers/gdrive_downloader.py,sha256=zeYJciTAJVRpu_eFjwgYLCpIa6hU1d71anqEBb564Rk,1145
39
40
  learning_loop_node/helpers/log_conf.py,sha256=hqVAa_9NnYEU6N0dcOKmph82p7MpgKqeF_eomTLYzWY,961
40
41
  learning_loop_node/helpers/misc.py,sha256=J29iBmsEUAraKKDN1m1NKiHQ3QrP5ub5HBU6cllSP2g,7384
41
- learning_loop_node/loop_communication.py,sha256=kc7GrkUS14Ka5OICaaOd_LZ61D-6O19GcyDEwckTxvM,7286
42
- learning_loop_node/node.py,sha256=IRV81q1G3-A6_BLNqB3NBT7T_dN5OXegBoM9JHMJuLM,11030
42
+ learning_loop_node/helpers/run.py,sha256=_uox-j3_K_bL3yCAwy3JYSOiIxrnhzVxyxWpCe8_J9U,876
43
+ learning_loop_node/loop_communication.py,sha256=opulqBKRLXlUQgjA3t0pg8CNA-JXJRCPPUspRxRuuGw,7556
44
+ learning_loop_node/node.py,sha256=-Tw8kbvDKm8bPMm51MsFEOQKxPJx3n6DZ65cWGVQ5Zw,11262
43
45
  learning_loop_node/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
44
46
  learning_loop_node/rest.py,sha256=omwlRHLnyG-kgCBVnZDk5_SAPobL9g7slWeX21wsPGw,1551
45
47
  learning_loop_node/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
48
  learning_loop_node/tests/annotator/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
- learning_loop_node/tests/annotator/conftest.py,sha256=G4ZvdZUdvPp9bYCzg3eEVkGCeXn9INZ3AcN7d5CyLkU,1931
49
+ learning_loop_node/tests/annotator/conftest.py,sha256=e83I8WNAUgCFmum1GCx_nSjP9uwAoPIwPk72elypNQY,2098
48
50
  learning_loop_node/tests/annotator/pytest.ini,sha256=8QdjmawLy1zAzXrJ88or1kpFDhJw0W5UOnDfGGs_igU,262
49
- learning_loop_node/tests/annotator/test_annotator_node.py,sha256=UWRXRSBc1e795ftkp7xrEXbyR4LYvFDDHRpZGqC3vr8,1974
51
+ learning_loop_node/tests/annotator/test_annotator_node.py,sha256=AuTqFvFyQYuxEdkNmjBZqBB7RYRgpoSuDsi7SjBVHfo,1997
50
52
  learning_loop_node/tests/detector/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
51
53
  learning_loop_node/tests/detector/conftest.py,sha256=gut-RaacarhWJNCvGEz7O7kj3cS7vJ4SvAxCmR87PIw,5263
52
54
  learning_loop_node/tests/detector/inbox_filter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
55
  learning_loop_node/tests/detector/inbox_filter/test_observation.py,sha256=k4WYdvnuV7d_r7zI4M2aA8WuBjm0aycQ0vj1rGE2q4w,1370
54
56
  learning_loop_node/tests/detector/inbox_filter/test_relevance_group.py,sha256=r-wABFQVsTNTjv7vYGr8wbHfOWy43F_B14ZDWHfiZ-A,7613
55
- learning_loop_node/tests/detector/inbox_filter/test_unexpected_observations_count.py,sha256=3KKwf-J9oJRMIuuVju2vT9IM9vWhKvswPiXJI8KxmcU,1661
57
+ learning_loop_node/tests/detector/inbox_filter/test_unexpected_observations_count.py,sha256=JbUnPZVjzdtAlp6cTZVAdXUluQYNueGU9eITNJKY-tU,1710
56
58
  learning_loop_node/tests/detector/pytest.ini,sha256=8QdjmawLy1zAzXrJ88or1kpFDhJw0W5UOnDfGGs_igU,262
57
59
  learning_loop_node/tests/detector/test.jpg,sha256=msA-vHPmvPiro_D102Qmn1fn4vNfooqYYEXPxZUmYpk,161390
58
- learning_loop_node/tests/detector/test_client_communication.py,sha256=PUjnWnY-9RCZe-gqrtWf3o0ylCNH3WuzHoL7v3eAjAQ,8984
60
+ learning_loop_node/tests/detector/test_client_communication.py,sha256=cVviUmAwbLY3LsJcY-D3ve-Jwxk9WVOrVupeh-PdKtA,8013
59
61
  learning_loop_node/tests/detector/test_detector_node.py,sha256=0ZMV6coAvdq-nH8CwY9_LR2tUcH9VLcAB1CWuwHQMpo,3023
60
- learning_loop_node/tests/detector/test_outbox.py,sha256=IfCz4iBmYA4bm3TK4q2NmWyzQCwZWhUbBrKQNHGxZM4,3007
62
+ learning_loop_node/tests/detector/test_outbox.py,sha256=8L2k792oBhS82fnw2D7sw-Kh1vok_-4PzGjrK7r1WpM,2629
61
63
  learning_loop_node/tests/detector/test_relevance_filter.py,sha256=ZKcCstFWCDxJzKdVlAe8E6sZzv5NiH8mADhaZjokHoU,2052
62
64
  learning_loop_node/tests/detector/testing_detector.py,sha256=MZajybyzISz2G1OENfLHgZhBcLCYzTR4iN9JkWpq5-s,551
63
65
  learning_loop_node/tests/general/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
64
- learning_loop_node/tests/general/conftest.py,sha256=oVuE-XZfUPjOXE8KLJgDbIMKIF9Mmgfna2rlupC44TE,2298
66
+ learning_loop_node/tests/general/conftest.py,sha256=kEtkuVA2wgny-YBkLDn7Ff5j6ShOPghQUU0cH9IIl_8,2430
65
67
  learning_loop_node/tests/general/pytest.ini,sha256=8QdjmawLy1zAzXrJ88or1kpFDhJw0W5UOnDfGGs_igU,262
66
68
  learning_loop_node/tests/general/test_data/file_1.txt,sha256=Lis06nfvbFPVCBZyEgQlfI_Nle2YDq1GQBlYvEfFtxw,19
67
69
  learning_loop_node/tests/general/test_data/file_2.txt,sha256=Xp8EETGhZBdVAgb4URowSSpOytwwwJdV0Renkdur7R8,19
@@ -97,6 +99,6 @@ learning_loop_node/trainer/test_executor.py,sha256=6BVGDN_6f5GEMMEvDLSG1yzMybSvg
97
99
  learning_loop_node/trainer/trainer_logic.py,sha256=eK-01qZzi10UjLMCQX8vy5eW2FoghPj3rzzDC-s3Si4,8792
98
100
  learning_loop_node/trainer/trainer_logic_generic.py,sha256=RQqon8JIVzxaNh0KdEe6tMxebsY0DgZllEohHR-AgqU,26846
99
101
  learning_loop_node/trainer/trainer_node.py,sha256=Dl4ZQAjjXQggibeBjvhXAoFClw1ZX2Kkt3v_fjrJnCI,4508
100
- learning_loop_node-0.13.6.dist-info/METADATA,sha256=z7dmbFXZUgUXgs-y0N-w-KwBDo8H2oVngoc1ja2Giok,12761
101
- learning_loop_node-0.13.6.dist-info/WHEEL,sha256=WGfLGfLX43Ei_YORXSnT54hxFygu34kMpcQdmgmEwCQ,88
102
- learning_loop_node-0.13.6.dist-info/RECORD,,
102
+ learning_loop_node-0.14.0.dist-info/METADATA,sha256=8gcoFu72XaljmTvPUIRXGk86GV0dhR9WGrGHM6zhRsQ,13186
103
+ learning_loop_node-0.14.0.dist-info/WHEEL,sha256=WGfLGfLX43Ei_YORXSnT54hxFygu34kMpcQdmgmEwCQ,88
104
+ learning_loop_node-0.14.0.dist-info/RECORD,,