From dff4d878b4fa79515c2eb307fa59c09702e4c474 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 9 Jun 2026 11:32:59 +0200 Subject: [PATCH 1/5] Add folder upload for DICOM series and MRXS datasets Uploads a whole folder via a two-step API: each file is streamed to a temp directory via upload_folder_chunk, then upload_folder_finalize detects the format, splits DICOM files by SeriesInstanceUID (one Image per series), assembles MRXS + its data directory, and delegates to the existing save_file() pipeline. SlideIOSlide and getSlideHandler are extended to open DICOM series directories (passing driver='DCM' to slideio), so tile-serving works transparently once the directory is stored in the imageset root. Both imageset templates gain an "Upload Folder" button wired to a webkitdirectory file input and a small vanilla-JS upload loop. Co-Authored-By: Claude Sonnet 4.6 --- exact/exact/images/dicom_utils.py | 38 +++++ exact/exact/images/models.py | 32 ++++ .../images/templates/images/imageset.html | 65 +++++++- .../images/templates/images/imageset_v2.html | 64 ++++++++ exact/exact/images/urls.py | 2 + exact/exact/images/views.py | 148 ++++++++++++++++++ exact/util/slide_server.py | 7 + exact/util/slideio.py | 8 +- 8 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 exact/exact/images/dicom_utils.py diff --git a/exact/exact/images/dicom_utils.py b/exact/exact/images/dicom_utils.py new file mode 100644 index 00000000..f2e65dd2 --- /dev/null +++ b/exact/exact/images/dicom_utils.py @@ -0,0 +1,38 @@ +from collections import defaultdict +from pathlib import Path + + +def split_dicom_by_series(folder_path: Path) -> dict: + """Return {series_uid: [Path, ...]} grouping for all DCM files under folder_path.""" + import pydicom + + series: dict = defaultdict(list) + for dcm_path in sorted(folder_path.rglob('*')): + if dcm_path.suffix.lower() != '.dcm': + continue + try: + ds = pydicom.dcmread(str(dcm_path), stop_before_pixels=True) + uid = str(getattr(ds, 'SeriesInstanceUID', 'unknown')) + series[uid].append(dcm_path) + except Exception: + pass + return dict(series) + + +def series_display_name(files: list) -> str: + """Derive a human-readable name for a DICOM series from the first file's tags.""" + if not files: + return 'dicom_series' + try: + import pydicom + ds = pydicom.dcmread(str(files[0]), stop_before_pixels=True) + parts = [] + for tag in ('Modality', 'SeriesDescription', 'SeriesNumber'): + val = getattr(ds, tag, None) + if val is not None: + parts.append(str(val).strip()) + if parts: + return '_'.join(parts) + except Exception: + pass + return 'dicom_series' diff --git a/exact/exact/images/models.py b/exact/exact/images/models.py index 7d77a4d0..fbaaa731 100644 --- a/exact/exact/images/models.py +++ b/exact/exact/images/models.py @@ -122,8 +122,40 @@ def save(self, *args, **kwargs): super(Image, self).save(*args, **kwargs) + def _save_from_directory(self, path: Path): + """Handle a pre-assembled directory (e.g. DICOM series folder).""" + osr = getSlideHandler(str(path)) + self.filename = path.name + self.save() + if osr.nFrames > 1: + for frame_id in range(osr.nFrames): + FrameDescription.objects.create( + Image=self, + frame_id=frame_id, + file_path=self.filename, + description=osr.frame_descriptors[frame_id], + frame_type=osr.frame_type, + ) + self.frames = osr.nFrames + self.defaultFrame = osr.default_frame + self.width, self.height = osr.level_dimensions[0] + try: + self.mpp = (float(osr.properties[openslide.PROPERTY_NAME_MPP_X]) + + float(osr.properties[openslide.PROPERTY_NAME_MPP_Y])) / 2 + except (KeyError, ValueError): + self.mpp = 0 + try: + self.objectivePower = osr.properties[openslide.PROPERTY_NAME_OBJECTIVE_POWER] + except (KeyError, ValueError): + self.objectivePower = 1 + self.save() + def save_file(self, path:Path): + if Path(path).is_dir(): + self._save_from_directory(Path(path)) + return + try: # check if the file can be opened natively, if not convert it try: diff --git a/exact/exact/images/templates/images/imageset.html b/exact/exact/images/templates/images/imageset.html index bf724106..f850f5ff 100644 --- a/exact/exact/images/templates/images/imageset.html +++ b/exact/exact/images/templates/images/imageset.html @@ -145,6 +145,62 @@ } }); } + +(function () { + var btn = document.getElementById('folder-upload-btn'); + var input = document.getElementById('folder-upload-input'); + var status = document.getElementById('folder-upload-status'); + if (!btn || !input) return; + + btn.addEventListener('click', function () { input.click(); }); + + input.addEventListener('change', async function () { + var files = Array.from(input.files); + if (!files.length) return; + + var batchId = (Date.now().toString(36) + Math.random().toString(36).slice(2)).replace(/[^a-zA-Z0-9]/g, ''); + var csrf = (document.cookie.match(/csrftoken=([^;]+)/) || [])[1] || ''; + + status.textContent = 'Uploading 0 / ' + files.length + ' files…'; + btn.classList.add('disabled'); + + for (var i = 0; i < files.length; i++) { + var fd = new FormData(); + fd.append('batch_id', batchId); + fd.append('relative_path', files[i].webkitRelativePath || files[i].name); + fd.append('file', files[i]); + fd.append('csrfmiddlewaretoken', csrf); + try { + var r = await fetch('{% url "images:upload_folder_chunk" imageset.id %}', { + method: 'POST', credentials: 'same-origin', body: fd, + headers: {'X-CSRFToken': csrf} + }); + if (!r.ok) { status.textContent = 'Upload error on file ' + (i + 1); btn.classList.remove('disabled'); return; } + } catch (e) { status.textContent = 'Network error: ' + e; btn.classList.remove('disabled'); return; } + status.textContent = 'Uploading ' + (i + 1) + ' / ' + files.length + ' files…'; + } + + status.textContent = 'Processing…'; + var fd2 = new FormData(); + fd2.append('batch_id', batchId); + fd2.append('csrfmiddlewaretoken', csrf); + try { + var resp = await fetch('{% url "images:upload_folder_finalize" imageset.id %}', { + method: 'POST', credentials: 'same-origin', body: fd2, + headers: {'X-CSRFToken': csrf} + }); + var data = await resp.json(); + if (data.errors && data.errors.length) { + status.textContent = 'Error: ' + data.errors.join('; '); + } else { + status.textContent = 'Done — ' + (data.files ? data.files.length : 0) + ' image(s) imported. Reloading…'; + setTimeout(function () { location.reload(); }, 1500); + } + } catch (e) { status.textContent = 'Finalize error: ' + e; } + btn.classList.remove('disabled'); + input.value = ''; + }); +}()); {% endblock additional_js %} @@ -656,8 +712,9 @@



