jimmy-yo commited on
Commit
96cf7ba
·
verified ·
1 Parent(s): f815fb3

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +106 -59
app.py CHANGED
@@ -1,29 +1,29 @@
1
  import os
2
  import mimetypes
3
- from typing import List, Tuple
4
 
5
  import cv2
6
  import gradio as gr
7
  import numpy as np
8
  from PIL import Image
9
  import torch
10
- from transformers import AutoProcessor, AutoModelForVision2Seq, TextStreamer
11
 
12
  # =========================
13
  # Config
14
  # =========================
15
  TITLE = "Qwen2-VL Visual Assistant"
16
- DESC = "Upload one image or a short video (≤15s). Ask anything in the chat like ChatGPT."
17
 
18
  MODEL_ID = "Qwen/Qwen2-VL-2B-Instruct"
19
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
20
  DTYPE = torch.float16 if DEVICE == "cuda" else torch.float32
21
 
22
- MAX_VIDEO_SECONDS = 15
23
- MAX_FILE_MB = 60
24
- MAX_SIDE = 768
25
- MAX_FRAMES = 4
26
- MAX_NEW_TOKENS = 256
27
 
28
  # =========================
29
  # Load model & processor
@@ -36,17 +36,36 @@ model = AutoModelForVision2Seq.from_pretrained(
36
  trust_remote_code=True,
37
  ).to(DEVICE)
38
 
 
39
  # =========================
40
  # Helpers
41
  # =========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  def is_image_file(path: str) -> bool:
43
  kind, _ = mimetypes.guess_type(path)
44
  return (kind or "").startswith("image/")
45
 
 
46
  def is_video_file(path: str) -> bool:
47
  kind, _ = mimetypes.guess_type(path)
48
  return (kind or "").startswith("video/")
49
 
 
50
  def resize_max_side(img: Image.Image, max_side: int = MAX_SIDE) -> Image.Image:
51
  w, h = img.size
52
  m = max(w, h)
@@ -55,20 +74,27 @@ def resize_max_side(img: Image.Image, max_side: int = MAX_SIDE) -> Image.Image:
55
  s = max_side / float(m)
56
  return img.resize((int(w * s), int(h * s)), Image.LANCZOS)
57
 
 
58
  def sample_video_frames(video_path: str, max_seconds=MAX_VIDEO_SECONDS, k=MAX_FRAMES) -> List[Image.Image]:
 
59
  cap = cv2.VideoCapture(video_path)
60
  if not cap.isOpened():
61
  return []
 
62
  fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
63
  total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
64
  duration = total / max(fps, 1)
 
 
65
  limit_frames = int(min(duration, max_seconds) * fps) if total else 0
66
  nframes = min(limit_frames or total, total) if total > 0 else 0
67
  if nframes == 0:
68
  nframes = int(min(duration, max_seconds) * fps) or 1
 
69
  k = max(1, min(k, nframes))
70
  idxs = [int(i * nframes / k + nframes / (2 * k)) for i in range(k)]
71
  idxs = sorted(set(max(0, min(nframes - 1, x)) for x in idxs))
 
72
  frames: List[Image.Image] = []
73
  for target in idxs:
74
  cap.set(cv2.CAP_PROP_POS_FRAMES, target)
