Skip to content

Commit e0a5b87

Browse files
Jintao MaJintao Ma
authored andcommitted
implemented strict cross-modal JSON validation in Description and Dense-Description with detailed error reporting, preserved global and item-level metadata during I/O, and resolved UI synchronization issues regarding hierarchical clip structures and annotation status indicators.
1 parent 72cb7b2 commit e0a5b87

8 files changed

Lines changed: 885 additions & 206 deletions

File tree

annotation_tool/controllers/classification/class_file_manager.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,40 @@ def __init__(self, main_window):
1111
self.ui = main_window.ui
1212

1313
def load_project(self, data, file_path):
14-
"""Load Classification Project"""
14+
"""
15+
Load Classification Project.
16+
Returns:
17+
bool: True if loaded successfully, False if validation failed or cancelled.
18+
"""
19+
20+
# 1. Strict Validation
1521
valid, err, warn = self.model.validate_gac_json(data)
22+
1623
if not valid:
17-
QMessageBox.critical(self.main, "JSON Error", err); return
24+
# Truncate extremely long error messages for display
25+
if len(err) > 1000:
26+
err = err[:1000] + "\n... (truncated)"
27+
28+
QMessageBox.critical(
29+
self.main,
30+
"Validation Error (Classification)",
31+
"The imported JSON contains critical errors and cannot be loaded.\n\n" + err
32+
)
33+
return False # [FIX] Return False to signal failure
34+
1835
if warn:
19-
QMessageBox.warning(self.main, "Warnings", warn)
36+
if len(warn) > 1000:
37+
warn = warn[:1000] + "\n... (truncated)"
2038

39+
res = QMessageBox.warning(
40+
self.main, "Validation Warnings",
41+
"The file contains warnings:\n\n" + warn + "\n\nContinue loading?",
42+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
43+
)
44+
if res != QMessageBox.StandardButton.Yes:
45+
return False # [FIX] Return False on user cancel
46+
47+
# 2. Clear Workspace (Only if validation passed)
2148
self._clear_workspace(full_reset=True)
2249

2350
self.model.current_working_directory = os.path.dirname(file_path)
@@ -80,13 +107,14 @@ def load_project(self, data, file_path):
80107
self.main.populate_action_tree()
81108
self.main.update_save_export_button_state()
82109

83-
# 2s block
84110
self.main.show_temp_msg(
85111
"Mode Switched",
86112
f"Project loaded with {len(self.model.action_item_data)} items.\n\nCurrent Mode: CLASSIFICATION",
87113
duration=1500,
88114
icon=QMessageBox.Icon.Information
89115
)
116+
117+
return True # [FIX] Explicitly return True on success
90118

91119
def save_json(self):
92120
if self.model.current_json_path:
Lines changed: 109 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
11
import os
22
import json
3+
import datetime
34
from PyQt6.QtWidgets import QFileDialog, QMessageBox
45
from PyQt6.QtCore import QUrl
56
from utils import natural_sort_key
67

78
class DenseFileManager:
89
"""
910
Handles JSON I/O for Dense Video Captioning projects.
11+
Includes validation and metadata preservation.
1012
"""
1113
def __init__(self, main_window):
1214
self.main = main_window
1315
self.model = main_window.model
1416

1517
def create_new_project(self):
1618
"""
17-
[NEW] Create a new Dense Description project (Blank).
18-
This method initializes the workspace for a fresh project.
19+
Create a new Dense Description project (Blank).
20+
Initializes default metadata and workspace.
1921
"""
20-
# 1. Safety Check: Ensure current project is closed/saved first
22+
# 1. Safety Check
2123
if not self.main.check_and_close_current_project():
2224
return
2325

@@ -30,61 +32,113 @@ def create_new_project(self):
3032
self.model.modalities = ["video"]
3133
self.model.dense_description_events = {}
3234

35+
# [NEW] Initialize default Global Metadata for new projects
36+
self.model.dense_global_metadata = {
37+
"version": "1.0",
38+
"date": datetime.date.today().isoformat(),
39+
"metadata": {
40+
"source": "SoccerNet Annotation Tool",
41+
"created_by": "User",
42+
"license": "CC-BY-NC 4.0"
43+
}
44+
}
45+
3346
# Reset paths
3447
self.model.current_working_directory = None
3548
self.model.current_json_path = None
3649

37-
# [CRITICAL] Mark as loaded to enable UI interactions (Add Video, Save, etc.)
50+
# Mark as loaded
3851
self.model.json_loaded = True
3952
self.model.is_data_dirty = True
4053

