endoreg-db 0.8.4.2__py3-none-any.whl → 0.8.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of endoreg-db might be problematic. Click here for more details.

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