Skip to content

Commit 4a574ae

Browse files
committed
ADD: add multifile and multichannel flag to local datastore
Adds flags to the local datastore init signaling the data is either multichannel or multi-file (each directory has a sample with multiple volumes Signed-off-by: Cavan Riley <cavan-riley@uiowa.edu>
1 parent 058ddfc commit 4a574ae

11 files changed

Lines changed: 265 additions & 26 deletions

File tree

monailabel/datastore/cvat.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import tempfile
1717
import time
1818
import urllib.parse
19+
from typing import Any, Dict
1920

2021
import numpy as np
2122
import requests
@@ -318,6 +319,32 @@ def download_from_cvat(self, max_retry_count=5, retry_wait_time=10):
318319
retry_count += 1
319320
return None
320321

322+
def add_directory(self, directory_id: str, filename: str, info: Dict[str, Any]) -> str:
323+
"""
324+
Not implemented for this datastore
325+
326+
Abstract method for adding a directory to cvat
327+
"""
328+
raise NotImplementedError("This datastore does not support adding directories")
329+
330+
def get_is_multichannel(self) -> bool:
331+
"""
332+
Not implemented for this datastore
333+
334+
Returns whether the application's studies is directed at multichannel (4D) data
335+
"""
336+
logging.info("The function get_is_multichannel is not implemented for this datastore")
337+
return False
338+
339+
def get_is_multi_file(self) -> bool:
340+
"""
341+
Not implemented for this datastore
342+
343+
Returns whether the application's studies is directed at directories containing multiple images per sample
344+
"""
345+
logger.info("The function get_is_multi_file is not implemented for this datastore")
346+
return False
347+
321348

322349
"""
323350
def main():

monailabel/datastore/dicom.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,29 @@ def _download_labeled_data(self):
264264
def datalist(self, full_path=True) -> List[Dict[str, Any]]:
265265
self._download_labeled_data()
266266
return super().datalist(full_path)
267+
268+
def add_directory(self, directory_id: str, filename: str, info: Dict[str, Any]) -> str:
269+
"""
270+
Not implemented
271+
272+
Abstract method for adding a directory to DICOMWeb
273+
"""
274+
raise NotImplementedError("This datastore does not support adding directories")
275+
276+
def get_is_multichannel(self) -> bool:
277+
"""
278+
Not implemented for this datastore
279+
280+
Returns whether the application's studies is directed at multichannel (4D) data
281+
"""
282+
logging.info("The function get_is_multichannel is not implemented for this datastore")
283+
return False
284+
285+
def get_is_multi_file(self) -> bool:
286+
"""
287+
Not implemented for this datastore
288+
289+
Returns whether the application's studies is directed at directories containing multiple images per sample
290+
"""
291+
logger.info("The function get_is_multi_file is not implemented for this datastore")
292+
return False

monailabel/datastore/dsa.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,32 @@ def status(self) -> Dict[str, Any]:
270270
def json(self):
271271
return self.datalist()
272272

273+
def add_directory(self, directory_id: str, filename: str, info: Dict[str, Any]) -> str:
274+
"""
275+
Not implemented for this datastore
276+
277+
Abstract method for adding a directory to dsa
278+
"""
279+
raise NotImplementedError("This datastore does not support adding directories")
280+
281+
def get_is_multichannel(self) -> bool:
282+
"""
283+
Not implemented for this datastore
284+
285+
Returns whether the application's studies is directed at multichannel (4D) data
286+
"""
287+
logging.info("The function get_is_multichannel is not implemented for this datastore")
288+
return False
289+
290+
def get_is_multi_file(self) -> bool:
291+
"""
292+
Not implemented for this datastore
293+
294+
Returns whether the application's studies is directed at directories containing multiple images per sample
295+
"""
296+
logger.info("The function get_is_multi_file is not implemented for this datastore")
297+
return False
298+
273299

274300
"""
275301
def main():

monailabel/datastore/local.py

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,11 @@ def __init__(
102102
images_dir: str = ".",
103103
labels_dir: str = "labels",
104104
datastore_config: str = "datastore_v2.json",
105-
extensions=("*.nii.gz", "*.nii"),
105+
extensions=("*.nii.gz", "*.nii", "*.nrrd"),
106106
auto_reload=False,
107107
read_only=False,
108+
multichannel: bool = False,
109+
multi_file: bool = False,
108110
):
109111
"""
110112
Creates a `LocalDataset` object
@@ -124,6 +126,8 @@ def __init__(
124126
self._ignore_event_config = False
125127
self._config_ts = 0
126128
self._auto_reload = auto_reload
129+
self._multichannel: bool = multichannel
130+
self._multi_file: bool = multi_file
127131

128132
logging.getLogger("filelock").setLevel(logging.ERROR)
129133

@@ -256,6 +260,18 @@ def datalist(self, full_path=True) -> List[Dict[str, Any]]:
256260
ds = json.loads(json.dumps(ds).replace(f"{self._datastore_path.rstrip(os.pathsep)}{os.pathsep}", ""))
257261
return ds
258262

263+
def get_is_multichannel(self) -> bool:
264+
"""
265+
Returns whether the dataset is multichannel or not
266+
"""
267+
return self._multichannel
268+
269+
def get_is_multi_file(self) -> bool:
270+
"""
271+
Returns whether the dataset is multichannel or not
272+
"""
273+
return self._multi_file
274+
259275
def get_image(self, image_id: str, params=None) -> Any:
260276
"""
261277
Retrieve image object based on image id
@@ -431,6 +447,43 @@ def refresh(self):
431447
"""
432448
self._reconcile_datastore()
433449

450+
def add_directory(self, directory_id: str, filename: str, info: Dict[str, Any]) -> str:
451+
"""
452+
Add a directory to the datastore
453+
454+
:param directory_id: the directory id
455+
:param filename: the filename
456+
:param info: additional info
457+
458+
:return: directory id
459+
"""
460+
id = os.path.basename(os.path.normpath(filename))
461+
if not directory_id:
462+
directory_id = id
463+
464+
logger.info(f"Adding Image: {directory_id} => {filename}")
465+
name = directory_id
466+
dest = os.path.realpath(os.path.join(self._datastore.image_path(), name))
467+
468+
with FileLock(self._lock_file):
469+
logger.debug("Acquired the lock!")
470+
if os.path.isdir(filename):
471+
if os.path.exists(dest):
472+
shutil.rmtree(dest)
473+
shutil.copytree(filename, dest)
474+
else:
475+
shutil.copy2(filename, dest)
476+
477+
info = info if info else {}
478+
info["ts"] = int(time.time())
479+
info["name"] = name
480+
481+
# images = get_directory_contents(filename)
482+
self._datastore.objects[directory_id] = ImageLabelModel(image=DataModel(info=info, ext=""))
483+
self._update_datastore_file(lock=False)
484+
logger.debug("Released the lock!")
485+
return directory_id
486+
434487
def add_image(self, image_id: str, image_filename: str, image_info: Dict[str, Any]) -> str:
435488
id, image_ext = self._to_id(os.path.basename(image_filename))
436489
if not image_id:
@@ -552,10 +605,17 @@ def _list_files(self, path, patterns):
552605
files = os.listdir(path)
553606

554607
filtered = dict()
555-
for pattern in patterns:
556-
matching = fnmatch.filter(files, pattern)
557-
for file in matching:
558-
filtered[os.path.basename(file)] = file
608+
if not self._multi_file:
609+
for pattern in patterns:
610+
matching = fnmatch.filter(files, pattern)
611+
for file in matching:
612+
filtered[os.path.basename(file)] = file
613+
else:
614+
ignored = {"labels", ".lock", os.path.basename(self._datastore_config_path).lower()}
615+
for file in files:
616+
abs_file = os.path.join(path, file)
617+
if os.path.isdir(abs_file) and file.lower() not in ignored:
618+
filtered[os.path.basename(file)] = file
559619
return filtered
560620

561621
def _reconcile_datastore(self):
@@ -585,24 +645,26 @@ def _add_non_existing_images(self) -> int:
585645
invalidate = 0
586646
self._init_from_datastore_file()
587647

588-
local_images = self._list_files(self._datastore.image_path(), self._extensions)
648+
local_files = self._list_files(self._datastore.image_path(), self._extensions)
589649

590-
image_ids = list(self._datastore.objects.keys())
591-
for image_file in local_images:
592-
image_id, image_ext = self._to_id(image_file)
593-
if image_id not in image_ids:
594-
logger.info(f"Adding New Image: {image_id} => {image_file}")
650+
ids = list(self._datastore.objects.keys())
651+
for file in local_files:
652+
if self._multi_file:
653+
# Directories have no extension — use the name as-is
654+
file_id = file
655+
file_ext_str = ""
656+
else:
657+
file_id, file_ext_str = self._to_id(file)
595658

596-
name = self._filename(image_id, image_ext)
597-
image_info = {
659+
if file_id not in ids:
660+
logger.info(f"Adding New Image: {file_id} => {file}")
661+
name = self._filename(file_id, file_ext_str)
662+
file_info = {
598663
"ts": int(time.time()),
599-
# "checksum": file_checksum(os.path.join(self._datastore.image_path(), name)),
600664
"name": name,
601665
}
602-
603666
invalidate += 1
604-
self._datastore.objects[image_id] = ImageLabelModel(image=DataModel(info=image_info, ext=image_ext))
605-
667+
self._datastore.objects[file_id] = ImageLabelModel(image=DataModel(info=file_info, ext=file_ext_str))
606668
return invalidate
607669

608670
def _add_non_existing_labels(self, tag) -> int:

monailabel/datastore/xnat.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,32 @@ def __upload_assessment(self, aiaa_model_name, image_id, file_path, type):
386386

387387
self._request_put(url, data, type=type)
388388

389+
def add_directory(self, directory_id: str, filename: str, info: Dict[str, Any]) -> str:
390+
"""
391+
Not implemented for this datastore
392+
393+
Abstract method for adding a directory to xnat
394+
"""
395+
raise NotImplementedError("This datastore does not support adding directories")
396+
397+
def get_is_multichannel(self) -> bool:
398+
"""
399+
Not implemented for this datastore
400+
401+
Returns whether the application's studies is directed at multichannel (4D) data
402+
"""
403+
logging.info("The function get_is_multichannel is not implemented for this datastore")
404+
return False
405+
406+
def get_is_multi_file(self) -> bool:
407+
"""
408+
Not implemented for this datastore
409+
410+
Returns whether the application's studies is directed at directories containing multiple images per sample
411+
"""
412+
logger.info("The function get_is_multi_file is not implemented for this datastore")
413+
return False
414+
389415

390416
"""
391417
def main():

monailabel/endpoints/datastore.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def add_image(
6868
logger.info(f"Image: {image}; File: {file}; params: {params}")
6969
file_ext = "".join(pathlib.Path(file.filename).suffixes) if file.filename else ".nii.gz"
7070

71-
image_id = image if image else os.path.basename(file.filename).replace(file_ext, "")
71+
id = image if image else os.path.basename(file.filename).replace(file_ext, "")
7272
image_file = tempfile.NamedTemporaryFile(suffix=file_ext).name
7373

7474
with open(image_file, "wb") as buffer:
@@ -79,8 +79,15 @@ def add_image(
7979
save_params: Dict[str, Any] = json.loads(params) if params else {}
8080
if user:
8181
save_params["user"] = user
82-
image_id = instance.datastore().add_image(image_id, image_file, save_params)
83-
return {"image": image_id}
82+
if not instance.datastore().get_is_multi_file():
83+
image_id = instance.datastore().add_image(id, image_file, save_params)
84+
return {"image": image_id}
85+
86+
if not os.path.isdir(image_file):
87+
raise HTTPException(status_code=400, detail="Multi-file datastore requires a directory, not a file")
88+
89+
directory_id = instance.datastore().add_directory(id, image_file, save_params)
90+
return {"image": directory_id}
8491

8592

8693
def remove_image(id: str, user: Optional[str] = None):

monailabel/interfaces/app.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ def __init__(
9090
self.app_dir = app_dir
9191
self.studies = studies
9292
self.conf = conf if conf else {}
93-
93+
self.multichannel: bool = strtobool(conf.get("multichannel", False))
94+
self.multi_file: bool = strtobool(conf.get("multi_file", False))
95+
self.input_channels = conf.get("input_channels", False)
9496
self.name = name
9597
self.description = description
9698
self.version = version
@@ -146,6 +148,8 @@ def init_datastore(self) -> Datastore:
146148
extensions=settings.MONAI_LABEL_DATASTORE_FILE_EXT,
147149
auto_reload=settings.MONAI_LABEL_DATASTORE_AUTO_RELOAD,
148150
read_only=settings.MONAI_LABEL_DATASTORE_READ_ONLY,
151+
multichannel=self.multichannel,
152+
multi_file=self.multi_file,
149153
)
150154

151155
def init_remote_datastore(self) -> Datastore:
@@ -282,6 +286,9 @@ def infer(self, request, datastore=None):
282286
)
283287

284288
request = copy.deepcopy(request)
289+
request["multi_file"] = self.multi_file
290+
request["multichannel"] = self.multichannel
291+
request["input_channels"] = self.input_channels
285292
request["description"] = task.description
286293

287294
image_id = request["image"]
@@ -292,7 +299,7 @@ def infer(self, request, datastore=None):
292299
else:
293300
request["image"] = datastore.get_image_uri(request["image"])
294301

295-
if os.path.isdir(request["image"]):
302+
if os.path.isdir(request["image"]) and not self.multi_file:
296303
logger.info("Input is a Directory; Consider it as DICOM")
297304

298305
logger.debug(f"Image => {request['image']}")
@@ -431,6 +438,10 @@ def train(self, request):
431438
)
432439

433440
request = copy.deepcopy(request)
441+
# 4D image support, send train task information regarding data
442+
request["multi_file"] = self.multi_file
443+
request["multichannel"] = self.multichannel
444+
request["input_channels"] = self.input_channels
434445
result = task(request, self.datastore())
435446

436447
# Run all scoring methods

monailabel/interfaces/datastore.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,18 @@ def refresh(self) -> None:
201201
"""
202202
pass
203203

204+
@abstractmethod
205+
def add_directory(self, directory_id: str, filename: str, info: Dict[str, Any]) -> str:
206+
"""
207+
Save a directory for the given directory id and return the newly saved directory's id
208+
209+
:param directory_id: the directory id for the image; If None then base filename will be used
210+
:param filename: the path to the directory
211+
:param info: additional info for the directory
212+
:return: the directory id for the saved image filename
213+
"""
214+
pass
215+
204216
@abstractmethod
205217
def add_image(self, image_id: str, image_filename: str, image_info: Dict[str, Any]) -> str:
206218
"""
@@ -279,3 +291,17 @@ def json(self):
279291
Return json representation of datastore
280292
"""
281293
pass
294+
295+
@abstractmethod
296+
def get_is_multichannel(self) -> bool:
297+
"""
298+
Returns whether the application's studies is directed at multichannel (4D) data
299+
"""
300+
pass
301+
302+
@abstractmethod
303+
def get_is_multi_file(self) -> bool:
304+
"""
305+
Returns whether the application's studies is directed at directories containing multiple images per sample
306+
"""
307+
pass

0 commit comments

Comments
 (0)