4154
# 4. Refresh UI
42-
# Clear tree (it's a new project, so it starts empty)
4355
self.main.dense_manager.populate_tree()
44-
45-
# Switch View to Index 4 (Dense Description)
4656
self.main.ui.show_dense_description_view()
4757
self.main.update_save_export_button_state()
4858

49-
# Unlock the specific Dense UI panels (Right panel, etc.)
5059
if hasattr(self.main, "prepare_new_dense_ui"):
5160
self.main.prepare_new_dense_ui()
5261

5362
self.main.statusBar().showMessage("Project Created — Dense Description Workspace Ready", 5000)
5463

5564
def load_project(self, data, file_path):
56-
"""Loads dense description project from JSON data."""
57-
# 1. Clear workspace
65+
"""
66+
Loads dense description project from JSON data.
67+
Performs strict validation and preserves metadata.
68+
"""
69+
# --- [STEP 1] VALIDATION ---
70+
# Call the strict validator defined in AppStateModel
71+
if hasattr(self.model, "validate_dense_json"):
72+
is_valid, error_msg, warning_msg = self.model.validate_dense_json(data)
73+
74+
if not is_valid:
75+
# Truncate extremely long error messages for display
76+
if len(error_msg) > 1000:
77+
error_msg = error_msg[:1000] + "\n... (truncated)"
78+
79+
QMessageBox.critical(
80+
self.main,
81+
"Validation Error (Dense Description)",
82+
"The imported JSON contains critical errors and cannot be loaded.\n\n" + error_msg,
83+
)
84+
return False
85+
86+
if warning_msg:
87+
# Show warnings but allow loading to proceed
88+
if len(warning_msg) > 1000:
89+
warning_msg = warning_msg[:1000] + "\n... (truncated)"
90+
res = QMessageBox.warning(
91+
self.main,
92+
"Validation Warnings",
93+
"The file contains warnings:\n\n"
94+
+ warning_msg
95+
+ "\n\nDo you want to continue loading?",
96+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
97+
)
98+
if res != QMessageBox.StandardButton.Yes:
99+
return False
100+
101+
# --- [STEP 2] CLEAR & SETUP ---
58102
self._clear_workspace(full_reset=True)
59103

60104
project_root = os.path.dirname(os.path.abspath(file_path))
61105
self.model.current_working_directory = project_root
62-
self.model.current_task_name = data.get("task", "Dense Captioning")
106+
self.model.current_task_name = data.get("dataset_name", data.get("task", "Dense Captioning"))
107+
108+
# [NEW] Preserve Global Metadata
109+
self.model.dense_global_metadata = {
110+
"version": data.get("version", "1.0"),
111+
"date": data.get("date", datetime.date.today().isoformat()),
112+
"metadata": data.get("metadata", {})
113+
}
63114

64115
missing_files = []
65116
loaded_count = 0
66117

67-
# 2. Iterate through items
118+
# --- [STEP 3] LOAD ITEMS ---
68119
for item in data.get("data", []):
69120
inputs = item.get("inputs", [])
70121
if not inputs: continue
71122

72123
raw_path = inputs[0].get("path", "")
124+
# ID Priority: explicit 'id' -> filename without extension
73125
aid = item.get("id") or os.path.splitext(os.path.basename(raw_path))[0]
74126

75-
# Resolve relative/absolute path
127+
# Resolve Path
76128
final_path = os.path.normpath(os.path.join(project_root, raw_path))
77129
if not os.path.exists(final_path):
78130
missing_files.append(aid)
79131

80-
# Register in Model
132+
# [NEW] Preserve Item-level Metadata (using AppState's imported_action_metadata)
133+
if "metadata" in item:
134+
self.model.imported_action_metadata[aid] = item["metadata"]
135+
136+
# Register Clip
81137
self.model.action_item_data.append({"name": aid, "path": final_path, "source_files": [final_path]})
82138
self.model.action_path_to_name[final_path] = aid
83139

84-
# [FIXED] Process Dense Events using the correct key 'dense_captions'
85-
# Looking for 'dense_captions' first, fallback to 'events'
140+
# Load Events (dense_captions)
86141
events = item.get("dense_captions", item.get("events", []))
87-
88142
if events:
89143
self.model.dense_description_events[final_path] = []
90144
for e in events:
@@ -98,9 +152,14 @@ def load_project(self, data, file_path):
98152
self.model.current_json_path = file_path
99153
self.model.json_loaded = True
100154

