kfkas commited on
Commit
f1454c7
·
1 Parent(s): 25bd246
Files changed (3) hide show
  1. app.py +11 -150
  2. requirements.txt +4 -0
  3. space.yaml +4 -0
app.py CHANGED
@@ -3,18 +3,13 @@ import shutil
3
  import cv2
4
  import base64
5
  import uuid
 
6
  from flask import Flask
7
-
8
  import gradio as gr
9
- import re
10
 
11
- # -----------------------------------------------------------------------------
12
- # Config: 앱 설정 (Gemma와 GPT4o 관련 설정은 제거)
13
- # -----------------------------------------------------------------------------
14
  class Config:
15
  """애플리케이션 설정 및 상수"""
16
-
17
- # 음식 메뉴 데이터
18
  FOOD_ITEMS = [
19
  {"name": "짜장면", "image": "images/food1.jpg", "price": 7.00},
20
  {"name": "짬뽕", "image": "images/food2.jpg", "price": 8.50},
@@ -25,11 +20,9 @@ class Config:
25
  {"name": "콜라", "image": "images/food6.jpg", "price": 12.00},
26
  {"name": "사이다", "image": "images/food6.jpg", "price": 12.00},
27
  ]
28
-
29
  # 알리바바 Qwen API 키 (기본값은 빈 문자열)
30
  QWEN_API_KEY = ""
31
 
32
- # 기본 프롬프트 템플릿
33
  DEFAULT_PROMPT_TEMPLATE = (
34
  "### Persona ###\n"
35
  "You are an expert tip calculation assistant focusing on service quality observed in a video.\n\n"
@@ -65,7 +58,6 @@ class Config:
65
  "Total Bill: $[Subtotal + Tip]"
66
  )
67
 
68
- # Gradio UI용 CSS
69
  CUSTOM_CSS = """
70
  #food-container {
71
  display: grid;
@@ -74,21 +66,17 @@ class Config:
74
  overflow-y: auto;
75
  height: 600px;
76
  }
77
-
78
- /* Qwen 버튼을 보라색으로 */
79
  #qwen-button {
80
  background-color: #8A2BE2 !important;
81
  color: white !important;
82
  border-color: #8A2BE2 !important;
83
  }
84
-
85
  #qwen-button:hover {
86
  background-color: #7722CC !important;
87
  }
88
  """
89
 
90
  def __init__(self):
91
- # 이미지 디렉토리 확인
92
  if not os.path.exists("images"):
93
  print("경고: 'images' 폴더를 찾을 수 없습니다. 음식 이미지가 표시되지 않을 수 있습니다.")
94
  for item in self.FOOD_ITEMS:
@@ -96,12 +84,8 @@ class Config:
96
  print(f"경고: 이미지 파일을 찾을 수 없습니다 - {item['image']}")
97
 
98
 
99
- # -----------------------------------------------------------------------------
100
- # ModelClients: 알리바바 Qwen API 클라이언트만 사용
101
- # -----------------------------------------------------------------------------
102
  class ModelClients:
103
- """알리바바 Qwen API 클라이언트 관리"""
104
-
105
  def __init__(self, config: Config):
106
  self.config = config
107
  from openai import OpenAI as QwenOpenAI
@@ -111,56 +95,40 @@ class ModelClients:
111
  )
112
 
113
  def encode_video_qwen(self, video_path):
114
- """Qwen API용 비디오 인코딩"""
115
  with open(video_path, "rb") as video_file:
116
  return base64.b64encode(video_file.read()).decode("utf-8")
117
 
118
 
119
- # -----------------------------------------------------------------------------
120
- # VideoProcessor: 비디오 처리 및 프레임 추출 기능 제공 (변경 없음)
121
- # -----------------------------------------------------------------------------
122
  class VideoProcessor:
123
- """비디오 처리 및 프레임 추출 기능 제공"""
124
-
125
  def extract_video_frames(self, video_path, output_folder=None, fps=1):
126
- """비디오 파일에서 프레임 추출"""
127
  if not video_path:
128
  return [], None
129
-
130
  if output_folder is None:
131
  output_folder = f"frames_list/frames_{uuid.uuid4().hex}"
132
-
133
  os.makedirs(output_folder, exist_ok=True)
134
  cap = cv2.VideoCapture(video_path)
135
-
136
  if not cap.isOpened():
137
  print(f"오류: 비디오 파일을 열 수 없습니다 - {video_path}")
138
  return [], None
139
-
140
  frame_paths = []
141
  frame_rate = cap.get(cv2.CAP_PROP_FPS)
142
-
143
  if not frame_rate or frame_rate == 0:
144
  print("경고: FPS를 읽을 수 없습니다, 기본값 4으로 설정합니다.")
145
  frame_rate = 4.0
146
-
147
  frame_interval = int(frame_rate / fps) if fps > 0 else 1
148
  if frame_interval <= 0:
149
  frame_interval = 1
150
-
151
  frame_count = 0
152
  saved_frame_count = 0
153
-
154
  while cap.isOpened():
155
  ret, frame = cap.read()
156
  if not ret:
157
  break
158
-
159
  if frame is None:
160
  print(f"경고: {frame_count}번째 프레임이 비어있습니다.")
161
  frame_count += 1
162
  continue
163
-
164
  if frame_count % frame_interval == 0:
165
  frame_path = os.path.join(output_folder, f"frame_{saved_frame_count}.jpg")
166
  try:
@@ -171,28 +139,22 @@ class VideoProcessor:
171
  print(f"경고: {frame_path} 저장 실패.")
172
  except Exception as e:
173
  print(f"경고: 프레임 저장 오류 ({frame_path}): {e}")
174
-
175
  frame_count += 1
176
-
177
  cap.release()
178
-
179
  if not frame_paths:
180
  print("경고: 프레임 ���출 실패.")
181
  if os.path.exists(output_folder):
182
  shutil.rmtree(output_folder)