Upload images as single files or as zip file with the images in its root directory (recommended). + For DICOM series or 3DHistech (MRXS) datasets, use the Upload Folder button. -
+
{% csrf_token %} @@ -669,6 +726,12 @@

Add files... + + + Upload Folder + + +

+
@@ -1066,5 +1073,62 @@

Download exports:

$('#myTab li:nth-child(3) button').tab('show') // Select third tab {% endif %} + {% endblock bodyblock%} diff --git a/exact/exact/images/urls.py b/exact/exact/images/urls.py index 5a46448e..cc59557d 100644 --- a/exact/exact/images/urls.py +++ b/exact/exact/images/urls.py @@ -23,6 +23,8 @@ re_path(r'^image/setfree/(\d+)/$', views.set_free, name='setfree_imageset'), re_path(r'^image/upload/(\d+)/$', views.upload_image, name='upload_image'), + re_path(r'^image/upload_folder_chunk/(\d+)/$', views.upload_folder_chunk, name='upload_folder_chunk'), + re_path(r'^image/upload_folder_finalize/(\d+)/$', views.upload_folder_finalize, name='upload_folder_finalize'), re_path(r'^image/rename/(\d+)/$', views.rename_image, name='rename_image'), re_path(r'^api/image/crop/(\d+)/(\d+)/(\d+)/(\d+)/(\d+)/(\d+)/(\d+)/$', views.crop_from_image, name='crop_from_image'), re_path(r'^image/(\d+)/(\d+)/(\d+)/tile/$', views.view_image, name='view_image'), diff --git a/exact/exact/images/views.py b/exact/exact/images/views.py index 93bcf01f..f8d6293a 100644 --- a/exact/exact/images/views.py +++ b/exact/exact/images/views.py @@ -63,6 +63,7 @@ from shutil import which import string import random +import tempfile import zipfile import hashlib import json @@ -510,6 +511,153 @@ def upload_image(request, imageset_id): return JsonResponse({'files': json_files}) +# --------------------------------------------------------------------------- +# Folder upload — two-step: chunk upload then finalize +# --------------------------------------------------------------------------- + +_BATCH_ID_RE = re.compile(r'^[a-zA-Z0-9_\-]{4,64}$') + + +@api_view(['POST']) +def upload_folder_chunk(request, imageset_id): + """Receive one file belonging to a folder-upload batch. + + Required POST fields: + batch_id — opaque ID generated by the client (alphanumeric + -_) + relative_path — path of this file within the uploaded folder + file — the file data + """ + imageset = get_object_or_404(ImageSet, id=imageset_id) + if not imageset.has_perm('edit_set', request.user) or imageset.image_lock: + return Response({}, status=HTTP_403_FORBIDDEN) + + batch_id = request.POST.get('batch_id', '') + relative_path = request.POST.get('relative_path', '') + if not _BATCH_ID_RE.match(batch_id): + return HttpResponseBadRequest('Invalid batch_id') + if not relative_path or '..' in relative_path or relative_path.startswith('/'): + return HttpResponseBadRequest('Invalid relative_path') + if 'file' not in request.FILES: + return HttpResponseBadRequest('No file attached') + + f = request.FILES['file'] + tmp_dir = Path(tempfile.gettempdir()) / 'exact_folder_upload' / batch_id + dest = tmp_dir / relative_path + dest.parent.mkdir(parents=True, exist_ok=True) + with open(dest, 'wb') as out: + for chunk in f.chunks(): + out.write(chunk) + + return Response({'ok': True}, status=HTTP_200_OK) + + +@api_view(['POST']) +def upload_folder_finalize(request, imageset_id): + """Process a completed folder-upload batch and create Image records. + + Required POST field: + batch_id — the same ID used for chunk uploads + """ + imageset = get_object_or_404(ImageSet, id=imageset_id) + if not imageset.has_perm('edit_set', request.user) or imageset.image_lock: + return Response({}, status=HTTP_403_FORBIDDEN) + + batch_id = request.POST.get('batch_id', '') + if not _BATCH_ID_RE.match(batch_id): + return HttpResponseBadRequest('Invalid batch_id') + + tmp_dir = Path(tempfile.gettempdir()) / 'exact_folder_upload' / batch_id + if not tmp_dir.exists(): + return HttpResponseBadRequest('Unknown or expired batch') + + results = [] + errors = [] + try: + results, errors = _process_folder_upload(imageset, tmp_dir) + finally: + shutil.rmtree(str(tmp_dir), ignore_errors=True) + + return Response({'files': results, 'errors': errors}, status=HTTP_200_OK) + + +def _folder_checksum(dir_path: Path) -> bytes: + h = hashlib.sha512() + for f in sorted(dir_path.rglob('*')): + if f.is_file(): + with open(f, 'rb') as fh: + while buf := fh.read(65536): + h.update(buf) + return h.digest() + + +def _process_folder_upload(imageset, tmp_dir: Path): + """Detect folder format and create Image records in imageset.""" + from exact.images.dicom_utils import split_dicom_by_series, series_display_name + + all_files = list(tmp_dir.rglob('*')) + dcm_files = [f for f in all_files if f.is_file() and f.suffix.lower() == '.dcm'] + mrxs_files = [f for f in all_files if f.is_file() and f.suffix.lower() == '.mrxs'] + + results = [] + errors = [] + + if mrxs_files: + for mrxs_src in mrxs_files: + try: + dest = Path(imageset.root_path()) / mrxs_src.name + shutil.copy2(str(mrxs_src), str(dest)) + + data_src = mrxs_src.parent / mrxs_src.stem + if data_src.exists() and data_src.is_dir(): + data_dest = Path(imageset.root_path()) / mrxs_src.stem + shutil.copytree(str(data_src), str(data_dest), dirs_exist_ok=True) + + fchecksum = hashlib.sha512(dest.read_bytes()).digest() + image = Image(name=mrxs_src.name, image_set=imageset, checksum=fchecksum) + image.save_file(dest) + results.append({'name': mrxs_src.name, 'id': image.id, 'type': 'image'}) + except Exception as e: + logger.error(f'Folder upload MRXS error: {e}') + errors.append(str(e)) + + elif dcm_files: + series = split_dicom_by_series(tmp_dir) + if not series: + errors.append('No readable DICOM series found in the uploaded folder') + for uid, files in series.items(): + try: + safe_uid = re.sub(r'[^a-zA-Z0-9._-]', '_', uid)[:64] + display = series_display_name(files) + folder_name = re.sub(r'[^a-zA-Z0-9._-]', '_', display)[:64] or safe_uid + + # Avoid collisions + series_dest = Path(imageset.root_path()) / folder_name + suffix = 1 + while series_dest.exists(): + series_dest = Path(imageset.root_path()) / f'{folder_name}_{suffix}' + suffix += 1 + series_dest.mkdir() + + for f in files: + shutil.copy2(str(f), str(series_dest / f.name)) + + fchecksum = _folder_checksum(series_dest) + image = Image(name=series_dest.name, image_set=imageset, checksum=fchecksum) + image.save_file(series_dest) + results.append({'name': series_dest.name, 'id': image.id, 'type': 'image'}) + except Exception as e: + logger.error(f'Folder upload DICOM series error: {e}') + errors.append(str(e)) + + else: + errors.append( + 'No recognised folder format found. ' + 'Expected a DICOM folder (.dcm files) or a 3DHistech folder (.mrxs file).' + ) + + return results, errors + + # @login_required # def imageview(request, image_id): # image = get_object_or_404(Image, id=image_id) diff --git a/exact/util/slide_server.py b/exact/util/slide_server.py index 51b605cb..0508f68a 100644 --- a/exact/util/slide_server.py +++ b/exact/util/slide_server.py @@ -592,6 +592,13 @@ def fileno(self): def getSlideHandler(path): + import pathlib as _pl + if _pl.Path(path).is_dir(): + dcm_files = list(_pl.Path(path).rglob('*.dcm')) + list(_pl.Path(path).rglob('*.DCM')) + if dcm_files: + return SlideIOSlide(str(path)) + raise ValueError(f'No supported format found in directory: {path}') + # Determine format of slide to see how to handle it. t = time.time() f = open(path,'rb') diff --git a/exact/util/slideio.py b/exact/util/slideio.py index 604b2188..d8c4a26b 100644 --- a/exact/util/slideio.py +++ b/exact/util/slideio.py @@ -25,9 +25,13 @@ def __reduce__(self): # Define how to pickle the object return (self.__class__, (self.fileName,)) - def __init__(self,filename): + def __init__(self, filename): self.fileName = filename - self.fh = slideio.open_slide(filename) + from pathlib import Path as _Path + if _Path(filename).is_dir(): + self.fh = slideio.open_slide(filename, 'DCM') + else: + self.fh = slideio.open_slide(filename) self.scene = self.fh.get_scene(0) From fbcb84a90c91bbb7b68f837cd37e9f794d2fb775 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 9 Jun 2026 12:09:30 +0200 Subject: [PATCH 2/5] Refactored cellvizio.py --- exact/util/cellvizio.py | 470 +++++++++++++++++++--------------------- 1 file changed, 226 insertions(+), 244 deletions(-) diff --git a/exact/util/cellvizio.py b/exact/util/cellvizio.py index 92a424eb..2e4e9662 100644 --- a/exact/util/cellvizio.py +++ b/exact/util/cellvizio.py @@ -1,204 +1,209 @@ """ - This is SlideRunner - An Open Source Annotation Tool + This is SlideRunner - An Open Source Annotation Tool for Digital Histology Slides. - Marc Aubreville, Pattern Recognition Lab, - Friedrich-Alexander University Erlangen-Nuremberg + Marc Aubreville, Pattern Recognition Lab, + Friedrich-Alexander University Erlangen-Nuremberg marc.aubreville@fau.de If you use this software in research, please citer our paper: M. Aubreville, C. Bertram, R. Klopfleisch and A. Maier: - SlideRunner - A Tool for Massive Cell Annotations in Whole Slide Images. - In: Bildverarbeitung für die Medizin 2018. + SlideRunner - A Tool for Massive Cell Annotations in Whole Slide Images. + In: Bildverarbeitung für die Medizin 2018. Springer Vieweg, Berlin, Heidelberg, 2018. pp. 309-314. - This file: Support for CellVizio MKT files. + This file: Support for CellVizio MKT files. """ import numpy as np -from pydicom.encaps import decode_data_sequence from PIL import Image -import io -import os import struct -from os import stat import openslide from util.enums import FrameType + +_CHUNK_HEADER_SIZE = 16 +_CSTA_MAGIC = b'\n tuple[list[tuple[int, int, int]], dict[str, str]]: + """Parse a type-5 MKT chunk body. + + Returns (index_entries, kv) where index_entries is a list of + (chunk_type, file_offset, data_size) and kv is all key=value metadata. + """ + n = struct.unpack(' tuple[list[tuple[int, int, int]], dict[str, str]]: + """Locate and parse the type-5 index/metadata chunk in an MKT file. + + Scans the last 64 KB (sufficient for all trailing metadata chunks) to find + the type-5 chunk, which always appears near the end of the file. + Returns (index_entries, kv_dict). """ + with open(filename, 'rb') as f: + f.seek(0, 2) + file_size = f.tell() + scan_size = min(65536, file_size) + f.seek(file_size - scan_size) + tail = f.read() + + pos = 0 + while pos < len(tail): + idx = tail.find(_CSTA_MAGIC, pos) + if idx == -1: + break + hdr = tail[idx : idx + _CHUNK_HEADER_SIZE] + if len(hdr) < _CHUNK_HEADER_SIZE: + break + chunk_type = struct.unpack('>H', hdr[8:10])[0] + chunk_size = struct.unpack('>I', hdr[10:14])[0] + if chunk_type == 5: + body = tail[idx + _CHUNK_HEADER_SIZE : idx + _CHUNK_HEADER_SIZE + chunk_size] + return _parse_type5_body(body) + pos = idx + 1 + + return [], {} - def __init__(self,filename): - #print('Opening:',filename) - self.fileName = filename; + +class ReadableCellVizioMKTDataset(): + + def __init__(self, filename): + self.fileName = filename self.fi = fileinfo() - self.fileHandle = open(filename, 'rb'); - self.fileHandle.seek(5) # we find the FPS at position 05 - self.fileHandle.seek(10) # we find the image size at position 10 - fSizeByte = self.fileHandle.read(4) - self.fi.size = int.from_bytes(fSizeByte, byteorder='big', signed=True) - self.filestats = stat(self.fileName) - self.fi.nImages=1000 - self.fi.nImages = int((self.filestats.st_size-self.fi.offset) / (self.fi.size+self.fi.gapBetweenImages)) - self.numberOfFrames = self.fi.nImages - - meta = self.getMostRelevantMetaInfo() - self.fi.width = int(meta.get('width', 0)) + + # Read image data size from the first chunk header (bytes 10–13, big-endian uint32) + with open(filename, 'rb') as f: + f.seek(10) + self.fi.size = struct.unpack('>I', f.read(4))[0] + + # Parse the type-5 chunk for the chunk index and all key=value metadata + chunk_index, self._kv = _load_chunk_index(filename) + + # Collect data offsets for every type-2 (image frame) chunk + self._frame_offsets = [ + entry[1] + _CHUNK_HEADER_SIZE + for entry in chunk_index if entry[0] == 2 + ] + self.numberOfFrames = len(self._frame_offsets) + self.fi.nImages = self.numberOfFrames + + meta = self._kv + self.fi.width = int(meta.get('width', 0)) self.fi.height = int(meta.get('height', 0)) self.fps = float(meta.get('framerate', 0)) - - self.geometry_imsize = [self.fi.height, self.fi.width] - self.imsize = [self.fi.height, self.fi.width] + + self.geometry_imsize = [self.fi.height, self.fi.width] + self.imsize = [self.fi.height, self.fi.width] self.geometry_tilesize = [(self.fi.height, self.fi.width)] - self.geometry_rows = [1] - self.geometry_columns = [1] - self.levels = [1] - self.channels = 1 - - self.fovx = float(meta.get('fovx', 250)) - self.fovy = float(meta.get('fovy', 250)) + self.geometry_rows = [1] + self.geometry_columns = [1] + self.levels = [1] + self.channels = 1 + + self.fovx = float(meta.get('fovx', 250)) + self.fovy = float(meta.get('fovy', 250)) print(f"fovx: {self.fovx}, fovy: {self.fovy}") - self.mpp_x = self.fovx/self.fi.width # approximate number for gastroflex, 250 ym field of view, 576 px - self.mpp_y = self.fovy/self.fi.height - - - # generate circular mask for this file - self.circMask = circularMask(self.fi.width,self.fi.height, self.fi.width-2).mask - #print('Circular mask shape:',self.circMask.shape) - self.properties = { openslide.PROPERTY_NAME_BACKGROUND_COLOR: '000000', - openslide.PROPERTY_NAME_MPP_X: self.mpp_x, - openslide.PROPERTY_NAME_MPP_Y: self.mpp_y, - openslide.PROPERTY_NAME_OBJECTIVE_POWER:20, - openslide.PROPERTY_NAME_VENDOR: 'MKT'} - - - def getMetaInfo(self): - # the meta information at the MKT file always starts with "00616C6C 6F776564 5F656761 696E5F65 6F666673 65745F70 61697273 3D" or allowed_egain_eoffset_pairs= in the hex code - # we need to read the whole file to find the meta information - with open(self.fileName, 'rb') as f: - fileContent = f.read() - # find start position with fileContent.find(b'allowed_egain_eoffset_pairs=') that searches for the byte sequence - metaStart = fileContent.rfind(b'allowed_egain_eoffset_pairs=') - metaEnd = fileContent.find(b'f', fFPSByte)[0] - - self.fi = fileinfo() - self.fileHandle.seek(10) # we find the image size at position 10 - fSizeByte = self.fileHandle.read(4) - self.fi.size = int.from_bytes(fSizeByte, byteorder='big', signed=True) - self.fi.nImages=1000 - - self.fi.width = 576 - if ((self.fi.size / (2 * self.fi.width)) % 2 != 0): - self.fi.width=512 - self.fi.height=int(self.fi.size/(2*self.fi.width)) - else: - self.fi.height=int(self.fi.size/(2*self.fi.width)) - - self.filestats = stat(self.fileName) - self.fi.nImages = int((self.filestats.st_size-self.fi.offset) / (self.fi.size+self.fi.gapBetweenImages)) - - - self.numberOfFrames = self.fi.nImages - - self.geometry_imsize = [self.fi.height, self.fi.width] - self.imsize = [self.fi.height, self.fi.width] - self.geometry_tilesize = [(self.fi.height, self.fi.width)] - self.geometry_rows = [1] - self.geometry_columns = [1] - self.levels = [1] - self.channels = 1 - self.mpp_x = 250/576 # approximate number for gastroflex, 250 ym field of view, 576 px - self.mpp_y = 250/576 - - self.properties = { openslide.PROPERTY_NAME_BACKGROUND_COLOR: '000000', - openslide.PROPERTY_NAME_MPP_X: self.mpp_x, - openslide.PROPERTY_NAME_MPP_Y: self.mpp_y, - openslide.PROPERTY_NAME_OBJECTIVE_POWER:20, - openslide.PROPERTY_NAME_VENDOR: 'MKT'} - - self.circMask = circularMask(self.fi.width,self.fi.height, self.fi.width-2).mask + self.mpp_x = self.fovx / self.fi.width + self.mpp_y = self.fovy / self.fi.height + + self.circMask = circularMask(self.fi.width, self.fi.height, self.fi.width - 2).mask + + self.properties = { + openslide.PROPERTY_NAME_BACKGROUND_COLOR: '000000', + openslide.PROPERTY_NAME_MPP_X: self.mpp_x, + openslide.PROPERTY_NAME_MPP_Y: self.mpp_y, + openslide.PROPERTY_NAME_OBJECTIVE_POWER: 20, + openslide.PROPERTY_NAME_VENDOR: 'MKT', + } + + def getMetaInfo(self) -> dict[str, str]: + """Return all key=value metadata from the type-5 chunk.""" + return self._kv + + def getMostRelevantMetaInfo(self) -> dict[str, str]: + """Return metadata values for all labeled fields (keys defined in meta_data_dict).""" + result = {k: self._kv[k] for k in self.meta_data_dict if k in self._kv} + if self.fps > 0: + result['duration_seconds'] = str(self.numberOfFrames / self.fps) + return result @property def meta_data(self) -> dict: - # Cache metadata to avoid repeated file I/O and parsing. - if not hasattr(self, "_meta_data_cache"): - self._meta_data_cache = self.getMostRelevantMetaInfo() - return self._meta_data_cache - + return self.getMostRelevantMetaInfo() + @property def meta_data_dict(self) -> dict: - meta_data_dict = { - 'width': 'Image width (pixels)', - 'height': 'Image height (pixels)', - 'framerate': 'Frame rate (fps)', - 'duration_seconds': 'Duration (seconds)', - 'patient_id': 'Patient ID', - 'fovx': 'Field of view x (micrometers)', - 'fovy': 'Field of view y (micrometers)' - } - return meta_data_dict + labels = { + # Patient / recording + 'patient_id': 'Patient ID', + 'biopsy': 'Biopsy taken', + 'duration_seconds': 'Duration (s)', + 'framerate': 'Frame rate (fps)', + 'mosaicing_enabled': 'Mosaicing enabled', + 'utc_offset_seconds': 'UTC offset (s)', + # Image geometry + 'width': 'Image width (px)', + 'height': 'Image height (px)', + 'fovx': 'FOV x (µm)', + 'fovy': 'FOV y (µm)', + 'pfovx': 'Probe FOV x (µm)', + 'pfovy': 'Probe FOV y (µm)', + 'bbox_min_x': 'Mosaic bbox min x (px)', + 'bbox_min_y': 'Mosaic bbox min y (px)', + 'bbox_max_x': 'Mosaic bbox max x (px)', + 'bbox_max_y': 'Mosaic bbox max y (px)', + # Display + 'pal_cropMin': 'Display window min', + 'pal_cropMax': 'Display window max', + 'mask_level': 'Mask level', + 'compression_type': 'Compression', + # Laser / acquisition + 'eocu_mode': 'Imaging mode', + 'eocu_laser_wavelength': 'Laser wavelength (nm)', + 'eocu_lpwr': 'Laser power (%)', + 'eocu_serial_number': 'EOCU serial number', + 'eocu_uid': 'EOCU UID', + # Probe + 'proflex_type': 'Probe type', + 'proflex_uid': 'Probe UID', + 'proflex_diameter': 'Probe diameter (mm)', + 'proflex_length': 'Probe length (m)', + 'proflex_lateral_res': 'Lateral resolution (µm)', + 'proflex_axial_res': 'Axial resolution (µm)', + 'proflex_working_dist': 'Working distance (µm)', + 'proflex_sensitivity': 'Probe sensitivity', + # Software + 'mzversion_str': 'Software version', + } + # Only return labels for fields present in this file + return {k: v for k, v in labels.items() if k in self._kv or k == 'duration_seconds'} @property def seriesInstanceUID(self) -> str: @@ -208,132 +213,109 @@ def seriesInstanceUID(self) -> str: def level_downsamples(self): return [1] - @property + @property def level_dimensions(self): return [self.geometry_imsize] - @property + @property def nFrames(self): return self.numberOfFrames - - @property - def frame_descriptors(self) -> list[str]: - """ returns a list of strings, used as descriptor for each frame - """ - return ['%.2f s (%d)' % (float(frame_id)/float(self.fps), frame_id) for frame_id in range(self.nFrames)] @property def frame_descriptors(self) -> list[str]: - return 0 - + return ['%.2f s (%d)' % (float(frame_id) / float(self.fps), frame_id) + for frame_id in range(self.nFrames)] - @property + @property def nLayers(self): return 1 @property def layer_descriptors(self) -> list[str]: - """ returns a list of strings, used as descriptor for each layer - """ return [''] @property def frame_type(self): return FrameType.TIMESERIES - def get_thumbnail(self, size): - return self.read_region((0,0),0, self.dimensions).resize(size) + return self.read_region((0, 0), 0, self.dimensions).resize(size) def readImage(self, position=0): + seekpos = self._frame_offsets[position] + image = np.fromfile(self.fileName, offset=seekpos, dtype=np.int16, + count=int(self.fi.size / 2)) - seekpos=self.fi.offset + self.fi.size*position + self.fi.gapBetweenImages*position - image = np.fromfile(self.fileName, offset=seekpos, dtype=np.int16, count=int(self.fi.size/2)) - - if (image.size > 0): + if image.size > 0: image = np.clip(image, 0, np.max(image)) - if (image.shape[0]!=self.fi.height* self.fi.width): + if image.shape[0] != self.fi.height * self.fi.width: image = np.zeros((self.fi.height, self.fi.width)) - image=np.reshape(image, newshape=(self.fi.height, self.fi.width)) - - return image + return np.reshape(image, (self.fi.height, self.fi.width)) - def scaleImageUINT8(self, image, mask = None): - # read image and scale to uint8 [0;255] format + def scaleImageUINT8(self, image, mask=None): + if mask is None: + mask = self.circMask - if (mask is None): - mask = self.circMask + maskedImage = image[mask] + cmin, cmax = np.percentile(maskedImage, 0.5), np.percentile(maskedImage, 99.5) + if cmax > 5000: + cmax = 5000 + dyn = cmax - cmin - maskedImage = image[mask] + compr = 255 / dyn + image = (image - cmin) * compr + return np.uint8(np.clip(np.round(image), 0, 255)) - cmin,cmax = np.percentile(maskedImage,0.5), np.percentile(maskedImage,99.5) - if (cmax>5000): - cmax=5000 - dyn=cmax-cmin - - # compress - compr=255/dyn - image = image-cmin - image = image*compr - - # limit to 0 - image = np.clip(np.round(image),0,255) - image=np.uint8(image) - - return image def readImageUINT8(self, position=0): - # read image and scale to uint8 [0;255] format - image=self.readImage(position) - - image = self.scaleImageUINT8(image) - return image + return self.scaleImageUINT8(self.readImage(position)) - def read_region(self, location: tuple, level:int, size:tuple, frame:int=0): - img = np.zeros((size[1],size[0],4), np.uint8) - img[:,:,3]=255 - offset=[0,0] - if (location[1]<0): + def read_region(self, location: tuple, level: int, size: tuple, frame: int = 0): + img = np.zeros((size[1], size[0], 4), np.uint8) + img[:, :, 3] = 255 + offset = [0, 0] + if location[1] < 0: offset[0] = -location[1] - location = (location[0],0) - if (location[0]<0): + location = (location[0], 0) + if location[0] < 0: offset[1] = -location[0] - location = (0,location[1]) + location = (0, location[1]) pixel_array = self.readImageUINT8(position=frame) - imgcut = pixel_array[location[1]:location[1]+size[1]-offset[0],location[0]:location[0]+size[0]-offset[1]] - imgcut = np.uint8(np.clip(np.float32(imgcut),0,255)) + imgcut = pixel_array[ + location[1] : location[1] + size[1] - offset[0], + location[0] : location[0] + size[0] - offset[1], + ] + imgcut = np.uint8(np.clip(np.float32(imgcut), 0, 255)) for k in range(3): - img[offset[0]:imgcut.shape[0]+offset[0],offset[1]:offset[1]+imgcut.shape[1],k] = imgcut + img[offset[0] : imgcut.shape[0] + offset[0], + offset[1] : offset[1] + imgcut.shape[1], k] = imgcut return Image.fromarray(img) - @property def dimensions(self): return self.level_dimensions[0] - def get_best_level_for_downsample(self,downsample): - return np.argmin(np.abs(np.asarray(self.level_downsamples)-downsample)) + def get_best_level_for_downsample(self, downsample): + return np.argmin(np.abs(np.asarray(self.level_downsamples) - downsample)) @property def level_count(self): return len(self.levels) - def imagePos_to_id(self, imagePos:tuple, level:int): + def imagePos_to_id(self, imagePos: tuple, level: int): id_x, id_y = imagePos - if (id_y>=self.geometry_rows[level]): - id_x=self.geometry_columns[level] # out of range - - if (id_x>=self.geometry_columns[level]): - id_y=self.geometry_rows[level] # out of range - return (id_x+(id_y*self.geometry_columns[level])) - - - def get_id(self, pixelX:int, pixelY:int, level:int) -> (int, int, int): - - id_x = round(-0.5+(pixelX/self.geometry_tilesize[level][1])) - id_y = round(-0.5+(pixelY/self.geometry_tilesize[level][0])) - - return (id_x,id_y), pixelX-(id_x*self.geometry_tilesize[level][0]), pixelY-(id_y*self.geometry_tilesize[level][1]), \ No newline at end of file + if id_y >= self.geometry_rows[level]: + id_x = self.geometry_columns[level] + if id_x >= self.geometry_columns[level]: + id_y = self.geometry_rows[level] + return id_x + (id_y * self.geometry_columns[level]) + + def get_id(self, pixelX: int, pixelY: int, level: int): + id_x = round(-0.5 + (pixelX / self.geometry_tilesize[level][1])) + id_y = round(-0.5 + (pixelY / self.geometry_tilesize[level][0])) + return ((id_x, id_y), + pixelX - (id_x * self.geometry_tilesize[level][0]), + pixelY - (id_y * self.geometry_tilesize[level][1])) From b36f821be5f39e1dd21e69117e7e309b0bf7eec1 Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 9 Jun 2026 12:09:46 +0200 Subject: [PATCH 3/5] Changed slideio.py to support DICOM CellVizio format. --- exact/util/slideio.py | 59 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/exact/util/slideio.py b/exact/util/slideio.py index d8c4a26b..a38c8edf 100644 --- a/exact/util/slideio.py +++ b/exact/util/slideio.py @@ -49,9 +49,19 @@ def __init__(self, filename): # Zeiss is funny and thinks BGR is the proper way to store images ... if (os.path.splitext(filename.upper())[-1] == '.CZI'): self.mode = 'BGRA' - - - #print('Circular mask shape:',self.circMask.shape) + + # SlideIO sometimes returns 0 for resolution on DICOM files that lack + # standard PixelSpacing tags. Fall back to pydicom for those tags. + if self.mpp_x == 0.0 and not _Path(filename).is_dir(): + _dcm = self._read_dcm_meta() + if _dcm is not None: + ps = (getattr(_dcm, 'PixelSpacing', None) + or getattr(_dcm, 'ImagerPixelSpacing', None) + or getattr(_dcm, 'NominalScannedPixelSpacing', None)) + if ps and float(ps[0]) > 0: + self.mpp_x = float(ps[1]) * 1000 # mm → µm + self.mpp_y = float(ps[0]) * 1000 + self.properties = { openslide.PROPERTY_NAME_BACKGROUND_COLOR: '000000', openslide.PROPERTY_NAME_MPP_X: self.mpp_x, openslide.PROPERTY_NAME_MPP_Y: self.mpp_y, @@ -75,11 +85,35 @@ def level_dimensions(self): def nFrames(self): return self.scene.num_z_slices + def _read_dcm_meta(self): + """Read DICOM metadata via pydicom; returns dataset or None.""" + if not hasattr(self, '_dcm_meta_cache'): + ext = os.path.splitext(self.fileName.lower())[1] + if ext == '.dcm': + try: + import pydicom + self._dcm_meta_cache = pydicom.dcmread(self.fileName, stop_before_pixels=True) + except Exception: + self._dcm_meta_cache = None + else: + self._dcm_meta_cache = None + return self._dcm_meta_cache + @property def frame_descriptors(self) -> list[str]: """ returns a list of strings, used as descriptor for each frame """ - return ['%.2f µ' % (self.scene.z_resolution*1E6*x) for x in range(self.scene.num_z_slices)] + z_res = self.scene.z_resolution * 1e6 + if z_res > 0: + return ['%.2f µ' % (z_res * x) for x in range(self.scene.num_z_slices)] + # For DICOM temporal sequences, fall back to FrameTime tag + _dcm = self._read_dcm_meta() + if _dcm is not None: + ft = getattr(_dcm, 'FrameTime', None) + if ft is not None: + frame_time_s = float(ft) / 1000.0 + return ['%.2f s (%d)' % (frame_time_s * x, x) for x in range(self.scene.num_z_slices)] + return ['Frame %d' % x for x in range(self.scene.num_z_slices)] @property def default_frame(self): @@ -97,6 +131,11 @@ def layer_descriptors(self) -> list[str]: @property def frame_type(self): + _dcm = self._read_dcm_meta() + if _dcm is not None: + modality = str(getattr(_dcm, 'Modality', '')) + if modality in ('CT', 'MR', 'PT', 'NM'): + return FrameType.ZSTACK return FrameType.TIMESERIES @@ -109,7 +148,15 @@ def read_region(self, location: tuple, level:int, size:tuple, frame:int=0): img = self.scene.read_block(rect=[*location, int(size[0]/ds), int(size[1]/ds)], size=size, slices=(frame, frame+1)) img_4ch = np.zeros([size[1], size[0],4], dtype=np.uint8) img_4ch[:,:,3] = 255 - img_4ch[:,:,0:3] = img if self.mode=='RGBA' else img[:,:,::-1] + if img.ndim == 2: + # Grayscale (single-channel) — broadcast to RGB + img_4ch[:,:,0] = img + img_4ch[:,:,1] = img + img_4ch[:,:,2] = img + elif self.mode == 'RGBA': + img_4ch[:,:,0:3] = img + else: + img_4ch[:,:,0:3] = img[:,:,::-1] return Image.fromarray(img_4ch) @property @@ -133,7 +180,7 @@ def imagePos_to_id(self, imagePos:tuple, level:int): return (id_x+(id_y*self.geometry_columns[level])) - def get_id(self, pixelX:int, pixelY:int, level:int) -> (int, int, int): + def get_id(self, pixelX:int, pixelY:int, level:int) -> tuple: id_x = round(-0.5+(pixelX/self.geometry_tilesize[level][1])) id_y = round(-0.5+(pixelY/self.geometry_tilesize[level][0])) From 369a45507ee5a3ce7b937aba8d3bfc1c5b36203f Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 9 Jun 2026 12:15:31 +0200 Subject: [PATCH 4/5] Revert "Add folder upload for DICOM series and MRXS datasets" This reverts commit dff4d878b4fa79515c2eb307fa59c09702e4c474. --- exact/exact/images/dicom_utils.py | 38 ----- exact/exact/images/models.py | 32 ---- .../images/templates/images/imageset.html | 65 +------- .../images/templates/images/imageset_v2.html | 64 -------- exact/exact/images/urls.py | 2 - exact/exact/images/views.py | 148 ------------------ exact/util/slide_server.py | 7 - exact/util/slideio.py | 8 +- 8 files changed, 3 insertions(+), 361 deletions(-) delete mode 100644 exact/exact/images/dicom_utils.py diff --git a/exact/exact/images/dicom_utils.py b/exact/exact/images/dicom_utils.py deleted file mode 100644 index f2e65dd2..00000000 --- a/exact/exact/images/dicom_utils.py +++ /dev/null @@ -1,38 +0,0 @@ -from collections import defaultdict -from pathlib import Path - - -def split_dicom_by_series(folder_path: Path) -> dict: - """Return {series_uid: [Path, ...]} grouping for all DCM files under folder_path.""" - import pydicom - - series: dict = defaultdict(list) - for dcm_path in sorted(folder_path.rglob('*')): - if dcm_path.suffix.lower() != '.dcm': - continue - try: - ds = pydicom.dcmread(str(dcm_path), stop_before_pixels=True) - uid = str(getattr(ds, 'SeriesInstanceUID', 'unknown')) - series[uid].append(dcm_path) - except Exception: - pass - return dict(series) - - -def series_display_name(files: list) -> str: - """Derive a human-readable name for a DICOM series from the first file's tags.""" - if not files: - return 'dicom_series' - try: - import pydicom - ds = pydicom.dcmread(str(files[0]), stop_before_pixels=True) - parts = [] - for tag in ('Modality', 'SeriesDescription', 'SeriesNumber'): - val = getattr(ds, tag, None) - if val is not None: - parts.append(str(val).strip()) - if parts: - return '_'.join(parts) - except Exception: - pass - return 'dicom_series' diff --git a/exact/exact/images/models.py b/exact/exact/images/models.py index fbaaa731..7d77a4d0 100644 --- a/exact/exact/images/models.py +++ b/exact/exact/images/models.py @@ -122,40 +122,8 @@ def save(self, *args, **kwargs): super(Image, self).save(*args, **kwargs) - def _save_from_directory(self, path: Path): - """Handle a pre-assembled directory (e.g. DICOM series folder).""" - osr = getSlideHandler(str(path)) - self.filename = path.name - self.save() - if osr.nFrames > 1: - for frame_id in range(osr.nFrames): - FrameDescription.objects.create( - Image=self, - frame_id=frame_id, - file_path=self.filename, - description=osr.frame_descriptors[frame_id], - frame_type=osr.frame_type, - ) - self.frames = osr.nFrames - self.defaultFrame = osr.default_frame - self.width, self.height = osr.level_dimensions[0] - try: - self.mpp = (float(osr.properties[openslide.PROPERTY_NAME_MPP_X]) + - float(osr.properties[openslide.PROPERTY_NAME_MPP_Y])) / 2 - except (KeyError, ValueError): - self.mpp = 0 - try: - self.objectivePower = osr.properties[openslide.PROPERTY_NAME_OBJECTIVE_POWER] - except (KeyError, ValueError): - self.objectivePower = 1 - self.save() - def save_file(self, path:Path): - if Path(path).is_dir(): - self._save_from_directory(Path(path)) - return - try: # check if the file can be opened natively, if not convert it try: diff --git a/exact/exact/images/templates/images/imageset.html b/exact/exact/images/templates/images/imageset.html index f850f5ff..bf724106 100644 --- a/exact/exact/images/templates/images/imageset.html +++ b/exact/exact/images/templates/images/imageset.html @@ -145,62 +145,6 @@ } }); } - -(function () { - var btn = document.getElementById('folder-upload-btn'); - var input = document.getElementById('folder-upload-input'); - var status = document.getElementById('folder-upload-status'); - if (!btn || !input) return; - - btn.addEventListener('click', function () { input.click(); }); - - input.addEventListener('change', async function () { - var files = Array.from(input.files); - if (!files.length) return; - - var batchId = (Date.now().toString(36) + Math.random().toString(36).slice(2)).replace(/[^a-zA-Z0-9]/g, ''); - var csrf = (document.cookie.match(/csrftoken=([^;]+)/) || [])[1] || ''; - - status.textContent = 'Uploading 0 / ' + files.length + ' files…'; - btn.classList.add('disabled'); - - for (var i = 0; i < files.length; i++) { - var fd = new FormData(); - fd.append('batch_id', batchId); - fd.append('relative_path', files[i].webkitRelativePath || files[i].name); - fd.append('file', files[i]); - fd.append('csrfmiddlewaretoken', csrf); - try { - var r = await fetch('{% url "images:upload_folder_chunk" imageset.id %}', { - method: 'POST', credentials: 'same-origin', body: fd, - headers: {'X-CSRFToken': csrf} - }); - if (!r.ok) { status.textContent = 'Upload error on file ' + (i + 1); btn.classList.remove('disabled'); return; } - } catch (e) { status.textContent = 'Network error: ' + e; btn.classList.remove('disabled'); return; } - status.textContent = 'Uploading ' + (i + 1) + ' / ' + files.length + ' files…'; - } - - status.textContent = 'Processing…'; - var fd2 = new FormData(); - fd2.append('batch_id', batchId); - fd2.append('csrfmiddlewaretoken', csrf); - try { - var resp = await fetch('{% url "images:upload_folder_finalize" imageset.id %}', { - method: 'POST', credentials: 'same-origin', body: fd2, - headers: {'X-CSRFToken': csrf} - }); - var data = await resp.json(); - if (data.errors && data.errors.length) { - status.textContent = 'Error: ' + data.errors.join('; '); - } else { - status.textContent = 'Done — ' + (data.files ? data.files.length : 0) + ' image(s) imported. Reloading…'; - setTimeout(function () { location.reload(); }, 1500); - } - } catch (e) { status.textContent = 'Finalize error: ' + e; } - btn.classList.remove('disabled'); - input.value = ''; - }); -}()); {% endblock additional_js %} @@ -712,9 +656,8 @@



