Mirko Trasciatti
commited on
Commit
·
79c7590
1
Parent(s):
9974014
Add goal crossbar annotation workflow
Browse files- app.py +489 -131
- tooltips.js +0 -38
app.py
CHANGED
|
@@ -51,12 +51,14 @@ YOLO_IOU_THRESHOLD = 0.02
|
|
| 51 |
PLAYER_TARGET_NAME = "person"
|
| 52 |
PLAYER_OBJECT_ID = 2
|
| 53 |
BALL_OBJECT_ID = 1
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
def get_yolo_model(model_filename: str = YOLO_DEFAULT_MODEL) -> YOLO:
|
|
@@ -174,21 +176,282 @@ def _compute_sam_window_from_kick(state: AppState, kick_frame: int | None) -> tu
|
|
| 174 |
total_frames = state.num_frames
|
| 175 |
if total_frames == 0:
|
| 176 |
return 0, 0
|
|
|
|
|
|
|
|
|
|
| 177 |
if kick_frame is None:
|
| 178 |
start_idx = 0
|
| 179 |
-
end_idx = total_frames
|
| 180 |
else:
|
| 181 |
-
fps = state.video_fps if state.video_fps and state.video_fps > 0 else 25.0
|
| 182 |
-
target_window_frames = max(1, int(round(fps * 4.0)))
|
| 183 |
-
half_window = target_window_frames // 2
|
| 184 |
start_idx = max(0, int(kick_frame) - half_window)
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
state.sam_window = (start_idx, end_idx)
|
| 189 |
return start_idx, end_idx
|
| 190 |
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
def _perform_yolo_ball_tracking(state: AppState, progress: gr.Progress | None = None) -> None:
|
| 193 |
if state is None or state.num_frames == 0:
|
| 194 |
raise gr.Error("Load a video first, then track with YOLO.")
|
|
@@ -595,6 +858,12 @@ class AppState:
|
|
| 595 |
self.is_sam_tracked: bool = False
|
| 596 |
self.is_player_detected: bool = False
|
| 597 |
self.is_player_propagated: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
|
| 599 |
def __repr__(self):
|
| 600 |
return f"AppState(video_frames={self.video_frames}, inference_session={self.inference_session is not None}, model={self.model is not None}, processor={self.processor is not None}, device={self.device}, dtype={self.dtype}, video_fps={self.video_fps}, masks_by_frame={self.masks_by_frame}, color_by_obj={self.color_by_obj}, clicks_by_frame_obj={self.clicks_by_frame_obj}, boxes_by_frame_obj={self.boxes_by_frame_obj}, composited_frames={self.composited_frames}, current_frame_idx={self.current_frame_idx}, current_obj_id={self.current_obj_id}, current_label={self.current_label}, current_clear_old={self.current_clear_old}, current_prompt_type={self.current_prompt_type}, pending_box_start={self.pending_box_start}, pending_box_start_frame_idx={self.pending_box_start_frame_idx}, pending_box_start_obj_id={self.pending_box_start_obj_id}, is_switching_model={self.is_switching_model}, model_repo_key={self.model_repo_key}, model_repo_id={self.model_repo_id}, session_repo_id={self.session_repo_id})"
|
|
@@ -693,6 +962,18 @@ def init_video_session(GLOBAL_STATE: gr.State, video: str | dict) -> tuple[AppSt
|
|
| 693 |
GLOBAL_STATE.impact_debug_speed_kmh = []
|
| 694 |
GLOBAL_STATE.impact_debug_speed_threshold_px = None
|
| 695 |
GLOBAL_STATE.impact_meters_per_px = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
GLOBAL_STATE.yolo_ball_centers = {}
|
| 697 |
GLOBAL_STATE.yolo_ball_boxes = {}
|
| 698 |
GLOBAL_STATE.yolo_ball_conf = {}
|
|
@@ -1094,6 +1375,8 @@ def compose_frame(state: AppState, frame_idx: int, remove_bg: bool = False) -> I
|
|
| 1094 |
base_np = focus_alpha * orig_np + (1.0 - focus_alpha) * base_np
|
| 1095 |
out_img = Image.fromarray(np.clip(base_np * 255.0, 0, 255).astype(np.uint8))
|
| 1096 |
|
|
|
|
|
|
|
| 1097 |
# Draw crosses for conditioning frames only (frames with recorded clicks)
|
| 1098 |
clicks_map = state.clicks_by_frame_obj.get(frame_idx)
|
| 1099 |
if state.show_click_marks and clicks_map:
|
|
@@ -2138,7 +2421,7 @@ def _player_has_masks(state: AppState) -> bool:
|
|
| 2138 |
|
| 2139 |
def _button_updates(state: AppState) -> tuple[Any, Any, Any]:
|
| 2140 |
yolo_ready = isinstance(state, AppState) and state.yolo_kick_frame is not None
|
| 2141 |
-
propagate_main_enabled = _ball_has_masks(state)
|
| 2142 |
detect_player_enabled = yolo_ready
|
| 2143 |
propagate_player_enabled = _player_has_masks(state)
|
| 2144 |
sam_tracked = isinstance(state, AppState) and getattr(state, "is_sam_tracked", False)
|
|
@@ -2167,7 +2450,7 @@ def _ball_button_updates(state: AppState) -> tuple[Any, Any]:
|
|
| 2167 |
|
| 2168 |
|
| 2169 |
def _ui_status_updates(state: AppState) -> tuple[Any, ...]:
|
| 2170 |
-
return _kick_button_updates(state) + _ball_button_updates(state)
|
| 2171 |
|
| 2172 |
|
| 2173 |
def _recompute_motion_metrics(state: AppState, target_obj_id: int = 1):
|
|
@@ -2584,15 +2867,29 @@ def _on_image_click_with_updates(
|
|
| 2584 |
img: Image.Image | np.ndarray,
|
| 2585 |
state: AppState,
|
| 2586 |
frame_idx: int,
|
| 2587 |
-
|
| 2588 |
label: str,
|
| 2589 |
clear_old: bool,
|
| 2590 |
evt: gr.SelectData,
|
| 2591 |
):
|
| 2592 |
-
|
| 2593 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2594 |
propagate_main_update, detect_btn_update, propagate_player_update = _button_updates(state)
|
| 2595 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2596 |
|
| 2597 |
|
| 2598 |
@spaces.GPU()
|
|
@@ -3143,33 +3440,49 @@ BUTTON_TOOLTIPS = {
|
|
| 3143 |
"After a player mask exists, SAM2 propagates it within the same kick-centric window. This keeps athlete and ball masks "
|
| 3144 |
"time-synced, enabling combined overlays, exports, and analytics comparing foot position to ball contact."
|
| 3145 |
),
|
| 3146 |
-
"
|
| 3147 |
-
"
|
| 3148 |
-
"
|
| 3149 |
-
|
| 3150 |
-
|
|
|
|
|
|
|
| 3151 |
),
|
| 3152 |
-
"
|
| 3153 |
-
"
|
| 3154 |
-
|
| 3155 |
-
|
| 3156 |
-
"
|
| 3157 |
-
"
|
| 3158 |
),
|
| 3159 |
}
|
| 3160 |
|
| 3161 |
-
|
| 3162 |
-
|
| 3163 |
-
|
| 3164 |
-
|
| 3165 |
-
|
| 3166 |
-
|
| 3167 |
-
|
| 3168 |
-
|
| 3169 |
-
|
| 3170 |
-
|
| 3171 |
-
|
| 3172 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3173 |
GLOBAL_STATE = gr.State(AppState())
|
| 3174 |
|
| 3175 |
gr.Markdown(
|
|
@@ -3217,17 +3530,7 @@ with gr.Blocks(
|
|
| 3217 |
)
|
| 3218 |
ckpt_progress = gr.Markdown(visible=False)
|
| 3219 |
load_status = gr.Markdown(visible=True)
|
| 3220 |
-
|
| 3221 |
-
reset_btn = gr.Button("Reset Session", variant="secondary", elem_id="btn-reset-session")
|
| 3222 |
-
with gr.Accordion("Session reset · details", open=False):
|
| 3223 |
-
gr.Markdown(
|
| 3224 |
-
_section_info_markdown(
|
| 3225 |
-
"Reset workflow",
|
| 3226 |
-
[
|
| 3227 |
-
("Reset Session", "btn-reset-session"),
|
| 3228 |
-
],
|
| 3229 |
-
)
|
| 3230 |
-
)
|
| 3231 |
with gr.Column(scale=1):
|
| 3232 |
gr.Markdown("**Preview**")
|
| 3233 |
preview = gr.Image(
|
|
@@ -3255,31 +3558,31 @@ with gr.Blocks(
|
|
| 3255 |
with gr.Row(elem_classes=["model-status"]):
|
| 3256 |
manual_kick_btn = gr.Button("⚽: N/A", interactive=False)
|
| 3257 |
manual_impact_btn = gr.Button("🚩: N/A", interactive=False)
|
| 3258 |
-
|
| 3259 |
-
|
| 3260 |
-
|
| 3261 |
-
|
| 3262 |
-
|
| 3263 |
-
|
| 3264 |
-
|
| 3265 |
-
|
| 3266 |
-
|
| 3267 |
-
|
| 3268 |
-
|
| 3269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3270 |
)
|
| 3271 |
-
|
| 3272 |
-
|
| 3273 |
-
|
| 3274 |
-
|
| 3275 |
-
|
| 3276 |
-
"Manual controls",
|
| 3277 |
-
[
|
| 3278 |
-
("⚽ Mark Kick", "btn-mark-kick"),
|
| 3279 |
-
("🚩 Mark Impact", "btn-mark-impact"),
|
| 3280 |
-
],
|
| 3281 |
)
|
| 3282 |
-
)
|
| 3283 |
with gr.Column(elem_classes=["model-section"]):
|
| 3284 |
with gr.Row(elem_classes=["model-row"]):
|
| 3285 |
gr.Markdown("YOLO13", elem_classes=["model-label"])
|
|
@@ -3296,18 +3599,6 @@ with gr.Blocks(
|
|
| 3296 |
yolo_kick_btn = gr.Button("⚽: N/A", interactive=False)
|
| 3297 |
yolo_impact_btn = gr.Button("🚩: N/A", interactive=False)
|
| 3298 |
yolo_plot = gr.Plot(label="YOLO kick diagnostics", show_label=True)
|
| 3299 |
-
with gr.Accordion("YOLO13 workflow · details", open=False):
|
| 3300 |
-
gr.Markdown(
|
| 3301 |
-
_section_info_markdown(
|
| 3302 |
-
"YOLO13 workflow",
|
| 3303 |
-
[
|
| 3304 |
-
("Detect Ball", "btn-detect-ball"),
|
| 3305 |
-
("Track Ball", "btn-track-ball-yolo"),
|
| 3306 |
-
("Detect Player", "btn-detect-player"),
|
| 3307 |
-
],
|
| 3308 |
-
)
|
| 3309 |
-
)
|
| 3310 |
-
gr.Markdown(f"#### YOLO kick diagnostics chart\n{BUTTON_TOOLTIPS['chart-yolo']}")
|
| 3311 |
with gr.Column(elem_classes=["model-section"]):
|
| 3312 |
with gr.Row(elem_classes=["model-row"]):
|
| 3313 |
gr.Markdown("SAM2", elem_classes=["model-label"])
|
|
@@ -3325,17 +3616,7 @@ with gr.Blocks(
|
|
| 3325 |
sam_kick_btn = gr.Button("⚽: N/A", interactive=False)
|
| 3326 |
sam_impact_btn = gr.Button("🚩: N/A", interactive=False)
|
| 3327 |
kick_plot = gr.Plot(label="Kick & impact diagnostics", show_label=True)
|
| 3328 |
-
|
| 3329 |
-
gr.Markdown(
|
| 3330 |
-
_section_info_markdown(
|
| 3331 |
-
"SAM2 tracking",
|
| 3332 |
-
[
|
| 3333 |
-
("Track Ball", "btn-track-ball-sam"),
|
| 3334 |
-
("Track Player", "btn-track-player-sam"),
|
| 3335 |
-
],
|
| 3336 |
-
)
|
| 3337 |
-
)
|
| 3338 |
-
gr.Markdown(f"#### Kick & impact diagnostics chart\n{BUTTON_TOOLTIPS['chart-sam']}")
|
| 3339 |
with gr.Row():
|
| 3340 |
min_impact_speed_slider = gr.Slider(
|
| 3341 |
label="Min impact speed (km/h)",
|
|
@@ -3356,6 +3637,11 @@ with gr.Blocks(
|
|
| 3356 |
ball_status = gr.Markdown(visible=False)
|
| 3357 |
propagate_status = gr.Markdown(visible=True)
|
| 3358 |
impact_status = gr.Markdown("Impact frame: not computed", visible=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3359 |
# Wire events
|
| 3360 |
def _on_video_change(GLOBAL_STATE: gr.State, video):
|
| 3361 |
GLOBAL_STATE, min_idx, max_idx, first_frame, status = init_video_session(GLOBAL_STATE, video)
|
|
@@ -3373,10 +3659,6 @@ with gr.Blocks(
|
|
| 3373 |
propagate_main_update,
|
| 3374 |
detect_btn_update,
|
| 3375 |
propagate_player_update,
|
| 3376 |
-
gr.update(value=OBJECT_LABELS[0]),
|
| 3377 |
-
gr.update(value="positive", visible=True, interactive=True),
|
| 3378 |
-
gr.update(value=False, interactive=True),
|
| 3379 |
-
gr.update(value="Points"),
|
| 3380 |
)
|
| 3381 |
|
| 3382 |
video_in.change(
|
|
@@ -3398,13 +3680,14 @@ with gr.Blocks(
|
|
| 3398 |
manual_impact_btn,
|
| 3399 |
detect_ball_btn,
|
| 3400 |
track_ball_yolo_btn,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3401 |
propagate_btn,
|
| 3402 |
detect_player_btn,
|
| 3403 |
propagate_player_btn,
|
| 3404 |
-
object_selector,
|
| 3405 |
-
label_radio,
|
| 3406 |
-
clear_old_chk,
|
| 3407 |
-
prompt_type,
|
| 3408 |
],
|
| 3409 |
show_progress=True,
|
| 3410 |
)
|
|
@@ -3434,13 +3717,14 @@ with gr.Blocks(
|
|
| 3434 |
manual_impact_btn,
|
| 3435 |
detect_ball_btn,
|
| 3436 |
track_ball_yolo_btn,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3437 |
propagate_btn,
|
| 3438 |
detect_player_btn,
|
| 3439 |
propagate_player_btn,
|
| 3440 |
-
object_selector,
|
| 3441 |
-
label_radio,
|
| 3442 |
-
clear_old_chk,
|
| 3443 |
-
prompt_type,
|
| 3444 |
],
|
| 3445 |
label="Examples",
|
| 3446 |
cache_examples=False,
|
|
@@ -3732,6 +4016,11 @@ with gr.Blocks(
|
|
| 3732 |
manual_impact_btn,
|
| 3733 |
detect_ball_btn,
|
| 3734 |
track_ball_yolo_btn,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3735 |
],
|
| 3736 |
)
|
| 3737 |
mark_impact_btn.click(
|
|
@@ -3753,15 +4042,20 @@ with gr.Blocks(
|
|
| 3753 |
manual_impact_btn,
|
| 3754 |
detect_ball_btn,
|
| 3755 |
track_ball_yolo_btn,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3756 |
],
|
| 3757 |
)
|
| 3758 |
|
| 3759 |
-
def
|
| 3760 |
-
if s is not None and
|
| 3761 |
-
s.current_obj_id =
|
| 3762 |
return gr.update()
|
| 3763 |
|
| 3764 |
-
|
| 3765 |
|
| 3766 |
def _sync_label(s: AppState, lab: str):
|
| 3767 |
if s is not None and lab is not None:
|
|
@@ -3830,7 +4124,7 @@ with gr.Blocks(
|
|
| 3830 |
|
| 3831 |
def _auto_detect_ball(
|
| 3832 |
state_in: AppState,
|
| 3833 |
-
|
| 3834 |
label_value: str,
|
| 3835 |
clear_old_value: bool,
|
| 3836 |
):
|
|
@@ -3862,8 +4156,7 @@ with gr.Blocks(
|
|
| 3862 |
frame_width, frame_height = frame.size
|
| 3863 |
x_center = max(0, min(frame_width - 1, int(x_center)))
|
| 3864 |
y_center = max(0, min(frame_height - 1, int(y_center)))
|
| 3865 |
-
obj_id_int =
|
| 3866 |
-
state_in.current_obj_id = obj_id_int
|
| 3867 |
label_str = label_value if label_value else state_in.current_label
|
| 3868 |
clear_old_flag = bool(clear_old_value)
|
| 3869 |
|
|
@@ -3902,7 +4195,7 @@ with gr.Blocks(
|
|
| 3902 |
|
| 3903 |
detect_ball_btn.click(
|
| 3904 |
_auto_detect_ball,
|
| 3905 |
-
inputs=[GLOBAL_STATE,
|
| 3906 |
outputs=[
|
| 3907 |
preview,
|
| 3908 |
ball_status,
|
|
@@ -3919,6 +4212,11 @@ with gr.Blocks(
|
|
| 3919 |
manual_impact_btn,
|
| 3920 |
detect_ball_btn,
|
| 3921 |
track_ball_yolo_btn,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3922 |
],
|
| 3923 |
)
|
| 3924 |
|
|
@@ -3977,6 +4275,11 @@ with gr.Blocks(
|
|
| 3977 |
manual_impact_btn,
|
| 3978 |
detect_ball_btn,
|
| 3979 |
track_ball_yolo_btn,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3980 |
],
|
| 3981 |
)
|
| 3982 |
|
|
@@ -4017,10 +4320,6 @@ with gr.Blocks(
|
|
| 4017 |
def _result(preview_img, status_text):
|
| 4018 |
propagate_main_update, detect_btn_update, propagate_player_update = _button_updates(state_in)
|
| 4019 |
status_updates = _ui_status_updates(state_in)
|
| 4020 |
-
selected_label = next(
|
| 4021 |
-
(label for label, oid in OBJECT_CHOICES if oid == state_in.current_obj_id),
|
| 4022 |
-
OBJECT_LABELS[0],
|
| 4023 |
-
)
|
| 4024 |
return (
|
| 4025 |
preview_img,
|
| 4026 |
gr.update(value=status_text, visible=True),
|
|
@@ -4029,7 +4328,7 @@ with gr.Blocks(
|
|
| 4029 |
propagate_main_update,
|
| 4030 |
detect_btn_update,
|
| 4031 |
propagate_player_update,
|
| 4032 |
-
gr.update(
|
| 4033 |
_impact_status_update(state_in),
|
| 4034 |
*status_updates,
|
| 4035 |
)
|
|
@@ -4102,10 +4401,7 @@ with gr.Blocks(
|
|
| 4102 |
status_text = (
|
| 4103 |
f"{_format_kick_status(state_in)} | ✅ Player auto-detected on frame {frame_idx} (conf={conf:.2f})"
|
| 4104 |
)
|
| 4105 |
-
|
| 4106 |
-
# Force mask overlay refresh now that the player has been stamped.
|
| 4107 |
-
state_in.composited_frames.pop(frame_idx, None)
|
| 4108 |
-
return _result(preview_img, status_text)
|
| 4109 |
|
| 4110 |
detect_player_btn.click(
|
| 4111 |
_auto_detect_player,
|
|
@@ -4118,7 +4414,7 @@ with gr.Blocks(
|
|
| 4118 |
propagate_btn,
|
| 4119 |
detect_player_btn,
|
| 4120 |
propagate_player_btn,
|
| 4121 |
-
|
| 4122 |
impact_status,
|
| 4123 |
yolo_kick_btn,
|
| 4124 |
yolo_impact_btn,
|
|
@@ -4128,6 +4424,11 @@ with gr.Blocks(
|
|
| 4128 |
manual_impact_btn,
|
| 4129 |
detect_ball_btn,
|
| 4130 |
track_ball_yolo_btn,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4131 |
],
|
| 4132 |
)
|
| 4133 |
|
|
@@ -4298,14 +4599,61 @@ with gr.Blocks(
|
|
| 4298 |
manual_impact_btn,
|
| 4299 |
detect_ball_btn,
|
| 4300 |
track_ball_yolo_btn,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4301 |
],
|
| 4302 |
)
|
| 4303 |
|
| 4304 |
# Image click to add a point and run forward on that frame
|
| 4305 |
preview.select(
|
| 4306 |
_on_image_click_with_updates,
|
| 4307 |
-
[preview, GLOBAL_STATE, frame_slider,
|
| 4308 |
-
[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4309 |
)
|
| 4310 |
|
| 4311 |
# Playback via MP4 rendering only
|
|
@@ -4386,6 +4734,11 @@ with gr.Blocks(
|
|
| 4386 |
manual_impact_btn,
|
| 4387 |
detect_ball_btn,
|
| 4388 |
track_ball_yolo_btn,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4389 |
],
|
| 4390 |
)
|
| 4391 |
|
|
@@ -4413,6 +4766,11 @@ with gr.Blocks(
|
|
| 4413 |
manual_impact_btn,
|
| 4414 |
detect_ball_btn,
|
| 4415 |
track_ball_yolo_btn,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4416 |
],
|
| 4417 |
)
|
| 4418 |
|
|
|
|
| 51 |
PLAYER_TARGET_NAME = "person"
|
| 52 |
PLAYER_OBJECT_ID = 2
|
| 53 |
BALL_OBJECT_ID = 1
|
| 54 |
+
GOAL_MODE_IDLE = "idle"
|
| 55 |
+
GOAL_MODE_PLACING_FIRST = "placing_first"
|
| 56 |
+
GOAL_MODE_PLACING_SECOND = "placing_second"
|
| 57 |
+
GOAL_MODE_EDITING = "editing"
|
| 58 |
+
GOAL_HANDLE_RADIUS_PX = 8
|
| 59 |
+
GOAL_HANDLE_HIT_RADIUS_PX = 28
|
| 60 |
+
GOAL_LINE_COLOR = (255, 214, 64)
|
| 61 |
+
GOAL_HANDLE_FILL = (10, 10, 10)
|
| 62 |
|
| 63 |
|
| 64 |
def get_yolo_model(model_filename: str = YOLO_DEFAULT_MODEL) -> YOLO:
|
|
|
|
| 176 |
total_frames = state.num_frames
|
| 177 |
if total_frames == 0:
|
| 178 |
return 0, 0
|
| 179 |
+
fps = state.video_fps if state.video_fps and state.video_fps > 0 else 25.0
|
| 180 |
+
target_window_frames = max(1, int(round(fps * 4.0)))
|
| 181 |
+
half_window = target_window_frames // 2
|
| 182 |
if kick_frame is None:
|
| 183 |
start_idx = 0
|
|
|
|
| 184 |
else:
|
|
|
|
|
|
|
|
|
|
| 185 |
start_idx = max(0, int(kick_frame) - half_window)
|
| 186 |
+
end_idx = min(total_frames, start_idx + target_window_frames)
|
| 187 |
+
if end_idx <= start_idx:
|
| 188 |
+
end_idx = min(total_frames, start_idx + 1)
|
| 189 |
state.sam_window = (start_idx, end_idx)
|
| 190 |
return start_idx, end_idx
|
| 191 |
|
| 192 |
|
| 193 |
+
def _goal_frame_dims(state: AppState, frame_idx: int | None = None) -> tuple[int, int]:
|
| 194 |
+
if state is None or not state.video_frames:
|
| 195 |
+
return 1, 1
|
| 196 |
+
idx = 0 if frame_idx is None else int(np.clip(frame_idx, 0, len(state.video_frames) - 1))
|
| 197 |
+
frame = state.video_frames[idx]
|
| 198 |
+
return frame.size
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _goal_norm_from_xy(state: AppState, frame_idx: int, x: int, y: int) -> tuple[float, float]:
|
| 202 |
+
width, height = _goal_frame_dims(state, frame_idx)
|
| 203 |
+
if width <= 0:
|
| 204 |
+
width = 1
|
| 205 |
+
if height <= 0:
|
| 206 |
+
height = 1
|
| 207 |
+
return (
|
| 208 |
+
float(np.clip(x / width, 0.0, 1.0)),
|
| 209 |
+
float(np.clip(y / height, 0.0, 1.0)),
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _goal_xy_from_norm(state: AppState, frame_idx: int, pt: tuple[float, float]) -> tuple[int, int]:
|
| 214 |
+
width, height = _goal_frame_dims(state, frame_idx)
|
| 215 |
+
return (
|
| 216 |
+
int(round(float(pt[0]) * width)),
|
| 217 |
+
int(round(float(pt[1]) * height)),
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _goal_points_for_drawing(state: AppState) -> list[tuple[float, float]]:
|
| 222 |
+
if state is None:
|
| 223 |
+
return []
|
| 224 |
+
if state.goal_mode in {GOAL_MODE_PLACING_FIRST, GOAL_MODE_PLACING_SECOND, GOAL_MODE_EDITING}:
|
| 225 |
+
return state.goal_points_norm
|
| 226 |
+
if state.goal_points_norm:
|
| 227 |
+
return state.goal_points_norm
|
| 228 |
+
return state.goal_confirmed_points_norm
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _goal_clear_preview_cache(state: AppState) -> None:
|
| 232 |
+
if state is None:
|
| 233 |
+
return
|
| 234 |
+
state.composited_frames.clear()
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _goal_has_confirmed(state: AppState) -> bool:
|
| 238 |
+
return isinstance(state, AppState) and len(state.goal_confirmed_points_norm) == 2
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _goal_set_status(state: AppState, text: str) -> None:
|
| 242 |
+
if state is None:
|
| 243 |
+
return
|
| 244 |
+
state.goal_status_text = text
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _goal_status_text(state: AppState) -> str:
|
| 248 |
+
if state is None:
|
| 249 |
+
return "Goal crossbar unavailable."
|
| 250 |
+
if state.goal_status_text:
|
| 251 |
+
return state.goal_status_text
|
| 252 |
+
if _goal_has_confirmed(state):
|
| 253 |
+
return "Goal crossbar confirmed. Click Start Mapping to adjust."
|
| 254 |
+
return "Goal crossbar inactive."
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def _goal_button_updates(state: AppState) -> tuple[Any, Any, Any, Any, Any]:
|
| 258 |
+
if state is None:
|
| 259 |
+
return (
|
| 260 |
+
gr.update(interactive=False),
|
| 261 |
+
gr.update(interactive=False),
|
| 262 |
+
gr.update(interactive=False),
|
| 263 |
+
gr.update(interactive=False),
|
| 264 |
+
gr.update(value="Goal crossbar unavailable.", visible=True),
|
| 265 |
+
)
|
| 266 |
+
start_enabled = state.goal_mode == GOAL_MODE_IDLE
|
| 267 |
+
confirm_enabled = len(state.goal_points_norm) == 2 and state.goal_mode in {
|
| 268 |
+
GOAL_MODE_PLACING_SECOND,
|
| 269 |
+
GOAL_MODE_EDITING,
|
| 270 |
+
}
|
| 271 |
+
clear_enabled = bool(state.goal_points_norm or state.goal_confirmed_points_norm)
|
| 272 |
+
back_enabled = bool(state.goal_prev_confirmed_points_norm)
|
| 273 |
+
status_update = gr.update(value=_goal_status_text(state), visible=True)
|
| 274 |
+
return (
|
| 275 |
+
gr.update(interactive=start_enabled),
|
| 276 |
+
gr.update(interactive=confirm_enabled),
|
| 277 |
+
gr.update(interactive=clear_enabled),
|
| 278 |
+
gr.update(interactive=back_enabled),
|
| 279 |
+
status_update,
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _goal_handle_hit_index(state: AppState, frame_idx: int, x: int, y: int) -> int | None:
|
| 284 |
+
points = state.goal_points_norm
|
| 285 |
+
if state is None or len(points) == 0:
|
| 286 |
+
return None
|
| 287 |
+
width, height = _goal_frame_dims(state, frame_idx)
|
| 288 |
+
max_dist = GOAL_HANDLE_HIT_RADIUS_PX
|
| 289 |
+
for idx, pt in enumerate(points):
|
| 290 |
+
px, py = _goal_xy_from_norm(state, frame_idx, pt)
|
| 291 |
+
dist = math.hypot(px - x, py - y)
|
| 292 |
+
if dist <= max_dist:
|
| 293 |
+
return idx
|
| 294 |
+
return None
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def _goal_current_frame_idx(state: AppState) -> int:
|
| 298 |
+
if state is None or state.num_frames == 0:
|
| 299 |
+
return 0
|
| 300 |
+
idx = int(getattr(state, "current_frame_idx", 0))
|
| 301 |
+
return int(np.clip(idx, 0, state.num_frames - 1))
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _goal_output_tuple(state: AppState, preview_img: Image.Image | None = None) -> tuple[Image.Image, Any, Any, Any, Any, Any]:
|
| 305 |
+
if state is None:
|
| 306 |
+
return (preview_img, *(gr.update(interactive=False) for _ in range(4)), gr.update(value="Goal crossbar unavailable.", visible=True))
|
| 307 |
+
idx = _goal_current_frame_idx(state)
|
| 308 |
+
if preview_img is None:
|
| 309 |
+
preview_img = update_frame_display(state, idx)
|
| 310 |
+
return (preview_img, *_goal_button_updates(state))
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def _goal_start_mapping(state: AppState) -> tuple[Image.Image, Any, Any, Any, Any, Any]:
|
| 314 |
+
if state is None or not state.video_frames:
|
| 315 |
+
raise gr.Error("Load a video first, then map the goal crossbar.")
|
| 316 |
+
state.goal_prev_confirmed_points_norm = list(state.goal_confirmed_points_norm)
|
| 317 |
+
state.goal_points_norm = []
|
| 318 |
+
state.goal_mode = GOAL_MODE_PLACING_FIRST
|
| 319 |
+
state.goal_dragging_idx = None
|
| 320 |
+
_goal_set_status(state, "Click the left goalpost to start the crossbar.")
|
| 321 |
+
_goal_clear_preview_cache(state)
|
| 322 |
+
return _goal_output_tuple(state)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def _goal_confirm_mapping(state: AppState) -> tuple[Image.Image, Any, Any, Any, Any, Any]:
|
| 326 |
+
if state is None:
|
| 327 |
+
return (None, *_goal_button_updates(state))
|
| 328 |
+
if len(state.goal_points_norm) != 2:
|
| 329 |
+
_goal_set_status(state, "Select both goal corners before confirming.")
|
| 330 |
+
return _goal_output_tuple(state)
|
| 331 |
+
state.goal_confirmed_points_norm = list(state.goal_points_norm)
|
| 332 |
+
state.goal_mode = GOAL_MODE_IDLE
|
| 333 |
+
state.goal_dragging_idx = None
|
| 334 |
+
_goal_set_status(state, "Goal crossbar saved. Click Start Mapping to adjust again.")
|
| 335 |
+
_goal_clear_preview_cache(state)
|
| 336 |
+
return _goal_output_tuple(state)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def _goal_clear_mapping(state: AppState) -> tuple[Image.Image, Any, Any, Any, Any, Any]:
|
| 340 |
+
if state is None:
|
| 341 |
+
return (None, *_goal_button_updates(state))
|
| 342 |
+
state.goal_points_norm = []
|
| 343 |
+
state.goal_confirmed_points_norm = []
|
| 344 |
+
state.goal_prev_confirmed_points_norm = []
|
| 345 |
+
state.goal_mode = GOAL_MODE_IDLE
|
| 346 |
+
state.goal_dragging_idx = None
|
| 347 |
+
_goal_set_status(state, "Goal crossbar cleared.")
|
| 348 |
+
_goal_clear_preview_cache(state)
|
| 349 |
+
return _goal_output_tuple(state)
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _goal_back_mapping(state: AppState) -> tuple[Image.Image, Any, Any, Any, Any, Any]:
|
| 353 |
+
if state is None:
|
| 354 |
+
return (None, *_goal_button_updates(state))
|
| 355 |
+
if not state.goal_prev_confirmed_points_norm:
|
| 356 |
+
_goal_set_status(state, "No previous goal crossbar to restore.")
|
| 357 |
+
return _goal_output_tuple(state)
|
| 358 |
+
state.goal_confirmed_points_norm = list(state.goal_prev_confirmed_points_norm)
|
| 359 |
+
state.goal_points_norm = list(state.goal_prev_confirmed_points_norm)
|
| 360 |
+
state.goal_prev_confirmed_points_norm = []
|
| 361 |
+
state.goal_mode = GOAL_MODE_IDLE
|
| 362 |
+
state.goal_dragging_idx = None
|
| 363 |
+
_goal_set_status(state, "Restored the previous goal crossbar.")
|
| 364 |
+
_goal_clear_preview_cache(state)
|
| 365 |
+
return _goal_output_tuple(state)
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def _goal_process_preview_click(
|
| 369 |
+
state: AppState,
|
| 370 |
+
frame_idx: int,
|
| 371 |
+
evt: gr.SelectData | None,
|
| 372 |
+
) -> tuple[Image.Image | None, bool]:
|
| 373 |
+
if state is None or state.goal_mode == GOAL_MODE_IDLE:
|
| 374 |
+
return None, False
|
| 375 |
+
x = y = None
|
| 376 |
+
if evt is not None:
|
| 377 |
+
try:
|
| 378 |
+
if hasattr(evt, "index") and isinstance(evt.index, (list, tuple)) and len(evt.index) == 2:
|
| 379 |
+
x, y = int(evt.index[0]), int(evt.index[1])
|
| 380 |
+
elif hasattr(evt, "value") and isinstance(evt.value, dict):
|
| 381 |
+
data = evt.value
|
| 382 |
+
if "x" in data and "y" in data:
|
| 383 |
+
x, y = int(data["x"]), int(data["y"])
|
| 384 |
+
except Exception:
|
| 385 |
+
x = y = None
|
| 386 |
+
if x is None or y is None:
|
| 387 |
+
_goal_set_status(state, "Could not read click coordinates. Please try again.")
|
| 388 |
+
return _goal_output_tuple(state)[0], True
|
| 389 |
+
|
| 390 |
+
norm_pt = _goal_norm_from_xy(state, frame_idx, x, y)
|
| 391 |
+
points = state.goal_points_norm
|
| 392 |
+
|
| 393 |
+
if state.goal_mode == GOAL_MODE_PLACING_FIRST:
|
| 394 |
+
state.goal_points_norm = [norm_pt]
|
| 395 |
+
state.goal_mode = GOAL_MODE_PLACING_SECOND
|
| 396 |
+
_goal_set_status(state, "Click the right goalpost to finish the crossbar.")
|
| 397 |
+
elif state.goal_mode == GOAL_MODE_PLACING_SECOND:
|
| 398 |
+
handle_idx = _goal_handle_hit_index(state, frame_idx, x, y)
|
| 399 |
+
if handle_idx is not None and handle_idx < len(points):
|
| 400 |
+
state.goal_points_norm[handle_idx] = norm_pt
|
| 401 |
+
_goal_set_status(state, "Adjusted the first corner. Click the other post.")
|
| 402 |
+
else:
|
| 403 |
+
if len(points) == 0:
|
| 404 |
+
state.goal_points_norm = [norm_pt]
|
| 405 |
+
_goal_set_status(state, "Click the next goalpost to finish the crossbar.")
|
| 406 |
+
elif len(points) == 1:
|
| 407 |
+
state.goal_points_norm.append(norm_pt)
|
| 408 |
+
state.goal_mode = GOAL_MODE_EDITING
|
| 409 |
+
_goal_set_status(state, "Adjust handles if needed, then Confirm.")
|
| 410 |
+
else:
|
| 411 |
+
state.goal_points_norm[1] = norm_pt
|
| 412 |
+
state.goal_mode = GOAL_MODE_EDITING
|
| 413 |
+
_goal_set_status(state, "Adjust handles if needed, then Confirm.")
|
| 414 |
+
elif state.goal_mode == GOAL_MODE_EDITING:
|
| 415 |
+
handle_idx = _goal_handle_hit_index(state, frame_idx, x, y)
|
| 416 |
+
if handle_idx is None and len(points) == 2:
|
| 417 |
+
# fall back to whichever endpoint is closest to click
|
| 418 |
+
px0, py0 = _goal_xy_from_norm(state, frame_idx, points[0])
|
| 419 |
+
px1, py1 = _goal_xy_from_norm(state, frame_idx, points[1])
|
| 420 |
+
dist0 = math.hypot(px0 - x, py0 - y)
|
| 421 |
+
dist1 = math.hypot(px1 - x, py1 - y)
|
| 422 |
+
handle_idx = 0 if dist0 <= dist1 else 1
|
| 423 |
+
if handle_idx is not None and handle_idx < len(points):
|
| 424 |
+
state.goal_points_norm[handle_idx] = norm_pt
|
| 425 |
+
_goal_set_status(state, "Handle moved. Press Confirm to save.")
|
| 426 |
+
state.goal_points_norm = state.goal_points_norm[:2]
|
| 427 |
+
_goal_clear_preview_cache(state)
|
| 428 |
+
preview_img = update_frame_display(state, frame_idx)
|
| 429 |
+
return preview_img, True
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def _draw_goal_overlay(state: AppState, frame_idx: int, image: Image.Image) -> None:
|
| 433 |
+
if state is None or image is None:
|
| 434 |
+
return
|
| 435 |
+
points = _goal_points_for_drawing(state)
|
| 436 |
+
if not points:
|
| 437 |
+
return
|
| 438 |
+
draw = ImageDraw.Draw(image)
|
| 439 |
+
px_points = [_goal_xy_from_norm(state, frame_idx, pt) for pt in points[:2]]
|
| 440 |
+
if len(px_points) >= 2:
|
| 441 |
+
draw.line(
|
| 442 |
+
[px_points[0], px_points[1]],
|
| 443 |
+
fill=GOAL_LINE_COLOR,
|
| 444 |
+
width=4,
|
| 445 |
+
)
|
| 446 |
+
handle_radius = max(4, GOAL_HANDLE_RADIUS_PX)
|
| 447 |
+
for cx, cy in px_points:
|
| 448 |
+
bbox = [
|
| 449 |
+
(cx - handle_radius, cy - handle_radius),
|
| 450 |
+
(cx + handle_radius, cy + handle_radius),
|
| 451 |
+
]
|
| 452 |
+
draw.ellipse(bbox, outline=GOAL_LINE_COLOR, fill=GOAL_HANDLE_FILL, width=2)
|
| 453 |
+
|
| 454 |
+
|
| 455 |
def _perform_yolo_ball_tracking(state: AppState, progress: gr.Progress | None = None) -> None:
|
| 456 |
if state is None or state.num_frames == 0:
|
| 457 |
raise gr.Error("Load a video first, then track with YOLO.")
|
|
|
|
| 858 |
self.is_sam_tracked: bool = False
|
| 859 |
self.is_player_detected: bool = False
|
| 860 |
self.is_player_propagated: bool = False
|
| 861 |
+
self.goal_mode: str = GOAL_MODE_IDLE
|
| 862 |
+
self.goal_points_norm: list[tuple[float, float]] = []
|
| 863 |
+
self.goal_confirmed_points_norm: list[tuple[float, float]] = []
|
| 864 |
+
self.goal_prev_confirmed_points_norm: list[tuple[float, float]] = []
|
| 865 |
+
self.goal_status_text: str = "Goal crossbar inactive."
|
| 866 |
+
self.goal_dragging_idx: int | None = None
|
| 867 |
|
| 868 |
def __repr__(self):
|
| 869 |
return f"AppState(video_frames={self.video_frames}, inference_session={self.inference_session is not None}, model={self.model is not None}, processor={self.processor is not None}, device={self.device}, dtype={self.dtype}, video_fps={self.video_fps}, masks_by_frame={self.masks_by_frame}, color_by_obj={self.color_by_obj}, clicks_by_frame_obj={self.clicks_by_frame_obj}, boxes_by_frame_obj={self.boxes_by_frame_obj}, composited_frames={self.composited_frames}, current_frame_idx={self.current_frame_idx}, current_obj_id={self.current_obj_id}, current_label={self.current_label}, current_clear_old={self.current_clear_old}, current_prompt_type={self.current_prompt_type}, pending_box_start={self.pending_box_start}, pending_box_start_frame_idx={self.pending_box_start_frame_idx}, pending_box_start_obj_id={self.pending_box_start_obj_id}, is_switching_model={self.is_switching_model}, model_repo_key={self.model_repo_key}, model_repo_id={self.model_repo_id}, session_repo_id={self.session_repo_id})"
|
|
|
|
| 962 |
GLOBAL_STATE.impact_debug_speed_kmh = []
|
| 963 |
GLOBAL_STATE.impact_debug_speed_threshold_px = None
|
| 964 |
GLOBAL_STATE.impact_meters_per_px = None
|
| 965 |
+
GLOBAL_STATE.goal_mode = GOAL_MODE_IDLE
|
| 966 |
+
GLOBAL_STATE.goal_points_norm = []
|
| 967 |
+
GLOBAL_STATE.goal_confirmed_points_norm = []
|
| 968 |
+
GLOBAL_STATE.goal_prev_confirmed_points_norm = []
|
| 969 |
+
GLOBAL_STATE.goal_status_text = "Goal crossbar inactive."
|
| 970 |
+
GLOBAL_STATE.goal_dragging_idx = None
|
| 971 |
+
GLOBAL_STATE.goal_mode = GOAL_MODE_IDLE
|
| 972 |
+
GLOBAL_STATE.goal_points_norm = []
|
| 973 |
+
GLOBAL_STATE.goal_confirmed_points_norm = []
|
| 974 |
+
GLOBAL_STATE.goal_prev_confirmed_points_norm = []
|
| 975 |
+
GLOBAL_STATE.goal_status_text = "Goal crossbar inactive."
|
| 976 |
+
GLOBAL_STATE.goal_dragging_idx = None
|
| 977 |
GLOBAL_STATE.yolo_ball_centers = {}
|
| 978 |
GLOBAL_STATE.yolo_ball_boxes = {}
|
| 979 |
GLOBAL_STATE.yolo_ball_conf = {}
|
|
|
|
| 1375 |
base_np = focus_alpha * orig_np + (1.0 - focus_alpha) * base_np
|
| 1376 |
out_img = Image.fromarray(np.clip(base_np * 255.0, 0, 255).astype(np.uint8))
|
| 1377 |
|
| 1378 |
+
_draw_goal_overlay(state, frame_idx, out_img)
|
| 1379 |
+
|
| 1380 |
# Draw crosses for conditioning frames only (frames with recorded clicks)
|
| 1381 |
clicks_map = state.clicks_by_frame_obj.get(frame_idx)
|
| 1382 |
if state.show_click_marks and clicks_map:
|
|
|
|
| 2421 |
|
| 2422 |
def _button_updates(state: AppState) -> tuple[Any, Any, Any]:
|
| 2423 |
yolo_ready = isinstance(state, AppState) and state.yolo_kick_frame is not None
|
| 2424 |
+
propagate_main_enabled = _ball_has_masks(state) or yolo_ready
|
| 2425 |
detect_player_enabled = yolo_ready
|
| 2426 |
propagate_player_enabled = _player_has_masks(state)
|
| 2427 |
sam_tracked = isinstance(state, AppState) and getattr(state, "is_sam_tracked", False)
|
|
|
|
| 2450 |
|
| 2451 |
|
| 2452 |
def _ui_status_updates(state: AppState) -> tuple[Any, ...]:
|
| 2453 |
+
return _kick_button_updates(state) + _ball_button_updates(state) + _goal_button_updates(state)
|
| 2454 |
|
| 2455 |
|
| 2456 |
def _recompute_motion_metrics(state: AppState, target_obj_id: int = 1):
|
|
|
|
| 2867 |
img: Image.Image | np.ndarray,
|
| 2868 |
state: AppState,
|
| 2869 |
frame_idx: int,
|
| 2870 |
+
obj_id: int,
|
| 2871 |
label: str,
|
| 2872 |
clear_old: bool,
|
| 2873 |
evt: gr.SelectData,
|
| 2874 |
):
|
| 2875 |
+
frame_idx = int(frame_idx)
|
| 2876 |
+
handled_preview = None
|
| 2877 |
+
handled = False
|
| 2878 |
+
if state is not None and state.goal_mode != GOAL_MODE_IDLE:
|
| 2879 |
+
handled_preview, handled = _goal_process_preview_click(state, frame_idx, evt)
|
| 2880 |
+
if handled and handled_preview is not None:
|
| 2881 |
+
preview_img = handled_preview
|
| 2882 |
+
else:
|
| 2883 |
+
preview_img = on_image_click(img, state, frame_idx, obj_id, label, clear_old, evt)
|
| 2884 |
propagate_main_update, detect_btn_update, propagate_player_update = _button_updates(state)
|
| 2885 |
+
status_updates = _ui_status_updates(state)
|
| 2886 |
+
return (
|
| 2887 |
+
preview_img,
|
| 2888 |
+
propagate_main_update,
|
| 2889 |
+
detect_btn_update,
|
| 2890 |
+
propagate_player_update,
|
| 2891 |
+
*status_updates,
|
| 2892 |
+
)
|
| 2893 |
|
| 2894 |
|
| 2895 |
@spaces.GPU()
|
|
|
|
| 3440 |
"After a player mask exists, SAM2 propagates it within the same kick-centric window. This keeps athlete and ball masks "
|
| 3441 |
"time-synced, enabling combined overlays, exports, and analytics comparing foot position to ball contact."
|
| 3442 |
),
|
| 3443 |
+
"btn-goal-start": (
|
| 3444 |
+
"Enters goal mapping mode so you can click the two crossbar corners. After the first click a handle appears; the second "
|
| 3445 |
+
"click closes the bar and exposes draggable anchors before you confirm."
|
| 3446 |
+
),
|
| 3447 |
+
"btn-goal-confirm": (
|
| 3448 |
+
"Locks the currently placed crossbar across the entire video. The line and handles stay visible on every frame and can "
|
| 3449 |
+
"be re-edited later by tapping Map Goal again."
|
| 3450 |
),
|
| 3451 |
+
"btn-goal-clear": (
|
| 3452 |
+
"Removes the current crossbar (and any in-progress points) so you can restart the goal alignment workflow from scratch."
|
| 3453 |
+
),
|
| 3454 |
+
"btn-goal-back": (
|
| 3455 |
+
"Restores the previously confirmed crossbar if the latest edits missed the mark. Useful when you want to compare two "
|
| 3456 |
+
"placements without re-clicking both corners."
|
| 3457 |
),
|
| 3458 |
}
|
| 3459 |
|
| 3460 |
+
|
| 3461 |
+
def _build_tooltip_script() -> str:
|
| 3462 |
+
data = json.dumps(BUTTON_TOOLTIPS)
|
| 3463 |
+
return f"""
|
| 3464 |
+
<script>
|
| 3465 |
+
const KT_TOOLTIPS = {data};
|
| 3466 |
+
function applyKTTitles() {{
|
| 3467 |
+
Object.entries(KT_TOOLTIPS).forEach(([id, text]) => {{
|
| 3468 |
+
const el = document.getElementById(id);
|
| 3469 |
+
if (el && !el.dataset.ktTooltip) {{
|
| 3470 |
+
el.dataset.ktTooltip = "1";
|
| 3471 |
+
el.setAttribute("title", text);
|
| 3472 |
+
}}
|
| 3473 |
+
}});
|
| 3474 |
+
}}
|
| 3475 |
+
const observer = new MutationObserver(() => applyKTTitles());
|
| 3476 |
+
observer.observe(document.body, {{ childList: true, subtree: true }});
|
| 3477 |
+
document.addEventListener("DOMContentLoaded", applyKTTitles);
|
| 3478 |
+
applyKTTitles();
|
| 3479 |
+
</script>
|
| 3480 |
+
"""
|
| 3481 |
+
|
| 3482 |
+
|
| 3483 |
+
TOOLTIP_SCRIPT = _build_tooltip_script()
|
| 3484 |
+
|
| 3485 |
+
with gr.Blocks(title="SAM2 Video (Transformers) - Interactive Segmentation", theme=theme, css=CUSTOM_CSS) as demo:
|
| 3486 |
GLOBAL_STATE = gr.State(AppState())
|
| 3487 |
|
| 3488 |
gr.Markdown(
|
|
|
|
| 3530 |
)
|
| 3531 |
ckpt_progress = gr.Markdown(visible=False)
|
| 3532 |
load_status = gr.Markdown(visible=True)
|
| 3533 |
+
reset_btn = gr.Button("Reset Session", variant="secondary", elem_id="btn-reset-session")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3534 |
with gr.Column(scale=1):
|
| 3535 |
gr.Markdown("**Preview**")
|
| 3536 |
preview = gr.Image(
|
|
|
|
| 3558 |
with gr.Row(elem_classes=["model-status"]):
|
| 3559 |
manual_kick_btn = gr.Button("⚽: N/A", interactive=False)
|
| 3560 |
manual_impact_btn = gr.Button("🚩: N/A", interactive=False)
|
| 3561 |
+
with gr.Row(elem_classes=["model-actions"]):
|
| 3562 |
+
goal_start_btn = gr.Button(
|
| 3563 |
+
"Map Goal",
|
| 3564 |
+
variant="secondary",
|
| 3565 |
+
elem_id="btn-goal-start",
|
| 3566 |
+
)
|
| 3567 |
+
goal_confirm_btn = gr.Button(
|
| 3568 |
+
"Confirm",
|
| 3569 |
+
variant="primary",
|
| 3570 |
+
interactive=False,
|
| 3571 |
+
elem_id="btn-goal-confirm",
|
| 3572 |
+
)
|
| 3573 |
+
goal_clear_btn = gr.Button(
|
| 3574 |
+
"Clear",
|
| 3575 |
+
variant="secondary",
|
| 3576 |
+
interactive=False,
|
| 3577 |
+
elem_id="btn-goal-clear",
|
| 3578 |
)
|
| 3579 |
+
goal_back_btn = gr.Button(
|
| 3580 |
+
"Back",
|
| 3581 |
+
variant="secondary",
|
| 3582 |
+
interactive=False,
|
| 3583 |
+
elem_id="btn-goal-back",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3584 |
)
|
| 3585 |
+
goal_status = gr.Markdown("Goal crossbar inactive.", elem_id="goal-status-text")
|
| 3586 |
with gr.Column(elem_classes=["model-section"]):
|
| 3587 |
with gr.Row(elem_classes=["model-row"]):
|
| 3588 |
gr.Markdown("YOLO13", elem_classes=["model-label"])
|
|
|
|
| 3599 |
yolo_kick_btn = gr.Button("⚽: N/A", interactive=False)
|
| 3600 |
yolo_impact_btn = gr.Button("🚩: N/A", interactive=False)
|
| 3601 |
yolo_plot = gr.Plot(label="YOLO kick diagnostics", show_label=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3602 |
with gr.Column(elem_classes=["model-section"]):
|
| 3603 |
with gr.Row(elem_classes=["model-row"]):
|
| 3604 |
gr.Markdown("SAM2", elem_classes=["model-label"])
|
|
|
|
| 3616 |
sam_kick_btn = gr.Button("⚽: N/A", interactive=False)
|
| 3617 |
sam_impact_btn = gr.Button("🚩: N/A", interactive=False)
|
| 3618 |
kick_plot = gr.Plot(label="Kick & impact diagnostics", show_label=True)
|
| 3619 |
+
gr.HTML(value=TOOLTIP_SCRIPT, visible=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3620 |
with gr.Row():
|
| 3621 |
min_impact_speed_slider = gr.Slider(
|
| 3622 |
label="Min impact speed (km/h)",
|
|
|
|
| 3637 |
ball_status = gr.Markdown(visible=False)
|
| 3638 |
propagate_status = gr.Markdown(visible=True)
|
| 3639 |
impact_status = gr.Markdown("Impact frame: not computed", visible=False)
|
| 3640 |
+
with gr.Row():
|
| 3641 |
+
obj_id_inp = gr.Number(value=1, precision=0, label="Object ID", scale=0)
|
| 3642 |
+
label_radio = gr.Radio(choices=["positive", "negative"], value="positive", label="Point label")
|
| 3643 |
+
clear_old_chk = gr.Checkbox(value=False, label="Clear old inputs for this object")
|
| 3644 |
+
prompt_type = gr.Radio(choices=["Points", "Boxes"], value="Points", label="Prompt type")
|
| 3645 |
# Wire events
|
| 3646 |
def _on_video_change(GLOBAL_STATE: gr.State, video):
|
| 3647 |
GLOBAL_STATE, min_idx, max_idx, first_frame, status = init_video_session(GLOBAL_STATE, video)
|
|
|
|
| 3659 |
propagate_main_update,
|
| 3660 |
detect_btn_update,
|
| 3661 |
propagate_player_update,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3662 |
)
|
| 3663 |
|
| 3664 |
video_in.change(
|
|
|
|
| 3680 |
manual_impact_btn,
|
| 3681 |
detect_ball_btn,
|
| 3682 |
track_ball_yolo_btn,
|
| 3683 |
+
goal_start_btn,
|
| 3684 |
+
goal_confirm_btn,
|
| 3685 |
+
goal_clear_btn,
|
| 3686 |
+
goal_back_btn,
|
| 3687 |
+
goal_status,
|
| 3688 |
propagate_btn,
|
| 3689 |
detect_player_btn,
|
| 3690 |
propagate_player_btn,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3691 |
],
|
| 3692 |
show_progress=True,
|
| 3693 |
)
|
|
|
|
| 3717 |
manual_impact_btn,
|
| 3718 |
detect_ball_btn,
|
| 3719 |
track_ball_yolo_btn,
|
| 3720 |
+
goal_start_btn,
|
| 3721 |
+
goal_confirm_btn,
|
| 3722 |
+
goal_clear_btn,
|
| 3723 |
+
goal_back_btn,
|
| 3724 |
+
goal_status,
|
| 3725 |
propagate_btn,
|
| 3726 |
detect_player_btn,
|
| 3727 |
propagate_player_btn,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3728 |
],
|
| 3729 |
label="Examples",
|
| 3730 |
cache_examples=False,
|
|
|
|
| 4016 |
manual_impact_btn,
|
| 4017 |
detect_ball_btn,
|
| 4018 |
track_ball_yolo_btn,
|
| 4019 |
+
goal_start_btn,
|
| 4020 |
+
goal_confirm_btn,
|
| 4021 |
+
goal_clear_btn,
|
| 4022 |
+
goal_back_btn,
|
| 4023 |
+
goal_status,
|
| 4024 |
],
|
| 4025 |
)
|
| 4026 |
mark_impact_btn.click(
|
|
|
|
| 4042 |
manual_impact_btn,
|
| 4043 |
detect_ball_btn,
|
| 4044 |
track_ball_yolo_btn,
|
| 4045 |
+
goal_start_btn,
|
| 4046 |
+
goal_confirm_btn,
|
| 4047 |
+
goal_clear_btn,
|
| 4048 |
+
goal_back_btn,
|
| 4049 |
+
goal_status,
|
| 4050 |
],
|
| 4051 |
)
|
| 4052 |
|
| 4053 |
+
def _sync_obj_id(s: AppState, oid):
|
| 4054 |
+
if s is not None and oid is not None:
|
| 4055 |
+
s.current_obj_id = int(oid)
|
| 4056 |
return gr.update()
|
| 4057 |
|
| 4058 |
+
obj_id_inp.change(_sync_obj_id, inputs=[GLOBAL_STATE, obj_id_inp], outputs=[])
|
| 4059 |
|
| 4060 |
def _sync_label(s: AppState, lab: str):
|
| 4061 |
if s is not None and lab is not None:
|
|
|
|
| 4124 |
|
| 4125 |
def _auto_detect_ball(
|
| 4126 |
state_in: AppState,
|
| 4127 |
+
obj_id,
|
| 4128 |
label_value: str,
|
| 4129 |
clear_old_value: bool,
|
| 4130 |
):
|
|
|
|
| 4156 |
frame_width, frame_height = frame.size
|
| 4157 |
x_center = max(0, min(frame_width - 1, int(x_center)))
|
| 4158 |
y_center = max(0, min(frame_height - 1, int(y_center)))
|
| 4159 |
+
obj_id_int = int(obj_id) if obj_id is not None else state_in.current_obj_id
|
|
|
|
| 4160 |
label_str = label_value if label_value else state_in.current_label
|
| 4161 |
clear_old_flag = bool(clear_old_value)
|
| 4162 |
|
|
|
|
| 4195 |
|
| 4196 |
detect_ball_btn.click(
|
| 4197 |
_auto_detect_ball,
|
| 4198 |
+
inputs=[GLOBAL_STATE, obj_id_inp, label_radio, clear_old_chk],
|
| 4199 |
outputs=[
|
| 4200 |
preview,
|
| 4201 |
ball_status,
|
|
|
|
| 4212 |
manual_impact_btn,
|
| 4213 |
detect_ball_btn,
|
| 4214 |
track_ball_yolo_btn,
|
| 4215 |
+
goal_start_btn,
|
| 4216 |
+
goal_confirm_btn,
|
| 4217 |
+
goal_clear_btn,
|
| 4218 |
+
goal_back_btn,
|
| 4219 |
+
goal_status,
|
| 4220 |
],
|
| 4221 |
)
|
| 4222 |
|
|
|
|
| 4275 |
manual_impact_btn,
|
| 4276 |
detect_ball_btn,
|
| 4277 |
track_ball_yolo_btn,
|
| 4278 |
+
goal_start_btn,
|
| 4279 |
+
goal_confirm_btn,
|
| 4280 |
+
goal_clear_btn,
|
| 4281 |
+
goal_back_btn,
|
| 4282 |
+
goal_status,
|
| 4283 |
],
|
| 4284 |
)
|
| 4285 |
|
|
|
|
| 4320 |
def _result(preview_img, status_text):
|
| 4321 |
propagate_main_update, detect_btn_update, propagate_player_update = _button_updates(state_in)
|
| 4322 |
status_updates = _ui_status_updates(state_in)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4323 |
return (
|
| 4324 |
preview_img,
|
| 4325 |
gr.update(value=status_text, visible=True),
|
|
|
|
| 4328 |
propagate_main_update,
|
| 4329 |
detect_btn_update,
|
| 4330 |
propagate_player_update,
|
| 4331 |
+
gr.update(),
|
| 4332 |
_impact_status_update(state_in),
|
| 4333 |
*status_updates,
|
| 4334 |
)
|
|
|
|
| 4401 |
status_text = (
|
| 4402 |
f"{_format_kick_status(state_in)} | ✅ Player auto-detected on frame {frame_idx} (conf={conf:.2f})"
|
| 4403 |
)
|
| 4404 |
+
return _result(update_frame_display(state_in, frame_idx), status_text)
|
|
|
|
|
|
|
|
|
|
| 4405 |
|
| 4406 |
detect_player_btn.click(
|
| 4407 |
_auto_detect_player,
|
|
|
|
| 4414 |
propagate_btn,
|
| 4415 |
detect_player_btn,
|
| 4416 |
propagate_player_btn,
|
| 4417 |
+
obj_id_inp,
|
| 4418 |
impact_status,
|
| 4419 |
yolo_kick_btn,
|
| 4420 |
yolo_impact_btn,
|
|
|
|
| 4424 |
manual_impact_btn,
|
| 4425 |
detect_ball_btn,
|
| 4426 |
track_ball_yolo_btn,
|
| 4427 |
+
goal_start_btn,
|
| 4428 |
+
goal_confirm_btn,
|
| 4429 |
+
goal_clear_btn,
|
| 4430 |
+
goal_back_btn,
|
| 4431 |
+
goal_status,
|
| 4432 |
],
|
| 4433 |
)
|
| 4434 |
|
|
|
|
| 4599 |
manual_impact_btn,
|
| 4600 |
detect_ball_btn,
|
| 4601 |
track_ball_yolo_btn,
|
| 4602 |
+
goal_start_btn,
|
| 4603 |
+
goal_confirm_btn,
|
| 4604 |
+
goal_clear_btn,
|
| 4605 |
+
goal_back_btn,
|
| 4606 |
+
goal_status,
|
| 4607 |
],
|
| 4608 |
)
|
| 4609 |
|
| 4610 |
# Image click to add a point and run forward on that frame
|
| 4611 |
preview.select(
|
| 4612 |
_on_image_click_with_updates,
|
| 4613 |
+
[preview, GLOBAL_STATE, frame_slider, obj_id_inp, label_radio, clear_old_chk],
|
| 4614 |
+
[
|
| 4615 |
+
preview,
|
| 4616 |
+
propagate_btn,
|
| 4617 |
+
detect_player_btn,
|
| 4618 |
+
propagate_player_btn,
|
| 4619 |
+
yolo_kick_btn,
|
| 4620 |
+
yolo_impact_btn,
|
| 4621 |
+
sam_kick_btn,
|
| 4622 |
+
sam_impact_btn,
|
| 4623 |
+
manual_kick_btn,
|
| 4624 |
+
manual_impact_btn,
|
| 4625 |
+
detect_ball_btn,
|
| 4626 |
+
track_ball_yolo_btn,
|
| 4627 |
+
goal_start_btn,
|
| 4628 |
+
goal_confirm_btn,
|
| 4629 |
+
goal_clear_btn,
|
| 4630 |
+
goal_back_btn,
|
| 4631 |
+
goal_status,
|
| 4632 |
+
],
|
| 4633 |
+
)
|
| 4634 |
+
|
| 4635 |
+
goal_start_btn.click(
|
| 4636 |
+
_goal_start_mapping,
|
| 4637 |
+
inputs=[GLOBAL_STATE],
|
| 4638 |
+
outputs=[preview, goal_start_btn, goal_confirm_btn, goal_clear_btn, goal_back_btn, goal_status],
|
| 4639 |
+
)
|
| 4640 |
+
|
| 4641 |
+
goal_confirm_btn.click(
|
| 4642 |
+
_goal_confirm_mapping,
|
| 4643 |
+
inputs=[GLOBAL_STATE],
|
| 4644 |
+
outputs=[preview, goal_start_btn, goal_confirm_btn, goal_clear_btn, goal_back_btn, goal_status],
|
| 4645 |
+
)
|
| 4646 |
+
|
| 4647 |
+
goal_clear_btn.click(
|
| 4648 |
+
_goal_clear_mapping,
|
| 4649 |
+
inputs=[GLOBAL_STATE],
|
| 4650 |
+
outputs=[preview, goal_start_btn, goal_confirm_btn, goal_clear_btn, goal_back_btn, goal_status],
|
| 4651 |
+
)
|
| 4652 |
+
|
| 4653 |
+
goal_back_btn.click(
|
| 4654 |
+
_goal_back_mapping,
|
| 4655 |
+
inputs=[GLOBAL_STATE],
|
| 4656 |
+
outputs=[preview, goal_start_btn, goal_confirm_btn, goal_clear_btn, goal_back_btn, goal_status],
|
| 4657 |
)
|
| 4658 |
|
| 4659 |
# Playback via MP4 rendering only
|
|
|
|
| 4734 |
manual_impact_btn,
|
| 4735 |
detect_ball_btn,
|
| 4736 |
track_ball_yolo_btn,
|
| 4737 |
+
goal_start_btn,
|
| 4738 |
+
goal_confirm_btn,
|
| 4739 |
+
goal_clear_btn,
|
| 4740 |
+
goal_back_btn,
|
| 4741 |
+
goal_status,
|
| 4742 |
],
|
| 4743 |
)
|
| 4744 |
|
|
|
|
| 4766 |
manual_impact_btn,
|
| 4767 |
detect_ball_btn,
|
| 4768 |
track_ball_yolo_btn,
|
| 4769 |
+
goal_start_btn,
|
| 4770 |
+
goal_confirm_btn,
|
| 4771 |
+
goal_clear_btn,
|
| 4772 |
+
goal_back_btn,
|
| 4773 |
+
goal_status,
|
| 4774 |
],
|
| 4775 |
)
|
| 4776 |
|
tooltips.js
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 1 |
-
(() => {
|
| 2 |
-
function getTooltipMap() {
|
| 3 |
-
const el = document.getElementById("kt-tooltips-data");
|
| 4 |
-
if (!el) {
|
| 5 |
-
return {};
|
| 6 |
-
}
|
| 7 |
-
try {
|
| 8 |
-
return JSON.parse(el.getAttribute("data-tooltips") || "{}");
|
| 9 |
-
} catch (err) {
|
| 10 |
-
console.warn("KickTrimmer tooltip JSON parse error", err);
|
| 11 |
-
return {};
|
| 12 |
-
}
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
function applyTooltips() {
|
| 16 |
-
const map = getTooltipMap();
|
| 17 |
-
Object.entries(map).forEach(([id, text]) => {
|
| 18 |
-
const target = document.getElementById(id);
|
| 19 |
-
if (target && !target.dataset.ktTooltip) {
|
| 20 |
-
target.dataset.ktTooltip = "1";
|
| 21 |
-
target.setAttribute("title", text);
|
| 22 |
-
}
|
| 23 |
-
});
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
function initObserver() {
|
| 27 |
-
applyTooltips();
|
| 28 |
-
const observer = new MutationObserver(() => applyTooltips());
|
| 29 |
-
observer.observe(document.body, { childList: true, subtree: true });
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
if (document.readyState === "loading") {
|
| 33 |
-
document.addEventListener("DOMContentLoaded", initObserver);
|
| 34 |
-
} else {
|
| 35 |
-
initObserver();
|
| 36 |
-
}
|
| 37 |
-
})();
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|