endoreg-db 0.8.5.0__py3-none-any.whl → 0.8.5.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/models/media/video/video_file.py +255 -147
- endoreg_db/models/metadata/sensitive_meta_logic.py +292 -56
- endoreg_db/services/video_import.py +312 -102
- endoreg_db/views/media/video_media.py +1 -1
- {endoreg_db-0.8.5.0.dist-info → endoreg_db-0.8.5.2.dist-info}/METADATA +1 -1
- {endoreg_db-0.8.5.0.dist-info → endoreg_db-0.8.5.2.dist-info}/RECORD +8 -8
- {endoreg_db-0.8.5.0.dist-info → endoreg_db-0.8.5.2.dist-info}/WHEEL +0 -0
- {endoreg_db-0.8.5.0.dist-info → endoreg_db-0.8.5.2.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,16 +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
|
-
|
|
487
|
+
|
|
453
488
|
# Delete file storage
|
|
454
489
|
if self.raw_file and self.raw_file.storage.exists(self.raw_file.name):
|
|
455
490
|
self.raw_file.storage.delete(self.raw_file.name)
|
|
456
|
-
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
|
+
):
|
|
457
494
|
self.processed_file.storage.delete(self.processed_file.name)
|
|
458
|
-
|
|
495
|
+
|
|
459
496
|
# Use proper database connection
|
|
460
497
|
if using is None:
|
|
461
|
-
using =
|
|
498
|
+
using = "default"
|
|
462
499
|
|
|
463
500
|
raw_file_path = self.get_raw_file_path()
|
|
464
501
|
if raw_file_path:
|
|
@@ -470,7 +507,7 @@ class VideoFile(models.Model):
|
|
|
470
507
|
logger.info(f"Removed processing lock: {lock_path}")
|
|
471
508
|
except Exception as e:
|
|
472
509
|
logger.warning(f"Could not remove processing lock {lock_path}: {e}")
|
|
473
|
-
|
|
510
|
+
|
|
474
511
|
try:
|
|
475
512
|
# Call parent delete with proper parameters
|
|
476
513
|
result = super().delete(using=using, keep_parents=keep_parents)
|
|
@@ -480,41 +517,58 @@ class VideoFile(models.Model):
|
|
|
480
517
|
logger.error(f"Error deleting VideoFile {self.uuid}: {e}")
|
|
481
518
|
raise
|
|
482
519
|
|
|
483
|
-
def validate_metadata_annotation(
|
|
520
|
+
def validate_metadata_annotation(
|
|
521
|
+
self, extracted_data_dict: Optional[dict] = None
|
|
522
|
+
) -> bool:
|
|
484
523
|
"""
|
|
485
524
|
Validate the metadata of the VideoFile instance.
|
|
486
|
-
|
|
525
|
+
|
|
487
526
|
Called after annotation in the frontend, this method deletes the associated active file, updates the sensitive meta data with the user annotated data.
|
|
488
527
|
It also ensures the video file is properly saved after the metadata update.
|
|
489
528
|
"""
|
|
529
|
+
from datetime import date as dt_date
|
|
530
|
+
|
|
490
531
|
from endoreg_db.models import SensitiveMeta
|
|
532
|
+
|
|
491
533
|
if not self.sensitive_meta:
|
|
492
|
-
|
|
493
|
-
|
|
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
|
+
|
|
494
544
|
# Delete the active file to ensure it is reprocessed with the new metadata
|
|
495
545
|
if self.active_file_path.exists():
|
|
496
546
|
self.active_file_path.unlink(missing_ok=True)
|
|
497
|
-
|
|
547
|
+
|
|
498
548
|
# Update sensitive metadata with user annotations
|
|
499
|
-
sensitive_meta = _update_text_metadata(
|
|
500
|
-
|
|
549
|
+
sensitive_meta = _update_text_metadata(
|
|
550
|
+
self, extracted_data_dict, overwrite=True
|
|
551
|
+
)
|
|
552
|
+
|
|
501
553
|
if sensitive_meta:
|
|
502
554
|
# Mark as processed after validation
|
|
503
555
|
self.get_or_create_state().mark_sensitive_meta_processed(save=True)
|
|
504
556
|
# Save the VideoFile instance to persist changes
|
|
505
557
|
self.save()
|
|
506
|
-
logger.info(
|
|
558
|
+
logger.info(
|
|
559
|
+
f"Metadata annotation validated and saved for video {self.uuid}."
|
|
560
|
+
)
|
|
507
561
|
return True
|
|
508
562
|
else:
|
|
509
|
-
logger.error(
|
|
563
|
+
logger.error(
|
|
564
|
+
f"Failed to validate metadata annotation for video {self.uuid}."
|
|
565
|
+
)
|
|
510
566
|
return False
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
567
|
+
|
|
514
568
|
def initialize(self):
|
|
515
569
|
"""
|
|
516
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.
|
|
517
|
-
|
|
571
|
+
|
|
518
572
|
Returns:
|
|
519
573
|
VideoFile: The initialized VideoFile instance.
|
|
520
574
|
"""
|
|
@@ -534,7 +588,6 @@ class VideoFile(models.Model):
|
|
|
534
588
|
# Initialize frames based on the video specs
|
|
535
589
|
self.initialize_frames()
|
|
536
590
|
|
|
537
|
-
|
|
538
591
|
return self
|
|
539
592
|
|
|
540
593
|
def __str__(self):
|
|
@@ -543,7 +596,9 @@ class VideoFile(models.Model):
|
|
|
543
596
|
"""
|
|
544
597
|
active_path = self.active_file_path
|
|
545
598
|
file_name = active_path.name if active_path else "No file"
|
|
546
|
-
state =
|
|
599
|
+
state = (
|
|
600
|
+
"Processed" if self.is_processed else ("Raw" if self.has_raw else "No File")
|
|
601
|
+
)
|
|
547
602
|
return f"VideoFile ({state}): {file_name} (UUID: {self.uuid})"
|
|
548
603
|
|
|
549
604
|
# --- Convenience state/meta helpers used in tests and admin workflows ---
|
|
@@ -572,7 +627,7 @@ class VideoFile(models.Model):
|
|
|
572
627
|
# Now call the original save method
|
|
573
628
|
"""
|
|
574
629
|
Saves the VideoFile instance to the database.
|
|
575
|
-
|
|
630
|
+
|
|
576
631
|
Overrides the default save method to persist changes to the VideoFile model.
|
|
577
632
|
"""
|
|
578
633
|
super().save(*args, **kwargs)
|
|
@@ -604,23 +659,71 @@ class VideoFile(models.Model):
|
|
|
604
659
|
def get_or_create_sensitive_meta(self) -> "SensitiveMeta":
|
|
605
660
|
"""
|
|
606
661
|
Retrieve the associated SensitiveMeta instance for this video, creating and assigning one if it does not exist.
|
|
607
|
-
|
|
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
|
+
|
|
608
692
|
Returns:
|
|
609
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
|
|
610
698
|
"""
|
|
699
|
+
from datetime import date as dt_date
|
|
700
|
+
|
|
611
701
|
from endoreg_db.models import SensitiveMeta
|
|
702
|
+
|
|
612
703
|
if self.sensitive_meta is None:
|
|
613
|
-
|
|
614
|
-
#
|
|
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
|
|
615
716
|
return self.sensitive_meta
|
|
616
717
|
|
|
617
|
-
def get_outside_segments(
|
|
718
|
+
def get_outside_segments(
|
|
719
|
+
self, only_validated: bool = False
|
|
720
|
+
) -> models.QuerySet["LabelVideoSegment"]:
|
|
618
721
|
"""
|
|
619
722
|
Return all video segments labeled as "outside" for this video.
|
|
620
|
-
|
|
723
|
+
|
|
621
724
|
Parameters:
|
|
622
725
|
only_validated (bool): If True, only segments with a validated state are included.
|
|
623
|
-
|
|
726
|
+
|
|
624
727
|
Returns:
|
|
625
728
|
QuerySet: A queryset of LabelVideoSegment instances labeled as "outside". Returns an empty queryset if the label does not exist or an error occurs.
|
|
626
729
|
"""
|
|
@@ -637,43 +740,48 @@ class VideoFile(models.Model):
|
|
|
637
740
|
logger.warning("Outside label not found in the database.")
|
|
638
741
|
return self.label_video_segments.none()
|
|
639
742
|
except Exception as e:
|
|
640
|
-
logger.error(
|
|
743
|
+
logger.error(
|
|
744
|
+
"Error getting outside segments for video %s: %s",
|
|
745
|
+
self.uuid,
|
|
746
|
+
e,
|
|
747
|
+
exc_info=True,
|
|
748
|
+
)
|
|
641
749
|
return self.label_video_segments.none()
|
|
642
|
-
|
|
750
|
+
|
|
643
751
|
@classmethod
|
|
644
752
|
def get_all_videos(cls) -> models.QuerySet["VideoFile"]:
|
|
645
753
|
"""
|
|
646
754
|
Returns a queryset containing all VideoFile records.
|
|
647
|
-
|
|
755
|
+
|
|
648
756
|
This class method retrieves every VideoFile instance in the database without filtering.
|
|
649
757
|
"""
|
|
650
758
|
return cast(models.QuerySet["VideoFile"], cls.objects.all())
|
|
651
|
-
|
|
759
|
+
|
|
652
760
|
def count_unmodified_others(self) -> int:
|
|
653
761
|
"""
|
|
654
762
|
Count the number of other VideoFile instances that have not been modified since creation.
|
|
655
|
-
|
|
763
|
+
|
|
656
764
|
Returns:
|
|
657
765
|
int: The count of VideoFile records, excluding this instance, where the modification timestamp matches the creation timestamp.
|
|
658
766
|
"""
|
|
659
767
|
return (
|
|
660
|
-
VideoFile.objects
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
.
|
|
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
|
|
664
773
|
)
|
|
665
774
|
|
|
666
|
-
|
|
667
775
|
def frame_number_to_s(self, frame_number: int) -> float:
|
|
668
776
|
"""
|
|
669
777
|
Convert a frame number to its corresponding time in seconds based on the video's frames per second (FPS).
|
|
670
|
-
|
|
778
|
+
|
|
671
779
|
Parameters:
|
|
672
780
|
frame_number (int): The frame number to convert.
|
|
673
|
-
|
|
781
|
+
|
|
674
782
|
Returns:
|
|
675
783
|
float: The time in seconds corresponding to the given frame number.
|
|
676
|
-
|
|
784
|
+
|
|
677
785
|
Raises:
|
|
678
786
|
ValueError: If the video's FPS is not set or is less than or equal to zero.
|
|
679
787
|
"""
|
|
@@ -681,18 +789,18 @@ class VideoFile(models.Model):
|
|
|
681
789
|
if fps is None or fps <= 0:
|
|
682
790
|
raise ValueError("FPS must be set and greater than zero.")
|
|
683
791
|
return frame_number / fps
|
|
684
|
-
|
|
792
|
+
|
|
685
793
|
def get_video_by_id(self, video_id: int) -> "VideoFile":
|
|
686
794
|
"""
|
|
687
795
|
Retrieve a VideoFile instance by its primary key (ID).
|
|
688
|
-
|
|
796
|
+
|
|
689
797
|
Parameters:
|
|
690
798
|
video_id (int): The primary key of the VideoFile to retrieve.
|
|
691
|
-
|
|
799
|
+
|
|
692
800
|
Returns:
|
|
693
801
|
VideoFile: The VideoFile instance with the specified ID.
|
|
694
|
-
|
|
802
|
+
|
|
695
803
|
Raises:
|
|
696
804
|
VideoFile.DoesNotExist: If no VideoFile with the given ID exists.
|
|
697
805
|
"""
|
|
698
|
-
return self.objects.get(pk=video_id)
|
|
806
|
+
return self.objects.get(pk=video_id)
|