Upload images as single files or as zip file with the images in its root directory (recommended). - For DICOM series or 3DHistech (MRXS) datasets, use the Upload Folder button. -
+
{% csrf_token %} @@ -726,12 +669,6 @@

Add files... - - - Upload Folder - - -

-
@@ -1073,62 +1066,5 @@

Download exports:

$('#myTab li:nth-child(3) button').tab('show') // Select third tab {% endif %} - {% endblock bodyblock%} diff --git a/exact/exact/images/urls.py b/exact/exact/images/urls.py index cc59557d..5a46448e 100644 --- a/exact/exact/images/urls.py +++ b/exact/exact/images/urls.py @@ -23,8 +23,6 @@ re_path(r'^image/setfree/(\d+)/$', views.set_free, name='setfree_imageset'), re_path(r'^image/upload/(\d+)/$', views.upload_image, name='upload_image'), - re_path(r'^image/upload_folder_chunk/(\d+)/$', views.upload_folder_chunk, name='upload_folder_chunk'), - re_path(r'^image/upload_folder_finalize/(\d+)/$', views.upload_folder_finalize, name='upload_folder_finalize'), re_path(r'^image/rename/(\d+)/$', views.rename_image, name='rename_image'), re_path(r'^api/image/crop/(\d+)/(\d+)/(\d+)/(\d+)/(\d+)/(\d+)/(\d+)/$', views.crop_from_image, name='crop_from_image'), re_path(r'^image/(\d+)/(\d+)/(\d+)/tile/$', views.view_image, name='view_image'), diff --git a/exact/exact/images/views.py b/exact/exact/images/views.py index f8d6293a..93bcf01f 100644 --- a/exact/exact/images/views.py +++ b/exact/exact/images/views.py @@ -63,7 +63,6 @@ from shutil import which import string import random -import tempfile import zipfile import hashlib import json @@ -511,153 +510,6 @@ def upload_image(request, imageset_id): return JsonResponse({'files': json_files}) -# --------------------------------------------------------------------------- -# Folder upload — two-step: chunk upload then finalize -# --------------------------------------------------------------------------- - -_BATCH_ID_RE = re.compile(r'^[a-zA-Z0-9_\-]{4,64}$') - - -@api_view(['POST']) -def upload_folder_chunk(request, imageset_id): - """Receive one file belonging to a folder-upload batch. - - Required POST fields: - batch_id — opaque ID generated by the client (alphanumeric + -_) - relative_path — path of this file within the uploaded folder - file — the file data - """ - imageset = get_object_or_404(ImageSet, id=imageset_id) - if not imageset.has_perm('edit_set', request.user) or imageset.image_lock: - return Response({}, status=HTTP_403_FORBIDDEN) - - batch_id = request.POST.get('batch_id', '') - relative_path = request.POST.get('relative_path', '') - if not _BATCH_ID_RE.match(batch_id): - return HttpResponseBadRequest('Invalid batch_id') - if not relative_path or '..' in relative_path or relative_path.startswith('/'): - return HttpResponseBadRequest('Invalid relative_path') - if 'file' not in request.FILES: - return HttpResponseBadRequest('No file attached') - - f = request.FILES['file'] - tmp_dir = Path(tempfile.gettempdir()) / 'exact_folder_upload' / batch_id - dest = tmp_dir / relative_path - dest.parent.mkdir(parents=True, exist_ok=True) - with open(dest, 'wb') as out: - for chunk in f.chunks(): - out.write(chunk) - - return Response({'ok': True}, status=HTTP_200_OK) - - -@api_view(['POST']) -def upload_folder_finalize(request, imageset_id): - """Process a completed folder-upload batch and create Image records. - - Required POST field: - batch_id — the same ID used for chunk uploads - """ - imageset = get_object_or_404(ImageSet, id=imageset_id) - if not imageset.has_perm('edit_set', request.user) or imageset.image_lock: - return Response({}, status=HTTP_403_FORBIDDEN) - - batch_id = request.POST.get('batch_id', '') - if not _BATCH_ID_RE.match(batch_id): - return HttpResponseBadRequest('Invalid batch_id') - - tmp_dir = Path(tempfile.gettempdir()) / 'exact_folder_upload' / batch_id - if not tmp_dir.exists(): - return HttpResponseBadRequest('Unknown or expired batch') - - results = [] - errors = [] - try: - results, errors = _process_folder_upload(imageset, tmp_dir) - finally: - shutil.rmtree(str(tmp_dir), ignore_errors=True) - - return Response({'files': results, 'errors': errors}, status=HTTP_200_OK) - - -def _folder_checksum(dir_path: Path) -> bytes: - h = hashlib.sha512() - for f in sorted(dir_path.rglob('*')): - if f.is_file(): - with open(f, 'rb') as fh: - while buf := fh.read(65536): - h.update(buf) - return h.digest() - - -def _process_folder_upload(imageset, tmp_dir: Path): - """Detect folder format and create Image records in imageset.""" - from exact.images.dicom_utils import split_dicom_by_series, series_display_name - - all_files = list(tmp_dir.rglob('*')) - dcm_files = [f for f in all_files if f.is_file() and f.suffix.lower() == '.dcm'] - mrxs_files = [f for f in all_files if f.is_file() and f.suffix.lower() == '.mrxs'] - - results = [] - errors = [] - - if mrxs_files: - for mrxs_src in mrxs_files: - try: - dest = Path(imageset.root_path()) / mrxs_src.name - shutil.copy2(str(mrxs_src), str(dest)) - - data_src = mrxs_src.parent / mrxs_src.stem - if data_src.exists() and data_src.is_dir(): - data_dest = Path(imageset.root_path()) / mrxs_src.stem - shutil.copytree(str(data_src), str(data_dest), dirs_exist_ok=True) - - fchecksum = hashlib.sha512(dest.read_bytes()).digest() - image = Image(name=mrxs_src.name, image_set=imageset, checksum=fchecksum) - image.save_file(dest) - results.append({'name': mrxs_src.name, 'id': image.id, 'type': 'image'}) - except Exception as e: - logger.error(f'Folder upload MRXS error: {e}') - errors.append(str(e)) - - elif dcm_files: - series = split_dicom_by_series(tmp_dir) - if not series: - errors.append('No readable DICOM series found in the uploaded folder') - for uid, files in series.items(): - try: - safe_uid = re.sub(r'[^a-zA-Z0-9._-]', '_', uid)[:64] - display = series_display_name(files) - folder_name = re.sub(r'[^a-zA-Z0-9._-]', '_', display)[:64] or safe_uid - - # Avoid collisions - series_dest = Path(imageset.root_path()) / folder_name - suffix = 1 - while series_dest.exists(): - series_dest = Path(imageset.root_path()) / f'{folder_name}_{suffix}' - suffix += 1 - series_dest.mkdir() - - for f in files: - shutil.copy2(str(f), str(series_dest / f.name)) - - fchecksum = _folder_checksum(series_dest) - image = Image(name=series_dest.name, image_set=imageset, checksum=fchecksum) - image.save_file(series_dest) - results.append({'name': series_dest.name, 'id': image.id, 'type': 'image'}) - except Exception as e: - logger.error(f'Folder upload DICOM series error: {e}') - errors.append(str(e)) - - else: - errors.append( - 'No recognised folder format found. ' - 'Expected a DICOM folder (.dcm files) or a 3DHistech folder (.mrxs file).' - ) - - return results, errors - - # @login_required # def imageview(request, image_id): # image = get_object_or_404(Image, id=image_id) diff --git a/exact/util/slide_server.py b/exact/util/slide_server.py index 0508f68a..51b605cb 100644 --- a/exact/util/slide_server.py +++ b/exact/util/slide_server.py @@ -592,13 +592,6 @@ def fileno(self): def getSlideHandler(path): - import pathlib as _pl - if _pl.Path(path).is_dir(): - dcm_files = list(_pl.Path(path).rglob('*.dcm')) + list(_pl.Path(path).rglob('*.DCM')) - if dcm_files: - return SlideIOSlide(str(path)) - raise ValueError(f'No supported format found in directory: {path}') - # Determine format of slide to see how to handle it. t = time.time() f = open(path,'rb') diff --git a/exact/util/slideio.py b/exact/util/slideio.py index a38c8edf..51a41296 100644 --- a/exact/util/slideio.py +++ b/exact/util/slideio.py @@ -25,13 +25,9 @@ def __reduce__(self): # Define how to pickle the object return (self.__class__, (self.fileName,)) - def __init__(self, filename): + def __init__(self,filename): self.fileName = filename - from pathlib import Path as _Path - if _Path(filename).is_dir(): - self.fh = slideio.open_slide(filename, 'DCM') - else: - self.fh = slideio.open_slide(filename) + self.fh = slideio.open_slide(filename) self.scene = self.fh.get_scene(0) From 0ead875071d070da3ffe4582e28b371ac165539f Mon Sep 17 00:00:00 2001 From: Marc Aubreville Date: Tue, 9 Jun 2026 12:19:02 +0200 Subject: [PATCH 5/5] Bugfix (missing symbol) --- exact/util/slideio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exact/util/slideio.py b/exact/util/slideio.py index 51a41296..fd7d09f8 100644 --- a/exact/util/slideio.py +++ b/exact/util/slideio.py @@ -48,7 +48,7 @@ def __init__(self,filename): # SlideIO sometimes returns 0 for resolution on DICOM files that lack # standard PixelSpacing tags. Fall back to pydicom for those tags. - if self.mpp_x == 0.0 and not _Path(filename).is_dir(): + if self.mpp_x == 0.0: _dcm = self._read_dcm_meta() if _dcm is not None: ps = (getattr(_dcm, 'PixelSpacing', None)