futurefantasy commited on
Commit
1fcc9a1
·
verified ·
1 Parent(s): cfe62fb

Add quick_start/run_example.py

Browse files
Files changed (1) hide show
  1. quick_start/run_example.py +487 -0
quick_start/run_example.py ADDED
@@ -0,0 +1,487 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import math
7
+ import os
8
+ import re
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+ from PIL import Image
15
+
16
+ THIS_DIR = Path(__file__).resolve().parent
17
+ MODEL_ROOT = THIS_DIR.parent
18
+ EXAMPLES_ROOT = MODEL_ROOT / "examples"
19
+ MANIFEST_PATH = EXAMPLES_ROOT / "examples_manifest.json"
20
+ CHUNK_ALL_SAMPLE_HZ = 2.0
21
+
22
+ SIGNED_NUM = r"([+-]?[0-9]+(?:\.[0-9]+)?)"
23
+ POINT_TIME_RE = re.compile(r"(?:Time|时间)[::]?\s*([0-9]+(?:\.[0-9]+)?)\s*s?", re.IGNORECASE)
24
+ POINT_PROGRESS_LINE_RE = re.compile(rf"(?im)^\s*(?:Progress|进度)[::]?\s*{SIGNED_NUM}\s*%")
25
+ INLINE_POINT_RE = re.compile(
26
+ rf"(?:Time|时间)[::]?\s*([0-9]+(?:\.[0-9]+)?)\s*s?\s*[,,]?\s*(?:Progress|进度)[::]?\s*{SIGNED_NUM}\s*%",
27
+ re.IGNORECASE,
28
+ )
29
+
30
+
31
+ def parse_args() -> argparse.Namespace:
32
+ parser = argparse.ArgumentParser(
33
+ description="Run the released VLAC model on one bundled example with chunk_all prompt and fixed 2hz video input."
34
+ )
35
+ parser.add_argument(
36
+ "--model-path",
37
+ type=Path,
38
+ default=MODEL_ROOT,
39
+ help="Path to the released VLAC model directory.",
40
+ )
41
+ parser.add_argument(
42
+ "--example-id",
43
+ type=str,
44
+ required=True,
45
+ help="Bundled example id, for example: example_01",
46
+ )
47
+ parser.add_argument(
48
+ "--output-jsonl",
49
+ type=Path,
50
+ default=None,
51
+ help="Optional output path. Defaults to quick_start/outputs/<example_id>.jsonl",
52
+ )
53
+ parser.add_argument(
54
+ "--max-new-tokens",
55
+ type=int,
56
+ default=1024,
57
+ help="Generation cap for the response.",
58
+ )
59
+ return parser.parse_args()
60
+
61
+
62
+ def load_manifest() -> dict[str, dict]:
63
+ payload = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
64
+ return {item["example_id"]: item for item in payload.get("examples", [])}
65
+
66
+
67
+ def strip_code_fence(text: str) -> str:
68
+ cleaned = str(text or "").strip()
69
+ if cleaned.startswith("```") and cleaned.endswith("```"):
70
+ lines = cleaned.splitlines()
71
+ if len(lines) >= 3:
72
+ return "\n".join(lines[1:-1]).strip()
73
+ return cleaned
74
+
75
+
76
+ def extract_plan_text(task_instruction: str, task_description: str) -> str:
77
+ lines = [line.strip() for line in str(task_description or "").splitlines() if line.strip()]
78
+ if not lines:
79
+ return ""
80
+ first_line = lines[0].rstrip("::")
81
+ normalized_instruction = str(task_instruction or "").strip().rstrip("::")
82
+ if normalized_instruction and first_line == normalized_instruction:
83
+ lines = lines[1:]
84
+ return "\n".join(lines).strip()
85
+
86
+
87
+ def build_chunk_all_prompt(metadata: dict[str, Any]) -> str:
88
+ task_instruction = str(metadata.get("task_instruction") or "").strip()
89
+ task_description = str(metadata.get("task_description") or "").strip()
90
+ plan_text = extract_plan_text(task_instruction, task_description)
91
+ task_and_plan = task_instruction if not plan_text else f"{task_instruction}\n{plan_text}"
92
+ return (
93
+ f"任务描述和具体规划: {task_and_plan}\n\n"
94
+ "请根据任务描述和具体规划,找到并逐点生成视频中的关键动作点和相应的进度标注。"
95
+ "输出格式要求:每个关键点一行,格式为:\n"
96
+ "时间: X.Xs, 进度: Y%\n\n"
97
+ "请严格按照上述格式输出,不要输出额外说明。"
98
+ )
99
+
100
+
101
+ def compute_sampled_indices_2hz(num_frames: int, fps: float, sample_hz: float) -> tuple[list[int], list[float]]:
102
+ if num_frames <= 0 or fps <= 0 or sample_hz <= 0:
103
+ return [], []
104
+ frame_ids = list(range(num_frames))
105
+ eligible_arr = np.array(frame_ids, dtype=float)
106
+ start_frame = frame_ids[0]
107
+ end_frame = frame_ids[-1]
108
+ duration_sec = max(0.0, (end_frame - start_frame) / fps)
109
+ step_sec = 1.0 / sample_hz
110
+
111
+ target_times: list[float] = []
112
+ current = 0.0
113
+ eps = 1e-9
114
+ while current <= duration_sec + eps:
115
+ target_times.append(round(current, 6))
116
+ current += step_sec
117
+ if not target_times:
118
+ target_times = [0.0]
119
+
120
+ sampled_indices: list[int] = []
121
+ sampled_timestamps_sec: list[float] = []
122
+ seen = set()
123
+ for target_time in target_times:
124
+ target_frame = start_frame + target_time * fps
125
+ pos = int(np.argmin(np.abs(eligible_arr - target_frame)))
126
+ idx = int(eligible_arr[pos])
127
+ if idx in seen:
128
+ continue
129
+ seen.add(idx)
130
+ sampled_indices.append(idx)
131
+ sampled_timestamps_sec.append(round((idx - start_frame) / fps, 6))
132
+ if not sampled_indices:
133
+ sampled_indices = [0]
134
+ sampled_timestamps_sec = [0.0]
135
+ return sampled_indices, sampled_timestamps_sec
136
+
137
+
138
+ def extract_sampled_frame_paths(
139
+ video_path: Path,
140
+ sampled_indices: list[int],
141
+ *,
142
+ temp_dir: Path,
143
+ ) -> tuple[list[str], dict[str, float]]:
144
+ try:
145
+ from decord import VideoReader, cpu
146
+ except ImportError as exc:
147
+ raise SystemExit("Missing dependency: decord is required for 2hz frame sampling in quick_start.") from exc
148
+
149
+ vr = VideoReader(str(video_path), ctx=cpu(0), num_threads=1)
150
+ actual_frame_count = len(vr)
151
+ if not sampled_indices:
152
+ raise SystemExit("No sampled frame indices were generated.")
153
+ if sampled_indices[-1] >= actual_frame_count:
154
+ raise SystemExit(
155
+ f"Bundled video is shorter than expected. Need frame index {sampled_indices[-1]}, got {actual_frame_count} frames."
156
+ )
157
+
158
+ batch = vr.get_batch(sampled_indices).asnumpy()
159
+ frame_paths: list[str] = []
160
+ for idx, frame in zip(sampled_indices, batch, strict=True):
161
+ frame_path = temp_dir / f"frame_{idx:06d}.jpg"
162
+ Image.fromarray(frame).save(frame_path, quality=95)
163
+ frame_paths.append(str(frame_path.resolve()))
164
+
165
+ video_stats = {
166
+ "decoded_frame_count": float(actual_frame_count),
167
+ "decoded_avg_fps": float(vr.get_avg_fps()),
168
+ }
169
+ return frame_paths, video_stats
170
+
171
+
172
+ def load_swift_runtime():
173
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
174
+ os.environ.setdefault("IMAGE_MAX_TOKEN_NUM", "256")
175
+ os.environ.setdefault("VIDEO_MAX_TOKEN_NUM", "256")
176
+ os.environ.setdefault("VIDEO_MIN_TOKEN_NUM", "4")
177
+ os.environ.setdefault("QWEN_VL_UTILS_MAX_FRAME_LIST", "0")
178
+ import torch
179
+ try:
180
+ from swift.llm import InferRequest, PtEngine, RequestConfig
181
+ except ImportError:
182
+ from swift.infer_engine import InferRequest, RequestConfig, TransformersEngine as PtEngine # type: ignore
183
+ return torch, InferRequest, PtEngine, RequestConfig
184
+
185
+
186
+ def dedupe_sorted_points(times: list[float], values: list[float]) -> tuple[list[float], list[float]]:
187
+ if not times:
188
+ return [], []
189
+ pairs = sorted(zip(times, values), key=lambda item: (item[0], item[1]))
190
+ out_times: list[float] = []
191
+ out_values: list[float] = []
192
+ cur_time = pairs[0][0]
193
+ bucket: list[float] = []
194
+ for time_val, progress_val in pairs:
195
+ if not math.isclose(time_val, cur_time, rel_tol=0.0, abs_tol=1e-9):
196
+ out_times.append(float(cur_time))
197
+ out_values.append(float(sum(bucket) / len(bucket)))
198
+ cur_time = time_val
199
+ bucket = [float(progress_val)]
200
+ else:
201
+ bucket.append(float(progress_val))
202
+ out_times.append(float(cur_time))
203
+ out_values.append(float(sum(bucket) / len(bucket)))
204
+ return out_times, out_values
205
+
206
+
207
+ def parse_point_blocks(text: str) -> tuple[list[float], list[float]]:
208
+ cleaned = strip_code_fence(text)
209
+ if not cleaned:
210
+ return [], []
211
+
212
+ inline_matches = INLINE_POINT_RE.findall(cleaned)
213
+ if inline_matches:
214
+ return dedupe_sorted_points(
215
+ [float(time_val) for time_val, _ in inline_matches],
216
+ [float(progress_val) for _, progress_val in inline_matches],
217
+ )
218
+
219
+ blocks = re.split(r"(?=(?:Time|时间)[::]?\s*[0-9])", cleaned, flags=re.IGNORECASE)
220
+ times: list[float] = []
221
+ values: list[float] = []
222
+ for block in blocks:
223
+ block = block.strip()
224
+ if not block:
225
+ continue
226
+ time_match = POINT_TIME_RE.search(block)
227
+ progress_match = POINT_PROGRESS_LINE_RE.search(block)
228
+ if time_match and progress_match:
229
+ times.append(float(time_match.group(1)))
230
+ values.append(float(progress_match.group(1)))
231
+ return dedupe_sorted_points(times, values)
232
+
233
+
234
+ def align_curve_to_gt_dense(
235
+ point_times_sec: list[float],
236
+ point_values: list[float],
237
+ gt_times_sec: list[float],
238
+ ) -> list[float]:
239
+ if not gt_times_sec or not point_values:
240
+ return []
241
+ if len(point_values) == 1:
242
+ return [float(point_values[0])] * len(gt_times_sec)
243
+ times, values = dedupe_sorted_points(point_times_sec, point_values)
244
+ if len(values) == 1:
245
+ return [float(values[0])] * len(gt_times_sec)
246
+ aligned = np.interp(
247
+ np.array(gt_times_sec, dtype=float),
248
+ np.array(times, dtype=float),
249
+ np.array(values, dtype=float),
250
+ left=float(values[0]),
251
+ right=float(values[-1]),
252
+ )
253
+ return [float(x) for x in aligned.tolist()]
254
+
255
+
256
+ def pearson_corr(xs: list[float], ys: list[float]) -> float | None:
257
+ if len(xs) != len(ys) or len(xs) < 2:
258
+ return None
259
+ x = np.array(xs, dtype=float)
260
+ y = np.array(ys, dtype=float)
261
+ if np.allclose(x, x[0]) or np.allclose(y, y[0]):
262
+ return None
263
+ value = float(np.corrcoef(x, y)[0, 1])
264
+ if math.isnan(value) or not math.isfinite(value):
265
+ return None
266
+ return value
267
+
268
+
269
+ def average_ranks(values: list[float]) -> list[float]:
270
+ arr = np.array(values, dtype=float)
271
+ order = np.argsort(arr, kind="mergesort")
272
+ ranks = np.zeros(arr.shape[0], dtype=float)
273
+ idx = 0
274
+ while idx < len(order):
275
+ next_idx = idx + 1
276
+ while next_idx < len(order) and math.isclose(
277
+ arr[order[next_idx]],
278
+ arr[order[idx]],
279
+ rel_tol=0.0,
280
+ abs_tol=1e-9,
281
+ ):
282
+ next_idx += 1
283
+ ranks[order[idx:next_idx]] = (idx + next_idx - 1) / 2.0 + 1.0
284
+ idx = next_idx
285
+ return ranks.tolist()
286
+
287
+
288
+ def spearman_corr(xs: list[float], ys: list[float]) -> float | None:
289
+ return pearson_corr(average_ranks(xs), average_ranks(ys))
290
+
291
+
292
+ def compute_curve_metrics(gt_dense_progress: list[float], pred_dense_progress: list[float]) -> dict[str, float | int | None]:
293
+ if not gt_dense_progress or len(gt_dense_progress) != len(pred_dense_progress):
294
+ return {
295
+ "point_count": 0,
296
+ "mae": None,
297
+ "rmse": None,
298
+ "pearson": None,
299
+ "spearman": None,
300
+ }
301
+ gt_arr = np.array(gt_dense_progress, dtype=float)
302
+ pred_arr = np.array(pred_dense_progress, dtype=float)
303
+ diff = pred_arr - gt_arr
304
+ return {
305
+ "point_count": int(len(gt_dense_progress)),
306
+ "mae": float(np.mean(np.abs(diff))),
307
+ "rmse": float(np.sqrt(np.mean(diff ** 2))),
308
+ "pearson": pearson_corr(pred_arr.tolist(), gt_arr.tolist()),
309
+ "spearman": spearman_corr(pred_arr.tolist(), gt_arr.tolist()),
310
+ }
311
+
312
+
313
+ def maybe_write_curve_plot(
314
+ *,
315
+ output_path: Path,
316
+ gt_times_sec: list[float],
317
+ gt_dense_progress: list[float],
318
+ pred_dense_progress: list[float],
319
+ pred_point_times_sec: list[float],
320
+ pred_point_progress: list[float],
321
+ ) -> str | None:
322
+ try:
323
+ import matplotlib.pyplot as plt
324
+ except ImportError:
325
+ return None
326
+
327
+ plot_path = output_path.with_name(f"{output_path.stem}_curve_compare.png")
328
+ fig, ax = plt.subplots(figsize=(10, 4.8))
329
+ ax.plot(gt_times_sec, gt_dense_progress, color="#2563eb", linewidth=2.2, label="GT dense progress")
330
+ if pred_dense_progress:
331
+ ax.plot(gt_times_sec, pred_dense_progress, color="#f97316", linewidth=2.2, label="Pred aligned curve")
332
+ if pred_point_progress:
333
+ ax.scatter(
334
+ pred_point_times_sec,
335
+ pred_point_progress,
336
+ color="#111827",
337
+ s=28,
338
+ zorder=3,
339
+ label="Pred chunk_all points",
340
+ )
341
+ ax.set_xlabel("Time (s)")
342
+ ax.set_ylabel("Progress (%)")
343
+ ax.grid(alpha=0.2, linewidth=0.8)
344
+ ax.legend(loc="best")
345
+ fig.tight_layout()
346
+ fig.savefig(plot_path, dpi=180)
347
+ plt.close(fig)
348
+ return str(plot_path.resolve())
349
+
350
+
351
+ def format_metric(value: float | None) -> str:
352
+ if value is None:
353
+ return "N/A"
354
+ return f"{value:.4f}"
355
+
356
+
357
+ def main() -> int:
358
+ args = parse_args()
359
+ manifest = load_manifest()
360
+ if args.example_id not in manifest:
361
+ raise SystemExit(f"Unknown example id: {args.example_id}")
362
+
363
+ example_info = manifest[args.example_id]
364
+ metadata_path = MODEL_ROOT / example_info["metadata_path"]
365
+ video_path = MODEL_ROOT / example_info["video_path"]
366
+ if not metadata_path.exists():
367
+ raise SystemExit(f"Missing metadata: {metadata_path}")
368
+ if not video_path.exists():
369
+ raise SystemExit(f"Missing video: {video_path}")
370
+
371
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
372
+ gt_dense_progress = [float(x) for x in list(metadata.get("benchmark_dense_progress") or [])]
373
+ fps = float(metadata.get("fps") or 0.0)
374
+ num_frames = int(metadata.get("num_frames") or len(gt_dense_progress))
375
+ if not gt_dense_progress:
376
+ raise SystemExit("Missing benchmark_dense_progress in example metadata.")
377
+ if fps <= 0 or num_frames <= 0:
378
+ raise SystemExit(f"Invalid metadata fps/num_frames: fps={fps}, num_frames={num_frames}")
379
+
380
+ prompt = build_chunk_all_prompt(metadata)
381
+ output_path = args.output_jsonl or (THIS_DIR / "outputs" / f"{args.example_id}.jsonl")
382
+ output_path.parent.mkdir(parents=True, exist_ok=True)
383
+
384
+ sampled_indices_2hz, sampled_timestamps_sec_2hz = compute_sampled_indices_2hz(
385
+ num_frames=num_frames,
386
+ fps=fps,
387
+ sample_hz=CHUNK_ALL_SAMPLE_HZ,
388
+ )
389
+ gt_times_sec = [round(frame_idx / fps, 6) for frame_idx in range(len(gt_dense_progress))]
390
+
391
+ torch, InferRequest, PtEngine, RequestConfig = load_swift_runtime()
392
+ device_map = "auto"
393
+ engine = PtEngine(
394
+ str(args.model_path.resolve()),
395
+ model_type="qwen3_moe_vl",
396
+ max_batch_size=1,
397
+ device_map=device_map,
398
+ )
399
+
400
+ with tempfile.TemporaryDirectory(prefix=f"{args.example_id}_2hz_frames_") as temp_dir_str:
401
+ temp_dir = Path(temp_dir_str)
402
+ frame_paths, video_stats = extract_sampled_frame_paths(
403
+ video_path,
404
+ sampled_indices_2hz,
405
+ temp_dir=temp_dir,
406
+ )
407
+ request = InferRequest(
408
+ messages=[
409
+ {
410
+ "role": "user",
411
+ "content": [
412
+ {"type": "video", "video": frame_paths},
413
+ {"type": "text", "text": prompt},
414
+ ],
415
+ }
416
+ ]
417
+ )
418
+ response = engine.infer(
419
+ [request],
420
+ RequestConfig(max_tokens=args.max_new_tokens, temperature=0.0, top_k=1, top_p=1.0),
421
+ )[0].choices[0].message.content
422
+
423
+ pred_point_times_sec, pred_point_progress = parse_point_blocks(response)
424
+ pred_dense_progress = align_curve_to_gt_dense(
425
+ point_times_sec=pred_point_times_sec,
426
+ point_values=pred_point_progress,
427
+ gt_times_sec=gt_times_sec,
428
+ )
429
+ curve_metrics = compute_curve_metrics(gt_dense_progress, pred_dense_progress)
430
+ plot_path = maybe_write_curve_plot(
431
+ output_path=output_path,
432
+ gt_times_sec=gt_times_sec,
433
+ gt_dense_progress=gt_dense_progress,
434
+ pred_dense_progress=pred_dense_progress,
435
+ pred_point_times_sec=pred_point_times_sec,
436
+ pred_point_progress=pred_point_progress,
437
+ )
438
+
439
+ output_row = {
440
+ "example_id": args.example_id,
441
+ "bucket": metadata.get("bucket"),
442
+ "global_episode_id": metadata.get("global_episode_id"),
443
+ "task_instruction": metadata.get("task_instruction"),
444
+ "task_description": metadata.get("task_description"),
445
+ "video_path": str(video_path.resolve()),
446
+ "prompt_variant": "chunk_all",
447
+ "input_sample_hz": CHUNK_ALL_SAMPLE_HZ,
448
+ "input_frame_indices_2hz": sampled_indices_2hz,
449
+ "input_timestamps_sec_2hz": sampled_timestamps_sec_2hz,
450
+ "input_frame_count_2hz": len(sampled_indices_2hz),
451
+ "decoded_video_stats": video_stats,
452
+ "prompt": prompt,
453
+ "response": response,
454
+ "benchmark_progress_type": metadata.get("benchmark_progress_type"),
455
+ "benchmark_progress_source": metadata.get("benchmark_progress_source"),
456
+ "benchmark_semantic_anchors": metadata.get("benchmark_semantic_anchors"),
457
+ "gt_dense_progress": gt_dense_progress,
458
+ "gt_dense_timestamps_sec": gt_times_sec,
459
+ "pred_curve_parse_ok": bool(pred_point_progress),
460
+ "pred_curve_point_times_sec": pred_point_times_sec,
461
+ "pred_curve_point_progress": pred_point_progress,
462
+ "pred_dense_progress_aligned_to_gt": pred_dense_progress,
463
+ "curve_compare_metrics": curve_metrics,
464
+ "curve_compare_plot": plot_path,
465
+ }
466
+ output_path.write_text(json.dumps(output_row, ensure_ascii=False) + "\n", encoding="utf-8")
467
+
468
+ print(f"example_id={args.example_id}")
469
+ print(f"video_path={video_path.resolve()}")
470
+ print(f"output_jsonl={output_path.resolve()}")
471
+ print(f"input_sample_hz={CHUNK_ALL_SAMPLE_HZ}")
472
+ print(f"input_frame_count_2hz={len(sampled_indices_2hz)}")
473
+ print(f"pred_keypoint_count={len(pred_point_progress)}")
474
+ print(f"gt_dense_point_count={len(gt_dense_progress)}")
475
+ print(f"curve_mae={format_metric(curve_metrics['mae'])}")
476
+ print(f"curve_rmse={format_metric(curve_metrics['rmse'])}")
477
+ print(f"curve_pearson={format_metric(curve_metrics['pearson'])}")
478
+ print(f"curve_spearman={format_metric(curve_metrics['spearman'])}")
479
+ if plot_path is not None:
480
+ print(f"curve_compare_plot={plot_path}")
481
+ print()
482
+ print(response)
483
+ return 0
484
+
485
+
486
+ if __name__ == "__main__":
487
+ raise SystemExit(main())