endoreg-db 0.8.2.1__py3-none-any.whl → 0.8.2.2__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 endoreg-db might be problematic. Click here for more details.
- endoreg_db/helpers/default_objects.py +48 -29
- endoreg_db/management/commands/import_video.py +5 -3
- endoreg_db/models/media/video/video_file.py +53 -44
- endoreg_db/models/metadata/sensitive_meta.py +6 -6
- endoreg_db/services/pseudonym_service.py +1 -1
- endoreg_db/services/video_import.py +280 -363
- {endoreg_db-0.8.2.1.dist-info → endoreg_db-0.8.2.2.dist-info}/METADATA +2 -2
- {endoreg_db-0.8.2.1.dist-info → endoreg_db-0.8.2.2.dist-info}/RECORD +10 -11
- endoreg_db/services/ollama_api_docs.py +0 -1528
- {endoreg_db-0.8.2.1.dist-info → endoreg_db-0.8.2.2.dist-info}/WHEEL +0 -0
- {endoreg_db-0.8.2.1.dist-info → endoreg_db-0.8.2.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import random
|
|
2
|
+
from typing import Optional
|
|
2
3
|
from endoreg_db.models import (
|
|
3
4
|
Center,
|
|
4
5
|
Gender,
|
|
@@ -17,6 +18,7 @@ import shutil
|
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from django.conf import settings # Import settings
|
|
19
20
|
from django.core.files.storage import default_storage # Import default storage
|
|
21
|
+
from django.db.models.fields.files import FieldFile
|
|
20
22
|
|
|
21
23
|
from endoreg_db.utils import (
|
|
22
24
|
create_mock_patient_name,
|
|
@@ -44,6 +46,10 @@ DEFAULT_INDICATIONS = [
|
|
|
44
46
|
DEFAULT_SEGMENTATION_MODEL_NAME = "image_multilabel_classification_colonoscopy_default"
|
|
45
47
|
|
|
46
48
|
DEFAULT_GENDER = "unknown"
|
|
49
|
+
DEFAULT_PATIENT_FIRST_NAME = "TestFirst"
|
|
50
|
+
DEFAULT_PATIENT_LAST_NAME = "TestLast"
|
|
51
|
+
DEFAULT_PATIENT_GENDER_NAME = "female"
|
|
52
|
+
DEFAULT_PATIENT_BIRTH_DATE = date(1970, 1, 1)
|
|
47
53
|
|
|
48
54
|
def get_information_source_prediction():
|
|
49
55
|
"""
|
|
@@ -170,33 +176,41 @@ def get_default_center() -> Center:
|
|
|
170
176
|
return center
|
|
171
177
|
|
|
172
178
|
def generate_patient(**kwargs) -> Patient:
|
|
173
|
-
"""
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
# Set default values
|
|
185
|
-
gender = kwargs.get("gender", get_random_gender())
|
|
186
|
-
if not isinstance(gender, Gender):
|
|
179
|
+
"""Create a Patient with deterministic defaults unless ``randomize=True`` is supplied."""
|
|
180
|
+
|
|
181
|
+
randomize = kwargs.pop("randomize", False)
|
|
182
|
+
|
|
183
|
+
gender = kwargs.get("gender")
|
|
184
|
+
if gender is None:
|
|
185
|
+
if randomize:
|
|
186
|
+
gender = get_random_gender()
|
|
187
|
+
else:
|
|
188
|
+
gender = Gender.objects.get(name=DEFAULT_PATIENT_GENDER_NAME)
|
|
189
|
+
elif not isinstance(gender, Gender):
|
|
187
190
|
gender = Gender.objects.get(name=gender)
|
|
188
191
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
first_name
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
192
|
+
first_name = kwargs.get("first_name")
|
|
193
|
+
last_name = kwargs.get("last_name")
|
|
194
|
+
if first_name is None or last_name is None:
|
|
195
|
+
if randomize:
|
|
196
|
+
generated_first, generated_last = create_mock_patient_name(gender=gender.name)
|
|
197
|
+
else:
|
|
198
|
+
generated_first, generated_last = DEFAULT_PATIENT_FIRST_NAME, DEFAULT_PATIENT_LAST_NAME
|
|
199
|
+
first_name = first_name or generated_first
|
|
200
|
+
last_name = last_name or generated_last
|
|
201
|
+
|
|
202
|
+
dob = kwargs.get("dob")
|
|
203
|
+
if dob is None:
|
|
204
|
+
birth_date = kwargs.get("birth_date", DEFAULT_PATIENT_BIRTH_DATE)
|
|
205
|
+
if isinstance(birth_date, date):
|
|
206
|
+
dob = birth_date
|
|
207
|
+
else:
|
|
208
|
+
dob = date.fromisoformat(str(birth_date))
|
|
209
|
+
|
|
210
|
+
center = kwargs.get("center")
|
|
197
211
|
if center is None:
|
|
198
212
|
center = get_default_center()
|
|
199
|
-
|
|
213
|
+
elif not isinstance(center, Center):
|
|
200
214
|
center = Center.objects.get(name=center)
|
|
201
215
|
|
|
202
216
|
patient = Patient(
|
|
@@ -261,6 +275,7 @@ def get_default_egd_pdf():
|
|
|
261
275
|
shutil.copy(egd_path, temp_file_path)
|
|
262
276
|
|
|
263
277
|
pdf_file = None
|
|
278
|
+
file_field: Optional[FieldFile] = None
|
|
264
279
|
try:
|
|
265
280
|
# Create the PDF record using the temporary file.
|
|
266
281
|
# delete_source=True will ensure temp_file_path is deleted by create_from_file
|
|
@@ -275,8 +290,11 @@ def get_default_egd_pdf():
|
|
|
275
290
|
raise RuntimeError("Failed to create PDF file object")
|
|
276
291
|
|
|
277
292
|
# Use storage API to check existence
|
|
278
|
-
|
|
279
|
-
|
|
293
|
+
file_field = pdf_file.file
|
|
294
|
+
if not isinstance(file_field, FieldFile):
|
|
295
|
+
raise RuntimeError("RawPdfFile.file did not return a FieldFile instance")
|
|
296
|
+
if not default_storage.exists(file_field.path):
|
|
297
|
+
raise RuntimeError(f"PDF file does not exist in storage at {file_field.path}")
|
|
280
298
|
|
|
281
299
|
# Check that the source temp file was deleted
|
|
282
300
|
if temp_file_path.exists():
|
|
@@ -308,10 +326,11 @@ def get_default_egd_pdf():
|
|
|
308
326
|
|
|
309
327
|
# pdf_file.file.path might fail if storage doesn't support direct paths (like S3)
|
|
310
328
|
# Prefer using storage API for checks. Logging path if available.
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
329
|
+
if file_field is not None:
|
|
330
|
+
try:
|
|
331
|
+
logger.info(f"PDF file created: {file_field.name}, Path: {file_field.path}")
|
|
332
|
+
except NotImplementedError:
|
|
333
|
+
logger.info(f"PDF file created: {file_field.name}, Path: (Not available from storage)")
|
|
315
334
|
|
|
316
335
|
|
|
317
336
|
return pdf_file
|
|
@@ -294,10 +294,12 @@ class Command(BaseCommand):
|
|
|
294
294
|
|
|
295
295
|
# Updated to handle new return signature (path, metadata)
|
|
296
296
|
cleaned_video_path, extracted_metadata = frame_cleaner.clean_video(
|
|
297
|
-
Path(video_file_obj.raw_file.path),
|
|
297
|
+
video_path=Path(video_file_obj.raw_file.path),
|
|
298
298
|
video_file_obj=video_file_obj, # Pass VideoFile object to store metadata
|
|
299
|
-
|
|
300
|
-
|
|
299
|
+
device_name=processor_name,
|
|
300
|
+
endoscope_roi=video_file_obj.processor.get_roi_endoscope_image if video_file_obj.processor else None,
|
|
301
|
+
output_path=video_file_obj.get_processed_file_path(),
|
|
302
|
+
technique="mask_overlay" # Use mask overlay technique as default, if not set this will be inferred.
|
|
301
303
|
)
|
|
302
304
|
|
|
303
305
|
# Save the cleaned video using Django's FileField
|
|
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Optional, Union, cast
|
|
|
7
7
|
|
|
8
8
|
from django.db import models
|
|
9
9
|
from django.core.files import File
|
|
10
|
+
from django.db.models.fields.files import FieldFile
|
|
10
11
|
from django.core.validators import FileExtensionValidator
|
|
11
12
|
from django.db.models import F
|
|
12
13
|
from endoreg_db.utils.calc_duration_seconds import _calc_duration_vf
|
|
@@ -127,36 +128,36 @@ class VideoFile(models.Model):
|
|
|
127
128
|
sensitive_meta = models.OneToOneField(
|
|
128
129
|
"SensitiveMeta", on_delete=models.SET_NULL, null=True, blank=True, related_name="video_file"
|
|
129
130
|
) # type: ignore
|
|
130
|
-
center = models.ForeignKey("Center", on_delete=models.PROTECT)
|
|
131
|
+
center = models.ForeignKey("Center", on_delete=models.PROTECT) # type: ignore
|
|
131
132
|
processor = models.ForeignKey(
|
|
132
133
|
"EndoscopyProcessor", on_delete=models.PROTECT, blank=True, null=True
|
|
133
|
-
)
|
|
134
|
+
) # type: ignore
|
|
134
135
|
video_meta = models.OneToOneField(
|
|
135
136
|
"VideoMeta", on_delete=models.SET_NULL, null=True, blank=True, related_name="video_file"
|
|
136
|
-
)
|
|
137
|
+
) # type: ignore
|
|
137
138
|
examination = models.ForeignKey(
|
|
138
139
|
"PatientExamination",
|
|
139
140
|
on_delete=models.SET_NULL,
|
|
140
141
|
blank=True,
|
|
141
142
|
null=True,
|
|
142
143
|
related_name="video_files",
|
|
143
|
-
)
|
|
144
|
+
) # type: ignore
|
|
144
145
|
patient = models.ForeignKey(
|
|
145
146
|
"Patient",
|
|
146
147
|
on_delete=models.SET_NULL,
|
|
147
148
|
blank=True,
|
|
148
149
|
null=True,
|
|
149
150
|
related_name="video_files",
|
|
150
|
-
)
|
|
151
|
+
) # type: ignore
|
|
151
152
|
ai_model_meta = models.ForeignKey(
|
|
152
153
|
"ModelMeta", on_delete=models.SET_NULL, blank=True, null=True
|
|
153
|
-
)
|
|
154
|
+
) # type: ignore
|
|
154
155
|
state = models.OneToOneField(
|
|
155
156
|
"VideoState", on_delete=models.SET_NULL, null=True, blank=True, related_name="video_file"
|
|
156
|
-
)
|
|
157
|
+
) # type: ignore
|
|
157
158
|
import_meta = models.OneToOneField(
|
|
158
159
|
"VideoImportMeta", on_delete=models.CASCADE, blank=True, null=True
|
|
159
|
-
)
|
|
160
|
+
) # type: ignore
|
|
160
161
|
|
|
161
162
|
original_file_name = models.CharField(max_length=255, blank=True, null=True)
|
|
162
163
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
|
@@ -197,7 +198,9 @@ class VideoFile(models.Model):
|
|
|
197
198
|
"""
|
|
198
199
|
from endoreg_db.models import FFMpegMeta
|
|
199
200
|
if self.video_meta is not None:
|
|
200
|
-
|
|
201
|
+
if self.video_meta.ffmpeg_meta is not None:
|
|
202
|
+
return self.video_meta.ffmpeg_meta
|
|
203
|
+
raise AssertionError("Expected FFMpegMeta instance.")
|
|
201
204
|
else:
|
|
202
205
|
self.initialize_video_specs()
|
|
203
206
|
ffmpeg_meta = self.video_meta.ffmpeg_meta if self.video_meta else None
|
|
@@ -216,20 +219,19 @@ class VideoFile(models.Model):
|
|
|
216
219
|
Raises:
|
|
217
220
|
Value Error if no active VideoFile is available.
|
|
218
221
|
"""
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
return url
|
|
222
|
+
active = self.active_file
|
|
223
|
+
if not isinstance(active, FieldFile):
|
|
224
|
+
raise ValueError("Active file is not a stored FieldFile instance.")
|
|
225
|
+
if not active.name:
|
|
226
|
+
raise ValueError("Active file has no associated name.")
|
|
227
|
+
return active.url
|
|
226
228
|
|
|
227
229
|
@property
|
|
228
|
-
def active_raw_file(self) ->
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
def active_raw_file(self) -> FieldFile:
|
|
231
|
+
raw = self.raw_file
|
|
232
|
+
if isinstance(raw, FieldFile) and raw.name:
|
|
233
|
+
return raw
|
|
234
|
+
raise ValueError("No raw file available for this video")
|
|
233
235
|
|
|
234
236
|
@property
|
|
235
237
|
def active_raw_file_url(self)-> str:
|
|
@@ -241,12 +243,10 @@ class VideoFile(models.Model):
|
|
|
241
243
|
|
|
242
244
|
Returns:
|
|
243
245
|
"""
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
url = _file.url
|
|
249
|
-
return url
|
|
246
|
+
raw = self.active_raw_file
|
|
247
|
+
if not raw.name:
|
|
248
|
+
raise ValueError("Active raw file has no associated name.")
|
|
249
|
+
return raw.url
|
|
250
250
|
|
|
251
251
|
|
|
252
252
|
# Pipeline Functions
|
|
@@ -363,7 +363,7 @@ class VideoFile(models.Model):
|
|
|
363
363
|
|
|
364
364
|
|
|
365
365
|
@property
|
|
366
|
-
def active_file(self) ->
|
|
366
|
+
def active_file(self) -> FieldFile:
|
|
367
367
|
"""
|
|
368
368
|
Return the active video file, preferring the processed file if available.
|
|
369
369
|
|
|
@@ -373,12 +373,15 @@ class VideoFile(models.Model):
|
|
|
373
373
|
Raises:
|
|
374
374
|
ValueError: If neither a processed nor a raw file is available.
|
|
375
375
|
"""
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
376
|
+
processed = self.processed_file
|
|
377
|
+
if isinstance(processed, FieldFile) and processed.name:
|
|
378
|
+
return processed
|
|
379
|
+
|
|
380
|
+
raw = self.raw_file
|
|
381
|
+
if isinstance(raw, FieldFile) and raw.name:
|
|
382
|
+
return raw
|
|
383
|
+
|
|
384
|
+
raise ValueError("No active file available. VideoFile has neither raw nor processed file.")
|
|
382
385
|
|
|
383
386
|
|
|
384
387
|
@property
|
|
@@ -393,13 +396,17 @@ class VideoFile(models.Model):
|
|
|
393
396
|
ValueError: If neither a processed nor raw file is present.
|
|
394
397
|
"""
|
|
395
398
|
active = self.active_file
|
|
396
|
-
if active
|
|
397
|
-
|
|
398
|
-
elif active
|
|
399
|
-
|
|
399
|
+
if active is self.processed_file:
|
|
400
|
+
path = _get_processed_file_path(self)
|
|
401
|
+
elif active is self.raw_file:
|
|
402
|
+
path = _get_raw_file_path(self)
|
|
400
403
|
else:
|
|
401
404
|
raise ValueError("No active file path available. VideoFile has neither raw nor processed file.")
|
|
402
405
|
|
|
406
|
+
if path is None:
|
|
407
|
+
raise ValueError("Active file path could not be resolved.")
|
|
408
|
+
return path
|
|
409
|
+
|
|
403
410
|
|
|
404
411
|
@classmethod
|
|
405
412
|
def create_from_file(cls, file_path: Union[str, Path], center_name: str, **kwargs) -> Optional["VideoFile"]:
|
|
@@ -448,13 +455,14 @@ class VideoFile(models.Model):
|
|
|
448
455
|
"""
|
|
449
456
|
# Ensure frames are deleted before the main instance
|
|
450
457
|
_delete_frames(self)
|
|
451
|
-
|
|
458
|
+
|
|
452
459
|
# Call the original delete method to remove the instance from the database
|
|
453
|
-
|
|
454
|
-
|
|
460
|
+
active_path = self.active_file_path
|
|
461
|
+
logger.info(f"Deleting VideoFile: {self.uuid} - {active_path}")
|
|
462
|
+
|
|
455
463
|
# Delete associated files if they exist
|
|
456
|
-
if
|
|
457
|
-
|
|
464
|
+
if active_path.exists():
|
|
465
|
+
active_path.unlink(missing_ok=True)
|
|
458
466
|
|
|
459
467
|
# Delete file storage
|
|
460
468
|
if self.raw_file and self.raw_file.storage.exists(self.raw_file.name):
|
|
@@ -479,8 +487,9 @@ class VideoFile(models.Model):
|
|
|
479
487
|
|
|
480
488
|
try:
|
|
481
489
|
# Call parent delete with proper parameters
|
|
482
|
-
super().delete(using=using, keep_parents=keep_parents)
|
|
490
|
+
result = super().delete(using=using, keep_parents=keep_parents)
|
|
483
491
|
logger.info(f"VideoFile {self.uuid} deleted successfully.")
|
|
492
|
+
return result
|
|
484
493
|
except Exception as e:
|
|
485
494
|
logger.error(f"Error deleting VideoFile {self.uuid}: {e}")
|
|
486
495
|
raise
|
|
@@ -37,7 +37,7 @@ class SensitiveMeta(models.Model):
|
|
|
37
37
|
blank=True,
|
|
38
38
|
null=True,
|
|
39
39
|
help_text="FK to the pseudo-anonymized Patient record."
|
|
40
|
-
)
|
|
40
|
+
) # type: ignore
|
|
41
41
|
patient_first_name = models.CharField(max_length=255, blank=True, null=True)
|
|
42
42
|
patient_last_name = models.CharField(max_length=255, blank=True, null=True)
|
|
43
43
|
patient_dob = models.DateTimeField(
|
|
@@ -51,24 +51,24 @@ class SensitiveMeta(models.Model):
|
|
|
51
51
|
blank=True,
|
|
52
52
|
null=True,
|
|
53
53
|
help_text="FK to the pseudo-anonymized PatientExamination record."
|
|
54
|
-
)
|
|
54
|
+
) # type: ignore
|
|
55
55
|
patient_gender = models.ForeignKey(
|
|
56
56
|
"Gender",
|
|
57
57
|
on_delete=models.CASCADE,
|
|
58
58
|
blank=True,
|
|
59
59
|
null=True,
|
|
60
|
-
)
|
|
60
|
+
) # type: ignore
|
|
61
61
|
examiners = models.ManyToManyField(
|
|
62
62
|
"Examiner",
|
|
63
63
|
blank=True,
|
|
64
64
|
help_text="Pseudo-anonymized examiner(s) associated with the examination."
|
|
65
|
-
)
|
|
65
|
+
) # type: ignore
|
|
66
66
|
center = models.ForeignKey(
|
|
67
67
|
"Center",
|
|
68
68
|
on_delete=models.CASCADE,
|
|
69
69
|
blank=True, # Should ideally be False if always required before save
|
|
70
70
|
null=True, # Should ideally be False
|
|
71
|
-
)
|
|
71
|
+
) # type: ignore
|
|
72
72
|
|
|
73
73
|
# Raw examiner names stored temporarily until pseudo-examiner is created/linked
|
|
74
74
|
examiner_first_name = models.CharField(max_length=255, blank=True, null=True, editable=False)
|
|
@@ -258,7 +258,7 @@ class SensitiveMeta(models.Model):
|
|
|
258
258
|
|
|
259
259
|
# 4. Handle ManyToMany linking (examiners) *after* the instance has a PK.
|
|
260
260
|
if examiner_to_link and self.pk and not self.examiners.filter(pk=examiner_to_link.pk).exists():
|
|
261
|
-
self.examiners.add(examiner_to_link)
|
|
261
|
+
self.examiners.add(examiner_to_link) # type: ignore
|
|
262
262
|
# Adding to M2M handles its own DB interaction, no second super().save() needed.
|
|
263
263
|
|
|
264
264
|
def mark_dob_verified(self):
|
|
@@ -56,7 +56,7 @@ def generate_patient_pseudonym(patient: Patient) -> Tuple[str, bool]:
|
|
|
56
56
|
patient.patient_hash = patient_hash
|
|
57
57
|
patient.save(update_fields=['patient_hash'])
|
|
58
58
|
|
|
59
|
-
logger.info(f"Generated and persisted pseudonym for patient {patient.id}
|
|
59
|
+
logger.info(f"Generated and persisted pseudonym for patient {patient.id}")
|
|
60
60
|
|
|
61
61
|
return patient_hash, True
|
|
62
62
|
|