183
  return [], None
184
-
185
  return frame_paths, output_folder
186
 
187
  def cleanup_temp_files(self, video_path, frame_folder):
188
- """임시 비디오 파일 및 프레임 폴더 정리"""
189
  if video_path and "temp_video_" in video_path and os.path.exists(video_path):
190
  try:
191
  os.remove(video_path)
192
  print(f"임시 비디오 파일 삭제: {video_path}")
193
  except OSError as e:
194
  print(f"임시 비디오 파일 삭제 오류: {e}")
195
-
196
  if frame_folder and os.path.exists(frame_folder):
197
  try:
198
  shutil.rmtree(frame_folder)
@@ -201,24 +163,18 @@ class VideoProcessor:
201
  print(f"프레임 폴더 삭제 오류: {e}")
202
 
203
 
204
- # -----------------------------------------------------------------------------
205
- # TipCalculator: 팁 계산 핵심 로직 (알리바바 Qwen만 사용)
206
- # -----------------------------------------------------------------------------
207
  class TipCalculator:
208
- """팁 계산 핵심 로직 (Alibaba Qwen만 사용)"""
209
-
210
  def __init__(self, config: Config, model_clients: ModelClients, video_processor: VideoProcessor):
211
  self.config = config
212
  self.model_clients = model_clients
213
  self.video_processor = video_processor
214
 
215
  def parse_llm_output(self, output_text):
216
- """LLM 출력을 파싱하여 팁 계산 결과 추출"""
217
  analysis = "Analysis not found."
218
  tip_percentage = 0.0
219
  tip_amount = 0.0
220
  total_bill = 0.0
221
-
222
  analysis_match = re.search(r"Analysis:\s*(.*?)Tip Percentage:", output_text, re.DOTALL | re.IGNORECASE)
223
  if analysis_match:
224
  analysis = analysis_match.group(1).strip()
@@ -226,7 +182,6 @@ class TipCalculator:
226
  analysis_match_alt = re.search(r"Analysis:\s*(.*)", output_text, re.DOTALL | re.IGNORECASE)
227
  if analysis_match_alt:
228
  analysis = analysis_match_alt.group(1).strip()
229
-
230
  percentage_match = re.search(r"Tip Percentage:\s*\*{0,2}(\d+(?:\.\d+)?)%\*{0,2}", output_text,
231
  re.DOTALL | re.IGNORECASE)
232
  if percentage_match:
@@ -235,7 +190,6 @@ class TipCalculator:
235
  except ValueError:
236
  print(f"경고: Tip Percentage 변환 실패 - {percentage_match.group(1)}")
237
  tip_percentage = 0.0
238
-
239
  tip_match = re.search(r"Tip Amount:\s*\$?\s*([0-9.]+)", output_text, re.IGNORECASE)
240
  if tip_match:
241
  try:
@@ -245,32 +199,24 @@ class TipCalculator:
245
  tip_amount = 0.0
246
  else:
247
  print(f"경고: 출력에서 Tip Amount를 찾을 수 없습니다:\n{output_text}")
248
-
249
  total_match = re.search(r"Total Bill:\s*\$?\s*([0-9.]+)", output_text, re.IGNORECASE)
250
  if total_match:
251
  try:
252
  total_bill = float(total_match.group(1))
253
  except ValueError:
254
  print(f"경고: Total Bill 변환 실패 - {total_match.group(1)}")
255
-
256
  if len(analysis) < 20 and analysis == "Analysis not found.":
257
  analysis = output_text
258
-
259
  return analysis, tip_percentage, tip_amount, output_text
260
 
261
  def process_tip_qwen(self, video_file_path, star_rating, user_review, calculated_subtotal, custom_prompt=None):
262
- """Qwen API를 사용한 팁 계산 처리 (비디오 캡션 생성 및 팁 산출)"""
263
  if not os.path.exists(video_file_path):
264
  return "Error: 비디오 파일 경로가 유효하지 않습니다.", 0.0, 0.0, [], None, ""
265
-
266
- # 비디오 -> base64 인코딩
267
  base64_video = self.model_clients.encode_video_qwen(video_file_path)
268
- # Omni 프롬프트
269
  omni_caption_prompt = '''
270
  Task 1: Describe the waiters' actions in these restaurant video frames. Please check for mistakes or negative behaviors.
271
  Task 2: Provide a short chronological summary of the entire scene.
272
  '''
273
- # Omni 스트리밍 호출
274
  omni_result = self.model_clients.qwen_client.chat.completions.create(
275
  model="qwen2.5-omni-7b",
276
  messages=[
@@ -281,10 +227,7 @@ Task 2: Provide a short chronological summary of the entire scene.
281
  {
282
  "role": "user",
283
  "content": [
284
- {
285
- "type": "video_url",
286
- "video_url": {"url": f"data:;base64,{base64_video}"},
287
- },
288
  {"type": "text", "text": omni_caption_prompt},
289
  ],
290
  },
@@ -293,7 +236,6 @@ Task 2: Provide a short chronological summary of the entire scene.
293
  stream=True,
294
  stream_options={"include_usage": True},
295
  )
296
- # 캡션 추출
297
  all_omni_chunks = list(omni_result)
298
  caption_text = ""
299
  for chunk in all_omni_chunks[:-1]:
@@ -303,8 +245,6 @@ Task 2: Provide a short chronological summary of the entire scene.
303
  caption_text += chunk.choices[0].delta.content
304
  if not caption_text.strip():
305
  caption_text = "(No caption from Omni)"
306
-
307
- # --- 2) qvq-max로 팁 계산 ---
308
  user_review = user_review.strip() if user_review else "(No user review)"
309
  if custom_prompt is None:
310
  prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
@@ -325,22 +265,12 @@ Task 2: Provide a short chronological summary of the entire scene.
325
  star_rating=star_rating,
326
  user_review=user_review
327
  )
