learning-loop-node 0.13.7__py3-none-any.whl → 0.15.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_classes/__init__.py +2 -2
- learning_loop_node/data_classes/image_metadata.py +5 -0
- learning_loop_node/data_classes/training.py +3 -2
- learning_loop_node/data_exchanger.py +3 -3
- learning_loop_node/detector/detector_logic.py +8 -5
- learning_loop_node/detector/detector_node.py +105 -44
- 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/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/detector/test_relevance_filter.py +3 -3
- learning_loop_node/tests/general/conftest.py +8 -2
- learning_loop_node/tests/trainer/conftest.py +2 -2
- learning_loop_node/trainer/trainer_logic_generic.py +16 -4
- {learning_loop_node-0.13.7.dist-info → learning_loop_node-0.15.0.dist-info}/METADATA +35 -38
- {learning_loop_node-0.13.7.dist-info → learning_loop_node-0.15.0.dist-info}/RECORD +25 -23
- {learning_loop_node-0.13.7.dist-info → learning_loop_node-0.15.0.dist-info}/WHEEL +0 -0
|
@@ -3,7 +3,7 @@ from .detections import (BoxDetection, ClassificationDetection, Detections, Obse
|
|
|
3
3
|
SegmentationDetection, Shape)
|
|
4
4
|
from .general import (AboutResponse, AnnotationNodeStatus, Category, Context, DetectionStatus, ErrorConfiguration,
|
|
5
5
|
ModelInformation, ModelVersionResponse, NodeState, NodeStatus)
|
|
6
|
-
from .image_metadata import ImageMetadata
|
|
6
|
+
from .image_metadata import ImageMetadata, ImagesMetadata
|
|
7
7
|
from .socket_response import SocketResponse
|
|
8
8
|
from .training import Errors, PretrainedModel, Training, TrainingError, TrainingOut, TrainingStateData, TrainingStatus
|
|
9
9
|
|
|
@@ -12,7 +12,7 @@ __all__ = [
|
|
|
12
12
|
'BoxDetection', 'ClassificationDetection', 'ImageMetadata', 'Observation', 'Point', 'PointDetection',
|
|
13
13
|
'SegmentationDetection', 'Shape', 'Detections',
|
|
14
14
|
'AnnotationNodeStatus', 'Category', 'Context', 'DetectionStatus', 'ErrorConfiguration',
|
|
15
|
-
'ModelInformation', 'NodeState', 'NodeStatus', 'ModelVersionResponse',
|
|
15
|
+
'ModelInformation', 'NodeState', 'NodeStatus', 'ModelVersionResponse', 'ImagesMetadata',
|
|
16
16
|
'SocketResponse',
|
|
17
17
|
'Errors', 'PretrainedModel', 'Training',
|
|
18
18
|
'TrainingError', 'TrainingOut', 'TrainingStateData', 'TrainingStatus',
|
|
@@ -35,3 +35,8 @@ class ImageMetadata():
|
|
|
35
35
|
|
|
36
36
|
def __len__(self):
|
|
37
37
|
return len(self.box_detections) + len(self.point_detections) + len(self.segmentation_detections) + len(self.classification_detections)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(**KWONLY_SLOTS)
|
|
41
|
+
class ImagesMetadata():
|
|
42
|
+
items: List[ImageMetadata] = field(default_factory=list, metadata={'description': 'List of image metadata'})
|
|
@@ -8,6 +8,7 @@ from uuid import uuid4
|
|
|
8
8
|
|
|
9
9
|
from ..enums import TrainerState
|
|
10
10
|
from ..helpers.misc import create_image_folder, create_training_folder
|
|
11
|
+
|
|
11
12
|
# pylint: disable=no-name-in-module
|
|
12
13
|
from .general import Category, Context
|
|
13
14
|
|
|
@@ -52,7 +53,7 @@ class Training():
|
|
|
52
53
|
training_folder: str # f'{project_folder}/trainings/{trainings_id}'
|
|
53
54
|
|
|
54
55
|
categories: List[Category]
|
|
55
|
-
hyperparameters:
|
|
56
|
+
hyperparameters: Dict[str, Any]
|
|
56
57
|
|
|
57
58
|
training_number: int
|
|
58
59
|
training_state: str
|
|
@@ -63,7 +64,7 @@ class Training():
|
|
|
63
64
|
base_model_uuid: Optional[str] = None # model uuid to continue training (is loaded from loop)
|
|
64
65
|
|
|
65
66
|
# NOTE: these are set later after the model has been uploaded
|
|
66
|
-
image_data: Optional[List[
|
|
67
|
+
image_data: Optional[List[Dict]] = None
|
|
67
68
|
skipped_image_count: Optional[int] = None
|
|
68
69
|
model_uuid_for_detecting: Optional[str] = None # Model uuid to load from the loop after training and upload
|
|
69
70
|
|
|
@@ -7,7 +7,7 @@ from glob import glob
|
|
|
7
7
|
from http import HTTPStatus
|
|
8
8
|
from io import BytesIO
|
|
9
9
|
from time import time
|
|
10
|
-
from typing import Dict, List, Optional
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
11
|
|
|
12
12
|
import aiofiles # type: ignore
|
|
13
13
|
|
|
@@ -68,7 +68,7 @@ class DataExchanger():
|
|
|
68
68
|
assert response.status_code == 200, response
|
|
69
69
|
return (response.json())['image_ids']
|
|
70
70
|
|
|
71
|
-
async def download_images_data(self, image_uuids: List[str], chunk_size: int = 100) -> List[Dict]:
|
|
71
|
+
async def download_images_data(self, image_uuids: List[str], chunk_size: int = 100) -> List[Dict[str, Any]]:
|
|
72
72
|
"""Download image annotations, tags, set and other information for the given image uuids."""
|
|
73
73
|
logging.info('Fetching annotations, tags, sets, etc. for %s images..', len(image_uuids))
|
|
74
74
|
|
|
@@ -78,7 +78,7 @@ class DataExchanger():
|
|
|
78
78
|
return []
|
|
79
79
|
|
|
80
80
|
progress_factor = 0.5 / num_image_ids # first 50% of progress is for downloading data
|
|
81
|
-
images_data: List[Dict] = []
|
|
81
|
+
images_data: List[Dict[str, Any]] = []
|
|
82
82
|
for i in range(0, num_image_ids, chunk_size):
|
|
83
83
|
self.progress = i * progress_factor
|
|
84
84
|
chunk_ids = image_uuids[i:i+chunk_size]
|
|
@@ -2,9 +2,7 @@ import logging
|
|
|
2
2
|
from abc import abstractmethod
|
|
3
3
|
from typing import List, Optional
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
from ..data_classes import ImageMetadata, ModelInformation
|
|
5
|
+
from ..data_classes import ImageMetadata, ImagesMetadata, ModelInformation
|
|
8
6
|
from ..globals import GLOBALS
|
|
9
7
|
from .exceptions import NodeNeedsRestartError
|
|
10
8
|
|
|
@@ -44,13 +42,18 @@ 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"""
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def batch_evaluate(self, images: List[bytes]) -> ImagesMetadata:
|
|
58
|
+
"""Evaluate a batch of images and return the detections.
|
|
59
|
+
The object should return empty detections if it is not initialized"""
|
|
@@ -1,36 +1,25 @@
|
|
|
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
9
|
from typing import Dict, List, Optional
|
|
12
10
|
|
|
13
|
-
import numpy as np
|
|
14
11
|
import socketio
|
|
15
12
|
from dacite import from_dict
|
|
16
13
|
from fastapi.encoders import jsonable_encoder
|
|
17
14
|
from socketio import AsyncClient
|
|
18
15
|
|
|
19
|
-
from ..data_classes import (
|
|
20
|
-
|
|
21
|
-
Category,
|
|
22
|
-
Context,
|
|
23
|
-
DetectionStatus,
|
|
24
|
-
ImageMetadata,
|
|
25
|
-
ModelInformation,
|
|
26
|
-
ModelVersionResponse,
|
|
27
|
-
Shape,
|
|
28
|
-
)
|
|
16
|
+
from ..data_classes import (AboutResponse, Category, Context, DetectionStatus, ImageMetadata, ImagesMetadata,
|
|
17
|
+
ModelInformation, ModelVersionResponse, Shape)
|
|
29
18
|
from ..data_classes.socket_response import SocketResponse
|
|
30
19
|
from ..data_exchanger import DataExchanger, DownloadError
|
|
31
20
|
from ..enums import OperationMode, VersionMode
|
|
32
21
|
from ..globals import GLOBALS
|
|
33
|
-
from ..helpers import environment_reader
|
|
22
|
+
from ..helpers import background_tasks, environment_reader, run
|
|
34
23
|
from ..node import Node
|
|
35
24
|
from .detector_logic import DetectorLogic
|
|
36
25
|
from .exceptions import NodeNeedsRestartError
|
|
@@ -227,7 +216,7 @@ class DetectorNode(Node):
|
|
|
227
216
|
async def detect(sid, data: Dict) -> Dict:
|
|
228
217
|
try:
|
|
229
218
|
det = await self.get_detections(
|
|
230
|
-
raw_image=
|
|
219
|
+
raw_image=data['image'],
|
|
231
220
|
camera_id=data.get('camera-id', None) or data.get('mac', None),
|
|
232
221
|
tags=data.get('tags', []),
|
|
233
222
|
source=data.get('source', None),
|
|
@@ -240,8 +229,29 @@ class DetectorNode(Node):
|
|
|
240
229
|
return detection_dict
|
|
241
230
|
except Exception as e:
|
|
242
231
|
self.log.exception('could not detect via socketio')
|
|
243
|
-
with open('/tmp/bad_img_from_socket_io.jpg', 'wb') as f:
|
|
244
|
-
|
|
232
|
+
# with open('/tmp/bad_img_from_socket_io.jpg', 'wb') as f:
|
|
233
|
+
# f.write(data['image'])
|
|
234
|
+
return {'error': str(e)}
|
|
235
|
+
|
|
236
|
+
@self.sio.event
|
|
237
|
+
async def batch_detect(sid, data: Dict) -> Dict:
|
|
238
|
+
try:
|
|
239
|
+
det = await self.get_batch_detections(
|
|
240
|
+
raw_images=data['images'],
|
|
241
|
+
tags=data.get('tags', []),
|
|
242
|
+
camera_id=data.get('camera-id', None) or data.get('mac', None),
|
|
243
|
+
source=data.get('source', None),
|
|
244
|
+
autoupload=data.get('autoupload', None),
|
|
245
|
+
creation_date=data.get('creation_date', None)
|
|
246
|
+
)
|
|
247
|
+
if det is None:
|
|
248
|
+
return {'error': 'no model loaded'}
|
|
249
|
+
detection_dict = jsonable_encoder(asdict(det))
|
|
250
|
+
return detection_dict
|
|
251
|
+
except Exception as e:
|
|
252
|
+
self.log.exception('could not detect via socketio')
|
|
253
|
+
# with open('/tmp/bad_img_from_socket_io.jpg', 'wb') as f:
|
|
254
|
+
# f.write(data['image'])
|
|
245
255
|
return {'error': str(e)}
|
|
246
256
|
|
|
247
257
|
@self.sio.event
|
|
@@ -279,9 +289,10 @@ class DetectorNode(Node):
|
|
|
279
289
|
return {'error': str(e)}
|
|
280
290
|
|
|
281
291
|
@self.sio.event
|
|
282
|
-
async def upload(sid, data: Dict) ->
|
|
283
|
-
|
|
292
|
+
async def upload(sid, data: Dict) -> Dict:
|
|
293
|
+
"""Upload an image with detections"""
|
|
284
294
|
|
|
295
|
+
self.log.debug('Processing upload via socketio.')
|
|
285
296
|
detection_data = data.get('detections', {})
|
|
286
297
|
if detection_data and self.detector_logic.model_info is not None:
|
|
287
298
|
try:
|
|
@@ -293,22 +304,19 @@ class DetectorNode(Node):
|
|
|
293
304
|
else:
|
|
294
305
|
image_metadata = ImageMetadata()
|
|
295
306
|
|
|
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
307
|
try:
|
|
307
|
-
await
|
|
308
|
+
await self.upload_images(
|
|
309
|
+
images=[data['image']],
|
|
310
|
+
image_metadata=image_metadata,
|
|
311
|
+
tags=data.get('tags', []),
|
|
312
|
+
source=data.get('source', None),
|
|
313
|
+
creation_date=data.get('creation_date', None),
|
|
314
|
+
upload_priority=data.get('upload_priority', False)
|
|
315
|
+
)
|
|
308
316
|
except Exception as e:
|
|
309
317
|
self.log.exception('could not upload via socketio')
|
|
310
318
|
return {'error': str(e)}
|
|
311
|
-
return
|
|
319
|
+
return {'status': 'OK'}
|
|
312
320
|
|
|
313
321
|
@self.sio.event
|
|
314
322
|
def connect(sid, environ, auth) -> None:
|
|
@@ -469,7 +477,7 @@ class DetectorNode(Node):
|
|
|
469
477
|
self.log.warning('Operation mode set to %s, but sync failed: %s', mode, e)
|
|
470
478
|
|
|
471
479
|
def reload(self, reason: str):
|
|
472
|
-
|
|
480
|
+
"""provide a cause for the reload"""
|
|
473
481
|
|
|
474
482
|
self.log.info('########## reloading app because %s', reason)
|
|
475
483
|
if os.path.isfile('/app/app_code/restart/restart.py'):
|
|
@@ -482,20 +490,20 @@ class DetectorNode(Node):
|
|
|
482
490
|
self.log.error('could not reload app')
|
|
483
491
|
|
|
484
492
|
async def get_detections(self,
|
|
485
|
-
raw_image:
|
|
486
|
-
camera_id: Optional[str],
|
|
493
|
+
raw_image: bytes,
|
|
487
494
|
tags: List[str],
|
|
495
|
+
*,
|
|
496
|
+
camera_id: Optional[str] = None,
|
|
488
497
|
source: Optional[str] = None,
|
|
489
498
|
autoupload: Optional[str] = None,
|
|
490
499
|
creation_date: Optional[str] = None) -> ImageMetadata:
|
|
491
500
|
""" Main processing function for the detector node when an image is received via REST or SocketIO.
|
|
492
|
-
This function infers the detections from the image, cares about uploading to the loop and returns the detections as
|
|
501
|
+
This function infers the detections from the image, cares about uploading to the loop and returns the detections as ImageMetadata object.
|
|
493
502
|
Note: raw_image is a numpy array of type uint8, but not in the correct shape!
|
|
494
503
|
It can be converted e.g. using cv2.imdecode(raw_image, cv2.IMREAD_COLOR)"""
|
|
495
504
|
|
|
496
505
|
await self.detection_lock.acquire()
|
|
497
|
-
|
|
498
|
-
detections = await loop.run_in_executor(None, self.detector_logic.evaluate_with_all_info, raw_image, tags, source, creation_date)
|
|
506
|
+
detections = await run.io_bound(self.detector_logic.evaluate_with_all_info, raw_image, tags, source, creation_date)
|
|
499
507
|
self.detection_lock.release()
|
|
500
508
|
|
|
501
509
|
fix_shape_detections(detections)
|
|
@@ -503,21 +511,74 @@ class DetectorNode(Node):
|
|
|
503
511
|
n_po, n_se = len(detections.point_detections), len(detections.segmentation_detections)
|
|
504
512
|
self.log.debug('Detected: %d boxes, %d points, %d segs, %d classes', n_bo, n_po, n_se, n_cl)
|
|
505
513
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
514
|
+
autoupload = autoupload or 'filtered'
|
|
515
|
+
if autoupload == 'filtered' and camera_id is not None:
|
|
516
|
+
background_tasks.create(self.relevance_filter.may_upload_detections(
|
|
517
|
+
detections, camera_id, raw_image, tags, source, creation_date
|
|
518
|
+
))
|
|
509
519
|
elif autoupload == 'all':
|
|
510
|
-
|
|
520
|
+
background_tasks.create(self.outbox.save(raw_image, detections, tags, source, creation_date))
|
|
511
521
|
elif autoupload == 'disabled':
|
|
512
522
|
pass
|
|
513
523
|
else:
|
|
514
524
|
self.log.error('unknown autoupload value %s', autoupload)
|
|
515
525
|
return detections
|
|
516
526
|
|
|
517
|
-
async def
|
|
518
|
-
|
|
527
|
+
async def get_batch_detections(self,
|
|
528
|
+
raw_images: List[bytes],
|
|
529
|
+
tags: List[str],
|
|
530
|
+
*,
|
|
531
|
+
camera_id: Optional[str] = None,
|
|
532
|
+
source: Optional[str] = None,
|
|
533
|
+
autoupload: Optional[str] = None,
|
|
534
|
+
creation_date: Optional[str] = None) -> ImagesMetadata:
|
|
535
|
+
""" Processing function for the detector node when a a batch inference is requested via SocketIO.
|
|
536
|
+
This function infers the detections from all images, cares about uploading to the loop and returns the detections as a list of ImageMetadata."""
|
|
537
|
+
|
|
538
|
+
await self.detection_lock.acquire()
|
|
539
|
+
all_detections = await run.io_bound(self.detector_logic.batch_evaluate, raw_images)
|
|
540
|
+
self.detection_lock.release()
|
|
541
|
+
|
|
542
|
+
for detections, raw_image in zip(all_detections.items, raw_images):
|
|
543
|
+
fix_shape_detections(detections)
|
|
544
|
+
n_bo, n_cl = len(detections.box_detections), len(detections.classification_detections)
|
|
545
|
+
n_po, n_se = len(detections.point_detections), len(detections.segmentation_detections)
|
|
546
|
+
self.log.debug('Detected: %d boxes, %d points, %d segs, %d classes', n_bo, n_po, n_se, n_cl)
|
|
547
|
+
|
|
548
|
+
autoupload = autoupload or 'filtered'
|
|
549
|
+
if autoupload == 'filtered' and camera_id is not None:
|
|
550
|
+
background_tasks.create(self.relevance_filter.may_upload_detections(
|
|
551
|
+
detections, camera_id, raw_image, tags, source, creation_date
|
|
552
|
+
))
|
|
553
|
+
elif autoupload == 'all':
|
|
554
|
+
background_tasks.create(self.outbox.save(raw_image, detections, tags, source, creation_date))
|
|
555
|
+
elif autoupload == 'disabled':
|
|
556
|
+
pass
|
|
557
|
+
else:
|
|
558
|
+
self.log.error('unknown autoupload value %s', autoupload)
|
|
559
|
+
return all_detections
|
|
560
|
+
|
|
561
|
+
async def upload_images(
|
|
562
|
+
self, *,
|
|
563
|
+
images: List[bytes],
|
|
564
|
+
image_metadata: Optional[ImageMetadata] = None,
|
|
565
|
+
tags: Optional[List[str]] = None,
|
|
566
|
+
source: Optional[str],
|
|
567
|
+
creation_date: Optional[str],
|
|
568
|
+
upload_priority: bool = False
|
|
569
|
+
) -> None:
|
|
570
|
+
"""Save images to the outbox using an asyncio executor.
|
|
571
|
+
Used by SIO and REST upload endpoints."""
|
|
572
|
+
|
|
573
|
+
if image_metadata is None:
|
|
574
|
+
image_metadata = ImageMetadata()
|
|
575
|
+
if tags is None:
|
|
576
|
+
tags = []
|
|
577
|
+
|
|
578
|
+
tags.append('picked_by_system')
|
|
579
|
+
|
|
519
580
|
for image in images:
|
|
520
|
-
await
|
|
581
|
+
await self.outbox.save(image, image_metadata, tags, source, creation_date, upload_priority)
|
|
521
582
|
|
|
522
583
|
def add_category_id_to_detections(self, model_info: ModelInformation, image_metadata: ImageMetadata):
|
|
523
584
|
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
|