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