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.
- 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 +207 -39
- endoreg_db/management/commands/validate_ai_models.py +124 -0
- endoreg_db/models/medical/hardware/endoscopy_processor.py +10 -1
- endoreg_db/models/metadata/model_meta_logic.py +116 -53
- endoreg_db/services/video_import.py +172 -179
- endoreg_db/utils/setup_config.py +177 -0
- {endoreg_db-0.8.4.2.dist-info → endoreg_db-0.8.4.3.dist-info}/METADATA +1 -1
- {endoreg_db-0.8.4.2.dist-info → endoreg_db-0.8.4.3.dist-info}/RECORD +13 -10
- {endoreg_db-0.8.4.2.dist-info → endoreg_db-0.8.4.3.dist-info}/WHEEL +0 -0
- {endoreg_db-0.8.4.2.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
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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[
|
|
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[
|
|
228
|
-
self.processing_context[
|
|
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[
|
|
254
|
-
center_name=self.processing_context[
|
|
255
|
-
processor_name=self.processing_context[
|
|
256
|
-
delete_source=self.processing_context[
|
|
257
|
-
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"],
|
|
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[
|
|
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[
|
|
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=[
|
|
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[
|
|
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[
|
|
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
|
|
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=
|
|
417
|
-
self.processing_context[
|
|
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(
|
|
423
|
-
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")
|
|
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[
|
|
437
|
-
self.processing_context[
|
|
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[
|
|
456
|
-
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}"
|
|
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(
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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[
|
|
517
|
-
self.processing_context[
|
|
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[
|
|
522
|
-
self.processing_context[
|
|
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(
|
|
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(
|
|
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
|
|
579
|
-
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"]
|
|
580
584
|
else:
|
|
581
|
-
raw_video_path = self.processing_context.get(
|
|
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(
|
|
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=[
|
|
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[
|
|
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[
|
|
634
|
-
if self.processing_context[
|
|
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=[
|
|
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[
|
|
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[
|
|
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,
|
|
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.
|
|
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(
|
|
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(
|
|
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[
|
|
877
|
-
self.processing_context[
|
|
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 [
|
|
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,
|
|
949
|
+
if self.current_video and hasattr(self.current_video, "state"):
|
|
961
950
|
try:
|
|
962
|
-
if self.processing_context.get(
|
|
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(
|
|
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(
|
|
990
|
-
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"):
|
|
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
|
+
)
|