101-
# 3. Refresh UI
155+
# --- [STEP 4] FINALIZE ---
102156
self.main.dense_manager.populate_tree()
103-
self.main.statusBar().showMessage(f"Dense Mode: Loaded {loaded_count} clips.", 2000)
157+
158+
if missing_files:
159+
QMessageBox.warning(self.main, "Load Warning", f"Could not find {len(missing_files)} video files locally.")
160+
else:
161+
self.main.statusBar().showMessage(f"Dense Mode: Loaded {loaded_count} clips.", 2000)
162+
104163
return True
105164

106165
def overwrite_json(self):
@@ -114,33 +173,41 @@ def export_json(self):
114173
return False
115174

116175
def _write_json(self, path):
117-
"""Serializes current dense description state to JSON."""
176+
"""Serializes current dense description state to JSON, preserving all metadata."""
177+
178+
# [NEW] Retrieve Global Metadata from Model (or defaults)
179+
global_meta = getattr(self.model, "dense_global_metadata", {})
180+
118181
output = {
119-
"version": "1.0",
182+
"version": global_meta.get("version", "1.0"),
183+
"date": global_meta.get("date", datetime.date.today().isoformat()),
120184
"task": "dense_video_captioning",
121185
"dataset_name": self.model.current_task_name,
186+
"metadata": global_meta.get("metadata", {
187+
"source": "SoccerNet Annotation Tool",
188+
"created_by": "User"
189+
}),
122190
"data": []
123191
}
124192

125193
base_dir = os.path.dirname(path)
126194

127-
# Sort items naturally so the output JSON is ordered
128195
sorted_items = sorted(
129196
self.model.action_item_data, key=lambda d: natural_sort_key(d.get("name", ""))
130197
)
131198

132199
for data in sorted_items:
133200
abs_path = data["path"]
201+
aid = data["name"]
134202
events = self.model.dense_description_events.get(abs_path, [])
135203

136204
try:
137205
rel_path = os.path.relpath(abs_path, base_dir).replace(os.sep, "/")
138206
except:
139207
rel_path = abs_path
140208

141-
# Export events with 'text' field
209+
# Format Events
142210
export_events = []
143-
# Sort events by time before export
144211
sorted_events = sorted(events, key=lambda x: x.get("position_ms", 0))
145212

146213
for e in sorted_events:
@@ -150,12 +217,19 @@ def _write_json(self, path):
150217
"text": e["text"]
151218
})
152219

153-
output["data"].append({
154-
"id": data["name"],
155-
"inputs": [{"type": "video", "path": rel_path}],
156-
# [FIXED] Use 'dense_captions' to maintain consistency with input format
220+
# Build Item Entry
221+
entry = {
222+
"id": aid,
223+
"inputs": [{"type": "video", "path": rel_path, "fps": 25}], # FPS is hardcoded or needs retrieval
157224
"dense_captions": export_events
158-
})
225+
}
226+
227+
# [NEW] Inject Item-level Metadata if available
228+
item_meta = self.model.imported_action_metadata.get(aid)
229+
if item_meta:
230+
entry["metadata"] = item_meta
231+
232+
output["data"].append(entry)
159233

160234
try:
161235
with open(path, "w", encoding="utf-8") as f:
@@ -172,10 +246,12 @@ def _write_json(self, path):
172246
def _clear_workspace(self, full_reset=False):
173247
"""Resets the workspace for Dense Description mode."""
174248
self.model.reset(full_reset)
249+
250+
# [NEW] Clear global metadata explicitly if full reset
251+
if full_reset:
252+
self.model.dense_global_metadata = {}
253+
175254
if hasattr(self.main, "dense_manager"):
176-
# Stop playback
177255
self.main.dense_manager.media_controller.stop()
178-
# Clear table
179256
self.main.dense_manager.right_panel.table.set_data([])
180-
# Clear text input
181-
self.main.dense_manager.right_panel.input_widget.set_text("")
257+
self.main.dense_manager.right_panel.input_widget.set_text("")

annotation_tool/controllers/description/desc_annotation_manager.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ def on_item_selected(self, current: QModelIndex, previous: QModelIndex):
5454
# We search by path first, fallback to ID if needed
5555
action_data = next((item for item in self.model.action_item_data if item.get("metadata", {}).get("path") == path), None)
5656
if not action_data:
57-
action_data = next((item for item in self.model.action_item_data if item.get("id") == current.text()), None)
57+
#action_data = next((item for item in self.model.action_item_data if item.get("id") == current.text()), None)
58+
action_data = next((item for item in self.model.action_item_data if item.get("id") == current.data()), None)
5859

5960
if not action_data:
6061
self.ui.caption_edit.setPlaceholderText("No metadata found for this item.")

0 commit comments

Comments
 (0)