328
-
329
  final_prompt = prompt.replace("{caption_text}", caption_text)
330
-
331
  qvq_result = self.model_clients.qwen_client.chat.completions.create(
332
  model="qwen2.5-vl-32b-instruct",
333
  messages=[
334
- {
335
- "role": "system",
336
- "content": [{"type": "text", "text": "You are a helpful assistant."}],
337
- },
338
- {
339
- "role": "user",
340
- "content": [
341
- {"type": "text", "text": final_prompt}
342
- ],
343
- },
344
  ],
345
  modalities=["text"],
346
  stream=True,
@@ -360,13 +290,11 @@ Task 2: Provide a short chronological summary of the entire scene.
360
  print("\n" + "=" * 20 + "Complete Response" + "=" * 20 + "\n")
361
  is_answering = True
362
  final_answer += d.content
363
-
364
  final_text = final_reasoning + "\n" + final_answer
365
  analysis, tip_percentage, tip_amount, output_text = self.parse_llm_output(final_text)
366
  return analysis, tip_percentage, tip_amount, [], None, output_text
367
 
368
  def calculate_manual_tip(self, tip_percent, subtotal):
369
- """백분율에 따른 수동 팁 계산"""
370
  tip_amount = subtotal * (tip_percent / 100)
371
  total_bill = subtotal + tip_amount
372
  analysis_output = f"Manual calculation using fixed tip percentage of {tip_percent}%."
@@ -375,51 +303,38 @@ Task 2: Provide a short chronological summary of the entire scene.
375
  return analysis_output, tip_output, total_bill_output
376
 
377
 
378
- # -----------------------------------------------------------------------------
379
- # UIHandler: Gradio UI 이벤트 및 콜백 처리 (Qwen만 사용, 알리바바 API 키 입력 필드 추가)
380
- # -----------------------------------------------------------------------------
381
  class UIHandler:
382
- """Gradio UI 이벤트 및 콜백 처리"""
383
-
384
  def __init__(self, config: Config, tip_calculator: TipCalculator, video_processor: VideoProcessor):
385
  self.config = config
386
  self.tip_calculator = tip_calculator
387
  self.video_processor = video_processor
388
 
389
  def update_subtotal_and_prompt(self, *args):
390
- """사용자 입력에 따라 소계 및 프롬프트 업데이트"""
391
  num_food_items = len(self.config.FOOD_ITEMS)
392
  quantities = args[:num_food_items]
393
  star_rating = args[num_food_items]
394
  user_review = args[num_food_items + 1]
395
-
396
  calculated_subtotal = 0.0
397
  for i in range(num_food_items):
398
  calculated_subtotal += self.config.FOOD_ITEMS[i]['price'] * quantities[i]
399
-
400
  user_review_text = user_review.strip() if user_review and user_review.strip() else "(No user review provided)"
401
-
402
  updated_prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
403
  calculated_subtotal=calculated_subtotal,
404
  star_rating=star_rating,
405
  user_review=user_review_text
406
  )
407
  updated_prompt = updated_prompt.replace("{caption_text}", "{{caption_text}}")
408
-
409
  return calculated_subtotal, updated_prompt
410
 
411
  def compute_tip(self, alibaba_key, video_file_obj, subtotal, star_rating, user_review, custom_prompt_text):
412
- """알리바바 Qwen 모델을 사용하여 팁 계산"""
413
  analysis_output = "계산을 시작합니다..."
414
  tip_percentage = 0.0
415
  tip_output = "$0.00"
416
  total_bill_output = f"${subtotal:.2f}"
417
-
418
  if video_file_obj is None:
419
  return "오류: 비디오 파일을 업로드해주세요.", "$0.00", total_bill_output, custom_prompt_text, gr.update(value=None)
420
-
421
  try:
422
- # 입력받은 알리바바 API 키가 있으면 Qwen 클라이언트를 재설정
423
  if alibaba_key and alibaba_key.strip():
424
  from openai import OpenAI as QwenOpenAI
