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