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.
- learning_loop_node/data_exchanger.py +1 -1
- learning_loop_node/detector/detector_logic.py +2 -4
- learning_loop_node/detector/detector_node.py +69 -43
- learning_loop_node/detector/inbox_filter/relevance_filter.py +11 -9
- learning_loop_node/detector/outbox.py +134 -44
- learning_loop_node/detector/rest/detect.py +3 -3
- learning_loop_node/detector/rest/upload.py +4 -3
- learning_loop_node/helpers/background_tasks.py +78 -0
- learning_loop_node/helpers/run.py +21 -0
- learning_loop_node/loop_communication.py +18 -18
- learning_loop_node/node.py +11 -4
- learning_loop_node/tests/annotator/conftest.py +9 -4
- learning_loop_node/tests/annotator/test_annotator_node.py +10 -2
- learning_loop_node/tests/detector/inbox_filter/test_unexpected_observations_count.py +4 -3
- learning_loop_node/tests/detector/test_client_communication.py +1 -23
- learning_loop_node/tests/detector/test_outbox.py +7 -16
- learning_loop_node/tests/general/conftest.py +8 -2
- {learning_loop_node-0.13.6.dist-info → learning_loop_node-0.14.0.dist-info}/METADATA +37 -40
- {learning_loop_node-0.13.6.dist-info → learning_loop_node-0.14.0.dist-info}/RECORD +20 -18
- {learning_loop_node-0.13.6.dist-info → learning_loop_node-0.14.0.dist-info}/WHEEL +0 -0
|
@@ -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:
|
|
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:
|
|
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
|
|
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=
|
|
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) ->
|
|
283
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
434
|
-
raise Exception(f'
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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(
|
|
506
|
-
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
103
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
learning_loop_node/node.py
CHANGED
|
@@ -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.
|
|
210
|
-
cookies=cookies, connector=connector))
|
|
216
|
+
self._client_session = aiohttp.ClientSession(cookies=cookies, connector=connector)
|
|
211
217
|
else:
|
|
212
|
-
self.
|
|
213
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
assert
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
67
|
-
| LOOP_PROJECT | PROJECT | Project
|
|
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
|
-
"
|
|
228
|
-
|
|
229
|
-
"
|
|
230
|
-
"
|
|
231
|
-
"
|
|
232
|
-
|
|
233
|
-
"
|
|
234
|
-
|
|
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=
|
|
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=
|
|
15
|
-
learning_loop_node/detector/detector_node.py,sha256=
|
|
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=
|
|
20
|
-
learning_loop_node/detector/outbox.py,sha256=
|
|
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=
|
|
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=
|
|
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/
|
|
42
|
-
learning_loop_node/
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
101
|
-
learning_loop_node-0.
|
|
102
|
-
learning_loop_node-0.
|
|
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,,
|
|
File without changes
|