endoreg-db 0.8.4.2__py3-none-any.whl → 0.8.4.4__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.

@@ -8,6 +8,7 @@ Changelog:
8
8
  October 14, 2025: Added file locking mechanism to prevent race conditions
9
9
  during concurrent video imports (matches PDF import pattern)
10
10
  """
11
+
11
12
  from datetime import date
12
13
  import logging
13
14
  import sys
@@ -35,50 +36,60 @@ MAX_LOCK_WAIT_SECONDS = 90 # New: wait up to 90s for a non-stale lock to clear
35
36
  logger = logging.getLogger(__name__)
36
37
 
37
38
 
38
- class VideoImportService():
39
+ class VideoImportService:
39
40
  """
40
41
  Service for importing and anonymizing video files.
41
42
  Uses a central video instance pattern for cleaner state management.
42
-
43
+
43
44
  Features (October 14, 2025):
44
45
  - File locking to prevent concurrent processing of the same video
45
46
  - Stale lock detection and reclamation (600s timeout)
46
47
  - Hash-based duplicate detection
47
48
  - Graceful fallback processing without lx_anonymizer
48
49
  """
49
-
50
+
50
51
  def __init__(self, project_root: Optional[Path] = None):
51
-
52
52
  # Set up project root path
53
53
  if project_root:
54
54
  self.project_root = Path(project_root)
55
55
  else:
56
56
  self.project_root = Path(__file__).parent.parent.parent.parent
57
-
57
+
58
58
  # Track processed files to prevent duplicates
59
- self.processed_files = set(str(Path(ANONYM_VIDEO_DIR) / file) for file in os.listdir(ANONYM_VIDEO_DIR))
60
-
59
+ try:
60
+ # Ensure anonym_video directory exists before listing files
61
+ anonym_video_dir = Path(ANONYM_VIDEO_DIR)
62
+ if anonym_video_dir.exists():
63
+ self.processed_files = set(str(anonym_video_dir / file) for file in os.listdir(ANONYM_VIDEO_DIR))
64
+ else:
65
+ logger.info(f"Creating anonym_videos directory: {anonym_video_dir}")
66
+ anonym_video_dir.mkdir(parents=True, exist_ok=True)
67
+ self.processed_files = set()
68
+ except Exception as e:
69
+ logger.warning(f"Failed to initialize processed files tracking: {e}")
70
+ self.processed_files = set()
71
+
61
72
  # Central video instance and processing context
62
73
  self.current_video: Optional[VideoFile] = None
63
74
  self.processing_context: Dict[str, Any] = {}
64
-
75
+
65
76
  self.delete_source = True
66
-
77
+
67
78
  self.logger = logging.getLogger(__name__)
68
-
69
- self.cleaner = None # This gets instantiated in the perform_frame_cleaning method
79
+
80
+ self.cleaner = None # This gets instantiated in the perform_frame_cleaning method
70
81
 
71
82
  def _require_current_video(self) -> VideoFile:
72
83
  """Return the current VideoFile or raise if it has not been initialized."""
73
84
  if self.current_video is None:
74
85
  raise RuntimeError("Current video instance is not set")
75
86
  return self.current_video
76
-
87
+
77
88
  @contextmanager
78
89
  def _file_lock(self, path: Path):
79
90
  """
80
91
  Create a file lock to prevent duplicate processing of the same video.
81
-
92
+
82
93
  This context manager creates a .lock file alongside the video file.
83
94
  If the lock file already exists, it checks if it's stale (older than
84
95
  STALE_LOCK_SECONDS) and reclaims it if necessary. If it's not stale,
@@ -102,24 +113,21 @@ class VideoImportService():
102
113
  except FileNotFoundError:
103
114
  # Race: lock removed between exists and stat; retry acquire in next loop
104
115
  age = None
105
-
116
+
106
117
  if age is not None and age > STALE_LOCK_SECONDS:
107
118
  try:
108
- logger.warning(
109
- "Stale lock detected for %s (age %.0fs). Reclaiming lock...",
110
- path, age
111
- )
119
+ logger.warning("Stale lock detected for %s (age %.0fs). Reclaiming lock...", path, age)
112
120
  lock_path.unlink()
113
121
  except Exception as e:
114
122
  logger.warning("Failed to remove stale lock %s: %s", lock_path, e)
115
123
  # Loop continues and retries acquire immediately
116
124
  continue
117
-
125
+
118
126
  # Not stale: wait until deadline, then give up gracefully
119
127
  if time.time() >= deadline:
120
128
  raise ValueError(f"File already being processed: {path}")
121
129
  time.sleep(1.0)
122
-
130
+
123
131
  os.write(fd, b"lock")
