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.

@@ -1,97 +1,100 @@
1
1
  """Concrete model for video files, handling both raw and processed states."""
2
2
 
3
3
  import logging
4
- from pathlib import Path
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
- _extract_frames,
33
- _initialize_frames,
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
- _create_frame_object,
42
- _bulk_create_frames,
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 _extract_frame_range as _extract_frame_range_helper
46
- from .video_file_frames._manage_frame_range import _delete_frame_range as _delete_frame_range_helper
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 .video_file_ai import (
58
- _predict_video_pipeline,
59
- _extract_text_from_video_frames,
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
- EndoscopyProcessor,
84
+ VideoImportMeta,
79
85
  VideoMeta,
80
- PatientExamination,
81
- Patient,
82
86
  VideoState,
83
- ModelMeta,
84
- VideoImportMeta,
85
- FFMpegMeta,
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(max_length=255, unique=True, help_text="Hash of the raw video file.")
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, unique=True, null=True, blank=True, help_text="Hash of the processed video file, unique if not null."
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", on_delete=models.SET_NULL, null=True, blank=True, related_name="video_file"
131
- ) # type: ignore
132
- center = models.ForeignKey("Center", on_delete=models.PROTECT) # type: ignore
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
- ) # type: ignore
149
+ ) # type: ignore
136
150
  video_meta = models.OneToOneField(
137
- "VideoMeta", on_delete=models.SET_NULL, null=True, blank=True, related_name="video_file"
138
- ) # type: ignore
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
- ) # type: ignore
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
- ) # type: ignore
170
+ ) # type: ignore
153
171
  ai_model_meta = models.ForeignKey(
154
172
  "ModelMeta", on_delete=models.SET_NULL, blank=True, null=True
155
- ) # type: ignore
173
+ ) # type: ignore
156
174
  state = models.OneToOneField(
157
- "VideoState", on_delete=models.SET_NULL, null=True, blank=True, related_name="video_file"
158
- ) # type: ignore
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
- ) # type: ignore
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(max_length=512, blank=True, help_text="Path to frames extracted from the raw video.")
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(default=dict, blank=True, help_text="AI prediction sequences based on raw frames.")
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(self, start_frame: int, end_frame: int, overwrite: bool = False, **kwargs) -> bool:
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('quality', 2)
279
- ext = kwargs.get('ext', "jpg")
280
- verbose = kwargs.get('verbose', False)
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 = {'quality', 'ext', 'verbose'}
284
- unexpected_kwargs = {k: v for k, v in kwargs.items() if k not in expected_helper_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(f"Unexpected keyword arguments for extract_specific_frame_range, will be ignored by helper: {unexpected_kwargs}")
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("No active file available. VideoFile has neither raw nor processed file.")
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("No active file path available. VideoFile has neither raw nor processed file.")
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(cls, file_path: Union[str, Path], center_name: str, **kwargs) -> Optional["VideoFile"]:
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("Center name must be provided to create VideoFile from file. You can set CENTER_NAME in environment variables.")
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, # Add this line
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, # Add this line
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(self.processed_file.name):
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 = 'default'
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(self, extracted_data_dict: Optional[dict] = None) -> bool:
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
- self.sensitive_meta = SensitiveMeta.objects.create(center=self.center)
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(self, extracted_data_dict, overwrite=True)
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(f"Metadata annotation validated and saved for video {self.uuid}.")
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(f"Failed to validate metadata annotation for video {self.uuid}.")
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 = "Processed" if self.is_processed else ("Raw" if self.has_raw else "No File")
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
- self.sensitive_meta = SensitiveMeta.objects.create(center = self.center)
614
- # Do not mark processed here; it will be set after extraction/validation steps
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(self, only_validated: bool = False) -> models.QuerySet["LabelVideoSegment"]:
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("Error getting outside segments for video %s: %s", self.uuid, e, exc_info=True)
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
- .filter(date_modified=F('date_created')) # compare the two fields in SQL
662
- .exclude(pk=self.pk) # exclude this instance
663
- .count() # run a fast COUNT(*) on the filtered set
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)