425
  self.tip_calculator.model_clients.qwen_client = QwenOpenAI(
@@ -433,7 +348,6 @@ class UIHandler:
433
  except Exception as e:
434
  print(f"임시 비디오 파일 생성 오류: {e}")
435
  return f"오류: 비디오 파일을 처리할 수 없습니다: {e}", "$0.00", total_bill_output, custom_prompt_text, None
436
-
437
  frame_folder = None
438
  try:
439
  analysis, tip_percentage, tip_amount, _, _, output_text = self.tip_calculator.process_tip_qwen(
@@ -454,11 +368,9 @@ class UIHandler:
454
  total_bill_output = f"${subtotal:.2f}"
455
  finally:
456
  self.video_processor.cleanup_temp_files(temp_video_path, frame_folder)
457
-
458
  return analysis_output, tip_output, total_bill_output, custom_prompt_text, gr.update(value=None)
459
 
460
  def auto_tip_and_invoice(self, alibaba_key, video_file_obj, subtotal, star_rating, review, prompt, *quantities):
461
- """AI 모델을 사용한 자동 팁 계산 및 청구서 업데이트 (알리바바 Qwen만 사용)"""
462
  analysis, tip_disp, total_bill_disp, prompt_out, vid_out = self.compute_tip(
463
  alibaba_key, video_file_obj, subtotal, star_rating, review, prompt
464
  )
@@ -466,72 +378,54 @@ class UIHandler:
466
  return analysis, tip_disp, total_bill_disp, prompt_out, vid_out, invoice
467
 
468
  def update_invoice_summary(self, *args):
469
- """수량 및 팁에 따라 청구서 요약 업데이트"""
470
  num_items = len(self.config.FOOD_ITEMS)
471
  quantities = args[:num_items]
472
-
473
  if len(args) >= num_items + 2:
474
  tip_str = args[num_items]
475
  total_bill_str = args[num_items + 1]
476
  else:
477
  tip_str = "$0.00"
478
  total_bill_str = "$0.00"
479
-
480
  summary = ""
481
  for i, q in enumerate(quantities):
482
  try:
483
  q_val = float(q)
484
  except:
485
  q_val = 0
486
-
487
  if q_val > 0:
488
  item = self.config.FOOD_ITEMS[i]
489
  total_price = item['price'] * q_val
490
  summary += f"{item['name']} x{int(q_val)} : ${total_price:.2f}\n"
491
-
492
  if summary == "":
493
  summary = "주문한 메뉴가 없습니다."
494
-
495
  summary += f"\nTip: {tip_str}\nTotal Bill: {total_bill_str}"
496
-
497
  return summary
498
 
499
  def manual_tip_and_invoice(self, tip_percent, subtotal, *quantities):
500
- """수동 팁 계산 및 청구서 업데이트"""
501
  analysis, tip_disp, total_bill_disp = self.tip_calculator.calculate_manual_tip(tip_percent, subtotal)
502
  invoice = self.update_invoice_summary(*quantities, tip_disp, total_bill_disp)
503
  return analysis, tip_disp, total_bill_disp, invoice
504
 
505
  def process_payment(self, total_bill):
506
- """총 청구액에 대한 결제 처리"""
507
  return f"{total_bill} 결제되었습니다."
508
 
509
 
510
- # -----------------------------------------------------------------------------
511
- # App: 모든 것을 연결하는 메인 애플리케이션 클래스
512
- # -----------------------------------------------------------------------------
513
  class App:
514
- """메인 애플리케이션 클래스"""
515
-
516
  def __init__(self):
517
  self.config = Config()
518
  self.model_clients = ModelClients(self.config)
519
  self.video_processor = VideoProcessor()
520
  self.tip_calculator = TipCalculator(self.config, self.model_clients, self.video_processor)
521
  self.ui_handler = UIHandler(self.config, self.tip_calculator, self.video_processor)
522
-
523
- # Flask 앱 (필요 시 사용)
524
  self.flask_app = Flask(__name__)
525
 
526
  def create_gradio_blocks(self):
527
- """Gradio Blocks 인터페이스 구성"""
528
  with gr.Blocks(title="Video Tip Calculation Interface", theme=gr.themes.Soft(),
529
  css=self.config.CUSTOM_CSS) as interface:
530
  gr.Markdown("## Video Tip Calculation Interface (Structured)")
531
-
532
  quantity_inputs = []
533
  subtotal_display = gr.Number(label="Subtotal ($)", value=0.0, interactive=False, visible=False)
534
-
535
  with gr.Row():
536
  with gr.Column(scale=2):
537
  gr.Markdown("### 1. Select Food Items")
@@ -556,13 +450,10 @@ class App:
556
  elem_id=f"qty_{item['name'].replace(' ', '_')}"
557
  )
558
  quantity_inputs.append(q_input)
559
-
560
  subtotal_visible_display = gr.Textbox(label="Subtotal", value="$0.00", interactive=False)
561
-
562
  gr.Markdown("### 2. Service Feedback")
563
  review_input = gr.Textbox(label="Review", placeholder="서비스 리뷰를 작성해주세요.", lines=3)
564
  rating_input = gr.Radio(choices=[1, 2, 3, 4, 5], value=3, label="⭐Star Rating (1-5)⭐", type="value")
565
-
566
  gr.Markdown("### 3. Calculate Tip")
567
  with gr.Row():
568
  btn_5 = gr.Button("5%")
@@ -570,21 +461,15 @@ class App:
570
  btn_15 = gr.Button("15%")
571
  btn_20 = gr.Button("20%")
572
  btn_25 = gr.Button("25%")
573
-
574
- # Qwen 모델 버튼만 남김
575
  with gr.Row():
576
  qwen_btn = gr.Button("Alibaba-Qwen", variant="tertiary", elem_id="qwen-button")
577
-
578
  gr.Markdown("### 4. Results")
579
  tip_display = gr.Textbox(label="Calculated Tip", value="$0.00", interactive=False)
580
- total_bill_display = gr.Textbox(label="Total Bill (Subtotal + Tip)", value="$0.00",
581
- interactive=False)
582
  payment_btn = gr.Button("결제하기")
583
  payment_result = gr.Textbox(label="Payment Result", value="", interactive=False)
584
-
585
  with gr.Column(scale=1):
586
  gr.Markdown("### 5. Upload & Prompt")
587
- # 알리바바 API 키 입력 필드 추가
588
  alibaba_key_input = gr.Textbox(label="Alibaba API Key", placeholder="Enter your Alibaba API Key", lines=1)
589
  video_input = gr.Video(label="Upload Service Video")
590
  prompt_display = gr.Textbox(
@@ -598,21 +483,15 @@ class App:
598
  user_review="(No user review provided)"
599
  ).replace("{caption_text}", "{{caption_text}}")
600
  )
601
-
602
  gr.Markdown("### 6. AI Analysis")
603
  analysis_display = gr.Textbox(label="AI Analysis", lines=10, max_lines=15, interactive=True)
604
-
605
  gr.Markdown("### 7. 청구서")
606
  order_summary_display = gr.Textbox(label="청구서", value="주문한 메뉴가 없습니다.", interactive=True)
607
-
608
- # Subtotal 값 업데이트 시 $ 표시 갱신
609
  subtotal_display.change(
610
  fn=lambda x: f"${x:.2f}",
611
  inputs=[subtotal_display],
612
  outputs=[subtotal_visible_display]
613
  )
614
-
615
- # 음식 수량, 별점, 리뷰가 바뀔 때마다 Subtotal, Prompt 업데이트
616
  inputs_for_prompt_update = quantity_inputs + [rating_input, review_input]
617
  outputs_for_prompt_update = [subtotal_display, prompt_display]
618
  for comp in inputs_for_prompt_update:
@@ -621,21 +500,16 @@ class App:
621
  inputs=inputs_for_prompt_update,