124
132
  os.close(fd)
125
133
  fd = None
@@ -132,11 +140,11 @@ class VideoImportService():
132
140
  lock_path.unlink()
133
141
  except OSError:
134
142
  pass
135
-
143
+
136
144
  def processed(self) -> bool:
137
145
  """Indicates if the current file has already been processed."""
138
- return getattr(self, '_processed', False)
139
-
146
+ return getattr(self, "_processed", False)
147
+
140
148
  def import_and_anonymize(
141
149
  self,
142
150
  file_path: Union[Path, str],
@@ -149,11 +157,13 @@ class VideoImportService():
149
157
  High-level helper that orchestrates the complete video import and anonymization process.
150
158
  Uses the central video instance pattern for improved state management.
151
159
  """
160
+ # DEFENSIVE: Initialize processing_context immediately to prevent KeyError crashes
161
+ self.processing_context = {"file_path": Path(file_path)}
162
+
152
163
  try:
153
164
  # Initialize processing context
154
- self._initialize_processing_context(file_path, center_name, processor_name,
155
- save_video, delete_source)
156
-
165
+ self._initialize_processing_context(file_path, center_name, processor_name, save_video, delete_source)
166
+
157
167
  # Validate and prepare file (may raise ValueError if another worker holds a non-stale lock)
158
168
  try:
159
169
  self._validate_and_prepare_file()
@@ -163,115 +173,119 @@ class VideoImportService():
163
173
  self.logger.info(f"Skipping {file_path}: {ve}")
164
174
  return None
165
175
  raise
166
-
176
+
167
177
  # Create or retrieve video instance
168
178
  self._create_or_retrieve_video_instance()
169
-
179
+
170
180
  # Create sensitive meta file, ensure raw is moved out of processing folder watched by file watcher.
171
181
  self._create_sensitive_file()
172
-
182
+
173
183
  # Setup processing environment
174
184
  self._setup_processing_environment()
175
-
185
+
176
186
  # Process frames and metadata
177
187
  self._process_frames_and_metadata()
178
-
188
+
179
189
  # Finalize processing
180
190
  self._finalize_processing()
181
-
191
+
182
192
  # Move files and cleanup
183
193
  self._cleanup_and_archive()
184
-
194
+
185
195
  return self.current_video
186
-
196
+
187
197
  except Exception as e:
188
- self.logger.error(f"Video import and anonymization failed for {file_path}: {e}")
198
+ # Safe file path access - handles cases where processing_context wasn't initialized
199
+ safe_file_path = getattr(self, "processing_context", {}).get("file_path", file_path)
200
+ # Debug: Log context state for troubleshooting
201
+ context_keys = list(getattr(self, "processing_context", {}).keys())
202
+ self.logger.debug(f"Context keys during error: {context_keys}")
203
+ self.logger.error(f"Video import and anonymization failed for {safe_file_path}: {e}")
189
204
  self._cleanup_on_error()
190
205
  raise
191
206
  finally:
192
207
  self._cleanup_processing_context()
193
208
 
194
- def _initialize_processing_context(self, file_path: Union[Path, str], center_name: str,
195
- processor_name: str, save_video: bool, delete_source: bool):
209
+ def _initialize_processing_context(self, file_path: Union[Path, str], center_name: str, processor_name: str, save_video: bool, delete_source: bool):
196
210
  """Initialize the processing context for the current video import."""
197
211
  self.processing_context = {
198
- 'file_path': Path(file_path),
199
- 'center_name': center_name,
200
- 'processor_name': processor_name,
201
- 'save_video': save_video,
202
- 'delete_source': delete_source,
203
- 'processing_started': False,
204
- 'frames_extracted': False,
205
- 'anonymization_completed': False,
206
- 'error_reason': None
212
+ "file_path": Path(file_path),
213
+ "center_name": center_name,
214
+ "processor_name": processor_name,
215
+ "save_video": save_video,
216
+ "delete_source": delete_source,
217
+ "processing_started": False,
218
+ "frames_extracted": False,
219
+ "anonymization_completed": False,
220
+ "error_reason": None,
207
221
  }
208
-
222
+
209
223
  self.logger.info(f"Initialized processing context for: {file_path}")
210
224
 
211
225
  def _validate_and_prepare_file(self):
212
226
  """
213
227
  Validate the video file and prepare for processing.
214
-
228
+
215
229
  Uses file locking to prevent concurrent processing of the same video file.
216
230
  This prevents race conditions where multiple workers might try to process
217
231
  the same video simultaneously.
218
-
232
+
219
233
  The lock is acquired here and held for the entire import process.
220
234
  See _file_lock() for lock reclamation logic.
221
235
  """
222
- file_path = self.processing_context['file_path']
223
-
236
+ file_path = self.processing_context["file_path"]
237
+
224
238
  # Acquire file lock to prevent concurrent processing
225
239
  # Lock will be held until finally block in import_and_anonymize()
226
240
  try:
227
- self.processing_context['_lock_context'] = self._file_lock(file_path)
228
- self.processing_context['_lock_context'].__enter__()
241
+ self.processing_context["_lock_context"] = self._file_lock(file_path)
242
+ self.processing_context["_lock_context"].__enter__()
229
243
  except Exception:
230
244
  self._cleanup_processing_context()
231
245
  raise
232
-
246
+
233
247
  self.logger.info("Acquired file lock for: %s", file_path)
234
-
248
+
235
249
  # Check if already processed (memory-based check)
236
250
  if str(file_path) in self.processed_files:
237
251
  self.logger.info("File %s already processed, skipping", file_path)
238
252
  self._processed = True
239
253
  raise ValueError(f"File already processed: {file_path}")
240
-
254
+
241
255
  # Check file exists
242
256
  if not file_path.exists():
243
257
  raise FileNotFoundError(f"Video file not found: {file_path}")
244
-
258
+
245
259
  self.logger.info("File validation completed for: %s", file_path)
246
260
 
247
261
  def _create_or_retrieve_video_instance(self):
248
262
  """Create or retrieve the VideoFile instance and move to final storage."""
249
-
263
+
250
264
  self.logger.info("Creating VideoFile instance...")
251
-
265
+
252
266
  self.current_video = VideoFile.create_from_file_initialized(
253
- file_path=self.processing_context['file_path'],
254
- center_name=self.processing_context['center_name'],
255
- processor_name=self.processing_context['processor_name'],
256
- delete_source=self.processing_context['delete_source'],
257
- save_video_file=self.processing_context['save_video'],
267
+ file_path=self.processing_context["file_path"],
268
+ center_name=self.processing_context["center_name"],
269
+ processor_name=self.processing_context["processor_name"],
270
+ delete_source=self.processing_context["delete_source"],
271
+ save_video_file=self.processing_context["save_video"],
258
272
  )
259
-
273
+
260
274
  if not self.current_video:
261
275
  raise RuntimeError("Failed to create VideoFile instance")
262
-
276
+
263
277
  # Immediately move to final storage locations
264
278
  self._move_to_final_storage()
265
-
279
+
266
280
  self.logger.info("Created VideoFile with UUID: %s", self.current_video.uuid)
267
-
281
+
268
282
  # Get and mark processing state
269
283
  state = VideoFile.get_or_create_state(self.current_video)
270
284
  if not state:
271
285
  raise RuntimeError("Failed to create VideoFile state")
272
-
286
+
273
287
  state.mark_processing_started(save=True)
274
- self.processing_context['processing_started'] = True
288
+ self.processing_context["processing_started"] = True
275
289
 
276
290
  def _move_to_final_storage(self):
277
291
  """
@@ -348,7 +362,6 @@ class VideoImportService():
348
362
  self.processing_context["raw_video_path"] = stored_raw_path
349
363
  self.processing_context["video_filename"] = stored_raw_path.name
350
364
 
351
-
352
365
  def _setup_processing_environment(self):
353
366
  """Setup the processing environment without file movement."""
354
367
  video = self._require_current_video()
@@ -358,38 +371,38 @@ class VideoImportService():
358
371
 
359
372
  # Initialize frame objects in database
360
373
  video.initialize_frames()
361
-
374
+
362
375
  # Extract frames BEFORE processing to prevent pipeline 1 conflicts
363
376
  self.logger.info("Pre-extracting frames to avoid pipeline conflicts...")
364
377
  try:
365
378
  frames_extracted = video.extract_frames(overwrite=False)
366
379
  if frames_extracted:
367
- self.processing_context['frames_extracted'] = True
380
+ self.processing_context["frames_extracted"] = True
368
381
  self.logger.info("Frame extraction completed successfully")
369
-
382
+
370
383
  # CRITICAL: Immediately save the frames_extracted state to database
371
384
  # to prevent refresh_from_db() in pipeline 1 from overriding it
372
385
  state = video.get_or_create_state()
373
386
  if not state.frames_extracted:
374
387
  state.frames_extracted = True
375
- state.save(update_fields=['frames_extracted'])
388
+ state.save(update_fields=["frames_extracted"])
376
389
  self.logger.info("Persisted frames_extracted=True to database")
377
390
  else:
378
391
  self.logger.warning("Frame extraction failed, but continuing...")
379
- self.processing_context['frames_extracted'] = False
392
+ self.processing_context["frames_extracted"] = False
380
393
  except Exception as e:
381
394
  self.logger.warning(f"Frame extraction failed during setup: {e}, but continuing...")
382
- self.processing_context['frames_extracted'] = False
383
-
395
+ self.processing_context["frames_extracted"] = False
396
+
384
397
  # Ensure default patient data
385
398
  self._ensure_default_patient_data(video_instance=video)
386
-
399
+
387
400
  self.logger.info("Processing environment setup completed")
388
401
 
389
402
  def _process_frames_and_metadata(self):
390
403
  """Process frames and extract metadata with anonymization."""
391
404
  # Check frame cleaning availability
392
- frame_cleaning_available, frame_cleaner = self._ensure_frame_cleaning_available()
405
+ frame_cleaning_available, frame_cleaner = self._ensure_frame_cleaning_available()
393
406
  video = self._require_current_video()
394
407
 
395
408
  raw_file_field = video.raw_file
@@ -402,25 +415,25 @@ class VideoImportService():
402
415
 
403
416
  try:
404
417
  self.logger.info("Starting frame-level anonymization with processor ROI masking...")
405
-
418
+
406
419
  # Get processor ROI information
407
420
  endoscope_data_roi_nested, endoscope_image_roi = self._get_processor_roi_info()
408
-
421
+
409
422
  # Perform frame cleaning with timeout to prevent blocking
410
423
  from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
411
-
424
+
412
425
  with ThreadPoolExecutor(max_workers=1) as executor:
413
426
  future = executor.submit(self._perform_frame_cleaning, endoscope_data_roi_nested, endoscope_image_roi)
414
427
  try:
415
428
  # Increased timeout to better accommodate ffmpeg + OCR
416
- future.result(timeout=300)
417
- self.processing_context['anonymization_completed'] = True
429
+ future.result(timeout=50000)
430
+ self.processing_context["anonymization_completed"] = True
418
431
  self.logger.info("Frame cleaning completed successfully within timeout")
419
432
  except FutureTimeoutError:
420
433
  self.logger.warning("Frame cleaning timed out; entering grace period check for cleaned output")
421
434
  # Grace period: detect if cleaned file appears shortly after timeout
422
- raw_video_path = self.processing_context.get('raw_video_path')
423
- video_filename = self.processing_context.get('video_filename', Path(raw_video_path).name if raw_video_path else "video.mp4")
435
+ raw_video_path = self.processing_context.get("raw_video_path")
436
+ video_filename = self.processing_context.get("video_filename", Path(raw_video_path).name if raw_video_path else "video.mp4")
424
437
  grace_seconds = 60
425
438
  expected_cleaned_path: Optional[Path] = None
426
439
  processed_field = video.processed_file
@@ -433,8 +446,8 @@ class VideoImportService():
433
446
  if expected_cleaned_path is not None:
434
447
  for _ in range(grace_seconds):
435
448
  if expected_cleaned_path.exists():
436
- self.processing_context['cleaned_video_path'] = expected_cleaned_path
437
- self.processing_context['anonymization_completed'] = True
449
+ self.processing_context["cleaned_video_path"] = expected_cleaned_path
450
+ self.processing_context["anonymization_completed"] = True
438
451
  self.logger.info("Detected cleaned video during grace period: %s", expected_cleaned_path)
439
452
  found = True
440
453
  break
@@ -452,11 +465,10 @@ class VideoImportService():
452
465
  except Exception as fallback_error:
453
466
  self.logger.error("Fallback anonymization also failed: %s", fallback_error)
454
467
  # If even fallback fails, mark as not anonymized but continue import
455
- self.processing_context['anonymization_completed'] = False
456
- self.processing_context['error_reason'] = f"Frame cleaning failed: {e}, Fallback failed: {fallback_error}"
468
+ self.processing_context["anonymization_completed"] = False
469
+ self.processing_context["error_reason"] = f"Frame cleaning failed: {e}, Fallback failed: {fallback_error}"
457
470
 
458
471
  def _save_anonymized_video(self):
459
-
460
472
  original_raw_file_path_to_delete = None
461
473
  original_raw_frame_dir_to_delete = None
462
474
  video = self._require_current_video()
@@ -467,9 +479,7 @@ class VideoImportService():
467
479
 
468
480
  new_processed_hash = get_video_hash(anonymized_video_path)
469
481
  if video.__class__.objects.filter(processed_video_hash=new_processed_hash).exclude(pk=video.pk).exists():
470
- raise ValueError(
471
- f"Processed video hash {new_processed_hash} already exists for another video (Video: {video.uuid})."
472
- )
482
+ raise ValueError(f"Processed video hash {new_processed_hash} already exists for another video (Video: {video.uuid}).")
473
483
 
474
484
  video.processed_video_hash = new_processed_hash
475
485
  video.processed_file.name = anonymized_video_path.relative_to(STORAGE_DIR).as_posix()
@@ -488,11 +498,11 @@ class VideoImportService():
488
498
 
489
499
  update_fields.extend(["raw_file", "video_hash"])
490
500
 
491
- transaction.on_commit(lambda: _cleanup_raw_assets(
492
- video_uuid=video.uuid,
493
- raw_file_path=original_raw_file_path_to_delete,
494
- raw_frame_dir=original_raw_frame_dir_to_delete
495
- ))
501
+ transaction.on_commit(
502
+ lambda: _cleanup_raw_assets(
503
+ video_uuid=video.uuid, raw_file_path=original_raw_file_path_to_delete, raw_frame_dir=original_raw_frame_dir_to_delete
504
+ )
505
+ )
496
506
 
497
507
  video.save(update_fields=update_fields)
498
508
  video.state.mark_anonymized(save=True)
@@ -510,20 +520,20 @@ class VideoImportService():
510
520
  if video is None:
511
521
  self.logger.warning("No VideoFile instance available for fallback anonymization")
512
522
 
513
-
514
523
  # Strategy 2: Simple copy (no processing, just copy raw to processed)
515
524
  self.logger.info("Using simple copy fallback (raw video will be used as 'processed' video)")
516
- self.processing_context['anonymization_completed'] = False
517
- self.processing_context['use_raw_as_processed'] = True
525
+ self.processing_context["anonymization_completed"] = False
526
+ self.processing_context["use_raw_as_processed"] = True
518
527
  self.logger.warning("Fallback: Video will be imported without anonymization (raw copy used)")
519
528
  except Exception as e:
520
529
  self.logger.error(f"Error during fallback anonymization: {e}", exc_info=True)
521
- self.processing_context['anonymization_completed'] = False
522
- self.processing_context['error_reason'] = str(e)
530
+ self.processing_context["anonymization_completed"] = False
531
+ self.processing_context["error_reason"] = str(e)
532
+
523
533
  def _finalize_processing(self):
524
534
  """Finalize processing and update video state."""
525
535
  self.logger.info("Updating video processing state...")
526
-
536
+
527
537
  with transaction.atomic():
528
538
  video = self._require_current_video()
529
539
  try:
@@ -532,36 +542,33 @@ class VideoImportService():
532
542
  self.logger.warning("Could not refresh VideoFile %s from DB: %s", video.uuid, refresh_error)
533
543
 
534
544
  state = video.get_or_create_state()
535
-
545
+
536
546
  # Only mark frames as extracted if they were successfully extracted
537
- if self.processing_context.get('frames_extracted', False):
547
+ if self.processing_context.get("frames_extracted", False):
538
548
  state.frames_extracted = True
539
549
  self.logger.info("Marked frames as extracted in state")
540
550
  else:
541
551
  self.logger.warning("Frames were not extracted, not updating state")
542
-
552
+
543
553
  # Always mark these as true (metadata extraction attempts were made)
544
554
  state.frames_initialized = True
545
555
  state.video_meta_extracted = True
546
556
  state.text_meta_extracted = True
547
-
557
+
548
558
  # ✅ FIX: Only mark as processed if anonymization actually completed
549
- anonymization_completed = self.processing_context.get('anonymization_completed', False)
559
+ anonymization_completed = self.processing_context.get("anonymization_completed", False)
550
560
  if anonymization_completed:
551
561
  state.mark_sensitive_meta_processed(save=False)
552
562
  self.logger.info("Anonymization completed - marking sensitive meta as processed")
553
563
  else:
554
- self.logger.warning(
555
- "Anonymization NOT completed - NOT marking as processed. "
556
- f"Reason: {self.processing_context.get('error_reason', 'Unknown')}"
557
- )
564
+ self.logger.warning(f"Anonymization NOT completed - NOT marking as processed. Reason: {self.processing_context.get('error_reason', 'Unknown')}")
558
565
  # Explicitly mark as NOT processed
559
566
  state.sensitive_meta_processed = False
560
-
567
+
561
568
  # Save all state changes
562
569
  state.save()
563
570
  self.logger.info("Video processing state updated")
564
-
571
+
565
572
  # Signal completion
566
573
  self._signal_completion()
567
574
 
@@ -575,12 +582,12 @@ class VideoImportService():
575
582
  video = self._require_current_video()
576
583
 
577
584
  processed_video_path = None
578
- if 'cleaned_video_path' in self.processing_context:
579
- processed_video_path = self.processing_context['cleaned_video_path']
585
+ if "cleaned_video_path" in self.processing_context:
586
+ processed_video_path = self.processing_context["cleaned_video_path"]
580
587
  else:
581
- raw_video_path = self.processing_context.get('raw_video_path')
588
+ raw_video_path = self.processing_context.get("raw_video_path")
582
589
  if raw_video_path and Path(raw_video_path).exists():
583
- video_filename = self.processing_context.get('video_filename', Path(raw_video_path).name)
590
+ video_filename = self.processing_context.get("video_filename", Path(raw_video_path).name)
584
591
  processed_filename = f"processed_{video_filename}"
585
592
  processed_video_path = Path(raw_video_path).parent / processed_filename
586
593
  try:
@@ -609,13 +616,13 @@ class VideoImportService():
609
616
  except Exception as exc:
610
617
  self.logger.error("Failed to update processed_file path: %s", exc)
611
618
  video.processed_file.name = f"anonym_videos/{anonym_video_filename}"
612
- video.save(update_fields=['processed_file'])
619
+ video.save(update_fields=["processed_file"])
613
620
  self.logger.info(
614
621
  "Updated processed_file path using fallback: %s",
615
622
  f"anonym_videos/{anonym_video_filename}",
616
623
  )
617
624
 
618
- self.processing_context['anonymization_completed'] = True
625
+ self.processing_context["anonymization_completed"] = True
619
626
  else:
620
627
  self.logger.warning("Processed video file not found after move: %s", anonym_target_path)
621
628
  except Exception as exc:
@@ -625,13 +632,14 @@ class VideoImportService():
625
632
 
626
633
  try:
627
634
  from endoreg_db.utils.paths import RAW_FRAME_DIR
635
+
628
636
  shutil.rmtree(RAW_FRAME_DIR, ignore_errors=True)
629
637
  self.logger.debug("Cleaned up temporary frames directory: %s", RAW_FRAME_DIR)
630
638
  except Exception as exc:
631
639
  self.logger.warning("Failed to remove directory %s: %s", RAW_FRAME_DIR, exc)
632
640
 
633
- source_path = self.processing_context['file_path']
634
- if self.processing_context['delete_source'] and Path(source_path).exists():
641
+ source_path = self.processing_context["file_path"]
642
+ if self.processing_context["delete_source"] and Path(source_path).exists():
635
643
  try:
636
644
  os.remove(source_path)
637
645
  self.logger.info("Removed remaining source file: %s", source_path)
@@ -642,25 +650,25 @@ class VideoImportService():
642
650
  self.logger.warning("No processed_file found after cleanup - video will be unprocessed")
643
651
  try:
644
652
  video.anonymize(delete_original_raw=self.delete_source)
645
- video.save(update_fields=['processed_file'])
653
+ video.save(update_fields=["processed_file"])
646
654
  self.logger.info("Late-stage anonymization succeeded")
647
655
  except Exception as e:
648
656
  self.logger.error("Late-stage anonymization failed: %s", e)
649
- self.processing_context['anonymization_completed'] = False
657
+ self.processing_context["anonymization_completed"] = False
650
658
 
651
659
  self.logger.info("Cleanup and archiving completed")
652
660
 
653
- self.processed_files.add(str(self.processing_context['file_path']))
661
+ self.processed_files.add(str(self.processing_context["file_path"]))
654
662
 
655
663
  with transaction.atomic():
656
664
  video.refresh_from_db()
657
- if hasattr(video, 'state') and self.processing_context.get('anonymization_completed'):
665
+ if hasattr(video, "state") and self.processing_context.get("anonymization_completed"):
658
666
  video.state.mark_sensitive_meta_processed(save=True)
659
667
 
660
668
  self.logger.info("Import and anonymization completed for VideoFile UUID: %s", video.uuid)
661
669
  self.logger.info("Raw video stored in: /data/videos")
662
670
  self.logger.info("Processed video stored in: /data/anonym_videos")
663
-
671
+
664
672
  def _create_sensitive_file(
665
673
  self,
666
674
  video_instance: VideoFile | None = None,
@@ -719,11 +727,10 @@ class VideoImportService():
719
727
  "Updated video.raw_file using fallback method: videos/sensitive/%s",
720
728
  target_file_path.name,
721
729
  )
722
-
730
+
723
731
  self.processing_context["raw_video_path"] = target_file_path
724
732
  self.processing_context["video_filename"] = target_file_path.name
725
733
 
726
-
727
734
  self.logger.info("Created sensitive file for %s at %s", video.uuid, target_file_path)
728
735
  return target_file_path
729
736
 
@@ -740,7 +747,7 @@ class VideoImportService():
740
747
  if processor:
741
748
  assert isinstance(processor, EndoscopyProcessor), "Processor is not of type EndoscopyProcessor"
742
749
  endoscope_image_roi = processor.get_roi_endoscope_image()
743
- endoscope_data_roi_nested = processor.get_rois()
750
+ endoscope_data_roi_nested = processor.get_sensitive_rois()
744
751
  self.logger.info("Retrieved processor ROI information: endoscope_image_roi=%s", endoscope_image_roi)
745
752
  else:
746
753
  self.logger.warning(
@@ -810,12 +817,10 @@ class VideoImportService():
810
817
  except Exception as exc:
811
818
  self.logger.error("Failed to update SensitiveMeta for video %s: %s", video.uuid, exc)
812
819
 
813
-
814
-
815
820
  def _ensure_frame_cleaning_available(self):
816
821
  """
817
822
  Ensure frame cleaning modules are available by adding lx-anonymizer to path.
818
-
823
+
819
824
  Returns:
820
825
  Tuple of (availability_flag, FrameCleaner_class, ReportReader_class)
821
826
  """
@@ -825,13 +830,11 @@ class VideoImportService():
825
830
 
826
831
  if FrameCleaner:
827
832
  return True, FrameCleaner()
828
-
833
+
829
834
  except Exception as e:
830
835
  self.logger.warning(f"Frame cleaning not available: {e} Please install or update lx_anonymizer.")
831
-
832
- return False, None
833
836
 
834
-
837
+ return False, None
835
838
 
836
839
  def _perform_frame_cleaning(self, endoscope_data_roi_nested, endoscope_image_roi):
837
840
  """Perform frame cleaning and anonymization."""
@@ -842,8 +845,8 @@ class VideoImportService():
842
845
  raise RuntimeError("Frame cleaning not available")
843
846
 
844
847
  # Prepare parameters for frame cleaning
845
- raw_video_path = self.processing_context.get('raw_video_path')
846
-
848
+ raw_video_path = self.processing_context.get("raw_video_path")
849
+
847
850
  if not raw_video_path or not Path(raw_video_path).exists():
848
851
  try:
849
852
  self.current_video = self._require_current_video()
@@ -851,35 +854,30 @@ class VideoImportService():
851
854
  except Exception:
852
855
  raise RuntimeError(f"Raw video path not found: {raw_video_path}")
853
856
 
854
-
855
857
  # Create temporary output path for cleaned video
856
- video_filename = self.processing_context.get('video_filename', Path(raw_video_path).name if raw_video_path else "video.mp4")
858
+ video_filename = self.processing_context.get("video_filename", Path(raw_video_path).name)
857
859
  cleaned_filename = f"cleaned_{video_filename}"
858
860
  if not raw_video_path:
859
861
  raise RuntimeError("raw_video_path is None after fallback, cannot construct cleaned_video_path")
860
862
  cleaned_video_path = Path(raw_video_path).parent / cleaned_filename
861
-
862
-
863
-
864
-
863
+
865
864
  # Clean video with ROI masking (heavy I/O operation)
866
865
  actual_cleaned_path, extracted_metadata = frame_cleaner.clean_video(
867
866
  video_path=Path(raw_video_path),
868
867
  endoscope_image_roi=endoscope_image_roi,
869
868
  endoscope_data_roi_nested=endoscope_data_roi_nested,
870
869
  output_path=cleaned_video_path,
871
- technique="mask_overlay"
870
+ technique="mask_overlay",
872
871
  )
873
-
874
-
872
+
875
873
  # Store cleaned video path for later use in _cleanup_and_archive
876
- self.processing_context['cleaned_video_path'] = actual_cleaned_path
877
- self.processing_context['extracted_metadata'] = extracted_metadata
878
-
874
+ self.processing_context["cleaned_video_path"] = actual_cleaned_path
875
+ self.processing_context["extracted_metadata"] = extracted_metadata
876
+
879
877
  # Update sensitive metadata with extracted information
880
878
  self._update_sensitive_metadata(extracted_metadata)
881
879
  self.logger.info(f"Extracted metadata from frame cleaning: {extracted_metadata}")
882
-
880
+
883
881
  self.logger.info(f"Frame cleaning with ROI masking completed: {actual_cleaned_path}")
884
882
  self.logger.info("Cleaned video will be moved to anonym_videos during cleanup")
885
883
 
@@ -897,13 +895,13 @@ class VideoImportService():
897
895
 
898
896
  sm = sensitive_meta
899
897
  updated_fields = []
900
-
898
+
901
899
  try:
902
900
  sm.update_from_dict(extracted_metadata)
903
901
  updated_fields = list(extracted_metadata.keys())
904
902
  except KeyError as e:
905
903
  self.logger.warning(f"Failed to update SensitiveMeta field {e}")
906
-
904
+
907
905
  if updated_fields:
908
906
  sm.save(update_fields=updated_fields)
909
907
  self.logger.info("Updated SensitiveMeta fields for video %s: %s", video.uuid, updated_fields)
@@ -927,22 +925,18 @@ class VideoImportService():
927
925
  except (ValueError, OSError):
928
926
  raw_exists = False
929
927
 
930
- video_processing_complete = (
931
- video.sensitive_meta is not None and
932
- video.video_meta is not None and
933
- raw_exists
934
- )
928
+ video_processing_complete = video.sensitive_meta is not None and video.video_meta is not None and raw_exists
935
929
 
936
930
  if video_processing_complete:
937
931
  self.logger.info("Video %s processing completed successfully - ready for validation", video.uuid)
938
932
 
939
933
  # Update completion flags if they exist
940
934
  completion_fields = []
941
- for field_name in ['import_completed', 'processing_complete', 'ready_for_validation']:
935
+ for field_name in ["import_completed", "processing_complete", "ready_for_validation"]:
942
936
  if hasattr(video, field_name):
943
937
  setattr(video, field_name, True)
944
938
  completion_fields.append(field_name)
945
-
939
+
946
940
  if completion_fields:
947
941
  video.save(update_fields=completion_fields)
948
942
  self.logger.info("Updated completion flags: %s", completion_fields)
@@ -951,15 +945,15 @@ class VideoImportService():
951
945
  "Video %s processing incomplete - missing required components",
952
946
  video.uuid,
953
947
  )
954
-
948
+
955
949
  except Exception as e:
956
950
  self.logger.warning(f"Failed to signal completion status: {e}")
957
951
 
958
952
  def _cleanup_on_error(self):
959
953
  """Cleanup processing context on error."""
960
- if self.current_video and hasattr(self.current_video, 'state'):
954
+ if self.current_video and hasattr(self.current_video, "state"):
961
955
  try:
962
- if self.processing_context.get('processing_started'):
956
+ if self.processing_context.get("processing_started"):
963
957
  self.current_video.state.frames_extracted = False
964
958
  self.current_video.state.frames_initialized = False
965
959
  self.current_video.state.video_meta_extracted = False
@@ -971,29 +965,32 @@ class VideoImportService():
971
965
  def _cleanup_processing_context(self):
972
966
  """
973
967
  Cleanup processing context and release file lock.
974
-
968
+
975
969
  This method is always called in the finally block of import_and_anonymize()
976
970
  to ensure the file lock is released even if processing fails.
977
971
  """
972
+ # DEFENSIVE: Ensure processing_context exists before accessing it
973
+ if not hasattr(self, "processing_context"):
974
+ self.processing_context = {}
975
+
978
976
  try:
979
977
  # Release file lock if it was acquired
980
- lock_context = self.processing_context.get('_lock_context')
978
+ lock_context = self.processing_context.get("_lock_context")
981
979
  if lock_context is not None:
982
980
  try:
983
981
  lock_context.__exit__(None, None, None)
984
982
  self.logger.info("Released file lock")
985
983
  except Exception as e:
986
984
  self.logger.warning(f"Error releasing file lock: {e}")
987
-
985
+
988
986
  # Remove file from processed set if processing failed
989
- file_path = self.processing_context.get('file_path')
990
- if file_path and not self.processing_context.get('anonymization_completed'):
987
+ file_path = self.processing_context.get("file_path")
988
+ if file_path and not self.processing_context.get("anonymization_completed"):
991
989
  file_path_str = str(file_path)
992
990
  if file_path_str in self.processed_files:
993
991
  self.processed_files.remove(file_path_str)
994
992
  self.logger.info(f"Removed {file_path_str} from processed files (failed processing)")
995
-
996
-
993
+
997
994
  except Exception as e:
998
995
  self.logger.warning(f"Error during context cleanup: {e}")
999
996
  finally:
@@ -1001,6 +998,7 @@ class VideoImportService():
1001
998
  self.current_video = None
1002
999
  self.processing_context = {}
1003
1000
 
1001
+
1004
1002
  # Convenience function for callers/tests that expect a module-level import_and_anonymize
1005
1003
  def import_and_anonymize(
1006
1004
  file_path,
@@ -1019,4 +1017,4 @@ def import_and_anonymize(
1019
1017
  processor_name=processor_name,
1020
1018
  save_video=save_video,
1021
1019
  delete_source=delete_source,
1022
- )
1020
+ )