CocoBro commited on
Commit
dabc4d4
·
1 Parent(s): 0bcb372

fix some bugs

Browse files
Files changed (2) hide show
  1. app.py +52 -76
  2. requirements.txt +1 -0
app.py CHANGED
@@ -1,6 +1,9 @@
1
  #!/usr/bin/env python3
2
  # -*- coding: utf-8 -*-
3
 
 
 
 
4
  import os
5
  import time
6
  import logging
@@ -10,34 +13,15 @@ from typing import Tuple, Optional, Dict, Any
10
  import gradio as gr
11
  import numpy as np
12
  import soundfile as sf
13
- import torch
14
- import torchaudio
15
- import librosa
16
-
17
- import hydra
18
- from omegaconf import OmegaConf
19
- from safetensors.torch import load_file
20
- import diffusers.schedulers as noise_schedulers
21
  from huggingface_hub import snapshot_download
22
 
23
- # ZeroGPU 关键:spaces
24
- import spaces
25
-
26
- from models.common import LoadPretrainedBase
27
- from utils.config import register_omegaconf_resolvers
28
-
29
 
30
  # -----------------------------
31
  # Logging
32
  # -----------------------------
33
- logging.basicConfig(
34
- level=logging.INFO,
35
- format="%(asctime)s - %(levelname)s - %(message)s"
36
- )
37
  logger = logging.getLogger("mmedit_space")
38
 
39
- register_omegaconf_resolvers()
40
-
41
 
42
  # ---------------------------------------------------------
43
  # HF Repo IDs(按你的默认需求)
@@ -57,8 +41,10 @@ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
57
  USE_AMP = os.environ.get("USE_AMP", "0") == "1"
58
  AMP_DTYPE = os.environ.get("AMP_DTYPE", "bf16") # "bf16" or "fp16"
59
 
60
- # ZeroGPU:缓存 CPU pipeline(不要缓存 CUDA)
61
- _PIPELINE_CACHE: Dict[str, Tuple[LoadPretrainedBase, object, int]] = {}
 
 
62
  _MODEL_DIR_CACHE: Dict[str, Tuple[Path, Path]] = {}
63
 
64
 
@@ -66,11 +52,6 @@ _MODEL_DIR_CACHE: Dict[str, Tuple[Path, Path]] = {}
66
  # 下载 repo(只下载一次;huggingface_hub 自带缓存)
67
  # ---------------------------------------------------------
68
  def resolve_model_dirs() -> Tuple[Path, Path]:
69
- """
70
- 返回:
71
- repo_root: 你的 MMEdit repo 的本地目录(包含 config.yaml / model.safetensors / vae/)
72
- qwen_root: Qwen2-Audio repo 的本地目录
73
- """
74
  cache_key = f"{MMEDIT_REPO_ID}@{MMEDIT_REVISION}::{QWEN_REPO_ID}@{QWEN_REVISION}"
75
  if cache_key in _MODEL_DIR_CACHE:
76
  return _MODEL_DIR_CACHE[cache_key]
@@ -81,7 +62,7 @@ def resolve_model_dirs() -> Tuple[Path, Path]:
81
  revision=MMEDIT_REVISION,
82
  local_dir=None,
83
  local_dir_use_symlinks=False,
84
- token=HF_TOKEN, # 私有 repo 时也可用
85
  )
86
  repo_root = Path(repo_root).resolve()
87
 
@@ -102,7 +83,12 @@ def resolve_model_dirs() -> Tuple[Path, Path]:
102
  # ---------------------------------------------------------
103
  # 你的音频加载(按你要求:orig -> 16k -> target_sr)
104
  # ---------------------------------------------------------
105
- def load_and_process_audio(audio_path: str, target_sr: int) -> torch.Tensor:
 
 
 
 
 
106
  path = Path(audio_path)
107
  if not path.exists():
108
  raise FileNotFoundError(f"Audio file not found: {audio_path}")
@@ -121,22 +107,14 @@ def load_and_process_audio(audio_path: str, target_sr: int) -> torch.Tensor:
121
  # 1) 先到 16k
122
  sr_mid = 16000
123
  if int(orig_sr) != sr_mid:
124
- waveform_np = librosa.resample(
125
- waveform_np,
126
- orig_sr=int(orig_sr),
127
- target_sr=sr_mid
128
- )
129
  orig_sr_mid = sr_mid