622
  outputs=outputs_for_prompt_update
623
  )
624
-
625
- # 수량 변화 시 청구서 텍스트 업데이트
626
  for comp in quantity_inputs:
627
  comp.change(
628
  fn=self.ui_handler.update_invoice_summary,
629
  inputs=quantity_inputs,
630
  outputs=order_summary_display
631
  )
632
-
633
- # 모델 호출 후 결과 업데이트 (알리바바 API 키 포함)
634
  compute_inputs = [alibaba_key_input, video_input, subtotal_display, rating_input, review_input, prompt_display] + quantity_inputs
635
  compute_outputs = [
636
  analysis_display, tip_display, total_bill_display, prompt_display, video_input, order_summary_display
637
  ]
638
-
639
  qwen_btn.click(
640
  fn=lambda alibaba_key, vid, sub, rat, rev, prom, *qty: self.ui_handler.auto_tip_and_invoice(
641
  alibaba_key, vid, sub, rat, rev, prom, *qty
@@ -643,8 +517,6 @@ class App:
643
  inputs=compute_inputs,
644
  outputs=compute_outputs
645
  )
646
-
647
- # 수동 팁 계산 버튼들
648
  btn_5.click(
649
  fn=lambda sub, *qty: self.ui_handler.manual_tip_and_invoice(5, sub, *qty),
650
  inputs=[subtotal_display] + quantity_inputs,
@@ -670,35 +542,24 @@ class App:
670
  inputs=[subtotal_display] + quantity_inputs,
671
  outputs=[analysis_display, tip_display, total_bill_display, order_summary_display]
672
  )
673
-
674
- # 결제 버튼
675
  payment_btn.click(
676
  fn=self.ui_handler.process_payment,
677
  inputs=[total_bill_display],
678
  outputs=[payment_result]
679
  )
680
-
681
  return interface
682
 
683
  def run_gradio(self):
684
- """Gradio 서버 실행"""
685
  interface = self.create_gradio_blocks()
686
  interface.launch(share=True)
687
 
688
  def run_flask(self):
689
- """Flask 서버 실행 (원한다면)"""
690
-
691
  @self.flask_app.route("/")
692
  def index():
693
  return "Hello Flask"
694
-
695
  self.flask_app.run(host="0.0.0.0", port=5000, debug=True)
696
 
697
 
698
  if __name__ == "__main__":
699
  app = App()
700
- # Gradio UI 실행
701
  app.run_gradio()
702
-
703
- # Flask 서버 실행 (원하면 아래 주석 해제)
704
- # app.run_flask()
 
3
  import cv2
4
  import base64
5
  import uuid
6
+ import re
7
  from flask import Flask
 
8
  import gradio as gr
 
9
 
10
+ # --- Config 클래스 (Gemma, GPT4o 제거, Qwen만 사용) ---
 
 
11
  class Config:
12
  """애플리케이션 설정 및 상수"""
 
 
13
  FOOD_ITEMS = [
14
  {"name": "짜장면", "image": "images/food1.jpg", "price": 7.00},
15
  {"name": "짬뽕", "image": "images/food2.jpg", "price": 8.50},
 
20
  {"name": "콜라", "image": "images/food6.jpg", "price": 12.00},
21
  {"name": "사이다", "image": "images/food6.jpg", "price": 12.00},
22
  ]
 
23
  # 알리바바 Qwen API 키 (기본값은 빈 문자열)
24
  QWEN_API_KEY = ""
25
 
 
26
  DEFAULT_PROMPT_TEMPLATE = (
27
  "### Persona ###\n"
28
  "You are an expert tip calculation assistant focusing on service quality observed in a video.\n\n"
 
58
  "Total Bill: $[Subtotal + Tip]"
59
  )
60
 
 
61
  CUSTOM_CSS = """
62
  #food-container {
63
  display: grid;
 
66
  overflow-y: auto;
67
  height: 600px;
68
  }
 
 
69
  #qwen-button {
70
  background-color: #8A2BE2 !important;
71
  color: white !important;
72
  border-color: #8A2BE2 !important;
73
  }
 
74
  #qwen-button:hover {
75
  background-color: #7722CC !important;
76
  }
77
  """
78
 
79
  def __init__(self):
 
80
  if not os.path.exists("images"):
81
  print("경고: 'images' 폴더를 찾을 수 없습니다. 음식 이미지가 표시되지 않을 수 있습니다.")
82
  for item in self.FOOD_ITEMS:
 
84
  print(f"경고: 이미지 파일을 찾을 수 없습니다 - {item['image']}")
85
 
86
 
87
+ # --- ModelClients (알리바바 Qwen API만 사용) ---
 
 
88
  class ModelClients:
 
 
89
  def __init__(self, config: Config):
90
  self.config = config
91
  from openai import OpenAI as QwenOpenAI
 
95
  )
96
 
97
  def encode_video_qwen(self, video_path):
 
98
  with open(video_path, "rb") as video_file:
99
  return base64.b64encode(video_file.read()).decode("utf-8")
100
 
101
 
102
+ # --- VideoProcessor: 비디오 프레임 추출 ---
 
 
103
  class VideoProcessor:
 
 
104
  def extract_video_frames(self, video_path, output_folder=None, fps=1):
 
105
  if not video_path:
106
  return [], None
 
107
  if output_folder is None:
108
  output_folder = f"frames_list/frames_{uuid.uuid4().hex}"
 
109
  os.makedirs(output_folder, exist_ok=True)
110
  cap = cv2.VideoCapture(video_path)
 
111
  if not cap.isOpened():
112
  print(f"오류: 비디오 파일을 열 수 없습니다 - {video_path}")
113
  return [], None
 
114
  frame_paths = []
115
  frame_rate = cap.get(cv2.CAP_PROP_FPS)
 
116
  if not frame_rate or frame_rate == 0:
117
  print("경고: FPS를 읽을 수 없습니다, 기본값 4으로 설정합니다.")
