| from pathlib import Path |
|
|
| import gradio as gr |
|
|
| from gesture_logic import FIRST, LAST, NEXT, PREV, NONE, infer_gesture_command |
| from hand_tracker import detect_hand |
| from state import ImageState |
|
|
|
|
| ASSETS_DIR = Path("assets") |
|
|
|
|
| APP_CSS = """ |
| #app-shell { |
| max-width: 1280px; |
| margin: 0 auto; |
| } |
| |
| .app-heading h1 { |
| font-size: 26px; |
| line-height: 1.2; |
| margin-bottom: 4px; |
| } |
| |
| .app-heading p { |
| color: #4b5563; |
| margin-top: 0; |
| } |
| |
| .preview-card { |
| border: 1px solid #e5e7eb; |
| border-radius: 8px; |
| background: #ffffff; |
| padding: 12px; |
| } |
| |
| .preview-title { |
| font-weight: 700; |
| margin: 0 0 8px; |
| } |
| |
| .preview-image { |
| height: min(46vw, 430px); |
| min-height: 280px; |
| overflow: hidden; |
| } |
| |
| .preview-image img, |
| .preview-image video { |
| width: 100% !important; |
| height: 100% !important; |
| object-fit: contain !important; |
| } |
| |
| .preview-image .icon-wrap, |
| .preview-image .empty, |
| .preview-image .label-wrap { |
| display: none !important; |
| } |
| |
| #status-box textarea { |
| font-size: 16px; |
| font-weight: 600; |
| } |
| |
| .command-row button { |
| min-height: 44px; |
| } |
| |
| @media (max-width: 900px) { |
| .preview-image { |
| height: 320px; |
| } |
| } |
| """ |
|
|
|
|
| def build_status(command: str, index: int, total: int) -> str: |
| if total == 0: |
| return f"Command: {command} | Index: 0/0 (no images in assets/)" |
| return f"Command: {command} | Index: {index + 1}/{total}" |
|
|
|
|
| def apply_command(command: str, current_index: int, webcam_frame=None): |
| image_state = ImageState(str(ASSETS_DIR)) |
| total = len(image_state.image_paths) |
| if total > 0: |
| if current_index is None: |
| image_state.index = 0 |
| else: |
| image_state.index = max(0, min(int(current_index), total - 1)) |
|
|
| |
| _ = detect_hand(webcam_frame) |
|
|
| if command == NEXT: |
| image_state.next_image() |
| elif command == PREV: |
| image_state.prev_image() |
| elif command == FIRST: |
| image_state.first_image() |
| elif command == LAST: |
| image_state.last_image() |
|
|
| current_image = image_state.get_current_image() |
| total = len(image_state.image_paths) |
| status = build_status(command, image_state.index, total) |
| return current_image, status, image_state.index |
|
|
|
|
| def on_prev(current_index: int): |
| return apply_command(PREV, current_index, None) |
|
|
|
|
| def on_next(current_index: int): |
| return apply_command(NEXT, current_index, None) |
|
|
|
|
| def on_first(current_index: int): |
| return apply_command(FIRST, current_index, None) |
|
|
|
|
| def on_last(current_index: int): |
| return apply_command(LAST, current_index, None) |
|
|
|
|
| def on_webcam_frame(webcam_frame, current_index: int): |
| hand_data = detect_hand(webcam_frame) |
| command = infer_gesture_command(hand_data) |
| return apply_command(command, current_index, webcam_frame) |
|
|
|
|
| def build_demo(): |
| initial_state = ImageState(str(ASSETS_DIR)) |
| initial_image = initial_state.get_current_image() |
| initial_total = len(initial_state.image_paths) |
| initial_status = build_status(NONE, initial_state.index, initial_total) |
|
|
| with gr.Blocks(title="手勢影像播放器", elem_id="app-shell") as demo: |
| gr.Markdown( |
| "# 手勢影像播放器\n使用按鈕或單手手勢切換圖片。", |
| elem_classes="app-heading", |
| ) |
|
|
| with gr.Row(): |
| with gr.Column(scale=1, elem_classes="preview-card"): |
| gr.Markdown("網路攝影機", elem_classes="preview-title") |
| webcam_input = gr.Image( |
| sources=["webcam"], |
| type="numpy", |
| streaming=True, |
| show_label=False, |
| height=420, |
| elem_classes="preview-image", |
| placeholder="", |
| ) |
| with gr.Column(scale=1, elem_classes="preview-card"): |
| gr.Markdown("目前圖片", elem_classes="preview-title") |
| current_image = gr.Image( |
| value=initial_image, |
| type="numpy", |
| show_label=False, |
| height=420, |
| elem_classes="preview-image", |
| buttons=[], |
| placeholder="", |
| ) |
|
|
| status_text = gr.Textbox( |
| value=initial_status, |
| label="狀態", |
| interactive=False, |
| elem_id="status-box", |
| ) |
| state_holder = gr.State(initial_state.index) |
|
|
| with gr.Row(elem_classes="command-row"): |
| btn_prev = gr.Button("PREV") |
| btn_next = gr.Button("NEXT") |
| btn_first = gr.Button("FIRST") |
| btn_last = gr.Button("LAST") |
|
|
| btn_prev.click( |
| fn=on_prev, |
| inputs=[state_holder], |
| outputs=[current_image, status_text, state_holder], |
| ) |
| btn_next.click( |
| fn=on_next, |
| inputs=[state_holder], |
| outputs=[current_image, status_text, state_holder], |
| ) |
| btn_first.click( |
| fn=on_first, |
| inputs=[state_holder], |
| outputs=[current_image, status_text, state_holder], |
| ) |
| btn_last.click( |
| fn=on_last, |
| inputs=[state_holder], |
| outputs=[current_image, status_text, state_holder], |
| ) |
| webcam_input.stream( |
| fn=on_webcam_frame, |
| inputs=[webcam_input, state_holder], |
| outputs=[current_image, status_text, state_holder], |
| ) |
| webcam_input.change( |
| fn=on_webcam_frame, |
| inputs=[webcam_input, state_holder], |
| outputs=[current_image, status_text, state_holder], |
| ) |
|
|
| return demo |
|
|
|
|
| if __name__ == "__main__": |
| app = build_demo() |
| app.launch(show_error=True, css=APP_CSS) |
|
|