130
  else:
131
  orig_sr_mid = int(orig_sr)
132
 
133
  # 2) 再到 target_sr(如 24k)
134
  if int(target_sr) != orig_sr_mid:
135
- waveform_np = librosa.resample(
136
- waveform_np,
137
- orig_sr=orig_sr_mid,
138
- target_sr=int(target_sr)
139
- )
140
 
141
  waveform = torch.from_numpy(waveform_np)
142
 
@@ -147,11 +125,7 @@ def load_and_process_audio(audio_path: str, target_sr: int) -> torch.Tensor:
147
  # 校验 repo 结构
148
  # ---------------------------------------------------------
149
  def assert_repo_layout(repo_root: Path) -> None:
150
- must = [
151
- repo_root / "config.yaml",
152
- repo_root / "model.safetensors",
153
- repo_root / "vae",
154
- ]
155
  for p in must:
156
  if not p.exists():
157
  raise FileNotFoundError(f"Missing required path: {p}")
@@ -162,19 +136,13 @@ def assert_repo_layout(repo_root: Path) -> None:
162
 
163
 
164
  # ---------------------------------------------------------
165
- # 关键:适配你这个 config.yaml 的路径写法
166
  # ---------------------------------------------------------
167
  def patch_paths_in_exp_config(exp_cfg: Dict[str, Any], repo_root: Path, qwen_root: Path) -> None:
168
- """
169
- 适配你 config.yaml:
170
- - pretrained_ckpt: ckpt/mmedit/vae/epoch=xx.ckpt -> repo_root/vae/epoch=xx.ckpt
171
- - model_path: ckpt/qwen2-audio-7B-instruct -> qwen_root (snapshot_download 结果)
172
- """
173
  # ---- 1) VAE ckpt ----
174
  vae_ckpt = exp_cfg["model"]["autoencoder"].get("pretrained_ckpt", None)
175
  if vae_ckpt:
176
  vae_ckpt = str(vae_ckpt).replace("\\", "/")
177
-
178
  idx = vae_ckpt.find("vae/")
179
  if idx != -1:
180
  vae_rel = vae_ckpt[idx:] # 从 vae/ 开始截断