118
  frame_rate = 4.0
 
119
  frame_interval = int(frame_rate / fps) if fps > 0 else 1
120
  if frame_interval <= 0:
121
  frame_interval = 1
 
122
  frame_count = 0
123
  saved_frame_count = 0
 
124
  while cap.isOpened():
125
  ret, frame = cap.read()
126
  if not ret:
127
  break
 
128
  if frame is None:
129
  print(f"경고: {frame_count}번째 프레임이 비어있습니다.")
130
  frame_count += 1
131
  continue
 
132
  if frame_count % frame_interval == 0:
133
  frame_path = os.path.join(output_folder, f"frame_{saved_frame_count}.jpg")
134
  try:
 
139
  print(f"경고: {frame_path} 저장 실패.")
140
  except Exception as e:
141
  print(f"경고: 프레임 저장 오류 ({frame_path}): {e}")
 
142
  frame_count += 1
 
143
  cap.release()
 
144
  if not frame_paths:
145
  print("경고: 프레임 ���출 실패.")
146
  if os.path.exists(output_folder):
147
  shutil.rmtree(output_folder)
148
  return [], None
 
149
  return frame_paths, output_folder
150
 
151
  def cleanup_temp_files(self, video_path, frame_folder):
 
152
  if video_path and "temp_video_" in video_path and os.path.exists(video_path):
153
  try:
154
  os.remove(video_path)
155
  print(f"임시 비디오 파일 삭제: {video_path}")
156
  except OSError as e:
157
  print(f"임시 비디오 파일 삭제 오류: {e}")
 
158
  if frame_folder and os.path.exists(frame_folder):
159
  try:
160
  shutil.rmtree(frame_folder)
 
163
  print(f"프레임 폴더 삭제 오류: {e}")
164
 
165
 
166
+ # --- TipCalculator (알리바바 Qwen API를 사용한 팁 계산) ---
 
 
167
  class TipCalculator:
 
 
168
  def __init__(self, config: Config, model_clients: ModelClients, video_processor: VideoProcessor):
169
  self.config = config
170
  self.model_clients = model_clients
171
  self.video_processor = video_processor
172
 
173
  def parse_llm_output(self, output_text):
 
174
  analysis = "Analysis not found."
175
  tip_percentage = 0.0
176
  tip_amount = 0.0
177
  total_bill = 0.0
 
178
  analysis_match = re.search(r"Analysis:\s*(.*?)Tip Percentage:", output_text, re.DOTALL | re.IGNORECASE)
179
  if analysis_match:
180
  analysis = analysis_match.group(1).strip()
 
182
  analysis_match_alt = re.search(r"Analysis:\s*(.*)", output_text, re.DOTALL | re.IGNORECASE)
183
  if analysis_match_alt:
184
  analysis = analysis_match_alt.group(1).strip()
 
185
  percentage_match = re.search(r"Tip Percentage:\s*\*{0,2}(\d+(?:\.\d+)?)%\*{0,2}", output_text,
186
  re.DOTALL | re.IGNORECASE)
187
  if percentage_match:
 
190
  except ValueError:
191
  print(f"경고: Tip Percentage 변환 실패 - {percentage_match.group(1)}")
192
  tip_percentage = 0.0
 
193
  tip_match = re.search(r"Tip Amount:\s*\$?\s*([0-9.]+)", output_text, re.IGNORECASE)
194
  if tip_match:
195
  try:
 
199
  tip_amount = 0.0
200
  else:
201
  print(f"경고: 출력에서 Tip Amount를 찾을 수 없습니다:\n{output_text}")
 
202
  total_match = re.search(r"Total Bill:\s*\$?\s*([0-9.]+)", output_text, re.IGNORECASE)
203
  if total_match:
204
  try:
205
  total_bill = float(total_match.group(1))
206
  except ValueError:
207
  print(f"경고: Total Bill 변환 실패 - {total_match.group(1)}")
 
208
  if len(analysis) < 20 and analysis == "Analysis not found.":
209
  analysis = output_text
 
210
  return analysis, tip_percentage, tip_amount, output_text
211
 
212
  def process_tip_qwen(self, video_file_path, star_rating, user_review, calculated_subtotal, custom_prompt=None):
 
213
  if not os.path.exists(video_file_path):
214
  return "Error: 비디오 파일 경로가 유효하지 않습니다.", 0.0, 0.0, [], None, ""
 
 
215
  base64_video = self.model_clients.encode_video_qwen(video_file_path)
 
216
  omni_caption_prompt = '''
217
  Task 1: Describe the waiters' actions in these restaurant video frames. Please check for mistakes or negative behaviors.
218
  Task 2: Provide a short chronological summary of the entire scene.
219
  '''
 
220
  omni_result = self.model_clients.qwen_client.chat.completions.create(
221
  model="qwen2.5-omni-7b",
222
  messages=[
 
227
  {
228
  "role": "user",
229
  "content": [
230
+ {"type": "video_url", "video_url": {"url": f"data:;base64,{base64_video}"}},
 
 
 
231
  {"type": "text", "text": omni_caption_prompt},
232
  ],
233
  },
 
236
  stream=True,
237
  stream_options={"include_usage": True},
238
  )
 
239
  all_omni_chunks = list(omni_result)
240
  caption_text = ""
241
  for chunk in all_omni_chunks[:-1]:
 
245
  caption_text += chunk.choices[0].delta.content
246
  if not caption_text.strip():
247
  caption_text = "(No caption from Omni)"
 
 
248
  user_review = user_review.strip() if user_review else "(No user review)"
249
  if custom_prompt is None:
250
  prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
 
265
  star_rating=star_rating,
266
  user_review=user_review
267
  )
 
268
  final_prompt = prompt.replace("{caption_text}", caption_text)
 