@@ -81,7 +107,9 @@ def sample_video_frames(video_path: str, max_seconds=MAX_VIDEO_SECONDS, k=MAX_FR
81
  cap.release()
82
  return frames
83
 
 
84
  def build_messages(prompt: str) -> list:
 
85
  return [{
86
  "role": "user",
87
  "content": [
@@ -90,72 +118,88 @@ def build_messages(prompt: str) -> list:
90
  ]
91
  }]
92
 
 
93
  # =========================
94
  # Chat function
95
  # =========================
96
- def chat_fn(history, file, prompt):
97
- # Append user msg first
98
- history = history + [[prompt, None]]
 
99
 
100
- if file is None:
 
101
  history[-1][1] = "⚠️ Please upload an image or a short video first."
102
  yield history
103
  return
104
 
105
- path = file.name
106
- images = []
107
-
108
- if is_image_file(path):
109
- images = [resize_max_side(Image.open(path).convert("RGB"))]
110
- elif is_video_file(path):
111
- images = sample_video_frames(path, MAX_VIDEO_SECONDS, MAX_FRAMES)
112
- else:
113
- history[-1][1] = "⚠️ Unsupported file type."
114
- yield history
115
- return
116
-
117
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  messages = build_messages(prompt)
119
- chat_str = processor.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
 
 
120
 
 
121
  enc = processor(
122
  text=chat_str,
123
- images=images if len(images) > 1 else images[0],
124
  return_tensors="pt"
125
  ).to(model.device)
126
 
127
- acc = ""
128
- streamer = TextStreamer(
129
- processor.tokenizer,
130
- skip_prompt=True,
131
- skip_special_tokens=True,
132
- callback=lambda s: None
133
- )
134
-
135
- # Run generation with streaming
136
  with torch.no_grad():
137
- for out in model.generate(
138
  **enc,
139
  max_new_tokens=MAX_NEW_TOKENS,
140
  do_sample=False,
141
- streamer=streamer,
142
- return_dict_in_generate=True,
143
- output_scores=False,
144
- ):
145
- text = processor.batch_decode(
146
- out[:, enc.input_ids.shape[-1]:], skip_special_tokens=True
147
- )[0]
148
- acc = text.strip()
149
- history[-1][1] = acc
150
- yield history
 
151
  except Exception as e:
 
152
  history[-1][1] = f"⚠️ Error: {str(e)}"
153
  yield history
154
 
 
155
  # =========================
156
  # Gradio UI
157
  # =========================
158
- CSS = "#chatbot {height: 520px; overflow:auto;}"
 
 
159
 
160
  with gr.Blocks(title=TITLE, css=CSS, theme=gr.themes.Soft()) as demo:
161
  gr.Markdown(f"# {TITLE}")
@@ -166,30 +210,33 @@ with gr.Blocks(title=TITLE, css=CSS, theme=gr.themes.Soft()) as demo:
166
  uploader = gr.File(
167
  label="Upload image or video",
168
  file_count="single",
169
- file_types=["image", "video"]
170
  )
171
  preview = gr.Image(label="Preview", interactive=False)
172
 
173
  with gr.Column(scale=7):
174
- chatbot = gr.Chatbot(label="Visual Assistant", elem_id="chatbot")
175
- prompt = gr.Textbox(
176
  label="Prompt",
177
  placeholder="Ask about the image/video…",
178
- lines=2
179
  )
180
- submit = gr.Button("Submit", variant="primary")
181
 
182
  # Preview on file change
183
- def on_file(file):
184
- if file is None: return None
185
- if is_image_file(file.name):
186
- return resize_max_side(Image.open(file.name).convert("RGB"))
187
- if is_video_file(file.name):
188
- return sample_video_frames(file.name, k=1)[0]
 
 
 
189
  return None
190
 
191
  uploader.change(on_file, inputs=[uploader], outputs=[preview])
192
- submit.click(chat_fn, inputs=[chatbot, uploader, prompt], outputs=[chatbot])
193
 
194
  if __name__ == "__main__":
195
  demo.queue().launch()
 
1
  import os
2
  import mimetypes
3
+ from typing import List, Optional
4
 
5
  import cv2
6
  import gradio as gr
7
  import numpy as np
8
  from PIL import Image
9
  import torch
10
+ from transformers import AutoProcessor, AutoModelForVision2Seq
11
 
12
  # =========================
13
  # Config
14
  # =========================
15
  TITLE = "Qwen2-VL Visual Assistant"
16
+ DESC = "Upload one image or a short video (≤15s). Ask anything like ChatGPT."
17
 
18
  MODEL_ID = "Qwen/Qwen2-VL-2B-Instruct"
19
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
20
  DTYPE = torch.float16 if DEVICE == "cuda" else torch.float32
21
 
22
+ MAX_VIDEO_SECONDS = 15 # hard limit for video duration we sample from
23
+ MAX_FILE_MB = 60 # guard rail for very large files
24
+ MAX_SIDE = 768 # resize longer side for images/frames
25
+ MAX_FRAMES = 4 # how many frames to sample from video
26
+ MAX_NEW_TOKENS = 256 # generation length
27
 
28
  # =========================
29
  # Load model & processor
 
36
  trust_remote_code=True,
37
  ).to(DEVICE)
