1- # Copyright (C) 2025 Intel Corporation
1+ # Copyright (C) 2025 Intel Corporation
22# SPDX-License-Identifier: Apache-2.0
33
4+ import os
5+ import sys
6+ import time
7+ import logging
8+ import argparse
9+ import numpy as np
10+ from collections import deque , OrderedDict
11+
12+ import torch
13+ import openvino as ov
14+ import openvino .properties as props
15+ from openvino .runtime import serialize
16+
17+ from lerobot .policies .act .configuration_act import ACTConfig
18+ from lerobot .policies .act .modeling_act import ACTPolicy
19+ from lerobot .datasets .lerobot_dataset import LeRobotDatasetMetadata
20+ from lerobot .datasets .utils import dataset_to_policy_features
21+ from lerobot .configs .types import FeatureType
22+ from lerobot .datasets .lerobot_dataset import LeRobotDataset
23+
24+
25+ DEBUG = os .environ .get ("DEBUG" , "0" ) == "1"
26+ logger = logging .getLogger (__name__ )
27+ logging .basicConfig (level = logging .DEBUG if DEBUG else logging .INFO )
28+
29+
30+ def create_placeholder_observation (input_features , batch_size = 1 , dtype = torch .float32 ):
31+ """Create placeholder tensors that mirror the model's expected inputs."""
32+ if not input_features :
33+ raise ValueError (
34+ "No input features provided for placeholder creation." )
35+
36+ data = OrderedDict ()
37+ for feature_key , feature in input_features .items ():
38+ if feature .shape is None :
39+ raise ValueError (
40+ f"Feature '{ feature_key } ' does not define a shape." )
41+
42+ full_shape = (batch_size , * feature .shape )
43+ # Use uniform random tensors to avoid introducing bias while keeping dtype consistent.
44+ data [feature_key ] = torch .rand (full_shape , dtype = dtype )
45+
46+ return data
47+
48+
49+ class ACTPolicyWrapper (torch .nn .Module ):
50+ """
51+ Wrapper for ACTPolicy to make it traceable for OpenVINO conversion.
52+ This wrapper is stateless and returns the entire action chunk.
53+ The calling code is responsible for managing the action queue.
54+ """
55+
56+ def __init__ (self , policy ):
57+ super ().__init__ ()
58+ self .policy = policy
59+ self .input_keys = list (self .policy .config .input_features .keys ())
60+ if self .policy .config .temporal_ensemble_coeff is not None :
61+ logger .warning (
62+ "The provided wrapper does not support temporal ensembling for tracing. "
63+ "Please use a model without temporal ensembling or implement external state management for it."
64+ "Setting temporal_ensemble_coeff to None."
65+ )
66+ self .policy .config .temporal_ensemble_coeff = None
67+
68+ @torch .no_grad ()
69+ def forward (self , * inputs ):
70+ """
71+ This method wraps `predict_action_chunk` and is JIT-traceable.
72+ It returns a full chunk of predicted actions.
73+ """
74+ if len (inputs ) != len (self .input_keys ):
75+ raise ValueError (
76+ f"Expected { len (self .input_keys )} inputs, received { len (inputs )} ."
77+ )
78+
79+ batch = {
80+ feature_key : tensor for feature_key , tensor in zip (self .input_keys , inputs )
81+ }
82+ print (batch )
83+
84+ self .policy .eval ()
85+ # predict_action_chunk is coming from ACTPolicy
86+ actions = self .policy .predict_action_chunk (batch )
87+ return actions
88+
89+
90+ def load_model (model_weight_path , dataset_dir ):
91+ if not os .path .exists (model_weight_path ):
92+ raise FileNotFoundError ("Model weight path does not exist." )
93+
94+ if not os .path .exists (dataset_dir ):
95+ raise FileNotFoundError ("Dataset directory does not exist." )
96+
97+ dataset_metadata = LeRobotDatasetMetadata (
98+ "sample_lerobot_dataset" ,
99+ root = dataset_dir
100+ )
101+ features = dataset_to_policy_features (dataset_metadata .features )
102+ output_features = {
103+ key : ft for key , ft in features .items () if ft .type is FeatureType .ACTION
104+ }
105+ input_features = {
106+ key : ft for key , ft in features .items () if key not in output_features
107+ }
108+ logger .info (f"Input features: { input_features } " )
109+ logger .info (f"Output features: { output_features } " )
110+
111+ cfg = ACTConfig (
112+ input_features = input_features ,
113+ output_features = output_features ,
114+ )
115+ from lerobot .configs .policies import PreTrainedConfig
116+ cfg = PreTrainedConfig .from_pretrained (
117+ model_weight_path
118+ )
119+
120+ policy = ACTPolicy .from_pretrained (
121+ model_weight_path ,
122+ config = cfg ,
123+ )
124+ policy .to ("cpu" )
125+ logger .debug (f"Policy model info:\n { policy } " )
126+
127+ return policy
128+
129+
130+ def convert_to_openvino (policy , output_dir ):
131+ example_input = create_placeholder_observation (
132+ policy .config .input_features )
133+
134+ logger .info ("Creating wrapper for JIT tracing..." )
135+ wrapper = ACTPolicyWrapper (policy )
136+ wrapper .eval ()
137+
138+ # Prepare individual tensor inputs for tracing
139+ example_inputs = tuple (example_input [key ] for key in wrapper .input_keys )
140+
141+ logger .info ("Tracing the model ..." )
142+ try :
143+ with torch .no_grad ():
144+ traced_policy = torch .jit .trace (
145+ wrapper ,
146+ example_inputs ,
147+ strict = False
148+ )
149+ except Exception as e :
150+ raise RuntimeError (
151+ f"Error during tracing: { e } . "
152+ "Ensure the model is compatible with JIT tracing and all inputs are tensors."
153+ )
154+
155+ logger .info ("Testing traced model ..." )
156+ with torch .no_grad ():
157+ traced_output = traced_policy (* example_inputs )
158+ logger .debug (f"Traced output shape: { traced_output .shape } " )
159+ logger .debug (f"Traced output: { traced_output } " )
160+
161+ logger .info ("Saving the traced model ..." )
162+ traced_model_path = os .path .join (output_dir , "traced_model.pt" )
163+ traced_policy .save (traced_model_path )
164+ logger .info (f"Traced model saved to: { traced_model_path } " )
165+
166+ logger .info ("Converting to OpenVINO ..." )
167+ ov_model_path = os .path .join (output_dir , "model.xml" )
168+ ov_input = []
169+ for tensor in example_inputs :
170+ np_dtype = tensor .numpy ().dtype
171+ ov_dtype = ov .Type (np_dtype )
172+ ov_input .append ((list (tensor .shape ), ov_dtype ))
173+
174+ ov_model = ov .convert_model (traced_policy , input = ov_input )
175+ ov .save_model (ov_model , ov_model_path )
176+
177+ core = ov .Core ()
178+ saved_model = core .read_model (ov_model_path )
179+ for i , inp in enumerate (saved_model .inputs ):
180+ # Set the name according to the original feature keys
181+ inp .get_tensor ().set_names ({wrapper .input_keys [i ]})
182+ for i , out in enumerate (saved_model .outputs ):
183+ out .get_tensor ().set_names ({f"outputs_{ i } " })
184+
185+ logger .info (f"{ saved_model } " )
186+ saved_xml_path = os .path .join (output_dir , "ov_model.xml" )
187+ saved_bin_path = os .path .join (output_dir , "ov_model.bin" )
188+ serialize (saved_model , saved_xml_path , saved_bin_path )
189+ logger .info (f"OpenVINO model saved to: { saved_xml_path } " )
190+
191+
192+ def evaluate_ov_model (ov_model_path , example_input , device = "CPU" , enable_npu_high_precision = False ):
193+ """Evaluate the OpenVINO model with example input."""
194+ core = ov .Core ()
195+ if not os .path .exists (ov_model_path ):
196+ raise FileNotFoundError (
197+ f"OpenVINO model path does not exist: { ov_model_path } " )
198+
199+ ov_model = core .read_model (ov_model_path )
200+
201+ # Priotize latency for real-time applications
202+ compile_properties = {
203+ props .hint .performance_mode (): props .hint .PerformanceMode .LATENCY
204+ }
205+
206+ if device == "NPU" and enable_npu_high_precision :
207+ logger .info ("Enabling NPU high precision mode for specific layers..." )
208+ compile_properties ["NPU_COMPILATION_MODE_PARAMS" ] = "compute-layers-with-higher-precision=Sqrt,Power,ReduceMean,Add"
209+
210+ compiled_model = core .compile_model (ov_model , device , compile_properties )
211+ inputs = {
212+ input_tensor .any_name : example_input [key ].numpy ()
213+ for input_tensor , key in zip (compiled_model .inputs , example_input .keys ())
214+ }
215+
216+ execution_time = []
217+ for i in range (5 ):
218+ logger .info (f"Warm-up run { i + 1 } ..." )
219+ results = compiled_model (inputs )
220+
221+ for i in range (100 ):
222+ logger .info (f"Inferencing run { i + 1 } ..." )
223+ start_time = time .time ()
224+ results = compiled_model (inputs )
225+ execution_time .append (time .time () - start_time )
226+
227+ logger .info (
228+ f"[{ device } ] - Average execution time over 100 runs: { np .mean (execution_time ):.6f} seconds" )
229+ # The output is a dictionary, get the result tensor
230+ return list (results .values ())[0 ]
231+
232+
4233def main ():
5234 parser = argparse .ArgumentParser (
6235 description = "Convert Lerobot ACT model to OpenVINO format."
@@ -17,12 +246,6 @@ def main():
17246 required = True ,
18247 help = "Path to the dataset directory."
19248 )
20- parser .add_argument (
21- "--output-dir" ,
22- type = str ,
23- default = "./data/ov_models" ,
24- help = "Directory to save the converted OpenVINO model."
25- )
26249 parser .add_argument (
27250 "--run-eval" ,
28251 action = "store_true" ,
@@ -43,24 +266,16 @@ def main():
43266
44267 args = parser .parse_args ()
45268
46- # ---- NEVER STORE TAINTED PATHS ----
269+ output_dir = "./data/ov_models"
47270 model_weight_dir = args .model_weight_dir
48271 dataset_dir = args .dataset_dir
49272 run_eval = args .run_eval
50273 eval_device = args .eval_device
51274 enable_npu_high_precision = args .enable_npu_high_precision
52275
53- # ---- SANITIZE OUTPUT DIR (NEW VARIABLE) ----
54- try :
55- safe_output_dir = validate_output_dir (args .output_dir )
56- except ValueError as e :
57- print (f"Invalid output_dir: { e } " , file = sys .stderr )
58- sys .exit (1 )
59-
60- # ---- SAFE SINK ----
276+ safe_output_dir = os .path .abspath (output_dir )
61277 os .makedirs (safe_output_dir , exist_ok = True )
62278
63- # ---- DATASET SETUP ----
64279 dataset_repo_id = os .path .basename (os .path .normpath (dataset_dir ))
65280 dataset_root = os .path .abspath (dataset_dir )
66281 dataset = LeRobotDataset (
@@ -78,7 +293,6 @@ def main():
78293 )
79294 policy .config .temporal_ensemble_coeff = None
80295
81- # ---- USE SAFE PATH ONLY ----
82296 convert_to_openvino (policy , safe_output_dir )
83297
84298 if run_eval :
@@ -117,4 +331,7 @@ def main():
117331
118332 logger .info ("### Conversion Summary ###" )
119333 logger .info (f"- Max difference: { max_diff :.4f} " )
120- logger .info (f"- Mean difference: { mean_diff :.4f} " )
334+ logger .info (f"- Mean difference: { mean_diff :.4f} " )
335+
336+ if __name__ == "__main__" :
337+ main ()
0 commit comments