269
  qvq_result = self.model_clients.qwen_client.chat.completions.create(
270
  model="qwen2.5-vl-32b-instruct",
271
  messages=[
272
+ {"role": "system", "content": [{"type": "text", "text": "You are a helpful assistant."}]},
273
+ {"role": "user", "content": [{"type": "text", "text": final_prompt}]},
 
 
 
 
 
 
 
 
274
  ],
275
  modalities=["text"],
276
  stream=True,
 
290
  print("\n" + "=" * 20 + "Complete Response" + "=" * 20 + "\n")
291
  is_answering = True
292
  final_answer += d.content
 
293
  final_text = final_reasoning + "\n" + final_answer
294
  analysis, tip_percentage, tip_amount, output_text = self.parse_llm_output(final_text)
295
  return analysis, tip_percentage, tip_amount, [], None, output_text
296
 
297
  def calculate_manual_tip(self, tip_percent, subtotal):
 
298
  tip_amount = subtotal * (tip_percent / 100)
299
  total_bill = subtotal + tip_amount
300
  analysis_output = f"Manual calculation using fixed tip percentage of {tip_percent}%."
 
303
  return analysis_output, tip_output, total_bill_output
304
 
305
 
306
+ # --- UIHandler: Gradio 인터페이스 이벤트 처리 (알리바바 API 키 입력 포함) ---
 
 
307
  class UIHandler:
 
 
308
  def __init__(self, config: Config, tip_calculator: TipCalculator, video_processor: VideoProcessor):
309
  self.config = config
310
  self.tip_calculator = tip_calculator
311
  self.video_processor = video_processor
312
 
313
  def update_subtotal_and_prompt(self, *args):
 
314
  num_food_items = len(self.config.FOOD_ITEMS)
315
  quantities = args[:num_food_items]
316
  star_rating = args[num_food_items]
317
  user_review = args[num_food_items + 1]
 
318
  calculated_subtotal = 0.0
319
  for i in range(num_food_items):
320
  calculated_subtotal += self.config.FOOD_ITEMS[i]['price'] * quantities[i]
 
321
  user_review_text = user_review.strip() if user_review and user_review.strip() else "(No user review provided)"
 
322
  updated_prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
323
  calculated_subtotal=calculated_subtotal,
324
  star_rating=star_rating,
325
  user_review=user_review_text
326
  )
327
  updated_prompt = updated_prompt.replace("{caption_text}", "{{caption_text}}")
 
328
  return calculated_subtotal, updated_prompt
329
 
330
  def compute_tip(self, alibaba_key, video_file_obj, subtotal, star_rating, user_review, custom_prompt_text):
 
331
  analysis_output = "계산을 시작합니다..."
332
  tip_percentage = 0.0
333
  tip_output = "$0.00"
334
  total_bill_output = f"${subtotal:.2f}"
 
335
  if video_file_obj is None:
336
  return "오류: 비디오 파일을 업로드해주세요.", "$0.00", total_bill_output, custom_prompt_text, gr.update(value=None)
 
337
  try:
 
338
  if alibaba_key and alibaba_key.strip():
339
  from openai import OpenAI as QwenOpenAI
