endoreg-db 0.8.3.7__py3-none-any.whl → 0.8.6.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.
- endoreg_db/data/ai_model_meta/default_multilabel_classification.yaml +23 -1
- endoreg_db/data/setup_config.yaml +38 -0
- endoreg_db/management/commands/create_model_meta_from_huggingface.py +19 -5
- endoreg_db/management/commands/load_ai_model_data.py +18 -15
- endoreg_db/management/commands/setup_endoreg_db.py +218 -33
- endoreg_db/models/media/pdf/raw_pdf.py +241 -97
- endoreg_db/models/media/video/pipe_1.py +30 -33
- endoreg_db/models/media/video/video_file.py +300 -187
- endoreg_db/models/medical/hardware/endoscopy_processor.py +10 -1
- endoreg_db/models/metadata/model_meta_logic.py +63 -43
- endoreg_db/models/metadata/sensitive_meta_logic.py +251 -25
- endoreg_db/serializers/__init__.py +26 -55
- endoreg_db/serializers/misc/__init__.py +1 -1
- endoreg_db/serializers/misc/file_overview.py +65 -35
- endoreg_db/serializers/misc/{vop_patient_data.py → sensitive_patient_data.py} +1 -1
- endoreg_db/serializers/video_examination.py +198 -0
- endoreg_db/services/lookup_service.py +228 -58
- endoreg_db/services/lookup_store.py +174 -30
- endoreg_db/services/pdf_import.py +585 -282
- endoreg_db/services/video_import.py +485 -242
- endoreg_db/urls/__init__.py +36 -23
- endoreg_db/urls/label_video_segments.py +2 -0
- endoreg_db/urls/media.py +3 -2
- endoreg_db/utils/setup_config.py +177 -0
- endoreg_db/views/__init__.py +5 -3
- endoreg_db/views/media/pdf_media.py +3 -1
- endoreg_db/views/media/video_media.py +1 -1
- endoreg_db/views/media/video_segments.py +187 -259
- endoreg_db/views/pdf/__init__.py +5 -8
- endoreg_db/views/pdf/pdf_stream.py +187 -0
- endoreg_db/views/pdf/reimport.py +110 -94
- endoreg_db/views/requirement/lookup.py +171 -287
- endoreg_db/views/video/__init__.py +0 -2
- endoreg_db/views/video/video_examination_viewset.py +202 -289
- {endoreg_db-0.8.3.7.dist-info → endoreg_db-0.8.6.3.dist-info}/METADATA +1 -2
- {endoreg_db-0.8.3.7.dist-info → endoreg_db-0.8.6.3.dist-info}/RECORD +38 -37
- endoreg_db/views/pdf/pdf_media.py +0 -239
- endoreg_db/views/pdf/pdf_stream_views.py +0 -127
- endoreg_db/views/video/video_media.py +0 -158
- {endoreg_db-0.8.3.7.dist-info → endoreg_db-0.8.6.3.dist-info}/WHEEL +0 -0
- {endoreg_db-0.8.3.7.dist-info → endoreg_db-0.8.6.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -8,74 +8,96 @@ 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
12
|
import logging
|
|
13
|
-
import sys
|
|
14
13
|
import os
|
|
14
|
+
import random
|
|
15
15
|
import shutil
|
|
16
|
+
import sys
|
|
16
17
|
import time
|
|
17
18
|
from contextlib import contextmanager
|
|
19
|
+
from datetime import date
|
|
18
20
|
from pathlib import Path
|
|
19
|
-
from typing import
|
|
21
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
22
|
+
|
|
20
23
|
from django.db import transaction
|
|
24
|
+
from django.db.models.fields.files import FieldFile
|
|
25
|
+
from lx_anonymizer import FrameCleaner
|
|
21
26
|
from moviepy import video
|
|
22
|
-
|
|
23
|
-
from endoreg_db.
|
|
24
|
-
import random
|
|
25
|
-
from endoreg_db.utils.hashs import get_video_hash
|
|
27
|
+
|
|
28
|
+
from endoreg_db.models import EndoscopyProcessor, SensitiveMeta, VideoFile
|
|
26
29
|
from endoreg_db.models.media.video.video_file_anonymize import _cleanup_raw_assets
|
|
27
|
-
from
|
|
28
|
-
from endoreg_db.
|
|
30
|
+
from endoreg_db.utils.hashs import get_video_hash
|
|
31
|
+
from endoreg_db.utils.paths import ANONYM_VIDEO_DIR, STORAGE_DIR, VIDEO_DIR
|
|
29
32
|
|
|
30
33
|
# File lock configuration (matches PDF import)
|
|
31
34
|
STALE_LOCK_SECONDS = 6000 # 100 minutes - reclaim locks older than this
|
|
32
|
-
MAX_LOCK_WAIT_SECONDS =
|
|
35
|
+
MAX_LOCK_WAIT_SECONDS = (
|
|
36
|
+
90 # New: wait up to 90s for a non-stale lock to clear before skipping
|
|
37
|
+
)
|
|
33
38
|
|
|
34
39
|
logger = logging.getLogger(__name__)
|
|
35
40
|
|
|
36
41
|
|
|
37
|
-
class VideoImportService
|
|
42
|
+
class VideoImportService:
|
|
38
43
|
"""
|
|
39
44
|
Service for importing and anonymizing video files.
|
|
40
45
|
Uses a central video instance pattern for cleaner state management.
|
|
41
|
-
|
|
46
|
+
|
|
42
47
|
Features (October 14, 2025):
|
|
43
48
|
- File locking to prevent concurrent processing of the same video
|
|
44
49
|
- Stale lock detection and reclamation (600s timeout)
|
|
45
50
|
- Hash-based duplicate detection
|
|
46
51
|
- Graceful fallback processing without lx_anonymizer
|
|
47
52
|
"""
|
|
48
|
-
|
|
53
|
+
|
|
49
54
|
def __init__(self, project_root: Optional[Path] = None):
|
|
50
|
-
|
|
51
55
|
# Set up project root path
|
|
52
56
|
if project_root:
|
|
53
57
|
self.project_root = Path(project_root)
|
|
54
58
|
else:
|
|
55
59
|
self.project_root = Path(__file__).parent.parent.parent.parent
|
|
56
|
-
|
|
60
|
+
|
|
57
61
|
# Track processed files to prevent duplicates
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
try:
|
|
63
|
+
# Ensure anonym_video directory exists before listing files
|
|
64
|
+
anonym_video_dir = Path(ANONYM_VIDEO_DIR)
|
|
65
|
+
if anonym_video_dir.exists():
|
|
66
|
+
self.processed_files = set(
|
|
67
|
+
str(anonym_video_dir / file)
|
|
68
|
+
for file in os.listdir(ANONYM_VIDEO_DIR)
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
logger.info(f"Creating anonym_videos directory: {anonym_video_dir}")
|
|
72
|
+
anonym_video_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
self.processed_files = set()
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.warning(f"Failed to initialize processed files tracking: {e}")
|
|
76
|
+
self.processed_files = set()
|
|
77
|
+
|
|
60
78
|
# Central video instance and processing context
|
|
61
79
|
self.current_video: Optional[VideoFile] = None
|
|
62
80
|
self.processing_context: Dict[str, Any] = {}
|
|
63
|
-
|
|
81
|
+
|
|
64
82
|
self.delete_source = True
|
|
65
|
-
|
|
83
|
+
|
|
66
84
|
self.logger = logging.getLogger(__name__)
|
|
67
85
|
|
|
86
|
+
self.cleaner = (
|
|
87
|
+
None # This gets instantiated in the perform_frame_cleaning method
|
|
88
|
+
)
|
|
89
|
+
|
|
68
90
|
def _require_current_video(self) -> VideoFile:
|
|
69
91
|
"""Return the current VideoFile or raise if it has not been initialized."""
|
|
70
92
|
if self.current_video is None:
|
|
71
93
|
raise RuntimeError("Current video instance is not set")
|
|
72
94
|
return self.current_video
|
|
73
|
-
|
|
95
|
+
|
|
74
96
|
@contextmanager
|
|
75
97
|
def _file_lock(self, path: Path):
|
|
76
98
|
"""
|
|
77
99
|
Create a file lock to prevent duplicate processing of the same video.
|
|
78
|
-
|
|
100
|
+
|
|
79
101
|
This context manager creates a .lock file alongside the video file.
|
|
80
102
|
If the lock file already exists, it checks if it's stale (older than
|
|
81
103
|
STALE_LOCK_SECONDS) and reclaims it if necessary. If it's not stale,
|
|
@@ -99,24 +121,27 @@ class VideoImportService():
|
|
|
99
121
|
except FileNotFoundError:
|
|
100
122
|
# Race: lock removed between exists and stat; retry acquire in next loop
|
|
101
123
|
age = None
|
|
102
|
-
|
|
124
|
+
|
|
103
125
|
if age is not None and age > STALE_LOCK_SECONDS:
|
|
104
126
|
try:
|
|
105
127
|
logger.warning(
|
|
106
128
|
"Stale lock detected for %s (age %.0fs). Reclaiming lock...",
|
|
107
|
-
path,
|
|
129
|
+
path,
|
|
130
|
+
age,
|
|
108
131
|
)
|
|
109
132
|
lock_path.unlink()
|
|
110
133
|
except Exception as e:
|
|
111
|
-
logger.warning(
|
|
134
|
+
logger.warning(
|
|
135
|
+
"Failed to remove stale lock %s: %s", lock_path, e
|
|
136
|
+
)
|
|
112
137
|
# Loop continues and retries acquire immediately
|
|
113
138
|
continue
|
|
114
|
-
|
|
139
|
+
|
|
115
140
|
# Not stale: wait until deadline, then give up gracefully
|
|
116
141
|
if time.time() >= deadline:
|
|
117
142
|
raise ValueError(f"File already being processed: {path}")
|
|
118
143
|
time.sleep(1.0)
|
|
119
|
-
|
|
144
|
+
|
|
120
145
|
os.write(fd, b"lock")
|
|
121
146
|
os.close(fd)
|
|
122
147
|
fd = None
|
|
@@ -129,11 +154,11 @@ class VideoImportService():
|
|
|
129
154
|
lock_path.unlink()
|
|
130
155
|
except OSError:
|
|
131
156
|
pass
|
|
132
|
-
|
|
157
|
+
|
|
133
158
|
def processed(self) -> bool:
|
|
134
159
|
"""Indicates if the current file has already been processed."""
|
|
135
|
-
return getattr(self,
|
|
136
|
-
|
|
160
|
+
return getattr(self, "_processed", False)
|
|
161
|
+
|
|
137
162
|
def import_and_anonymize(
|
|
138
163
|
self,
|
|
139
164
|
file_path: Union[Path, str],
|
|
@@ -146,11 +171,15 @@ class VideoImportService():
|
|
|
146
171
|
High-level helper that orchestrates the complete video import and anonymization process.
|
|
147
172
|
Uses the central video instance pattern for improved state management.
|
|
148
173
|
"""
|
|
174
|
+
# DEFENSIVE: Initialize processing_context immediately to prevent KeyError crashes
|
|
175
|
+
self.processing_context = {"file_path": Path(file_path)}
|
|
176
|
+
|
|
149
177
|
try:
|
|
150
178
|
# Initialize processing context
|
|
151
|
-
self._initialize_processing_context(
|
|
152
|
-
|
|
153
|
-
|
|
179
|
+
self._initialize_processing_context(
|
|
180
|
+
file_path, center_name, processor_name, save_video, delete_source
|
|
181
|
+
)
|
|
182
|
+
|
|
154
183
|
# Validate and prepare file (may raise ValueError if another worker holds a non-stale lock)
|
|
155
184
|
try:
|
|
156
185
|
self._validate_and_prepare_file()
|
|
@@ -160,115 +189,130 @@ class VideoImportService():
|
|
|
160
189
|
self.logger.info(f"Skipping {file_path}: {ve}")
|
|
161
190
|
return None
|
|
162
191
|
raise
|
|
163
|
-
|
|
192
|
+
|
|
164
193
|
# Create or retrieve video instance
|
|
165
194
|
self._create_or_retrieve_video_instance()
|
|
166
|
-
|
|
195
|
+
|
|
167
196
|
# Create sensitive meta file, ensure raw is moved out of processing folder watched by file watcher.
|
|
168
197
|
self._create_sensitive_file()
|
|
169
|
-
|
|
198
|
+
|
|
170
199
|
# Setup processing environment
|
|
171
200
|
self._setup_processing_environment()
|
|
172
|
-
|
|
201
|
+
|
|
173
202
|
# Process frames and metadata
|
|
174
203
|
self._process_frames_and_metadata()
|
|
175
|
-
|
|
204
|
+
|
|
176
205
|
# Finalize processing
|
|
177
206
|
self._finalize_processing()
|
|
178
|
-
|
|
207
|
+
|
|
179
208
|
# Move files and cleanup
|
|
180
209
|
self._cleanup_and_archive()
|
|
181
|
-
|
|
210
|
+
|
|
182
211
|
return self.current_video
|
|
183
|
-
|
|
212
|
+
|
|
184
213
|
except Exception as e:
|
|
185
|
-
|
|
214
|
+
# Safe file path access - handles cases where processing_context wasn't initialized
|
|
215
|
+
safe_file_path = getattr(self, "processing_context", {}).get(
|
|
216
|
+
"file_path", file_path
|
|
217
|
+
)
|
|
218
|
+
# Debug: Log context state for troubleshooting
|
|
219
|
+
context_keys = list(getattr(self, "processing_context", {}).keys())
|
|
220
|
+
self.logger.debug(f"Context keys during error: {context_keys}")
|
|
221
|
+
self.logger.error(
|
|
222
|
+
f"Video import and anonymization failed for {safe_file_path}: {e}"
|
|
223
|
+
)
|
|
186
224
|
self._cleanup_on_error()
|
|
187
225
|
raise
|
|
188
226
|
finally:
|
|
189
227
|
self._cleanup_processing_context()
|
|
190
228
|
|
|
191
|
-
def _initialize_processing_context(
|
|
192
|
-
|
|
229
|
+
def _initialize_processing_context(
|
|
230
|
+
self,
|
|
231
|
+
file_path: Union[Path, str],
|
|
232
|
+
center_name: str,
|
|
233
|
+
processor_name: str,
|
|
234
|
+
save_video: bool,
|
|
235
|
+
delete_source: bool,
|
|
236
|
+
):
|
|
193
237
|
"""Initialize the processing context for the current video import."""
|
|
194
238
|
self.processing_context = {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
239
|
+
"file_path": Path(file_path),
|
|
240
|
+
"center_name": center_name,
|
|
241
|
+
"processor_name": processor_name,
|
|
242
|
+
"save_video": save_video,
|
|
243
|
+
"delete_source": delete_source,
|
|
244
|
+
"processing_started": False,
|
|
245
|
+
"frames_extracted": False,
|
|
246
|
+
"anonymization_completed": False,
|
|
247
|
+
"error_reason": None,
|
|
204
248
|
}
|
|
205
|
-
|
|
249
|
+
|
|
206
250
|
self.logger.info(f"Initialized processing context for: {file_path}")
|
|
207
251
|
|
|
208
252
|
def _validate_and_prepare_file(self):
|
|
209
253
|
"""
|
|
210
254
|
Validate the video file and prepare for processing.
|
|
211
|
-
|
|
255
|
+
|
|
212
256
|
Uses file locking to prevent concurrent processing of the same video file.
|
|
213
257
|
This prevents race conditions where multiple workers might try to process
|
|
214
258
|
the same video simultaneously.
|
|
215
|
-
|
|
259
|
+
|
|
216
260
|
The lock is acquired here and held for the entire import process.
|
|
217
261
|
See _file_lock() for lock reclamation logic.
|
|
218
262
|
"""
|
|
219
|
-
file_path = self.processing_context[
|
|
220
|
-
|
|
263
|
+
file_path = self.processing_context["file_path"]
|
|
264
|
+
|
|
221
265
|
# Acquire file lock to prevent concurrent processing
|
|
222
266
|
# Lock will be held until finally block in import_and_anonymize()
|
|
223
267
|
try:
|
|
224
|
-
self.processing_context[
|
|
225
|
-
self.processing_context[
|
|
268
|
+
self.processing_context["_lock_context"] = self._file_lock(file_path)
|
|
269
|
+
self.processing_context["_lock_context"].__enter__()
|
|
226
270
|
except Exception:
|
|
227
271
|
self._cleanup_processing_context()
|
|
228
272
|
raise
|
|
229
|
-
|
|
273
|
+
|
|
230
274
|
self.logger.info("Acquired file lock for: %s", file_path)
|
|
231
|
-
|
|
275
|
+
|
|
232
276
|
# Check if already processed (memory-based check)
|
|
233
277
|
if str(file_path) in self.processed_files:
|
|
234
278
|
self.logger.info("File %s already processed, skipping", file_path)
|
|
235
279
|
self._processed = True
|
|
236
280
|
raise ValueError(f"File already processed: {file_path}")
|
|
237
|
-
|
|
281
|
+
|
|
238
282
|
# Check file exists
|
|
239
283
|
if not file_path.exists():
|
|
240
284
|
raise FileNotFoundError(f"Video file not found: {file_path}")
|
|
241
|
-
|
|
285
|
+
|
|
242
286
|
self.logger.info("File validation completed for: %s", file_path)
|
|
243
287
|
|
|
244
288
|
def _create_or_retrieve_video_instance(self):
|
|
245
289
|
"""Create or retrieve the VideoFile instance and move to final storage."""
|
|
246
|
-
|
|
290
|
+
|
|
247
291
|
self.logger.info("Creating VideoFile instance...")
|
|
248
|
-
|
|
292
|
+
|
|
249
293
|
self.current_video = VideoFile.create_from_file_initialized(
|
|
250
|
-
file_path=self.processing_context[
|
|
251
|
-
center_name=self.processing_context[
|
|
252
|
-
processor_name=self.processing_context[
|
|
253
|
-
delete_source=self.processing_context[
|
|
254
|
-
save_video_file=self.processing_context[
|
|
294
|
+
file_path=self.processing_context["file_path"],
|
|
295
|
+
center_name=self.processing_context["center_name"],
|
|
296
|
+
processor_name=self.processing_context["processor_name"],
|
|
297
|
+
delete_source=self.processing_context["delete_source"],
|
|
298
|
+
save_video_file=self.processing_context["save_video"],
|
|
255
299
|
)
|
|
256
|
-
|
|
300
|
+
|
|
257
301
|
if not self.current_video:
|
|
258
302
|
raise RuntimeError("Failed to create VideoFile instance")
|
|
259
|
-
|
|
303
|
+
|
|
260
304
|
# Immediately move to final storage locations
|
|
261
305
|
self._move_to_final_storage()
|
|
262
|
-
|
|
306
|
+
|
|
263
307
|
self.logger.info("Created VideoFile with UUID: %s", self.current_video.uuid)
|
|
264
|
-
|
|
308
|
+
|
|
265
309
|
# Get and mark processing state
|
|
266
310
|
state = VideoFile.get_or_create_state(self.current_video)
|
|
267
311
|
if not state:
|
|
268
312
|
raise RuntimeError("Failed to create VideoFile state")
|
|
269
|
-
|
|
313
|
+
|
|
270
314
|
state.mark_processing_started(save=True)
|
|
271
|
-
self.processing_context[
|
|
315
|
+
self.processing_context["processing_started"] = True
|
|
272
316
|
|
|
273
317
|
def _move_to_final_storage(self):
|
|
274
318
|
"""
|
|
@@ -302,12 +346,23 @@ class VideoImportService():
|
|
|
302
346
|
except Exception:
|
|
303
347
|
stored_raw_path = None
|
|
304
348
|
|
|
305
|
-
# Fallback: derive from UUID + suffix
|
|
349
|
+
# Fallback: derive from UUID + suffix - ALWAYS use UUID for consistency
|
|
306
350
|
if not stored_raw_path:
|
|
307
351
|
suffix = source_path.suffix or ".mp4"
|
|
308
352
|
uuid_str = getattr(_current_video, "uuid", None)
|
|
309
|
-
|
|
353
|
+
if uuid_str:
|
|
354
|
+
filename = f"{uuid_str}{suffix}"
|
|
355
|
+
else:
|
|
356
|
+
# Emergency fallback with timestamp to avoid conflicts
|
|
357
|
+
import time
|
|
358
|
+
|
|
359
|
+
timestamp = int(time.time())
|
|
360
|
+
filename = f"video_{timestamp}{suffix}"
|
|
361
|
+
self.logger.warning(
|
|
362
|
+
"No UUID available, using timestamp-based filename: %s", filename
|
|
363
|
+
)
|
|
310
364
|
stored_raw_path = videos_dir / filename
|
|
365
|
+
self.logger.debug("Using UUID-based raw filename: %s", filename)
|
|
311
366
|
|
|
312
367
|
delete_source = bool(self.processing_context.get("delete_source", True))
|
|
313
368
|
stored_raw_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -322,7 +377,9 @@ class VideoImportService():
|
|
|
322
377
|
except Exception:
|
|
323
378
|
shutil.copy2(source_path, stored_raw_path)
|
|
324
379
|
os.remove(source_path)
|
|
325
|
-
self.logger.info(
|
|
380
|
+
self.logger.info(
|
|
381
|
+
"Copied & removed raw video to: %s", stored_raw_path
|
|
382
|
+
)
|
|
326
383
|
else:
|
|
327
384
|
shutil.copy2(source_path, stored_raw_path)
|
|
328
385
|
self.logger.info("Copied raw video to: %s", stored_raw_path)
|
|
@@ -345,7 +402,6 @@ class VideoImportService():
|
|
|
345
402
|
self.processing_context["raw_video_path"] = stored_raw_path
|
|
346
403
|
self.processing_context["video_filename"] = stored_raw_path.name
|
|
347
404
|
|
|
348
|
-
|
|
349
405
|
def _setup_processing_environment(self):
|
|
350
406
|
"""Setup the processing environment without file movement."""
|
|
351
407
|
video = self._require_current_video()
|
|
@@ -353,71 +409,96 @@ class VideoImportService():
|
|
|
353
409
|
# Initialize video specifications
|
|
354
410
|
video.initialize_video_specs()
|
|
355
411
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
412
|
+
|
|
413
|
+
|
|
359
414
|
# Extract frames BEFORE processing to prevent pipeline 1 conflicts
|
|
360
415
|
self.logger.info("Pre-extracting frames to avoid pipeline conflicts...")
|
|
361
416
|
try:
|
|
362
417
|
frames_extracted = video.extract_frames(overwrite=False)
|
|
363
418
|
if frames_extracted:
|
|
364
|
-
self.processing_context[
|
|
419
|
+
self.processing_context["frames_extracted"] = True
|
|
365
420
|
self.logger.info("Frame extraction completed successfully")
|
|
366
|
-
|
|
421
|
+
# Initialize frame objects in database
|
|
422
|
+
video.initialize_frames(video.get_frame_paths())
|
|
423
|
+
|
|
367
424
|
# CRITICAL: Immediately save the frames_extracted state to database
|
|
368
425
|
# to prevent refresh_from_db() in pipeline 1 from overriding it
|
|
369
426
|
state = video.get_or_create_state()
|
|
370
427
|
if not state.frames_extracted:
|
|
371
428
|
state.frames_extracted = True
|
|
372
|
-
state.save(update_fields=[
|
|
429
|
+
state.save(update_fields=["frames_extracted"])
|
|
373
430
|
self.logger.info("Persisted frames_extracted=True to database")
|
|
374
431
|
else:
|
|
375
432
|
self.logger.warning("Frame extraction failed, but continuing...")
|
|
376
|
-
self.processing_context[
|
|
433
|
+
self.processing_context["frames_extracted"] = False
|
|
377
434
|
except Exception as e:
|
|
378
|
-
self.logger.warning(
|
|
379
|
-
|
|
380
|
-
|
|
435
|
+
self.logger.warning(
|
|
436
|
+
f"Frame extraction failed during setup: {e}, but continuing..."
|
|
437
|
+
)
|
|
438
|
+
self.processing_context["frames_extracted"] = False
|
|
439
|
+
|
|
381
440
|
# Ensure default patient data
|
|
382
441
|
self._ensure_default_patient_data(video_instance=video)
|
|
383
|
-
|
|
442
|
+
|
|
384
443
|
self.logger.info("Processing environment setup completed")
|
|
385
444
|
|
|
386
445
|
def _process_frames_and_metadata(self):
|
|
387
446
|
"""Process frames and extract metadata with anonymization."""
|
|
388
447
|
# Check frame cleaning availability
|
|
389
|
-
frame_cleaning_available, frame_cleaner
|
|
448
|
+
frame_cleaning_available, frame_cleaner = (
|
|
449
|
+
self._ensure_frame_cleaning_available()
|
|
450
|
+
)
|
|
390
451
|
video = self._require_current_video()
|
|
391
452
|
|
|
392
453
|
raw_file_field = video.raw_file
|
|
393
|
-
has_raw_file = isinstance(raw_file_field, FieldFile) and bool(
|
|
454
|
+
has_raw_file = isinstance(raw_file_field, FieldFile) and bool(
|
|
455
|
+
raw_file_field.name
|
|
456
|
+
)
|
|
394
457
|
|
|
395
458
|
if not (frame_cleaning_available and has_raw_file):
|
|
396
|
-
self.logger.warning(
|
|
459
|
+
self.logger.warning(
|
|
460
|
+
"Frame cleaning not available or conditions not met, using fallback anonymization."
|
|
461
|
+
)
|
|
397
462
|
self._fallback_anonymize_video()
|
|
398
463
|
return
|
|
399
464
|
|
|
400
465
|
try:
|
|
401
|
-
self.logger.info(
|
|
402
|
-
|
|
466
|
+
self.logger.info(
|
|
467
|
+
"Starting frame-level anonymization with processor ROI masking..."
|
|
468
|
+
)
|
|
469
|
+
|
|
403
470
|
# Get processor ROI information
|
|
404
|
-
endoscope_data_roi_nested, endoscope_image_roi =
|
|
405
|
-
|
|
471
|
+
endoscope_data_roi_nested, endoscope_image_roi = (
|
|
472
|
+
self._get_processor_roi_info()
|
|
473
|
+
)
|
|
474
|
+
|
|
406
475
|
# Perform frame cleaning with timeout to prevent blocking
|
|
407
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
408
|
-
|
|
476
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
477
|
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
478
|
+
|
|
409
479
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
410
|
-
future = executor.submit(
|
|
480
|
+
future = executor.submit(
|
|
481
|
+
self._perform_frame_cleaning,
|
|
482
|
+
endoscope_data_roi_nested,
|
|
483
|
+
endoscope_image_roi,
|
|
484
|
+
)
|
|
411
485
|
try:
|
|
412
486
|
# Increased timeout to better accommodate ffmpeg + OCR
|
|
413
|
-
future.result(timeout=
|
|
414
|
-
self.processing_context[
|
|
415
|
-
self.logger.info(
|
|
487
|
+
future.result(timeout=50000)
|
|
488
|
+
self.processing_context["anonymization_completed"] = True
|
|
489
|
+
self.logger.info(
|
|
490
|
+
"Frame cleaning completed successfully within timeout"
|
|
491
|
+
)
|
|
416
492
|
except FutureTimeoutError:
|
|
417
|
-
self.logger.warning(
|
|
493
|
+
self.logger.warning(
|
|
494
|
+
"Frame cleaning timed out; entering grace period check for cleaned output"
|
|
495
|
+
)
|
|
418
496
|
# Grace period: detect if cleaned file appears shortly after timeout
|
|
419
|
-
raw_video_path = self.processing_context.get(
|
|
420
|
-
video_filename = self.processing_context.get(
|
|
497
|
+
raw_video_path = self.processing_context.get("raw_video_path")
|
|
498
|
+
video_filename = self.processing_context.get(
|
|
499
|
+
"video_filename",
|
|
500
|
+
Path(raw_video_path).name if raw_video_path else "video.mp4",
|
|
501
|
+
)
|
|
421
502
|
grace_seconds = 60
|
|
422
503
|
expected_cleaned_path: Optional[Path] = None
|
|
423
504
|
processed_field = video.processed_file
|
|
@@ -430,46 +511,68 @@ class VideoImportService():
|
|
|
430
511
|
if expected_cleaned_path is not None:
|
|
431
512
|
for _ in range(grace_seconds):
|
|
432
513
|
if expected_cleaned_path.exists():
|
|
433
|
-
self.processing_context[
|
|
434
|
-
|
|
435
|
-
|
|
514
|
+
self.processing_context["cleaned_video_path"] = (
|
|
515
|
+
expected_cleaned_path
|
|
516
|
+
)
|
|
517
|
+
self.processing_context["anonymization_completed"] = (
|
|
518
|
+
True
|
|
519
|
+
)
|
|
520
|
+
self.logger.info(
|
|
521
|
+
"Detected cleaned video during grace period: %s",
|
|
522
|
+
expected_cleaned_path,
|
|
523
|
+
)
|
|
436
524
|
found = True
|
|
437
525
|
break
|
|
438
526
|
time.sleep(1)
|
|
439
527
|
else:
|
|
440
528
|
self._fallback_anonymize_video()
|
|
441
529
|
if not found:
|
|
442
|
-
raise TimeoutError(
|
|
530
|
+
raise TimeoutError(
|
|
531
|
+
"Frame cleaning operation timed out - likely Ollama connection issue"
|
|
532
|
+
)
|
|
443
533
|
|
|
444
534
|
except Exception as e:
|
|
445
|
-
self.logger.warning(
|
|
535
|
+
self.logger.warning(
|
|
536
|
+
"Frame cleaning failed (reason: %s), falling back to simple copy", e
|
|
537
|
+
)
|
|
446
538
|
# Try fallback anonymization when frame cleaning fails
|
|
447
539
|
try:
|
|
448
540
|
self._fallback_anonymize_video()
|
|
449
541
|
except Exception as fallback_error:
|
|
450
|
-
self.logger.error(
|
|
542
|
+
self.logger.error(
|
|
543
|
+
"Fallback anonymization also failed: %s", fallback_error
|
|
544
|
+
)
|
|
451
545
|
# If even fallback fails, mark as not anonymized but continue import
|
|
452
|
-
self.processing_context[
|
|
453
|
-
self.processing_context[
|
|
546
|
+
self.processing_context["anonymization_completed"] = False
|
|
547
|
+
self.processing_context["error_reason"] = (
|
|
548
|
+
f"Frame cleaning failed: {e}, Fallback failed: {fallback_error}"
|
|
549
|
+
)
|
|
454
550
|
|
|
455
551
|
def _save_anonymized_video(self):
|
|
456
|
-
|
|
457
552
|
original_raw_file_path_to_delete = None
|
|
458
553
|
original_raw_frame_dir_to_delete = None
|
|
459
554
|
video = self._require_current_video()
|
|
460
555
|
anonymized_video_path = video.get_target_anonymized_video_path()
|
|
461
556
|
|
|
462
557
|
if not anonymized_video_path.exists():
|
|
463
|
-
raise RuntimeError(
|
|
558
|
+
raise RuntimeError(
|
|
559
|
+
f"Processed video file not found after assembly for {video.uuid}: {anonymized_video_path}"
|
|
560
|
+
)
|
|
464
561
|
|
|
465
562
|
new_processed_hash = get_video_hash(anonymized_video_path)
|
|
466
|
-
if
|
|
563
|
+
if (
|
|
564
|
+
video.__class__.objects.filter(processed_video_hash=new_processed_hash)
|
|
565
|
+
.exclude(pk=video.pk)
|
|
566
|
+
.exists()
|
|
567
|
+
):
|
|
467
568
|
raise ValueError(
|
|
468
569
|
f"Processed video hash {new_processed_hash} already exists for another video (Video: {video.uuid})."
|
|
469
570
|
)
|
|
470
571
|
|
|
471
572
|
video.processed_video_hash = new_processed_hash
|
|
472
|
-
video.processed_file.name = anonymized_video_path.relative_to(
|
|
573
|
+
video.processed_file.name = anonymized_video_path.relative_to(
|
|
574
|
+
STORAGE_DIR
|
|
575
|
+
).as_posix()
|
|
473
576
|
|
|
474
577
|
update_fields = [
|
|
475
578
|
"processed_video_hash",
|
|
@@ -485,11 +588,13 @@ class VideoImportService():
|
|
|
485
588
|
|
|
486
589
|
update_fields.extend(["raw_file", "video_hash"])
|
|
487
590
|
|
|
488
|
-
transaction.on_commit(
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
591
|
+
transaction.on_commit(
|
|
592
|
+
lambda: _cleanup_raw_assets(
|
|
593
|
+
video_uuid=video.uuid,
|
|
594
|
+
raw_file_path=original_raw_file_path_to_delete,
|
|
595
|
+
raw_frame_dir=original_raw_frame_dir_to_delete,
|
|
596
|
+
)
|
|
597
|
+
)
|
|
493
598
|
|
|
494
599
|
video.save(update_fields=update_fields)
|
|
495
600
|
video.state.mark_anonymized(save=True)
|
|
@@ -505,60 +610,75 @@ class VideoImportService():
|
|
|
505
610
|
self.logger.info("Attempting fallback video anonymization...")
|
|
506
611
|
video = self.current_video
|
|
507
612
|
if video is None:
|
|
508
|
-
self.logger.warning(
|
|
509
|
-
|
|
613
|
+
self.logger.warning(
|
|
614
|
+
"No VideoFile instance available for fallback anonymization"
|
|
615
|
+
)
|
|
510
616
|
|
|
511
617
|
# Strategy 2: Simple copy (no processing, just copy raw to processed)
|
|
512
|
-
self.logger.info(
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
self.
|
|
618
|
+
self.logger.info(
|
|
619
|
+
"Using simple copy fallback (raw video will be used as 'processed' video)"
|
|
620
|
+
)
|
|
621
|
+
self.processing_context["anonymization_completed"] = False
|
|
622
|
+
self.processing_context["use_raw_as_processed"] = True
|
|
623
|
+
self.logger.warning(
|
|
624
|
+
"Fallback: Video will be imported without anonymization (raw copy used)"
|
|
625
|
+
)
|
|
516
626
|
except Exception as e:
|
|
517
|
-
self.logger.error(
|
|
518
|
-
|
|
519
|
-
|
|
627
|
+
self.logger.error(
|
|
628
|
+
f"Error during fallback anonymization: {e}", exc_info=True
|
|
629
|
+
)
|
|
630
|
+
self.processing_context["anonymization_completed"] = False
|
|
631
|
+
self.processing_context["error_reason"] = str(e)
|
|
632
|
+
|
|
520
633
|
def _finalize_processing(self):
|
|
521
634
|
"""Finalize processing and update video state."""
|
|
522
635
|
self.logger.info("Updating video processing state...")
|
|
523
|
-
|
|
636
|
+
|
|
524
637
|
with transaction.atomic():
|
|
525
638
|
video = self._require_current_video()
|
|
526
639
|
try:
|
|
527
640
|
video.refresh_from_db()
|
|
528
641
|
except Exception as refresh_error:
|
|
529
|
-
self.logger.warning(
|
|
642
|
+
self.logger.warning(
|
|
643
|
+
"Could not refresh VideoFile %s from DB: %s",
|
|
644
|
+
video.uuid,
|
|
645
|
+
refresh_error,
|
|
646
|
+
)
|
|
530
647
|
|
|
531
648
|
state = video.get_or_create_state()
|
|
532
|
-
|
|
649
|
+
|
|
533
650
|
# Only mark frames as extracted if they were successfully extracted
|
|
534
|
-
if self.processing_context.get(
|
|
651
|
+
if self.processing_context.get("frames_extracted", False):
|
|
535
652
|
state.frames_extracted = True
|
|
536
653
|
self.logger.info("Marked frames as extracted in state")
|
|
537
654
|
else:
|
|
538
655
|
self.logger.warning("Frames were not extracted, not updating state")
|
|
539
|
-
|
|
656
|
+
|
|
540
657
|
# Always mark these as true (metadata extraction attempts were made)
|
|
541
658
|
state.frames_initialized = True
|
|
542
659
|
state.video_meta_extracted = True
|
|
543
660
|
state.text_meta_extracted = True
|
|
544
|
-
|
|
661
|
+
|
|
545
662
|
# ✅ FIX: Only mark as processed if anonymization actually completed
|
|
546
|
-
anonymization_completed = self.processing_context.get(
|
|
663
|
+
anonymization_completed = self.processing_context.get(
|
|
664
|
+
"anonymization_completed", False
|
|
665
|
+
)
|
|
547
666
|
if anonymization_completed:
|
|
548
667
|
state.mark_sensitive_meta_processed(save=False)
|
|
549
|
-
self.logger.info(
|
|
668
|
+
self.logger.info(
|
|
669
|
+
"Anonymization completed - marking sensitive meta as processed"
|
|
670
|
+
)
|
|
550
671
|
else:
|
|
551
672
|
self.logger.warning(
|
|
552
|
-
"Anonymization NOT completed - NOT marking as processed. "
|
|
553
|
-
f"Reason: {self.processing_context.get('error_reason', 'Unknown')}"
|
|
673
|
+
f"Anonymization NOT completed - NOT marking as processed. Reason: {self.processing_context.get('error_reason', 'Unknown')}"
|
|
554
674
|
)
|
|
555
675
|
# Explicitly mark as NOT processed
|
|
556
676
|
state.sensitive_meta_processed = False
|
|
557
|
-
|
|
677
|
+
|
|
558
678
|
# Save all state changes
|
|
559
679
|
state.save()
|
|
560
680
|
self.logger.info("Video processing state updated")
|
|
561
|
-
|
|
681
|
+
|
|
562
682
|
# Signal completion
|
|
563
683
|
self._signal_completion()
|
|
564
684
|
|
|
@@ -572,17 +692,20 @@ class VideoImportService():
|
|
|
572
692
|
video = self._require_current_video()
|
|
573
693
|
|
|
574
694
|
processed_video_path = None
|
|
575
|
-
if
|
|
576
|
-
processed_video_path = self.processing_context[
|
|
695
|
+
if "cleaned_video_path" in self.processing_context:
|
|
696
|
+
processed_video_path = self.processing_context["cleaned_video_path"]
|
|
577
697
|
else:
|
|
578
|
-
raw_video_path = self.processing_context.get(
|
|
698
|
+
raw_video_path = self.processing_context.get("raw_video_path")
|
|
579
699
|
if raw_video_path and Path(raw_video_path).exists():
|
|
580
|
-
|
|
581
|
-
|
|
700
|
+
# Use UUID-based naming to avoid conflicts
|
|
701
|
+
suffix = Path(raw_video_path).suffix or ".mp4"
|
|
702
|
+
processed_filename = f"processed_{video.uuid}{suffix}"
|
|
582
703
|
processed_video_path = Path(raw_video_path).parent / processed_filename
|
|
583
704
|
try:
|
|
584
705
|
shutil.copy2(str(raw_video_path), str(processed_video_path))
|
|
585
|
-
self.logger.info(
|
|
706
|
+
self.logger.info(
|
|
707
|
+
"Copied raw video for processing: %s", processed_video_path
|
|
708
|
+
)
|
|
586
709
|
except Exception as exc:
|
|
587
710
|
self.logger.error("Failed to copy raw video: %s", exc)
|
|
588
711
|
processed_video_path = None
|
|
@@ -602,62 +725,86 @@ class VideoImportService():
|
|
|
602
725
|
relative_path = anonym_target_path.relative_to(storage_root)
|
|
603
726
|
video.processed_file.name = str(relative_path)
|
|
604
727
|
video.save(update_fields=["processed_file"])
|
|
605
|
-
self.logger.info(
|
|
728
|
+
self.logger.info(
|
|
729
|
+
"Updated processed_file path to: %s", relative_path
|
|
730
|
+
)
|
|
606
731
|
except Exception as exc:
|
|
607
|
-
self.logger.error(
|
|
608
|
-
|
|
609
|
-
|
|
732
|
+
self.logger.error(
|
|
733
|
+
"Failed to update processed_file path: %s", exc
|
|
734
|
+
)
|
|
735
|
+
video.processed_file.name = (
|
|
736
|
+
f"anonym_videos/{anonym_video_filename}"
|
|
737
|
+
)
|
|
738
|
+
video.save(update_fields=["processed_file"])
|
|
610
739
|
self.logger.info(
|
|
611
740
|
"Updated processed_file path using fallback: %s",
|
|
612
741
|
f"anonym_videos/{anonym_video_filename}",
|
|
613
742
|
)
|
|
614
743
|
|
|
615
|
-
self.processing_context[
|
|
744
|
+
self.processing_context["anonymization_completed"] = True
|
|
616
745
|
else:
|
|
617
|
-
self.logger.warning(
|
|
746
|
+
self.logger.warning(
|
|
747
|
+
"Processed video file not found after move: %s",
|
|
748
|
+
anonym_target_path,
|
|
749
|
+
)
|
|
618
750
|
except Exception as exc:
|
|
619
|
-
self.logger.error(
|
|
751
|
+
self.logger.error(
|
|
752
|
+
"Failed to move processed video to anonym_videos: %s", exc
|
|
753
|
+
)
|
|
620
754
|
else:
|
|
621
|
-
self.logger.warning(
|
|
755
|
+
self.logger.warning(
|
|
756
|
+
"No processed video available - processed_file will remain empty"
|
|
757
|
+
)
|
|
622
758
|
|
|
623
759
|
try:
|
|
624
760
|
from endoreg_db.utils.paths import RAW_FRAME_DIR
|
|
761
|
+
|
|
625
762
|
shutil.rmtree(RAW_FRAME_DIR, ignore_errors=True)
|
|
626
|
-
self.logger.debug(
|
|
763
|
+
self.logger.debug(
|
|
764
|
+
"Cleaned up temporary frames directory: %s", RAW_FRAME_DIR
|
|
765
|
+
)
|
|
627
766
|
except Exception as exc:
|
|
628
767
|
self.logger.warning("Failed to remove directory %s: %s", RAW_FRAME_DIR, exc)
|
|
629
768
|
|
|
630
|
-
source_path = self.processing_context[
|
|
631
|
-
if self.processing_context[
|
|
769
|
+
source_path = self.processing_context["file_path"]
|
|
770
|
+
if self.processing_context["delete_source"] and Path(source_path).exists():
|
|
632
771
|
try:
|
|
633
772
|
os.remove(source_path)
|
|
634
773
|
self.logger.info("Removed remaining source file: %s", source_path)
|
|
635
774
|
except Exception as exc:
|
|
636
|
-
self.logger.warning(
|
|
775
|
+
self.logger.warning(
|
|
776
|
+
"Failed to remove source file %s: %s", source_path, exc
|
|
777
|
+
)
|
|
637
778
|
|
|
638
779
|
if not video.processed_file or not Path(video.processed_file.path).exists():
|
|
639
|
-
self.logger.warning(
|
|
780
|
+
self.logger.warning(
|
|
781
|
+
"No processed_file found after cleanup - video will be unprocessed"
|
|
782
|
+
)
|
|
640
783
|
try:
|
|
641
784
|
video.anonymize(delete_original_raw=self.delete_source)
|
|
642
|
-
video.save(update_fields=[
|
|
785
|
+
video.save(update_fields=["processed_file"])
|
|
643
786
|
self.logger.info("Late-stage anonymization succeeded")
|
|
644
787
|
except Exception as e:
|
|
645
788
|
self.logger.error("Late-stage anonymization failed: %s", e)
|
|
646
|
-
self.processing_context[
|
|
789
|
+
self.processing_context["anonymization_completed"] = False
|
|
647
790
|
|
|
648
791
|
self.logger.info("Cleanup and archiving completed")
|
|
649
792
|
|
|
650
|
-
self.processed_files.add(str(self.processing_context[
|
|
793
|
+
self.processed_files.add(str(self.processing_context["file_path"]))
|
|
651
794
|
|
|
652
795
|
with transaction.atomic():
|
|
653
796
|
video.refresh_from_db()
|
|
654
|
-
if hasattr(video,
|
|
797
|
+
if hasattr(video, "state") and self.processing_context.get(
|
|
798
|
+
"anonymization_completed"
|
|
799
|
+
):
|
|
655
800
|
video.state.mark_sensitive_meta_processed(save=True)
|
|
656
801
|
|
|
657
|
-
self.logger.info(
|
|
802
|
+
self.logger.info(
|
|
803
|
+
"Import and anonymization completed for VideoFile UUID: %s", video.uuid
|
|
804
|
+
)
|
|
658
805
|
self.logger.info("Raw video stored in: /data/videos")
|
|
659
806
|
self.logger.info("Processed video stored in: /data/anonym_videos")
|
|
660
|
-
|
|
807
|
+
|
|
661
808
|
def _create_sensitive_file(
|
|
662
809
|
self,
|
|
663
810
|
video_instance: VideoFile | None = None,
|
|
@@ -681,7 +828,9 @@ class VideoImportService():
|
|
|
681
828
|
if source_path is None:
|
|
682
829
|
raise ValueError("No file path available for creating sensitive file")
|
|
683
830
|
if not raw_field:
|
|
684
|
-
raise ValueError(
|
|
831
|
+
raise ValueError(
|
|
832
|
+
"VideoFile must have a raw_file to create a sensitive file"
|
|
833
|
+
)
|
|
685
834
|
|
|
686
835
|
target_dir = VIDEO_DIR / "sensitive"
|
|
687
836
|
if not target_dir.exists():
|
|
@@ -691,9 +840,13 @@ class VideoImportService():
|
|
|
691
840
|
target_file_path = target_dir / source_path.name
|
|
692
841
|
try:
|
|
693
842
|
shutil.move(str(source_path), str(target_file_path))
|
|
694
|
-
self.logger.info(
|
|
843
|
+
self.logger.info(
|
|
844
|
+
"Moved raw file to sensitive directory: %s", target_file_path
|
|
845
|
+
)
|
|
695
846
|
except Exception as exc:
|
|
696
|
-
self.logger.warning(
|
|
847
|
+
self.logger.warning(
|
|
848
|
+
"Failed to move raw file to sensitive dir, copying instead: %s", exc
|
|
849
|
+
)
|
|
697
850
|
shutil.copy(str(source_path), str(target_file_path))
|
|
698
851
|
try:
|
|
699
852
|
os.remove(source_path)
|
|
@@ -707,7 +860,10 @@ class VideoImportService():
|
|
|
707
860
|
relative_path = target_file_path.relative_to(storage_root)
|
|
708
861
|
video.raw_file.name = str(relative_path)
|
|
709
862
|
video.save(update_fields=["raw_file"])
|
|
710
|
-
self.logger.info(
|
|
863
|
+
self.logger.info(
|
|
864
|
+
"Updated video.raw_file to point to sensitive location: %s",
|
|
865
|
+
relative_path,
|
|
866
|
+
)
|
|
711
867
|
except Exception as exc:
|
|
712
868
|
self.logger.warning("Failed to set relative path, using fallback: %s", exc)
|
|
713
869
|
video.raw_file.name = f"videos/sensitive/{target_file_path.name}"
|
|
@@ -716,15 +872,18 @@ class VideoImportService():
|
|
|
716
872
|
"Updated video.raw_file using fallback method: videos/sensitive/%s",
|
|
717
873
|
target_file_path.name,
|
|
718
874
|
)
|
|
719
|
-
|
|
875
|
+
|
|
720
876
|
self.processing_context["raw_video_path"] = target_file_path
|
|
721
877
|
self.processing_context["video_filename"] = target_file_path.name
|
|
722
878
|
|
|
723
|
-
|
|
724
|
-
|
|
879
|
+
self.logger.info(
|
|
880
|
+
"Created sensitive file for %s at %s", video.uuid, target_file_path
|
|
881
|
+
)
|
|
725
882
|
return target_file_path
|
|
726
883
|
|
|
727
|
-
def _get_processor_roi_info(
|
|
884
|
+
def _get_processor_roi_info(
|
|
885
|
+
self,
|
|
886
|
+
) -> Tuple[Optional[List[List[Dict[str, Any]]]], Optional[Dict[str, Any]]]:
|
|
728
887
|
"""Get processor ROI information for masking."""
|
|
729
888
|
endoscope_data_roi_nested = None
|
|
730
889
|
endoscope_image_roi = None
|
|
@@ -735,10 +894,15 @@ class VideoImportService():
|
|
|
735
894
|
video_meta = getattr(video, "video_meta", None)
|
|
736
895
|
processor = getattr(video_meta, "processor", None) if video_meta else None
|
|
737
896
|
if processor:
|
|
738
|
-
assert isinstance(processor, EndoscopyProcessor),
|
|
897
|
+
assert isinstance(processor, EndoscopyProcessor), (
|
|
898
|
+
"Processor is not of type EndoscopyProcessor"
|
|
899
|
+
)
|
|
739
900
|
endoscope_image_roi = processor.get_roi_endoscope_image()
|
|
740
|
-
endoscope_data_roi_nested = processor.
|
|
741
|
-
self.logger.info(
|
|
901
|
+
endoscope_data_roi_nested = processor.get_sensitive_rois()
|
|
902
|
+
self.logger.info(
|
|
903
|
+
"Retrieved processor ROI information: endoscope_image_roi=%s",
|
|
904
|
+
endoscope_image_roi,
|
|
905
|
+
)
|
|
742
906
|
else:
|
|
743
907
|
self.logger.warning(
|
|
744
908
|
"No processor found for video %s, proceeding without ROI masking",
|
|
@@ -760,28 +924,40 @@ class VideoImportService():
|
|
|
760
924
|
|
|
761
925
|
return endoscope_data_roi_nested, endoscope_image_roi
|
|
762
926
|
|
|
763
|
-
def _ensure_default_patient_data(
|
|
927
|
+
def _ensure_default_patient_data(
|
|
928
|
+
self, video_instance: VideoFile | None = None
|
|
929
|
+
) -> None:
|
|
764
930
|
"""Ensure minimum patient data is present on the video's SensitiveMeta."""
|
|
765
931
|
|
|
766
932
|
video = video_instance or self._require_current_video()
|
|
767
933
|
|
|
768
934
|
sensitive_meta = getattr(video, "sensitive_meta", None)
|
|
769
935
|
if not sensitive_meta:
|
|
770
|
-
self.logger.info(
|
|
936
|
+
self.logger.info(
|
|
937
|
+
"No SensitiveMeta found for video %s, creating default", video.uuid
|
|
938
|
+
)
|
|
771
939
|
default_data = {
|
|
772
940
|
"patient_first_name": "Patient",
|
|
773
941
|
"patient_last_name": "Unknown",
|
|
774
942
|
"patient_dob": date(1990, 1, 1),
|
|
775
943
|
"examination_date": date.today(),
|
|
776
|
-
"center_name": video.center.name
|
|
944
|
+
"center_name": video.center.name
|
|
945
|
+
if video.center
|
|
946
|
+
else "university_hospital_wuerzburg",
|
|
777
947
|
}
|
|
778
948
|
try:
|
|
779
949
|
sensitive_meta = SensitiveMeta.create_from_dict(default_data)
|
|
780
950
|
video.sensitive_meta = sensitive_meta
|
|
781
951
|
video.save(update_fields=["sensitive_meta"])
|
|
782
|
-
self.logger.info(
|
|
952
|
+
self.logger.info(
|
|
953
|
+
"Created default SensitiveMeta for video %s", video.uuid
|
|
954
|
+
)
|
|
783
955
|
except Exception as exc:
|
|
784
|
-
self.logger.error(
|
|
956
|
+
self.logger.error(
|
|
957
|
+
"Failed to create default SensitiveMeta for video %s: %s",
|
|
958
|
+
video.uuid,
|
|
959
|
+
exc,
|
|
960
|
+
)
|
|
785
961
|
return
|
|
786
962
|
else:
|
|
787
963
|
update_data: Dict[str, Any] = {}
|
|
@@ -805,14 +981,16 @@ class VideoImportService():
|
|
|
805
981
|
list(update_data.keys()),
|
|
806
982
|
)
|
|
807
983
|
except Exception as exc:
|
|
808
|
-
self.logger.error(
|
|
809
|
-
|
|
810
|
-
|
|
984
|
+
self.logger.error(
|
|
985
|
+
"Failed to update SensitiveMeta for video %s: %s",
|
|
986
|
+
video.uuid,
|
|
987
|
+
exc,
|
|
988
|
+
)
|
|
811
989
|
|
|
812
990
|
def _ensure_frame_cleaning_available(self):
|
|
813
991
|
"""
|
|
814
992
|
Ensure frame cleaning modules are available by adding lx-anonymizer to path.
|
|
815
|
-
|
|
993
|
+
|
|
816
994
|
Returns:
|
|
817
995
|
Tuple of (availability_flag, FrameCleaner_class, ReportReader_class)
|
|
818
996
|
"""
|
|
@@ -821,14 +999,14 @@ class VideoImportService():
|
|
|
821
999
|
from lx_anonymizer import FrameCleaner # type: ignore[import]
|
|
822
1000
|
|
|
823
1001
|
if FrameCleaner:
|
|
824
|
-
return True, FrameCleaner
|
|
825
|
-
|
|
1002
|
+
return True, FrameCleaner()
|
|
1003
|
+
|
|
826
1004
|
except Exception as e:
|
|
827
|
-
self.logger.warning(
|
|
828
|
-
|
|
829
|
-
|
|
1005
|
+
self.logger.warning(
|
|
1006
|
+
f"Frame cleaning not available: {e} Please install or update lx_anonymizer."
|
|
1007
|
+
)
|
|
830
1008
|
|
|
831
|
-
|
|
1009
|
+
return False, None
|
|
832
1010
|
|
|
833
1011
|
def _perform_frame_cleaning(self, endoscope_data_roi_nested, endoscope_image_roi):
|
|
834
1012
|
"""Perform frame cleaning and anonymization."""
|
|
@@ -839,8 +1017,8 @@ class VideoImportService():
|
|
|
839
1017
|
raise RuntimeError("Frame cleaning not available")
|
|
840
1018
|
|
|
841
1019
|
# Prepare parameters for frame cleaning
|
|
842
|
-
raw_video_path = self.processing_context.get(
|
|
843
|
-
|
|
1020
|
+
raw_video_path = self.processing_context.get("raw_video_path")
|
|
1021
|
+
|
|
844
1022
|
if not raw_video_path or not Path(raw_video_path).exists():
|
|
845
1023
|
try:
|
|
846
1024
|
self.current_video = self._require_current_video()
|
|
@@ -848,33 +1026,40 @@ class VideoImportService():
|
|
|
848
1026
|
except Exception:
|
|
849
1027
|
raise RuntimeError(f"Raw video path not found: {raw_video_path}")
|
|
850
1028
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1029
|
+
# Create temporary output path for cleaned video using UUID to avoid naming conflicts
|
|
1030
|
+
video = self._require_current_video()
|
|
1031
|
+
# Ensure raw_video_path is not None
|
|
1032
|
+
if not raw_video_path:
|
|
1033
|
+
raise RuntimeError(
|
|
1034
|
+
"raw_video_path is None, cannot construct cleaned_video_path"
|
|
1035
|
+
)
|
|
1036
|
+
suffix = Path(raw_video_path).suffix or ".mp4"
|
|
1037
|
+
cleaned_filename = f"cleaned_{video.uuid}{suffix}"
|
|
855
1038
|
cleaned_video_path = Path(raw_video_path).parent / cleaned_filename
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1039
|
+
self.logger.debug("Using UUID-based cleaned filename: %s", cleaned_filename)
|
|
1040
|
+
|
|
859
1041
|
# Clean video with ROI masking (heavy I/O operation)
|
|
860
1042
|
actual_cleaned_path, extracted_metadata = frame_cleaner.clean_video(
|
|
861
1043
|
video_path=Path(raw_video_path),
|
|
862
1044
|
endoscope_image_roi=endoscope_image_roi,
|
|
863
1045
|
endoscope_data_roi_nested=endoscope_data_roi_nested,
|
|
864
1046
|
output_path=cleaned_video_path,
|
|
865
|
-
technique="mask_overlay"
|
|
1047
|
+
technique="mask_overlay",
|
|
866
1048
|
)
|
|
867
|
-
|
|
868
|
-
|
|
1049
|
+
|
|
869
1050
|
# Store cleaned video path for later use in _cleanup_and_archive
|
|
870
|
-
self.processing_context[
|
|
871
|
-
self.processing_context[
|
|
872
|
-
|
|
1051
|
+
self.processing_context["cleaned_video_path"] = actual_cleaned_path
|
|
1052
|
+
self.processing_context["extracted_metadata"] = extracted_metadata
|
|
1053
|
+
|
|
873
1054
|
# Update sensitive metadata with extracted information
|
|
874
1055
|
self._update_sensitive_metadata(extracted_metadata)
|
|
875
|
-
self.logger.info(
|
|
876
|
-
|
|
877
|
-
|
|
1056
|
+
self.logger.info(
|
|
1057
|
+
f"Extracted metadata from frame cleaning: {extracted_metadata}"
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
self.logger.info(
|
|
1061
|
+
f"Frame cleaning with ROI masking completed: {actual_cleaned_path}"
|
|
1062
|
+
)
|
|
878
1063
|
self.logger.info("Cleaned video will be moved to anonym_videos during cleanup")
|
|
879
1064
|
|
|
880
1065
|
def _update_sensitive_metadata(self, extracted_metadata: Dict[str, Any]):
|
|
@@ -891,22 +1076,67 @@ class VideoImportService():
|
|
|
891
1076
|
|
|
892
1077
|
sm = sensitive_meta
|
|
893
1078
|
updated_fields = []
|
|
894
|
-
|
|
1079
|
+
|
|
1080
|
+
# Ensure center is set from video.center if not in extracted_metadata
|
|
1081
|
+
metadata_to_update = extracted_metadata.copy()
|
|
1082
|
+
|
|
1083
|
+
# FIX: Set center object instead of center_name string
|
|
1084
|
+
if not hasattr(sm, "center") or not sm.center:
|
|
1085
|
+
if video.center:
|
|
1086
|
+
metadata_to_update["center"] = video.center
|
|
1087
|
+
self.logger.debug(
|
|
1088
|
+
"Added center object '%s' to metadata for SensitiveMeta update",
|
|
1089
|
+
video.center.name,
|
|
1090
|
+
)
|
|
1091
|
+
else:
|
|
1092
|
+
center_name = metadata_to_update.get("center_name")
|
|
1093
|
+
if center_name:
|
|
1094
|
+
try:
|
|
1095
|
+
from ..models.administration import Center
|
|
1096
|
+
|
|
1097
|
+
center_obj = Center.objects.get(name=center_name)
|
|
1098
|
+
metadata_to_update["center"] = center_obj
|
|
1099
|
+
self.logger.debug(
|
|
1100
|
+
"Loaded center object '%s' from center_name", center_name
|
|
1101
|
+
)
|
|
1102
|
+
metadata_to_update.pop("center_name", None)
|
|
1103
|
+
except Center.DoesNotExist:
|
|
1104
|
+
self.logger.error(
|
|
1105
|
+
"Center '%s' not found in database", center_name
|
|
1106
|
+
)
|
|
1107
|
+
return
|
|
1108
|
+
|
|
895
1109
|
try:
|
|
896
|
-
sm.update_from_dict(
|
|
897
|
-
updated_fields = list(
|
|
1110
|
+
sm.update_from_dict(metadata_to_update)
|
|
1111
|
+
updated_fields = list(
|
|
1112
|
+
extracted_metadata.keys()
|
|
1113
|
+
) # Only log originally extracted fields
|
|
898
1114
|
except KeyError as e:
|
|
899
1115
|
self.logger.warning(f"Failed to update SensitiveMeta field {e}")
|
|
900
|
-
|
|
1116
|
+
return
|
|
1117
|
+
|
|
901
1118
|
if updated_fields:
|
|
902
|
-
|
|
903
|
-
|
|
1119
|
+
try:
|
|
1120
|
+
sm.save() # Remove update_fields to allow all necessary fields to be saved
|
|
1121
|
+
self.logger.info(
|
|
1122
|
+
"Updated SensitiveMeta fields for video %s: %s",
|
|
1123
|
+
video.uuid,
|
|
1124
|
+
updated_fields,
|
|
1125
|
+
)
|
|
904
1126
|
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1127
|
+
state = video.get_or_create_state()
|
|
1128
|
+
state.mark_sensitive_meta_processed(save=True)
|
|
1129
|
+
self.logger.info(
|
|
1130
|
+
"Marked sensitive metadata as processed for video %s", video.uuid
|
|
1131
|
+
)
|
|
1132
|
+
except Exception as e:
|
|
1133
|
+
self.logger.error(f"Failed to save SensitiveMeta: {e}")
|
|
1134
|
+
raise # Re-raise to trigger fallback in calling method
|
|
908
1135
|
else:
|
|
909
|
-
self.logger.info(
|
|
1136
|
+
self.logger.info(
|
|
1137
|
+
"No SensitiveMeta fields updated for video %s - all existing values preserved",
|
|
1138
|
+
video.uuid,
|
|
1139
|
+
)
|
|
910
1140
|
|
|
911
1141
|
def _signal_completion(self):
|
|
912
1142
|
"""Signal completion to the tracking system."""
|
|
@@ -922,21 +1152,28 @@ class VideoImportService():
|
|
|
922
1152
|
raw_exists = False
|
|
923
1153
|
|
|
924
1154
|
video_processing_complete = (
|
|
925
|
-
video.sensitive_meta is not None
|
|
926
|
-
video.video_meta is not None
|
|
927
|
-
raw_exists
|
|
1155
|
+
video.sensitive_meta is not None
|
|
1156
|
+
and video.video_meta is not None
|
|
1157
|
+
and raw_exists
|
|
928
1158
|
)
|
|
929
1159
|
|
|
930
1160
|
if video_processing_complete:
|
|
931
|
-
self.logger.info(
|
|
1161
|
+
self.logger.info(
|
|
1162
|
+
"Video %s processing completed successfully - ready for validation",
|
|
1163
|
+
video.uuid,
|
|
1164
|
+
)
|
|
932
1165
|
|
|
933
1166
|
# Update completion flags if they exist
|
|
934
1167
|
completion_fields = []
|
|
935
|
-
for field_name in [
|
|
1168
|
+
for field_name in [
|
|
1169
|
+
"import_completed",
|
|
1170
|
+
"processing_complete",
|
|
1171
|
+
"ready_for_validation",
|
|
1172
|
+
]:
|
|
936
1173
|
if hasattr(video, field_name):
|
|
937
1174
|
setattr(video, field_name, True)
|
|
938
1175
|
completion_fields.append(field_name)
|
|
939
|
-
|
|
1176
|
+
|
|
940
1177
|
if completion_fields:
|
|
941
1178
|
video.save(update_fields=completion_fields)
|
|
942
1179
|
self.logger.info("Updated completion flags: %s", completion_fields)
|
|
@@ -945,15 +1182,15 @@ class VideoImportService():
|
|
|
945
1182
|
"Video %s processing incomplete - missing required components",
|
|
946
1183
|
video.uuid,
|
|
947
1184
|
)
|
|
948
|
-
|
|
1185
|
+
|
|
949
1186
|
except Exception as e:
|
|
950
1187
|
self.logger.warning(f"Failed to signal completion status: {e}")
|
|
951
1188
|
|
|
952
1189
|
def _cleanup_on_error(self):
|
|
953
1190
|
"""Cleanup processing context on error."""
|
|
954
|
-
if self.current_video and hasattr(self.current_video,
|
|
1191
|
+
if self.current_video and hasattr(self.current_video, "state"):
|
|
955
1192
|
try:
|
|
956
|
-
if self.processing_context.get(
|
|
1193
|
+
if self.processing_context.get("processing_started"):
|
|
957
1194
|
self.current_video.state.frames_extracted = False
|
|
958
1195
|
self.current_video.state.frames_initialized = False
|
|
959
1196
|
self.current_video.state.video_meta_extracted = False
|
|
@@ -965,29 +1202,34 @@ class VideoImportService():
|
|
|
965
1202
|
def _cleanup_processing_context(self):
|
|
966
1203
|
"""
|
|
967
1204
|
Cleanup processing context and release file lock.
|
|
968
|
-
|
|
1205
|
+
|
|
969
1206
|
This method is always called in the finally block of import_and_anonymize()
|
|
970
1207
|
to ensure the file lock is released even if processing fails.
|
|
971
1208
|
"""
|
|
1209
|
+
# DEFENSIVE: Ensure processing_context exists before accessing it
|
|
1210
|
+
if not hasattr(self, "processing_context"):
|
|
1211
|
+
self.processing_context = {}
|
|
1212
|
+
|
|
972
1213
|
try:
|
|
973
1214
|
# Release file lock if it was acquired
|
|
974
|
-
lock_context = self.processing_context.get(
|
|
1215
|
+
lock_context = self.processing_context.get("_lock_context")
|
|
975
1216
|
if lock_context is not None:
|
|
976
1217
|
try:
|
|
977
1218
|
lock_context.__exit__(None, None, None)
|
|
978
1219
|
self.logger.info("Released file lock")
|
|
979
1220
|
except Exception as e:
|
|
980
1221
|
self.logger.warning(f"Error releasing file lock: {e}")
|
|
981
|
-
|
|
1222
|
+
|
|
982
1223
|
# Remove file from processed set if processing failed
|
|
983
|
-
file_path = self.processing_context.get(
|
|
984
|
-
if file_path and not self.processing_context.get(
|
|
1224
|
+
file_path = self.processing_context.get("file_path")
|
|
1225
|
+
if file_path and not self.processing_context.get("anonymization_completed"):
|
|
985
1226
|
file_path_str = str(file_path)
|
|
986
1227
|
if file_path_str in self.processed_files:
|
|
987
1228
|
self.processed_files.remove(file_path_str)
|
|
988
|
-
self.logger.info(
|
|
989
|
-
|
|
990
|
-
|
|
1229
|
+
self.logger.info(
|
|
1230
|
+
f"Removed {file_path_str} from processed files (failed processing)"
|
|
1231
|
+
)
|
|
1232
|
+
|
|
991
1233
|
except Exception as e:
|
|
992
1234
|
self.logger.warning(f"Error during context cleanup: {e}")
|
|
993
1235
|
finally:
|
|
@@ -995,6 +1237,7 @@ class VideoImportService():
|
|
|
995
1237
|
self.current_video = None
|
|
996
1238
|
self.processing_context = {}
|
|
997
1239
|
|
|
1240
|
+
|
|
998
1241
|
# Convenience function for callers/tests that expect a module-level import_and_anonymize
|
|
999
1242
|
def import_and_anonymize(
|
|
1000
1243
|
file_path,
|
|
@@ -1013,4 +1256,4 @@ def import_and_anonymize(
|
|
|
1013
1256
|
processor_name=processor_name,
|
|
1014
1257
|
save_video=save_video,
|
|
1015
1258
|
delete_source=delete_source,
|
|
1016
|
-
)
|
|
1259
|
+
)
|