11import os
22import json
3+ import datetime
34from PyQt6 .QtWidgets import QFileDialog , QMessageBox
45from PyQt6 .QtCore import QUrl
56from utils import natural_sort_key
67
78class 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 \n Do 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 ("" )
0 commit comments