340
  self.tip_calculator.model_clients.qwen_client = QwenOpenAI(
 
348
  except Exception as e:
349
  print(f"임시 비디오 파일 생성 오류: {e}")
350
  return f"오류: 비디오 파일을 처리할 수 없습니다: {e}", "$0.00", total_bill_output, custom_prompt_text, None
 
351
  frame_folder = None
352
  try:
353
  analysis, tip_percentage, tip_amount, _, _, output_text = self.tip_calculator.process_tip_qwen(
 
368
  total_bill_output = f"${subtotal:.2f}"
369
  finally:
370
  self.video_processor.cleanup_temp_files(temp_video_path, frame_folder)
 
371
  return analysis_output, tip_output, total_bill_output, custom_prompt_text, gr.update(value=None)
372
 
373
  def auto_tip_and_invoice(self, alibaba_key, video_file_obj, subtotal, star_rating, review, prompt, *quantities):
 
374
  analysis, tip_disp, total_bill_disp, prompt_out, vid_out = self.compute_tip(
375
  alibaba_key, video_file_obj, subtotal, star_rating, review, prompt
376
  )
 
378
  return analysis, tip_disp, total_bill_disp, prompt_out, vid_out, invoice
379
 
380
  def update_invoice_summary(self, *args):
 
381
  num_items = len(self.config.FOOD_ITEMS)
382
  quantities = args[:num_items]
 
383
  if len(args) >= num_items + 2:
384
  tip_str = args[num_items]
385
  total_bill_str = args[num_items + 1]
386
  else:
387
  tip_str = "$0.00"
388
  total_bill_str = "$0.00"
 
389
  summary = ""
390
  for i, q in enumerate(quantities):
391
  try:
392
  q_val = float(q)
393
  except:
394
  q_val = 0
 
395
  if q_val > 0:
396
  item = self.config.FOOD_ITEMS[i]
397
  total_price = item['price'] * q_val
398
  summary += f"{item['name']} x{int(q_val)} : ${total_price:.2f}\n"
 
399
  if summary == "":
400
  summary = "주문한 메뉴가 없습니다."
 
401
  summary += f"\nTip: {tip_str}\nTotal Bill: {total_bill_str}"
 
402
  return summary
403
 
404
  def manual_tip_and_invoice(self, tip_percent, subtotal, *quantities):
 
405
  analysis, tip_disp, total_bill_disp = self.tip_calculator.calculate_manual_tip(tip_percent, subtotal)
406
  invoice = self.update_invoice_summary(*quantities, tip_disp, total_bill_disp)
407
  return analysis, tip_disp, total_bill_disp, invoice
408
 
409
  def process_payment(self, total_bill):
 
410
  return f"{total_bill} 결제되었습니다."
411
 
412
 
413
+ # --- App: 모든 컴포넌트 연결 및 Gradio 인터페이스 실행 ---
 
 
414
  class App:
 
 
415
  def __init__(self):
416
  self.config = Config()
417
  self.model_clients = ModelClients(self.config)
418
  self.video_processor = VideoProcessor()
419
  self.tip_calculator = TipCalculator(self.config, self.model_clients, self.video_processor)
420
  self.ui_handler = UIHandler(self.config, self.tip_calculator, self.video_processor)
 
 
421
  self.flask_app = Flask(__name__)
422
 
423
  def create_gradio_blocks(self):
 
424
  with gr.Blocks(title="Video Tip Calculation Interface", theme=gr.themes.Soft(),
425
  css=self.config.CUSTOM_CSS) as interface:
426
  gr.Markdown("## Video Tip Calculation Interface (Structured)")
 
427
  quantity_inputs = []
428
  subtotal_display = gr.Number(label="Subtotal ($)", value=0.0, interactive=False, visible=False)
 
429
  with gr.Row():
430
  with gr.Column(scale=2):
431
  gr.Markdown("### 1. Select Food Items")
 
450
  elem_id=f"qty_{item['name'].replace(' ', '_')}"
451
  )
452
  quantity_inputs.append(q_input)
 
453
  subtotal_visible_display = gr.Textbox(label="Subtotal", value="$0.00", interactive=False)
 
454
  gr.Markdown("### 2. Service Feedback")
455
  review_input = gr.Textbox(label="Review", placeholder="서비스 리뷰를 작성해주세요.", lines=3)
456
  rating_input = gr.Radio(choices=[1, 2, 3, 4, 5], value=3, label="⭐Star Rating (1-5)⭐", type="value")
 
457
  gr.Markdown("### 3. Calculate Tip")
458
  with gr.Row():
459
  btn_5 = gr.Button("5%")
 
461
  btn_15 = gr.Button("15%")
462
  btn_20 = gr.Button("20%")
463
  btn_25 = gr.Button("25%")
 
 
464
  with gr.Row():
465
  qwen_btn = gr.Button("Alibaba-Qwen", variant="tertiary", elem_id="qwen-button")
 
466
  gr.Markdown("### 4. Results")
467
  tip_display = gr.Textbox(label="Calculated Tip", value="$0.00", interactive=False)
468
+ total_bill_display = gr.Textbox(label="Total Bill (Subtotal + Tip)", value="$0.00", interactive=False)
 
469
  payment_btn = gr.Button("결제하기")
470
  payment_result = gr.Textbox(label="Payment Result", value="", interactive=False)
 
471
  with gr.Column(scale=1):
472
  gr.Markdown("### 5. Upload & Prompt")
 
473
  alibaba_key_input = gr.Textbox(label="Alibaba API Key", placeholder="Enter your Alibaba API Key", lines=1)
474
  video_input = gr.Video(label="Upload Service Video")
475
  prompt_display = gr.Textbox(
 
483
  user_review="(No user review provided)"
484
  ).replace("{caption_text}", "{{caption_text}}")
485
  )
 
486
  gr.Markdown("### 6. AI Analysis")
487
  analysis_display = gr.Textbox(label="AI Analysis", lines=10, max_lines=15, interactive=True)
 
488
  gr.Markdown("### 7. 청구서")
489
  order_summary_display = gr.Textbox(label="청구서", value="주문한 메뉴가 없습니다.", interactive=True)
 
 
490
  subtotal_display.change(
491
  fn=lambda x: f"${x:.2f}",
492
  inputs=[subtotal_display],
493
  outputs=[subtotal_visible_display]
494
  )
 
 
495
  inputs_for_prompt_update = quantity_inputs + [rating_input, review_input]
496
  outputs_for_prompt_update = [subtotal_display, prompt_display]
497
  for comp in inputs_for_prompt_update:
 
500
  inputs=inputs_for_prompt_update,
501
  outputs=outputs_for_prompt_update
502
  )
 
 
503
  for comp in quantity_inputs:
504
  comp.change(
505
  fn=self.ui_handler.update_invoice_summary,
506
  inputs=quantity_inputs,
507
  outputs=order_summary_display
508
  )
 
 
509
  compute_inputs = [alibaba_key_input, video_input, subtotal_display, rating_input, review_input, prompt_display] + quantity_inputs
510
  compute_outputs = [
511
  analysis_display, tip_display, total_bill_display, prompt_display, video_input, order_summary_display
512
  ]
 
513
  qwen_btn.click(
514
  fn=lambda alibaba_key, vid, sub, rat, rev, prom, *qty: self.ui_handler.auto_tip_and_invoice(
515
  alibaba_key, vid, sub, rat, rev, prom, *qty
 
517
  inputs=compute_inputs,
518
  outputs=compute_outputs
519
  )
 
 
520
  btn_5.click(
521
  fn=lambda sub, *qty: self.ui_handler.manual_tip_and_invoice(5, sub, *qty),
522
  inputs=[subtotal_display] + quantity_inputs,
 
542
  inputs=[subtotal_display] + quantity_inputs,
543
  outputs=[analysis_display, tip_display, total_bill_display, order_summary_display]
544
  )
 
 
545
  payment_btn.click(
546
  fn=self.ui_handler.process_payment,
547
  inputs=[total_bill_display],
548
  outputs=[payment_result]
549
  )
 
550
  return interface
551
 
552
  def run_gradio(self):
 
553
  interface = self.create_gradio_blocks()
554
  interface.launch(share=True)
555
 
556
  def run_flask(self):
 
 
557
  @self.flask_app.route("/")
558
  def index():
559
  return "Hello Flask"
 
560
  self.flask_app.run(host="0.0.0.0", port=5000, debug=True)
561
 
562
 
563
  if __name__ == "__main__":
564
  app = App()
 
565
  app.run_gradio()
 
 
 
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio
2
+ opencv-python
3
+ flask
4
+ openai
space.yaml ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ runtime: python
2
+ python_version: "3.10"
3
+ hardware:
4
+ gpu: false