@@ -202,17 +170,17 @@ def patch_paths_in_exp_config(exp_cfg: Dict[str, Any], repo_root: Path, qwen_roo
202
 
203
  # ---------------------------------------------------------
204
  # Scheduler(与你 exp_cfg.model.noise_scheduler_name 对齐)
205
- # 注意有些 repo_id 不存在 scheduler 子目录会 404
206
- # 这里给一个 fallback,避免直接炸。
207
  # ---------------------------------------------------------
208
  def build_scheduler(exp_cfg: Dict[str, Any]):
 
 
209
  name = exp_cfg["model"].get("noise_scheduler_name", "stabilityai/stable-diffusion-2-1")
210
  try:
211
  scheduler = noise_schedulers.DDIMScheduler.from_pretrained(name, subfolder="scheduler", token=HF_TOKEN)
212
  return scheduler
213
  except Exception as e:
214
- logger.warning(f"DDIMScheduler.from_pretrained failed for '{name}', fallback to default DDIM config. err={e}")
215
- # fallback:不依赖远端 repo
216
  return noise_schedulers.DDIMScheduler(
217
  num_train_timesteps=1000,
218
  beta_start=0.00085,
@@ -224,21 +192,35 @@ def build_scheduler(exp_cfg: Dict[str, Any]):
224
  )
225
 
226
 
227
- def _amp_ctx(device: torch.device):
228
- # ZeroGPU:只有在 device=cuda 且你明确开启 USE_AMP 才 autocast
 
229
  if not USE_AMP:
230
  return torch.autocast("cuda", enabled=False)
 
231
  if device.type != "cuda":
232
  return torch.autocast("cpu", enabled=False)
 
233
  dtype = torch.bfloat16 if AMP_DTYPE.lower() == "bf16" else torch.float16
234
  return torch.autocast("cuda", dtype=dtype, enabled=True)
235
 
236
 
237
  # ---------------------------------------------------------
238
  # 冷启动:load+cache pipeline(缓存 CPU 上的 model)
239
- # ZeroGPU 启动阶段一般没有 CUDA,所以这里不要 model.to("cuda")
240
  # ---------------------------------------------------------
241
- def load_pipeline_cpu() -> Tuple[LoadPretrainedBase, object, int]:
 
 
 
 
 
 
 
 
 
 
 
 
242
  cache_key = f"{MMEDIT_REPO_ID}@{MMEDIT_REVISION}::{QWEN_REPO_ID}@{QWEN_REVISION}"
243
  if cache_key in _PIPELINE_CACHE:
244
  return _PIPELINE_CACHE[cache_key]
@@ -257,15 +239,13 @@ def load_pipeline_cpu() -> Tuple[LoadPretrainedBase, object, int]:
257
  logger.info(f"patched pretrained_ckpt = {exp_cfg['model']['autoencoder'].get('pretrained_ckpt')}")
258
  logger.info(f"patched qwen model_path = {exp_cfg['model']['content_encoder']['text_encoder'].get('model_path')}")
259
 
260
- # instantiate model(在 CPU 上构建)
261
  model: LoadPretrainedBase = hydra.utils.instantiate(exp_cfg["model"], _convert_="all")
262
 
263
- # load weights(你的 mmedit 权重)
264
  ckpt_path = repo_root / "model.safetensors"
265
  sd = load_file(str(ckpt_path))
266
  model.load_pretrained(sd)
267
 
268
- # 强制留在 CPU(ZeroGPU 关键)
269
  model = model.to(torch.device("cpu")).eval()
270
 
271
  scheduler = build_scheduler(exp_cfg)
@@ -279,10 +259,8 @@ def load_pipeline_cpu() -> Tuple[LoadPretrainedBase, object, int]:
279
  # ---------------------------------------------------------
280
  # 推理:audio + caption -> edited audio
281
  # ZeroGPU:必须用 @spaces.GPU
282
- # 并且:函数内再把模型搬到 cuda,推完搬回 cpu
283
  # ---------------------------------------------------------
284
  @spaces.GPU
285
- @torch.no_grad()
286
  def run_edit(
287
  audio_file: str,
288
  caption: str,
@@ -291,6 +269,8 @@ def run_edit(
291
  guidance_rescale: float,
292
  seed: int,
293
  ) -> Tuple[Optional[str], str]:
 
 
294
  if audio_file is None or not Path(audio_file).exists():
295
  return None, "Error: please upload an audio file."
296
 
@@ -303,7 +283,7 @@ def run_edit(
303
 
304
  # 2) ZeroGPU 进入 GPU 区域后,cuda 才会 available
305
  if not torch.cuda.is_available():
306
- return None, "Error: ZeroGPU did not allocate CUDA. Please retry (queue) or check Space hardware."
307
 
308
  device = torch.device("cuda")
309
  logger.info(f"[GPU] torch.cuda.is_available={torch.cuda.is_available()}, device={device}")
@@ -325,7 +305,6 @@ def run_edit(
325
  "task": ["audio_editing"],
326
  }
327
 
328
- # 与 infer.config 对齐
329
  kwargs = {
330
  "num_steps": int(num_steps),
331
  "guidance_scale": float(guidance_scale),
@@ -336,20 +315,20 @@ def run_edit(
336
  kwargs.update(batch)
337
 
338
  t0 = time.time()
339
- with _amp_ctx(device):
340
- out = model.inference(scheduler=scheduler, **kwargs)
 
341
  dt = time.time() - t0
342
 
343
  out_audio = out[0, 0].detach().float().cpu().numpy()
344
  out_path = OUTPUT_DIR / f"{Path(audio_file).stem}_edited.wav"
345
  sf.write(str(out_path), out_audio, samplerate=target_sr)
346
 
347
- # 4) 推完立刻把模型搬回 CPU(ZeroGPU 关键:避免缓存残留 cuda tensor)
348
  model_cpu = model.to("cpu")
349
  del model
350
  torch.cuda.empty_cache()
351
 
352
- # 5) 更新缓存(仍然缓存 CPU 版本)
353
  cache_key = f"{MMEDIT_REPO_ID}@{MMEDIT_REVISION}::{QWEN_REPO_ID}@{QWEN_REVISION}"
354
  _PIPELINE_CACHE[cache_key] = (model_cpu, scheduler, target_sr)
355
 
@@ -363,14 +342,12 @@ def build_demo():
363
  with gr.Blocks(title="MMEdit (ZeroGPU)") as demo:
364
  gr.Markdown("# MMEdit ZeroGPU(audio + caption → edited audio)")
365
 
366
-
367
  with gr.Row():
368
  with gr.Column():
369
  audio_in = gr.Audio(label="Input Audio", type="filepath")
370
  caption = gr.Textbox(label="Caption (Edit Instruction)", lines=3)
371
 
372
- # 注意:Spaces允许你 push wav 示例。
373
- # 最稳的方式:你自己在 Space repo 放一个很小的 demo wav(几百 KB)。
374
  gr.Examples(
375
  label="example inputs",
376
  examples=[
@@ -403,16 +380,15 @@ def build_demo():
403
  gr.Markdown(
404
  "## 注意事项\n"
405
  "1) ZeroGPU 首次点击会分配 GPU,可能稍慢。\n"
406
- "2) 如果遇到错误重试(尤其是首启动时)。\n"
407
- "3) 原始音频保留可能有bug\n"
408
  )
 
409
  return demo
410
 
411
 
412
  if __name__ == "__main__":
413
  demo = build_demo()
414
  port = int(os.environ.get("PORT", "7860"))
415
- # ZeroGPU:强烈建议 queue;并禁用 SSR 更稳
416
  demo.queue().launch(
417
  server_name="0.0.0.0",
418
  server_port=port,
 
1
  #!/usr/bin/env python3
2
  # -*- coding: utf-8 -*-
3
 
4
+ # ZeroGPU 关键:必须最先导入
5
+ import spaces
6
+
7
  import os
8
  import time
9
  import logging
 
13
  import gradio as gr
14
  import numpy as np
15
  import soundfile as sf
 
 
 
 
 
 
 
 
16
  from huggingface_hub import snapshot_download
17
 
 
 
 
 
 
 
18
 
19
  # -----------------------------
20
  # Logging
21
  # -----------------------------
22
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
 
 
 
23
  logger = logging.getLogger("mmedit_space")
24
 
 
 
25
 
26
  # ---------------------------------------------------------
27
  # HF Repo IDs(按你的默认需求)
 
41
  USE_AMP = os.environ.get("USE_AMP", "0") == "1"
42
  AMP_DTYPE = os.environ.get("AMP_DTYPE", "bf16") # "bf16" or "fp16"
43
 
44
+ # ZeroGPU:缓存 CPU pipeline(不要缓存 CUDA Tensor
45
+ # cache: key -> (model_cpu, scheduler, target_sr)
46
+ _PIPELINE_CACHE: Dict[str, Tuple[object, object, int]] = {}
47
+ # cache: key -> (repo_root, qwen_root)
48
  _MODEL_DIR_CACHE: Dict[str, Tuple[Path, Path]] = {}
49
 
50
 
 
52
  # 下载 repo(只下载一次;huggingface_hub 自带缓存)
53
  # ---------------------------------------------------------
54
  def resolve_model_dirs() -> Tuple[Path, Path]:
 
 
 
 
 
55
  cache_key = f"{MMEDIT_REPO_ID}@{MMEDIT_REVISION}::{QWEN_REPO_ID}@{QWEN_REVISION}"
56
  if cache_key in _MODEL_DIR_CACHE:
57
  return _MODEL_DIR_CACHE[cache_key]
 
62
  revision=MMEDIT_REVISION,
63
  local_dir=None,
64
  local_dir_use_symlinks=False,
65
+ token=HF_TOKEN,
66
  )
67
  repo_root = Path(repo_root).resolve()
68
 
 
83
  # ---------------------------------------------------------
84
  # 你的音频加载(按你要求:orig -> 16k -> target_sr)
85
  # ---------------------------------------------------------
86
+ def load_and_process_audio(audio_path: str, target_sr: int):
87
+ # 延迟导入(避免启动阶段触发 CUDA 初始化)
88
+ import torch
89
+ import torchaudio
90
+ import librosa
91
+
92
  path = Path(audio_path)
93
  if not path.exists():
94
  raise FileNotFoundError(f"Audio file not found: {audio_path}")
 
107
  # 1) 先到 16k
108
  sr_mid = 16000
109
  if int(orig_sr) != sr_mid:
110
+ waveform_np = librosa.resample(waveform_np, orig_sr=int(orig_sr), target_sr=sr_mid)
 
 
 
 
111
  orig_sr_mid = sr_mid
112
  else:
113
  orig_sr_mid = int(orig_sr)
114
 
115
  # 2) 再到 target_sr(如 24k)
116
  if int(target_sr) != orig_sr_mid:
117
+ waveform_np = librosa.resample(waveform_np, orig_sr=orig_sr_mid, target_sr=int(target_sr))
 
 
 
 
118
 
119
  waveform = torch.from_numpy(waveform_np)
120
 
 
125
  # 校验 repo 结构
126
  # ---------------------------------------------------------
127
  def assert_repo_layout(repo_root: Path) -> None:
128
+ must = [repo_root / "config.yaml", repo_root / "model.safetensors", repo_root / "vae"]
 
 
 
 
129
  for p in must:
130
  if not p.exists():
131
  raise FileNotFoundError(f"Missing required path: {p}")
 
136
 
137
 
138
  # ---------------------------------------------------------
139
+ # 适配 config.yaml 的路径写法
140
  # ---------------------------------------------------------
141
  def patch_paths_in_exp_config(exp_cfg: Dict[str, Any], repo_root: Path, qwen_root: Path) -> None:
 
 
 
 
 
142
  # ---- 1) VAE ckpt ----
143
  vae_ckpt = exp_cfg["model"]["autoencoder"].get("pretrained_ckpt", None)
144
  if vae_ckpt:
145
  vae_ckpt = str(vae_ckpt).replace("\\", "/")
 
146
  idx = vae_ckpt.find("vae/")
147
  if idx != -1:
148
  vae_rel = vae_ckpt[idx:] # 从 vae/ 开始截断
 
170
 
171
  # ---------------------------------------------------------
172
  # Scheduler(与你 exp_cfg.model.noise_scheduler_name 对齐)
173
+ # 带 fallback避免 404
 
174
  # ---------------------------------------------------------
175
  def build_scheduler(exp_cfg: Dict[str, Any]):
176
+ import diffusers.schedulers as noise_schedulers
177
+
178
  name = exp_cfg["model"].get("noise_scheduler_name", "stabilityai/stable-diffusion-2-1")
179
  try:
180
  scheduler = noise_schedulers.DDIMScheduler.from_pretrained(name, subfolder="scheduler", token=HF_TOKEN)
181
  return scheduler
182
  except Exception as e:
183
+ logger.warning(f"DDIMScheduler.from_pretrained failed for '{name}', fallback. err={e}")
 
184
  return noise_schedulers.DDIMScheduler(
185
  num_train_timesteps=1000,
186
  beta_start=0.00085,
 
192
  )
193
 
194
 
195
+ def amp_autocast(device):
196
+ import torch
197
+
198
  if not USE_AMP:
199
  return torch.autocast("cuda", enabled=False)
200
+
201
  if device.type != "cuda":
202
  return torch.autocast("cpu", enabled=False)
203
+
204
  dtype = torch.bfloat16 if AMP_DTYPE.lower() == "bf16" else torch.float16
205
  return torch.autocast("cuda", dtype=dtype, enabled=True)
206
 
207
 
208
  # ---------------------------------------------------------
209
  # 冷启动:load+cache pipeline(缓存 CPU 上的 model)
 
210
  # ---------------------------------------------------------
211
+ def load_pipeline_cpu() -> Tuple[object, object, int]:
212
+ # 延迟导入(避免启动阶段触发 CUDA 初始化)
213
+ import torch
214
+ import hydra
215
+ from omegaconf import OmegaConf
216
+ from safetensors.torch import load_file
217
+
218
+ # 你的项目依赖也延迟导入
219
+ from models.common import LoadPretrainedBase
220
+ from utils.config import register_omegaconf_resolvers
221
+
222
+ register_omegaconf_resolvers()
223
+
224
  cache_key = f"{MMEDIT_REPO_ID}@{MMEDIT_REVISION}::{QWEN_REPO_ID}@{QWEN_REVISION}"
225
  if cache_key in _PIPELINE_CACHE:
226
  return _PIPELINE_CACHE[cache_key]
 
239
  logger.info(f"patched pretrained_ckpt = {exp_cfg['model']['autoencoder'].get('pretrained_ckpt')}")
240
  logger.info(f"patched qwen model_path = {exp_cfg['model']['content_encoder']['text_encoder'].get('model_path')}")
241
 
 
242
  model: LoadPretrainedBase = hydra.utils.instantiate(exp_cfg["model"], _convert_="all")
243
 
 
244
  ckpt_path = repo_root / "model.safetensors"
245
  sd = load_file(str(ckpt_path))
246
  model.load_pretrained(sd)
247
 
248
+ # ZeroGPU:缓存 CPU
249
  model = model.to(torch.device("cpu")).eval()
250
 
251
  scheduler = build_scheduler(exp_cfg)
 
259
  # ---------------------------------------------------------
260
  # 推理:audio + caption -> edited audio
261
  # ZeroGPU:必须用 @spaces.GPU
 
262
  # ---------------------------------------------------------
263
  @spaces.GPU
 
264
  def run_edit(
265
  audio_file: str,
266
  caption: str,
 
269
  guidance_rescale: float,
270
  seed: int,
271
  ) -> Tuple[Optional[str], str]:
272
+ import torch
273
+
274
  if audio_file is None or not Path(audio_file).exists():
275
  return None, "Error: please upload an audio file."
276
 
 
283
 
284
  # 2) ZeroGPU 进入 GPU 区域后,cuda 才会 available
285
  if not torch.cuda.is_available():
286
+ return None, "Error: ZeroGPU did not allocate CUDA. Please retry or check Space hardware."
287
 
288
  device = torch.device("cuda")
289
  logger.info(f"[GPU] torch.cuda.is_available={torch.cuda.is_available()}, device={device}")
 
305
  "task": ["audio_editing"],
306
  }
307
 
 
308
  kwargs = {
309
  "num_steps": int(num_steps),
310
  "guidance_scale": float(guidance_scale),
 
315
  kwargs.update(batch)
316
 
317
  t0 = time.time()
318
+ with torch.no_grad():
319
+ with amp_autocast(device):
320
+ out = model.inference(scheduler=scheduler, **kwargs)
321
  dt = time.time() - t0
322
 
323
  out_audio = out[0, 0].detach().float().cpu().numpy()
324
  out_path = OUTPUT_DIR / f"{Path(audio_file).stem}_edited.wav"
325
  sf.write(str(out_path), out_audio, samplerate=target_sr)
326
 
327
+ # 4) 推完立刻把模型搬回 CPU(避免缓存残留 cuda tensor)
328
  model_cpu = model.to("cpu")
329
  del model
330
  torch.cuda.empty_cache()
331
 
 
332
  cache_key = f"{MMEDIT_REPO_ID}@{MMEDIT_REVISION}::{QWEN_REPO_ID}@{QWEN_REVISION}"
333
  _PIPELINE_CACHE[cache_key] = (model_cpu, scheduler, target_sr)
334
 
 
342
  with gr.Blocks(title="MMEdit (ZeroGPU)") as demo:
343
  gr.Markdown("# MMEdit ZeroGPU(audio + caption → edited audio)")
344
 
 
345
  with gr.Row():
346
  with gr.Column():
347
  audio_in = gr.Audio(label="Input Audio", type="filepath")
348
  caption = gr.Textbox(label="Caption (Edit Instruction)", lines=3)
349
 
350
+ # 注意:Space建议推大 wav;你可以换成更小的 demo wav
 
351
  gr.Examples(
352
  label="example inputs",
353
  examples=[
 
380
  gr.Markdown(
381
  "## 注意事项\n"
382
  "1) ZeroGPU 首次点击会分配 GPU,可能稍慢。\n"
383
+ "2) 如果首次报 cuda 不可用通常重试即可。\n"
 
384
  )
385
+
386
  return demo
387
 
388
 
389
  if __name__ == "__main__":
390
  demo = build_demo()
391
  port = int(os.environ.get("PORT", "7860"))
 
392
  demo.queue().launch(
393
  server_name="0.0.0.0",
394
  server_port=port,
requirements.txt CHANGED
@@ -1,4 +1,5 @@
1
  gradio==4.26.0
 
2
  # --- Core Framework (Pinned Versions) ---
3
  torch==2.5.1
4
  torchvision==0.20.1
 
1
  gradio==4.26.0
2
+ spaces>=0.13.0
3
  # --- Core Framework (Pinned Versions) ---
4
  torch==2.5.1
5
  torchvision==0.20.1