38
 
39
+
40
  # =========================
41
  # Helpers
42
  # =========================
43
+ def get_path(file_obj) -> Optional[str]:
44
+ """
45
+ Gradio <File> may arrive as:
46
+ - str (tmp path) -> return as-is
47
+ - dict {"name": "..."} -> return ["name"] or ["path"]
48
+ - SimpleNamespace with .name -> return .name
49
+ """
50
+ if file_obj is None:
51
+ return None
52
+ if isinstance(file_obj, str):
53
+ return file_obj
54
+ if isinstance(file_obj, dict):
55
+ return file_obj.get("name") or file_obj.get("path")
56
+ return getattr(file_obj, "name", None)
57
+
58
+
59
  def is_image_file(path: str) -> bool:
60
  kind, _ = mimetypes.guess_type(path)
61
  return (kind or "").startswith("image/")
62
 
63
+
64
  def is_video_file(path: str) -> bool:
65
  kind, _ = mimetypes.guess_type(path)
66
  return (kind or "").startswith("video/")
67
 
68
+
69
  def resize_max_side(img: Image.Image, max_side: int = MAX_SIDE) -> Image.Image:
70
  w, h = img.size
71
  m = max(w, h)
 
74
  s = max_side / float(m)
75
  return img.resize((int(w * s), int(h * s)), Image.LANCZOS)
76
 
77
+
78
  def sample_video_frames(video_path: str, max_seconds=MAX_VIDEO_SECONDS, k=MAX_FRAMES) -> List[Image.Image]:
79
+ """Take K evenly spaced frames within the first `max_seconds`."""
80
  cap = cv2.VideoCapture(video_path)
81
  if not cap.isOpened():
82
  return []
83
+
84
  fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
85
  total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
86
  duration = total / max(fps, 1)
87
+
88
+ # Only consider frames within MAX_VIDEO_SECONDS (or all if shorter)
89
  limit_frames = int(min(duration, max_seconds) * fps) if total else 0
90
  nframes = min(limit_frames or total, total) if total > 0 else 0
91
  if nframes == 0:
92
  nframes = int(min(duration, max_seconds) * fps) or 1
93
+
94
  k = max(1, min(k, nframes))
95
  idxs = [int(i * nframes / k + nframes / (2 * k)) for i in range(k)]
96
  idxs = sorted(set(max(0, min(nframes - 1, x)) for x in idxs))
97
+
98
  frames: List[Image.Image] = []
99
  for target in idxs:
100
  cap.set(cv2.CAP_PROP_POS_FRAMES, target)
 
107
  cap.release()
108
  return frames
109
 
110
+
111
  def build_messages(prompt: str) -> list:
112
+ """Qwen2-VL chat template expects <image> tag in user turn."""
113
  return [{
114
  "role": "user",
115
  "content": [
 
118
  ]
119
  }]
120
 
121
+
122
  # =========================
123
  # Chat function
124
  # =========================
125
+ def chat_fn(history, file_obj, prompt):
126
+ history = history or []
127
+ history.append([prompt, ""]) # add user bubble first
128
+ yield history # show immediately
129
 
130
+ path = get_path(file_obj)
131
+ if not path or not os.path.exists(path):
132
  history[-1][1] = "⚠️ Please upload an image or a short video first."
