endoreg-db 0.8.5.1__py3-none-any.whl → 0.8.5.3__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/models/media/video/video_file.py +255 -148
- endoreg_db/models/metadata/sensitive_meta_logic.py +360 -67
- endoreg_db/services/video_import.py +3 -2
- {endoreg_db-0.8.5.1.dist-info → endoreg_db-0.8.5.3.dist-info}/METADATA +1 -1
- {endoreg_db-0.8.5.1.dist-info → endoreg_db-0.8.5.3.dist-info}/RECORD +7 -7
- {endoreg_db-0.8.5.1.dist-info → endoreg_db-0.8.5.3.dist-info}/WHEEL +0 -0
- {endoreg_db-0.8.5.1.dist-info → endoreg_db-0.8.5.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,97 +1,100 @@
|
|
|
1
1
|
"""Concrete model for video files, handling both raw and processed states."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
|
|
4
|
+
import os
|
|
5
5
|
import uuid
|
|
6
|
+
from pathlib import Path
|
|
6
7
|
from typing import TYPE_CHECKING, Optional, Union, cast
|
|
7
|
-
import os
|
|
8
8
|
|
|
9
|
-
from django.db import models
|
|
10
9
|
from django.core.files import File
|
|
11
|
-
from django.db.models.fields.files import FieldFile
|
|
12
10
|
from django.core.validators import FileExtensionValidator
|
|
11
|
+
from django.db import models
|
|
13
12
|
from django.db.models import F
|
|
13
|
+
from django.db.models.fields.files import FieldFile
|
|
14
|
+
|
|
14
15
|
from endoreg_db.utils.calc_duration_seconds import _calc_duration_vf
|
|
15
16
|
|
|
17
|
+
from ...label import Label, LabelVideoSegment
|
|
18
|
+
from ...state import VideoState
|
|
19
|
+
from ...utils import ANONYM_VIDEO_DIR, VIDEO_DIR
|
|
20
|
+
|
|
16
21
|
# --- Import model-specific function modules ---
|
|
17
22
|
from .create_from_file import _create_from_file
|
|
23
|
+
from .pipe_1 import _pipe_1, _test_after_pipe_1
|
|
24
|
+
from .pipe_2 import _pipe_2
|
|
25
|
+
from .video_file_ai import _extract_text_from_video_frames, _predict_video_pipeline
|
|
18
26
|
from .video_file_anonymize import (
|
|
19
27
|
_anonymize,
|
|
20
|
-
_create_anonymized_frame_files,
|
|
21
28
|
_cleanup_raw_assets,
|
|
22
|
-
|
|
23
|
-
from .video_file_meta import (
|
|
24
|
-
_update_text_metadata,
|
|
25
|
-
_update_video_meta,
|
|
26
|
-
_get_fps,
|
|
27
|
-
_get_endo_roi,
|
|
28
|
-
_get_crop_template,
|
|
29
|
-
_initialize_video_specs,
|
|
29
|
+
_create_anonymized_frame_files,
|
|
30
30
|
)
|
|
31
31
|
from .video_file_frames import (
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
_bulk_create_frames,
|
|
33
|
+
_create_frame_object,
|
|
34
34
|
_delete_frames,
|
|
35
|
+
_extract_frames,
|
|
36
|
+
_get_frame,
|
|
37
|
+
_get_frame_number,
|
|
35
38
|
_get_frame_path,
|
|
36
39
|
_get_frame_paths,
|
|
37
|
-
_get_frame_number,
|
|
38
|
-
_get_frames,
|
|
39
|
-
_get_frame,
|
|
40
40
|
_get_frame_range,
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
_get_frames,
|
|
42
|
+
_initialize_frames,
|
|
43
43
|
)
|
|
44
|
+
|
|
44
45
|
# Update import aliases for clarity and to use as helpers
|
|
45
|
-
from .video_file_frames._manage_frame_range import
|
|
46
|
-
|
|
46
|
+
from .video_file_frames._manage_frame_range import (
|
|
47
|
+
_delete_frame_range as _delete_frame_range_helper,
|
|
48
|
+
)
|
|
49
|
+
from .video_file_frames._manage_frame_range import (
|
|
50
|
+
_extract_frame_range as _extract_frame_range_helper,
|
|
51
|
+
)
|
|
47
52
|
from .video_file_io import (
|
|
48
53
|
_delete_with_file,
|
|
49
54
|
_get_base_frame_dir,
|
|
50
|
-
_set_frame_dir,
|
|
51
55
|
_get_frame_dir_path,
|
|
52
|
-
_get_temp_anonymized_frame_dir,
|
|
53
|
-
_get_target_anonymized_video_path,
|
|
54
|
-
_get_raw_file_path,
|
|
55
56
|
_get_processed_file_path,
|
|
57
|
+
_get_raw_file_path,
|
|
58
|
+
_get_target_anonymized_video_path,
|
|
59
|
+
_get_temp_anonymized_frame_dir,
|
|
60
|
+
_set_frame_dir,
|
|
56
61
|
)
|
|
57
|
-
from .
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
from .video_file_meta import (
|
|
63
|
+
_get_crop_template,
|
|
64
|
+
_get_endo_roi,
|
|
65
|
+
_get_fps,
|
|
66
|
+
_initialize_video_specs,
|
|
67
|
+
_update_text_metadata,
|
|
68
|
+
_update_video_meta,
|
|
60
69
|
)
|
|
61
70
|
|
|
62
|
-
from .pipe_1 import _pipe_1, _test_after_pipe_1
|
|
63
|
-
from .pipe_2 import _pipe_2
|
|
64
|
-
|
|
65
|
-
from ...utils import VIDEO_DIR, ANONYM_VIDEO_DIR
|
|
66
|
-
from ...state import VideoState
|
|
67
|
-
from ...label import LabelVideoSegment, Label
|
|
68
|
-
|
|
69
|
-
|
|
70
71
|
# Configure logging
|
|
71
72
|
logger = logging.getLogger(__name__) # Changed from "video_file"
|
|
72
73
|
|
|
73
74
|
if TYPE_CHECKING:
|
|
74
75
|
from endoreg_db.models import (
|
|
75
76
|
Center,
|
|
77
|
+
EndoscopyProcessor,
|
|
78
|
+
FFMpegMeta,
|
|
76
79
|
Frame,
|
|
80
|
+
ModelMeta,
|
|
81
|
+
Patient,
|
|
82
|
+
PatientExamination,
|
|
77
83
|
SensitiveMeta,
|
|
78
|
-
|
|
84
|
+
VideoImportMeta,
|
|
79
85
|
VideoMeta,
|
|
80
|
-
PatientExamination,
|
|
81
|
-
Patient,
|
|
82
86
|
VideoState,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
87
90
|
class VideoQuerySet(models.QuerySet):
|
|
88
91
|
def next_after(self, last_id=None):
|
|
89
92
|
"""
|
|
90
93
|
Return the next VideoFile instance with a primary key greater than the given last_id.
|
|
91
|
-
|
|
94
|
+
|
|
92
95
|
Parameters:
|
|
93
96
|
last_id (int or None): The primary key to start after. If None or invalid, returns the first instance.
|
|
94
|
-
|
|
97
|
+
|
|
95
98
|
Returns:
|
|
96
99
|
VideoFile or None: The next VideoFile instance, or None if not found.
|
|
97
100
|
"""
|
|
@@ -103,9 +106,10 @@ class VideoQuerySet(models.QuerySet):
|
|
|
103
106
|
q = self if last_id is None else self.filter(pk__gt=last_id)
|
|
104
107
|
return q.order_by("pk").first()
|
|
105
108
|
|
|
109
|
+
|
|
106
110
|
class VideoFile(models.Model):
|
|
107
111
|
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
|
108
|
-
|
|
112
|
+
|
|
109
113
|
objects = VideoQuerySet.as_manager()
|
|
110
114
|
|
|
111
115
|
raw_file = models.FileField(
|
|
@@ -121,55 +125,81 @@ class VideoFile(models.Model):
|
|
|
121
125
|
blank=True,
|
|
122
126
|
)
|
|
123
127
|
|
|
124
|
-
video_hash = models.CharField(
|
|
128
|
+
video_hash = models.CharField(
|
|
129
|
+
max_length=255, unique=True, help_text="Hash of the raw video file."
|
|
130
|
+
)
|
|
125
131
|
processed_video_hash = models.CharField(
|
|
126
|
-
max_length=255,
|
|
132
|
+
max_length=255,
|
|
133
|
+
unique=True,
|
|
134
|
+
null=True,
|
|
135
|
+
blank=True,
|
|
136
|
+
help_text="Hash of the processed video file, unique if not null.",
|
|
127
137
|
)
|
|
128
138
|
|
|
129
139
|
sensitive_meta = models.OneToOneField(
|
|
130
|
-
"SensitiveMeta",
|
|
131
|
-
|
|
132
|
-
|
|
140
|
+
"SensitiveMeta",
|
|
141
|
+
on_delete=models.SET_NULL,
|
|
142
|
+
null=True,
|
|
143
|
+
blank=True,
|
|
144
|
+
related_name="video_file",
|
|
145
|
+
) # type: ignore
|
|
146
|
+
center = models.ForeignKey("Center", on_delete=models.PROTECT) # type: ignore
|
|
133
147
|
processor = models.ForeignKey(
|
|
134
148
|
"EndoscopyProcessor", on_delete=models.PROTECT, blank=True, null=True
|
|
135
|
-
)
|
|
149
|
+
) # type: ignore
|
|
136
150
|
video_meta = models.OneToOneField(
|
|
137
|
-
"VideoMeta",
|
|
138
|
-
|
|
151
|
+
"VideoMeta",
|
|
152
|
+
on_delete=models.SET_NULL,
|
|
153
|
+
null=True,
|
|
154
|
+
blank=True,
|
|
155
|
+
related_name="video_file",
|
|
156
|
+
) # type: ignore
|
|
139
157
|
examination = models.ForeignKey(
|
|
140
158
|
"PatientExamination",
|
|
141
159
|
on_delete=models.SET_NULL,
|
|
142
160
|
blank=True,
|
|
143
161
|
null=True,
|
|
144
162
|
related_name="video_files",
|
|
145
|
-
)
|
|
163
|
+
) # type: ignore
|
|
146
164
|
patient = models.ForeignKey(
|
|
147
165
|
"Patient",
|
|
148
166
|
on_delete=models.SET_NULL,
|
|
149
167
|
blank=True,
|
|
150
168
|
null=True,
|
|
151
169
|
related_name="video_files",
|
|
152
|
-
)
|
|
170
|
+
) # type: ignore
|
|
153
171
|
ai_model_meta = models.ForeignKey(
|
|
154
172
|
"ModelMeta", on_delete=models.SET_NULL, blank=True, null=True
|
|
155
|
-
)
|
|
173
|
+
) # type: ignore
|
|
156
174
|
state = models.OneToOneField(
|
|
157
|
-
"VideoState",
|
|
158
|
-
|
|
175
|
+
"VideoState",
|
|
176
|
+
on_delete=models.SET_NULL,
|
|
177
|
+
null=True,
|
|
178
|
+
blank=True,
|
|
179
|
+
related_name="video_file",
|
|
180
|
+
) # type: ignore
|
|
159
181
|
import_meta = models.OneToOneField(
|
|
160
182
|
"VideoImportMeta", on_delete=models.CASCADE, blank=True, null=True
|
|
161
|
-
)
|
|
183
|
+
) # type: ignore
|
|
162
184
|
|
|
163
185
|
original_file_name = models.CharField(max_length=255, blank=True, null=True)
|
|
164
186
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
|
165
|
-
frame_dir = models.CharField(
|
|
187
|
+
frame_dir = models.CharField(
|
|
188
|
+
max_length=512,
|
|
189
|
+
blank=True,
|
|
190
|
+
help_text="Path to frames extracted from the raw video.",
|
|
191
|
+
)
|
|
166
192
|
fps = models.FloatField(blank=True, null=True)
|
|
167
193
|
duration = models.FloatField(blank=True, null=True)
|
|
168
194
|
frame_count = models.IntegerField(blank=True, null=True)
|
|
169
195
|
width = models.IntegerField(blank=True, null=True)
|
|
170
196
|
height = models.IntegerField(blank=True, null=True)
|
|
171
197
|
suffix = models.CharField(max_length=10, blank=True, null=True)
|
|
172
|
-
sequences = models.JSONField(
|
|
198
|
+
sequences = models.JSONField(
|
|
199
|
+
default=dict,
|
|
200
|
+
blank=True,
|
|
201
|
+
help_text="AI prediction sequences based on raw frames.",
|
|
202
|
+
)
|
|
173
203
|
date = models.DateField(blank=True, null=True)
|
|
174
204
|
meta = models.JSONField(blank=True, null=True)
|
|
175
205
|
date_created = models.DateTimeField(auto_now_add=True)
|
|
@@ -188,16 +218,16 @@ class VideoFile(models.Model):
|
|
|
188
218
|
ai_model_meta: "ModelMeta"
|
|
189
219
|
import_meta: "VideoImportMeta"
|
|
190
220
|
|
|
191
|
-
|
|
192
221
|
@property
|
|
193
222
|
def ffmpeg_meta(self) -> "FFMpegMeta":
|
|
194
223
|
"""
|
|
195
224
|
Return the associated FFMpegMeta instance for this video, initializing video specs if necessary.
|
|
196
|
-
|
|
225
|
+
|
|
197
226
|
Returns:
|
|
198
227
|
FFMpegMeta: The FFMpegMeta object containing metadata for this video.
|
|
199
228
|
"""
|
|
200
229
|
from endoreg_db.models import FFMpegMeta
|
|
230
|
+
|
|
201
231
|
if self.video_meta is not None:
|
|
202
232
|
if self.video_meta.ffmpeg_meta is not None:
|
|
203
233
|
return self.video_meta.ffmpeg_meta
|
|
@@ -208,8 +238,8 @@ class VideoFile(models.Model):
|
|
|
208
238
|
assert isinstance(ffmpeg_meta, FFMpegMeta), "Expected FFMpegMeta instance."
|
|
209
239
|
return ffmpeg_meta
|
|
210
240
|
|
|
211
|
-
|
|
212
241
|
# Exception message constants
|
|
242
|
+
|
|
213
243
|
NO_ACTIVE_FILE = "Has no raw file"
|
|
214
244
|
NO_FILE_ASSOCIATED = "Active file has no associated file."
|
|
215
245
|
|
|
@@ -227,7 +257,7 @@ class VideoFile(models.Model):
|
|
|
227
257
|
assert _file is not None, self.NO_ACTIVE_FILE
|
|
228
258
|
if not _file or not _file.name:
|
|
229
259
|
raise ValueError(self.NO_FILE_ASSOCIATED)
|
|
230
|
-
return _file.url
|
|
260
|
+
return _file.url
|
|
231
261
|
|
|
232
262
|
# Pipeline Functions
|
|
233
263
|
pipe_1 = _pipe_1
|
|
@@ -255,35 +285,39 @@ class VideoFile(models.Model):
|
|
|
255
285
|
create_frame_object = _create_frame_object
|
|
256
286
|
bulk_create_frames = _bulk_create_frames
|
|
257
287
|
|
|
258
|
-
|
|
259
|
-
|
|
260
288
|
# Define new methods that call the helper functions
|
|
261
|
-
def extract_specific_frame_range(
|
|
289
|
+
def extract_specific_frame_range(
|
|
290
|
+
self, start_frame: int, end_frame: int, overwrite: bool = False, **kwargs
|
|
291
|
+
) -> bool:
|
|
262
292
|
"""
|
|
263
293
|
Extract frames from the video within the specified frame range.
|
|
264
|
-
|
|
294
|
+
|
|
265
295
|
Parameters:
|
|
266
296
|
start_frame (int): The starting frame number (inclusive).
|
|
267
297
|
end_frame (int): The ending frame number (exclusive).
|
|
268
298
|
overwrite (bool): Whether to overwrite existing frames in the range.
|
|
269
|
-
|
|
299
|
+
|
|
270
300
|
Returns:
|
|
271
301
|
bool: True if frame extraction was successful, False otherwise.
|
|
272
|
-
|
|
302
|
+
|
|
273
303
|
Additional keyword arguments:
|
|
274
304
|
quality (int, optional): Quality setting for extracted frames.
|
|
275
305
|
ext (str, optional): File extension for extracted frames.
|
|
276
306
|
verbose (bool, optional): Whether to enable verbose output.
|
|
277
307
|
"""
|
|
278
|
-
quality = kwargs.get(
|
|
279
|
-
ext = kwargs.get(
|
|
280
|
-
verbose = kwargs.get(
|
|
308
|
+
quality = kwargs.get("quality", 2)
|
|
309
|
+
ext = kwargs.get("ext", "jpg")
|
|
310
|
+
verbose = kwargs.get("verbose", False)
|
|
281
311
|
|
|
282
312
|
# Log if unexpected kwargs are passed, beyond those used by the helper
|
|
283
|
-
expected_helper_kwargs = {
|
|
284
|
-
unexpected_kwargs = {
|
|
313
|
+
expected_helper_kwargs = {"quality", "ext", "verbose"}
|
|
314
|
+
unexpected_kwargs = {
|
|
315
|
+
k: v for k, v in kwargs.items() if k not in expected_helper_kwargs
|
|
316
|
+
}
|
|
285
317
|
if unexpected_kwargs:
|
|
286
|
-
logger.warning(
|
|
318
|
+
logger.warning(
|
|
319
|
+
f"Unexpected keyword arguments for extract_specific_frame_range, will be ignored by helper: {unexpected_kwargs}"
|
|
320
|
+
)
|
|
287
321
|
|
|
288
322
|
return _extract_frame_range_helper(
|
|
289
323
|
video=self,
|
|
@@ -292,7 +326,7 @@ class VideoFile(models.Model):
|
|
|
292
326
|
quality=quality,
|
|
293
327
|
overwrite=overwrite,
|
|
294
328
|
ext=ext,
|
|
295
|
-
verbose=verbose
|
|
329
|
+
verbose=verbose,
|
|
296
330
|
)
|
|
297
331
|
|
|
298
332
|
def delete_specific_frame_range(self, start_frame: int, end_frame: int) -> None:
|
|
@@ -300,9 +334,7 @@ class VideoFile(models.Model):
|
|
|
300
334
|
Deletes frame files for a specific range [start_frame, end_frame).
|
|
301
335
|
"""
|
|
302
336
|
_delete_frame_range_helper(
|
|
303
|
-
video=self,
|
|
304
|
-
start_frame=start_frame,
|
|
305
|
-
end_frame=end_frame
|
|
337
|
+
video=self, start_frame=start_frame, end_frame=end_frame
|
|
306
338
|
)
|
|
307
339
|
|
|
308
340
|
delete_with_file = _delete_with_file
|
|
@@ -320,8 +352,6 @@ class VideoFile(models.Model):
|
|
|
320
352
|
|
|
321
353
|
predict_video = _predict_video_pipeline
|
|
322
354
|
extract_text_from_frames = _extract_text_from_video_frames
|
|
323
|
-
|
|
324
|
-
|
|
325
355
|
|
|
326
356
|
@classmethod
|
|
327
357
|
def check_hash_exists(cls, video_hash: str) -> bool:
|
|
@@ -340,16 +370,15 @@ class VideoFile(models.Model):
|
|
|
340
370
|
Return True if a raw video file is associated with this instance.
|
|
341
371
|
"""
|
|
342
372
|
return bool(self.raw_file and self.raw_file.name)
|
|
343
|
-
|
|
344
373
|
|
|
345
374
|
@property
|
|
346
375
|
def active_file(self) -> FieldFile:
|
|
347
376
|
"""
|
|
348
377
|
Return the active video file, preferring the processed file if available.
|
|
349
|
-
|
|
378
|
+
|
|
350
379
|
Returns:
|
|
351
380
|
File: The processed file if present; otherwise, the raw file.
|
|
352
|
-
|
|
381
|
+
|
|
353
382
|
Raises:
|
|
354
383
|
ValueError: If neither a processed nor a raw file is available.
|
|
355
384
|
"""
|
|
@@ -361,17 +390,18 @@ class VideoFile(models.Model):
|
|
|
361
390
|
if isinstance(raw, FieldFile) and raw.name:
|
|
362
391
|
return raw
|
|
363
392
|
|
|
364
|
-
raise ValueError(
|
|
365
|
-
|
|
393
|
+
raise ValueError(
|
|
394
|
+
"No active file available. VideoFile has neither raw nor processed file."
|
|
395
|
+
)
|
|
366
396
|
|
|
367
397
|
@property
|
|
368
398
|
def active_file_path(self) -> Path:
|
|
369
399
|
"""
|
|
370
400
|
Return the filesystem path of the active video file.
|
|
371
|
-
|
|
401
|
+
|
|
372
402
|
Returns:
|
|
373
403
|
Path: The path to the processed file if available, otherwise the raw file.
|
|
374
|
-
|
|
404
|
+
|
|
375
405
|
Raises:
|
|
376
406
|
ValueError: If neither a processed nor raw file is present.
|
|
377
407
|
"""
|
|
@@ -381,15 +411,18 @@ class VideoFile(models.Model):
|
|
|
381
411
|
elif active is self.raw_file:
|
|
382
412
|
path = _get_raw_file_path(self)
|
|
383
413
|
else:
|
|
384
|
-
raise ValueError(
|
|
414
|
+
raise ValueError(
|
|
415
|
+
"No active file path available. VideoFile has neither raw nor processed file."
|
|
416
|
+
)
|
|
385
417
|
|
|
386
418
|
if path is None:
|
|
387
419
|
raise ValueError("Active file path could not be resolved.")
|
|
388
420
|
return path
|
|
389
421
|
|
|
390
|
-
|
|
391
422
|
@classmethod
|
|
392
|
-
def create_from_file(
|
|
423
|
+
def create_from_file(
|
|
424
|
+
cls, file_path: Union[str, Path], center_name: str, **kwargs
|
|
425
|
+
) -> Optional["VideoFile"]:
|
|
393
426
|
# Ensure file_path is a Path object
|
|
394
427
|
if isinstance(file_path, str):
|
|
395
428
|
file_path = Path(file_path)
|
|
@@ -398,7 +431,9 @@ class VideoFile(models.Model):
|
|
|
398
431
|
try:
|
|
399
432
|
center_name = os.environ["CENTER_NAME"]
|
|
400
433
|
except KeyError:
|
|
401
|
-
logger.error(
|
|
434
|
+
logger.error(
|
|
435
|
+
"Center name must be provided to create VideoFile from file. You can set CENTER_NAME in environment variables."
|
|
436
|
+
)
|
|
402
437
|
return None
|
|
403
438
|
return _create_from_file(cls, file_path, center_name=center_name, **kwargs)
|
|
404
439
|
|
|
@@ -406,10 +441,10 @@ class VideoFile(models.Model):
|
|
|
406
441
|
def create_from_file_initialized(
|
|
407
442
|
cls,
|
|
408
443
|
file_path: Union[str, Path],
|
|
409
|
-
center_name:str,
|
|
444
|
+
center_name: str,
|
|
410
445
|
processor_name: Optional[str] = None,
|
|
411
|
-
delete_source:bool = False,
|
|
412
|
-
save_video_file:bool = True,
|
|
446
|
+
delete_source: bool = False,
|
|
447
|
+
save_video_file: bool = True, # Add this line
|
|
413
448
|
):
|
|
414
449
|
"""
|
|
415
450
|
Creates a VideoFile instance from a given video file path.
|
|
@@ -427,16 +462,16 @@ class VideoFile(models.Model):
|
|
|
427
462
|
center_name=center_name,
|
|
428
463
|
processor_name=processor_name,
|
|
429
464
|
delete_source=delete_source,
|
|
430
|
-
save=save_video_file,
|
|
465
|
+
save=save_video_file, # Add this line
|
|
431
466
|
)
|
|
432
467
|
|
|
433
468
|
video_file = video_file.initialize()
|
|
434
469
|
return video_file
|
|
435
|
-
|
|
470
|
+
|
|
436
471
|
def delete(self, using=None, keep_parents=False) -> tuple[int, dict[str, int]]:
|
|
437
472
|
"""
|
|
438
473
|
Delete the VideoFile instance, including associated files and frames.
|
|
439
|
-
|
|
474
|
+
|
|
440
475
|
Overrides the default delete method to ensure proper cleanup of related resources.
|
|
441
476
|
"""
|
|
442
477
|
# Ensure frames are deleted before the main instance
|
|
@@ -449,17 +484,18 @@ class VideoFile(models.Model):
|
|
|
449
484
|
# Delete associated files if they exist
|
|
450
485
|
if active_path.exists():
|
|
451
486
|
active_path.unlink(missing_ok=True)
|
|
452
|
-
|
|
453
|
-
|
|
487
|
+
|
|
454
488
|
# Delete file storage
|
|
455
489
|
if self.raw_file and self.raw_file.storage.exists(self.raw_file.name):
|
|
456
490
|
self.raw_file.storage.delete(self.raw_file.name)
|
|
457
|
-
if self.processed_file and self.processed_file.storage.exists(
|
|
491
|
+
if self.processed_file and self.processed_file.storage.exists(
|
|
492
|
+
self.processed_file.name
|
|
493
|
+
):
|
|
458
494
|
self.processed_file.storage.delete(self.processed_file.name)
|
|
459
|
-
|
|
495
|
+
|
|
460
496
|
# Use proper database connection
|
|
461
497
|
if using is None:
|
|
462
|
-
using =
|
|
498
|
+
using = "default"
|
|
463
499
|
|
|
464
500
|
raw_file_path = self.get_raw_file_path()
|
|
465
501
|
if raw_file_path:
|
|
@@ -471,7 +507,7 @@ class VideoFile(models.Model):
|
|
|
471
507
|
logger.info(f"Removed processing lock: {lock_path}")
|
|
472
508
|
except Exception as e:
|
|
473
509
|
logger.warning(f"Could not remove processing lock {lock_path}: {e}")
|
|
474
|
-
|
|
510
|
+
|
|
475
511
|
try:
|
|
476
512
|
# Call parent delete with proper parameters
|
|
477
513
|
result = super().delete(using=using, keep_parents=keep_parents)
|
|
@@ -481,41 +517,58 @@ class VideoFile(models.Model):
|
|
|
481
517
|
logger.error(f"Error deleting VideoFile {self.uuid}: {e}")
|
|
482
518
|
raise
|
|
483
519
|
|
|
484
|
-
def validate_metadata_annotation(
|
|
520
|
+
def validate_metadata_annotation(
|
|
521
|
+
self, extracted_data_dict: Optional[dict] = None
|
|
522
|
+
) -> bool:
|
|
485
523
|
"""
|
|
486
524
|
Validate the metadata of the VideoFile instance.
|
|
487
|
-
|
|
525
|
+
|
|
488
526
|
Called after annotation in the frontend, this method deletes the associated active file, updates the sensitive meta data with the user annotated data.
|
|
489
527
|
It also ensures the video file is properly saved after the metadata update.
|
|
490
528
|
"""
|
|
529
|
+
from datetime import date as dt_date
|
|
530
|
+
|
|
491
531
|
from endoreg_db.models import SensitiveMeta
|
|
532
|
+
|
|
492
533
|
if not self.sensitive_meta:
|
|
493
|
-
|
|
494
|
-
|
|
534
|
+
# CRITICAL FIX: Use create_from_dict with default patient data
|
|
535
|
+
default_data = {
|
|
536
|
+
"patient_first_name": "Patient",
|
|
537
|
+
"patient_last_name": "Unknown",
|
|
538
|
+
"patient_dob": dt_date(1990, 1, 1),
|
|
539
|
+
"examination_date": dt_date.today(),
|
|
540
|
+
"center": self.center,
|
|
541
|
+
}
|
|
542
|
+
self.sensitive_meta = SensitiveMeta.create_from_dict(default_data)
|
|
543
|
+
|
|
495
544
|
# Delete the active file to ensure it is reprocessed with the new metadata
|
|
496
545
|
if self.active_file_path.exists():
|
|
497
546
|
self.active_file_path.unlink(missing_ok=True)
|
|
498
|
-
|
|
547
|
+
|
|
499
548
|
# Update sensitive metadata with user annotations
|
|
500
|
-
sensitive_meta = _update_text_metadata(
|
|
501
|
-
|
|
549
|
+
sensitive_meta = _update_text_metadata(
|
|
550
|
+
self, extracted_data_dict, overwrite=True
|
|
551
|
+
)
|
|
552
|
+
|
|
502
553
|
if sensitive_meta:
|
|
503
554
|
# Mark as processed after validation
|
|
504
555
|
self.get_or_create_state().mark_sensitive_meta_processed(save=True)
|
|
505
556
|
# Save the VideoFile instance to persist changes
|
|
506
557
|
self.save()
|
|
507
|
-
logger.info(
|
|
558
|
+
logger.info(
|
|
559
|
+
f"Metadata annotation validated and saved for video {self.uuid}."
|
|
560
|
+
)
|
|
508
561
|
return True
|
|
509
562
|
else:
|
|
510
|
-
logger.error(
|
|
563
|
+
logger.error(
|
|
564
|
+
f"Failed to validate metadata annotation for video {self.uuid}."
|
|
565
|
+
)
|
|
511
566
|
return False
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
567
|
+
|
|
515
568
|
def initialize(self):
|
|
516
569
|
"""
|
|
517
570
|
Initialize the VideoFile instance by updating metadata, setting up video specs, assigning frame directory, ensuring related state and sensitive metadata exist, saving the instance, and initializing frames.
|
|
518
|
-
|
|
571
|
+
|
|
519
572
|
Returns:
|
|
520
573
|
VideoFile: The initialized VideoFile instance.
|
|
521
574
|
"""
|
|
@@ -535,7 +588,6 @@ class VideoFile(models.Model):
|
|
|
535
588
|
# Initialize frames based on the video specs
|
|
536
589
|
self.initialize_frames()
|
|
537
590
|
|
|
538
|
-
|
|
539
591
|
return self
|
|
540
592
|
|
|
541
593
|
def __str__(self):
|
|
@@ -544,7 +596,9 @@ class VideoFile(models.Model):
|
|
|
544
596
|
"""
|
|
545
597
|
active_path = self.active_file_path
|
|
546
598
|
file_name = active_path.name if active_path else "No file"
|
|
547
|
-
state =
|
|
599
|
+
state = (
|
|
600
|
+
"Processed" if self.is_processed else ("Raw" if self.has_raw else "No File")
|
|
601
|
+
)
|
|
548
602
|
return f"VideoFile ({state}): {file_name} (UUID: {self.uuid})"
|
|
549
603
|
|
|
550
604
|
# --- Convenience state/meta helpers used in tests and admin workflows ---
|
|
@@ -573,7 +627,7 @@ class VideoFile(models.Model):
|
|
|
573
627
|
# Now call the original save method
|
|
574
628
|
"""
|
|
575
629
|
Saves the VideoFile instance to the database.
|
|
576
|
-
|
|
630
|
+
|
|
577
631
|
Overrides the default save method to persist changes to the VideoFile model.
|
|
578
632
|
"""
|
|
579
633
|
super().save(*args, **kwargs)
|
|
@@ -605,23 +659,71 @@ class VideoFile(models.Model):
|
|
|
605
659
|
def get_or_create_sensitive_meta(self) -> "SensitiveMeta":
|
|
606
660
|
"""
|
|
607
661
|
Retrieve the associated SensitiveMeta instance for this video, creating and assigning one if it does not exist.
|
|
608
|
-
|
|
662
|
+
|
|
663
|
+
**Two-Phase Patient Data Pattern:**
|
|
664
|
+
This method implements a two-phase approach to handle incomplete patient data:
|
|
665
|
+
|
|
666
|
+
**Phase 1: Initial Creation (with defaults)**
|
|
667
|
+
- Creates SensitiveMeta with default patient data to prevent hash calculation errors
|
|
668
|
+
- Default values: patient_first_name="Patient", patient_last_name="Unknown", patient_dob=1990-01-01
|
|
669
|
+
- Allows video import to proceed even without extracted patient data
|
|
670
|
+
- Temporary hash and pseudo-entities are created
|
|
671
|
+
|
|
672
|
+
**Phase 2: Update (with extracted data)**
|
|
673
|
+
- Real patient data is extracted later (e.g., from video OCR via lx_anonymizer)
|
|
674
|
+
- update_from_dict() is called with actual patient information
|
|
675
|
+
- Hash is recalculated automatically using real data
|
|
676
|
+
- Correct pseudo-entities are created/linked based on new hash
|
|
677
|
+
|
|
678
|
+
**Example workflow:**
|
|
679
|
+
```python
|
|
680
|
+
# Phase 1: Video creation
|
|
681
|
+
video = VideoFile.create_from_file_initialized(...)
|
|
682
|
+
video.initialize() # Calls this method
|
|
683
|
+
# → SensitiveMeta created with defaults
|
|
684
|
+
# → Hash: sha256("Patient Unknown 1990-01-01...")
|
|
685
|
+
|
|
686
|
+
# Phase 2: Frame cleaning extracts real data
|
|
687
|
+
extracted = {"patient_first_name": "Max", "patient_last_name": "Mustermann", ...}
|
|
688
|
+
video.sensitive_meta.update_from_dict(extracted)
|
|
689
|
+
# → Hash: sha256("Max Mustermann 1985-03-15...") (RECALCULATED)
|
|
690
|
+
```
|
|
691
|
+
|
|
609
692
|
Returns:
|
|
610
693
|
SensitiveMeta: The related SensitiveMeta instance.
|
|
694
|
+
|
|
695
|
+
See Also:
|
|
696
|
+
- sensitive_meta_logic.perform_save_logic() for hash calculation details
|
|
697
|
+
- sensitive_meta_logic.update_sensitive_meta_from_dict() for update mechanism
|
|
611
698
|
"""
|
|
699
|
+
from datetime import date as dt_date
|
|
700
|
+
|
|
612
701
|
from endoreg_db.models import SensitiveMeta
|
|
702
|
+
|
|
613
703
|
if self.sensitive_meta is None:
|
|
614
|
-
|
|
615
|
-
#
|
|
704
|
+
# Use create_from_dict with default patient data
|
|
705
|
+
# to prevent "First name is required to calculate patient hash" error
|
|
706
|
+
default_data = {
|
|
707
|
+
"patient_first_name": "Patient",
|
|
708
|
+
"patient_last_name": "Unknown",
|
|
709
|
+
"patient_dob": dt_date(1990, 1, 1),
|
|
710
|
+
"examination_date": dt_date.today(),
|
|
711
|
+
"center": self.center,
|
|
712
|
+
}
|
|
713
|
+
self.sensitive_meta = SensitiveMeta.create_from_dict(default_data)
|
|
714
|
+
self.save(update_fields=["sensitive_meta"])
|
|
715
|
+
# Do not mark state as processed here; it will be set after extraction/validation steps
|
|
616
716
|
return self.sensitive_meta
|
|
617
717
|
|
|
618
|
-
def get_outside_segments(
|
|
718
|
+
def get_outside_segments(
|
|
719
|
+
self, only_validated: bool = False
|
|
720
|
+
) -> models.QuerySet["LabelVideoSegment"]:
|
|
619
721
|
"""
|
|
620
722
|
Return all video segments labeled as "outside" for this video.
|
|
621
|
-
|
|
723
|
+
|
|
622
724
|
Parameters:
|
|
623
725
|
only_validated (bool): If True, only segments with a validated state are included.
|
|
624
|
-
|
|
726
|
+
|
|
625
727
|
Returns:
|
|
626
728
|
QuerySet: A queryset of LabelVideoSegment instances labeled as "outside". Returns an empty queryset if the label does not exist or an error occurs.
|
|
627
729
|
"""
|
|
@@ -638,43 +740,48 @@ class VideoFile(models.Model):
|
|
|
638
740
|
logger.warning("Outside label not found in the database.")
|
|
639
741
|
return self.label_video_segments.none()
|
|
640
742
|
except Exception as e:
|
|
641
|
-
logger.error(
|
|
743
|
+
logger.error(
|
|
744
|
+
"Error getting outside segments for video %s: %s",
|
|
745
|
+
self.uuid,
|
|
746
|
+
e,
|
|
747
|
+
exc_info=True,
|
|
748
|
+
)
|
|
642
749
|
return self.label_video_segments.none()
|
|
643
|
-
|
|
750
|
+
|
|
644
751
|
@classmethod
|
|
645
752
|
def get_all_videos(cls) -> models.QuerySet["VideoFile"]:
|
|
646
753
|
"""
|
|
647
754
|
Returns a queryset containing all VideoFile records.
|
|
648
|
-
|
|
755
|
+
|
|
649
756
|
This class method retrieves every VideoFile instance in the database without filtering.
|
|
650
757
|
"""
|
|
651
758
|
return cast(models.QuerySet["VideoFile"], cls.objects.all())
|
|
652
|
-
|
|
759
|
+
|
|
653
760
|
def count_unmodified_others(self) -> int:
|
|
654
761
|
"""
|
|
655
762
|
Count the number of other VideoFile instances that have not been modified since creation.
|
|
656
|
-
|
|
763
|
+
|
|
657
764
|
Returns:
|
|
658
765
|
int: The count of VideoFile records, excluding this instance, where the modification timestamp matches the creation timestamp.
|
|
659
766
|
"""
|
|
660
767
|
return (
|
|
661
|
-
VideoFile.objects
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
.
|
|
768
|
+
VideoFile.objects.filter(
|
|
769
|
+
date_modified=F("date_created")
|
|
770
|
+
) # compare the two fields in SQL
|
|
771
|
+
.exclude(pk=self.pk) # exclude this instance
|
|
772
|
+
.count() # run a fast COUNT(*) on the filtered set
|
|
665
773
|
)
|
|
666
774
|
|
|
667
|
-
|
|
668
775
|
def frame_number_to_s(self, frame_number: int) -> float:
|
|
669
776
|
"""
|
|
670
777
|
Convert a frame number to its corresponding time in seconds based on the video's frames per second (FPS).
|
|
671
|
-
|
|
778
|
+
|
|
672
779
|
Parameters:
|
|
673
780
|
frame_number (int): The frame number to convert.
|
|
674
|
-
|
|
781
|
+
|
|
675
782
|
Returns:
|
|
676
783
|
float: The time in seconds corresponding to the given frame number.
|
|
677
|
-
|
|
784
|
+
|
|
678
785
|
Raises:
|
|
679
786
|
ValueError: If the video's FPS is not set or is less than or equal to zero.
|
|
680
787
|
"""
|
|
@@ -682,18 +789,18 @@ class VideoFile(models.Model):
|
|
|
682
789
|
if fps is None or fps <= 0:
|
|
683
790
|
raise ValueError("FPS must be set and greater than zero.")
|
|
684
791
|
return frame_number / fps
|
|
685
|
-
|
|
792
|
+
|
|
686
793
|
def get_video_by_id(self, video_id: int) -> "VideoFile":
|
|
687
794
|
"""
|
|
688
795
|
Retrieve a VideoFile instance by its primary key (ID).
|
|
689
|
-
|
|
796
|
+
|
|
690
797
|
Parameters:
|
|
691
798
|
video_id (int): The primary key of the VideoFile to retrieve.
|
|
692
|
-
|
|
799
|
+
|
|
693
800
|
Returns:
|
|
694
801
|
VideoFile: The VideoFile instance with the specified ID.
|
|
695
|
-
|
|
802
|
+
|
|
696
803
|
Raises:
|
|
697
804
|
VideoFile.DoesNotExist: If no VideoFile with the given ID exists.
|
|
698
805
|
"""
|
|
699
|
-
return self.objects.get(pk=video_id)
|
|
806
|
+
return self.objects.get(pk=video_id)
|