133
  yield history
134
  return
135
 
136
+ # Size guard
 
 
 
 
 
 
 
 
 
 
 
137
  try:
138
+ if os.path.getsize(path) > MAX_FILE_MB * 1024 * 1024:
139
+ history[-1][1] = f"⚠️ File is larger than {MAX_FILE_MB} MB."
140
+ yield history
141
+ return
142
+ except Exception:
143
+ pass
144
+
145
+ # Collect images for the model
146
+ images: List[Image.Image] = []
147
+ try:
148
+ if is_image_file(path):
149
+ images = [resize_max_side(Image.open(path).convert("RGB"))]
150
+ elif is_video_file(path):
151
+ images = sample_video_frames(path, MAX_VIDEO_SECONDS, MAX_FRAMES)
152
+ if not images:
153
+ history[-1][1] = "⚠️ Could not read frames from the video."
154
+ yield history
155
+ return
156
+ else:
157
+ history[-1][1] = "⚠️ Unsupported file type."
158
+ yield history
159
+ return
160
+
161
+ # Build chat input
162
  messages = build_messages(prompt)
163
+ chat_str = processor.apply_chat_template(
164
+ messages, add_generation_prompt=True, tokenize=False
165
+ )
166
 
167
+ # Processor expects a LIST of images (even when it's 1)
168
  enc = processor(
169
  text=chat_str,
170
+ images=images, # ALWAYS a list
171
  return_tensors="pt"
172
  ).to(model.device)
173
 
174
+ # Inference (one-shot; more robust than streaming for Spaces CPU)
 
 
 
 
 
 
 
 
175
  with torch.no_grad():
176
+ out_ids = model.generate(
177
  **enc,
178
  max_new_tokens=MAX_NEW_TOKENS,
179
  do_sample=False,
180
+ )
181
+
182
+ # Decode only the generated continuation
183
+ cut = enc["input_ids"].shape[-1]
184
+ text = processor.batch_decode(
185
+ out_ids[:, cut:], skip_special_tokens=True
186
+ )[0].strip()
187
+
188
+ history[-1][1] = text if text else "(no output)"
189
+ yield history
190
+
191
  except Exception as e:
192
+ # Show clean error in the bubble
193
  history[-1][1] = f"⚠️ Error: {str(e)}"
194
  yield history
195
 
196
+
197
  # =========================
198
  # Gradio UI
199
  # =========================
200
+ CSS = """
201
+ #chat {height: 560px}
202
+ """
203
 
204
  with gr.Blocks(title=TITLE, css=CSS, theme=gr.themes.Soft()) as demo:
205
  gr.Markdown(f"# {TITLE}")
 
210
  uploader = gr.File(
211
  label="Upload image or video",
212
  file_count="single",
213
+ file_types=["image", "video"],
214
  )
215
  preview = gr.Image(label="Preview", interactive=False)
216
 
217
  with gr.Column(scale=7):
218
+ chat = gr.Chatbot(label="Visual Assistant", elem_id="chat")
219
+ prompt_in = gr.Textbox(
220
  label="Prompt",
221
  placeholder="Ask about the image/video…",
222
+ lines=2,
223
  )
224
+ send = gr.Button("Submit", variant="primary")
225
 
226
  # Preview on file change
227
+ def on_file(file_obj):
228
+ path = get_path(file_obj)
229
+ if not path or not os.path.exists(path):
230
+ return None
231
+ if is_image_file(path):
232
+ return resize_max_side(Image.open(path).convert("RGB"))
233
+ if is_video_file(path):
234
+ frames = sample_video_frames(path, k=1)
235
+ return frames[0] if frames else None
236
  return None
237
 
238
  uploader.change(on_file, inputs=[uploader], outputs=[preview])
239
+ send.click(chat_fn, inputs=[chat, uploader, prompt_in], outputs=[chat])
240
 
241
  if __name__ == "__main__":
242
  demo.queue().launch()