Spaces:
Runtime error
Runtime error
Cursor Agent commited on
Commit ·
5cdc8c7
1
Parent(s): ab620ec
Enhance ball rings with neon glow effect (additive blending)
Browse files
app.py
CHANGED
|
@@ -782,21 +782,12 @@ def _speed_to_color(ratio: float) -> tuple[int, int, int]:
|
|
| 782 |
return tuple(int(v) for v in blended)
|
| 783 |
|
| 784 |
|
| 785 |
-
def
|
| 786 |
-
"""Map a speed value (km/h) to the
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
elif speed_kmh < 90:
|
| 792 |
-
color = (154, 77, 255) # Intense Violet
|
| 793 |
-
elif speed_kmh < 110:
|
| 794 |
-
color = (214, 51, 132) # Fuchsia
|
| 795 |
-
elif speed_kmh < 130:
|
| 796 |
-
color = (255, 77, 109) # Strong Pink
|
| 797 |
-
else:
|
| 798 |
-
color = (255, 162, 0) # Neon Orange
|
| 799 |
-
return tuple(channel / 255.0 for channel in color)
|
| 800 |
|
| 801 |
|
| 802 |
def _angle_between(v1: tuple[float, float], v2: tuple[float, float]) -> float:
|
|
@@ -823,6 +814,17 @@ BALL_RING_THICKNESS_PX = 2.0 # Thinner rings
|
|
| 823 |
BALL_RING_FEATHER_SIGMA = 1.0 # Less blurred
|
| 824 |
BALL_RING_INTENSITY_GAMMA = 0.5 # Higher contrast (for < 1.0 gamma on mask values)
|
| 825 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 826 |
|
| 827 |
def _maybe_upscale_for_display(image: Image.Image) -> Image.Image:
|
| 828 |
if image is None:
|
|
@@ -945,7 +947,7 @@ def compose_frame(state: AppState, frame_idx: int, remove_bg: bool = False) -> I
|
|
| 945 |
focus_mask = np.maximum(focus_mask, mask_np)
|
| 946 |
|
| 947 |
ghost_mask = _build_ball_trail_mask(state, frame_idx)
|
| 948 |
-
|
| 949 |
|
| 950 |
if len(masks) != 0:
|
| 951 |
if remove_bg:
|
|
@@ -966,7 +968,7 @@ def compose_frame(state: AppState, frame_idx: int, remove_bg: bool = False) -> I
|
|
| 966 |
out_img = Image.fromarray(result_np)
|
| 967 |
else:
|
| 968 |
overlay_masks = masks
|
| 969 |
-
if (ghost_mask is not None or
|
| 970 |
overlay_masks = {oid: mask for oid, mask in masks.items() if oid != BALL_OBJECT_ID}
|
| 971 |
if overlay_masks:
|
| 972 |
out_img = overlay_masks_on_frame(out_img, overlay_masks, state.color_by_obj, alpha=0.65)
|
|
@@ -998,14 +1000,18 @@ def compose_frame(state: AppState, frame_idx: int, remove_bg: bool = False) -> I
|
|
| 998 |
base_np = focus_alpha * orig_np + (1.0 - focus_alpha) * base_np
|
| 999 |
out_img = Image.fromarray(np.clip(base_np * 255.0, 0, 255).astype(np.uint8))
|
| 1000 |
|
| 1001 |
-
if
|
| 1002 |
-
|
|
|
|
|
|
|
| 1003 |
if current_union_mask is not None:
|
| 1004 |
-
|
| 1005 |
-
|
|
|
|
|
|
|
| 1006 |
base_np = np.array(out_img).astype(np.float32) / 255.0
|
| 1007 |
alpha_val = getattr(state, "fx_ring_alpha", BALL_RING_ALPHA)
|
| 1008 |
-
added_light =
|
| 1009 |
base_np = np.clip(base_np + added_light, 0.0, 1.0)
|
| 1010 |
|
| 1011 |
if focus_mask is not None:
|
|
@@ -1218,15 +1224,21 @@ def _build_ball_trail_mask(state: AppState, frame_idx: int) -> np.ndarray | None
|
|
| 1218 |
return trail_mask
|
| 1219 |
|
| 1220 |
|
| 1221 |
-
def
|
|
|
|
|
|
|
| 1222 |
if (
|
| 1223 |
state is None
|
| 1224 |
or not state.fx_ball_ring_enabled
|
| 1225 |
or state.masks_by_frame is None
|
| 1226 |
):
|
| 1227 |
return None
|
| 1228 |
-
|
| 1229 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1230 |
return None
|
| 1231 |
|
| 1232 |
start_idx = max(int(kick_candidate) + 1, int(frame_idx) + 1)
|
|
@@ -1240,7 +1252,10 @@ def _build_ball_ring_layer(state: AppState, frame_idx: int) -> np.ndarray | None
|
|
| 1240 |
if start_idx >= end_idx:
|
| 1241 |
return None
|
| 1242 |
|
| 1243 |
-
|
|
|
|
|
|
|
|
|
|
| 1244 |
|
| 1245 |
# Iterate in REVERSE order so that later frames (further in time/distance) are drawn first,
|
| 1246 |
# and earlier frames (closer in time/distance) are drawn on top.
|
|
@@ -1260,10 +1275,9 @@ def _build_ball_ring_layer(state: AppState, frame_idx: int) -> np.ndarray | None
|
|
| 1260 |
mask_np = np.clip(mask_np, 0.0, 1.0)
|
| 1261 |
if mask_np.max() <= FX_EPS:
|
| 1262 |
continue
|
| 1263 |
-
if
|
| 1264 |
-
|
| 1265 |
-
|
| 1266 |
-
if ring_layer.shape[:2] != mask_np.shape:
|
| 1267 |
continue
|
| 1268 |
centroid = _compute_mask_centroid(mask_np)
|
| 1269 |
if centroid is None:
|
|
@@ -1279,54 +1293,61 @@ def _build_ball_ring_layer(state: AppState, frame_idx: int) -> np.ndarray | None
|
|
| 1279 |
radius = float(max(radius_x, radius_y))
|
| 1280 |
if radius <= 1.5:
|
| 1281 |
continue
|
| 1282 |
-
ring_mask = np.zeros_like(mask_np, dtype=np.float32)
|
| 1283 |
# Use dynamic parameters from state if available, else defaults
|
| 1284 |
thick_val = getattr(state, "fx_ring_thickness", BALL_RING_THICKNESS_PX)
|
| 1285 |
-
|
| 1286 |
center = (int(round(cx)), int(round(cy)))
|
| 1287 |
radius_int = max(1, int(round(radius)))
|
| 1288 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1289 |
# Multi-pass drawing for neon effect: Glow -> Mid -> Core
|
| 1290 |
# 1. Outer Glow
|
| 1291 |
t_glow = max(1, int(round(thick_val * 4.0)))
|
| 1292 |
-
cv2.circle(
|
| 1293 |
-
|
| 1294 |
# 2. Inner Glow
|
| 1295 |
t_mid = max(1, int(round(thick_val * 2.0)))
|
| 1296 |
-
cv2.circle(
|
| 1297 |
|
| 1298 |
# 3. Core (Sharp)
|
| 1299 |
t_core = max(1, int(round(thick_val)))
|
| 1300 |
-
cv2.circle(
|
| 1301 |
|
| 1302 |
-
# Soft blur to blend the stepped layers into a smooth gradient
|
| 1303 |
feather_val = getattr(state, "fx_ring_feather", BALL_RING_FEATHER_SIGMA)
|
| 1304 |
-
|
| 1305 |
-
|
| 1306 |
-
if max_val <= FX_EPS:
|
| 1307 |
continue
|
| 1308 |
-
|
| 1309 |
gamma_val = getattr(state, "fx_ring_gamma", BALL_RING_INTENSITY_GAMMA)
|
| 1310 |
if abs(gamma_val - 1.0) > 1e-6:
|
| 1311 |
-
|
| 1312 |
-
|
| 1313 |
-
|
| 1314 |
-
fps = state.video_fps if state.video_fps and state.video_fps > 1e-3 else 25.0
|
| 1315 |
-
distance_m = state.goal_distance_m if state.goal_distance_m and state.goal_distance_m > 0 else 16.5
|
| 1316 |
-
delta_frames = idx - int(kick_candidate)
|
| 1317 |
-
if delta_frames <= 0:
|
| 1318 |
-
delta_frames = 1
|
| 1319 |
-
time_s = max(delta_frames / fps, 1.0 / fps)
|
| 1320 |
-
speed_m_s = distance_m / time_s
|
| 1321 |
-
speed_kmh = speed_m_s * 3.6
|
| 1322 |
-
color_rgb = np.array(_speed_range_color(speed_kmh), dtype=np.float32)
|
| 1323 |
|
| 1324 |
-
|
|
|
|
| 1325 |
|
| 1326 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1327 |
return None
|
| 1328 |
|
| 1329 |
-
|
|
|
|
|
|
|
|
|
|
| 1330 |
|
| 1331 |
|
| 1332 |
def _ensure_color_for_obj(state: AppState, obj_id: int):
|
|
|
|
| 782 |
return tuple(int(v) for v in blended)
|
| 783 |
|
| 784 |
|
| 785 |
+
def _speed_to_ring_color(speed_kmh: float) -> tuple[float, float, float]:
|
| 786 |
+
"""Map a speed value (km/h) to the discrete palette used across the app."""
|
| 787 |
+
for threshold, color in SPEED_COLOR_STOPS:
|
| 788 |
+
if speed_kmh < threshold:
|
| 789 |
+
return color
|
| 790 |
+
return SPEED_COLOR_ABOVE_MAX
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 791 |
|
| 792 |
|
| 793 |
def _angle_between(v1: tuple[float, float], v2: tuple[float, float]) -> float:
|
|
|
|
| 814 |
BALL_RING_FEATHER_SIGMA = 1.0 # Less blurred
|
| 815 |
BALL_RING_INTENSITY_GAMMA = 0.5 # Higher contrast (for < 1.0 gamma on mask values)
|
| 816 |
|
| 817 |
+
# Speed range palette (mirrors iOS app)
|
| 818 |
+
SPEED_COLOR_STOPS = [
|
| 819 |
+
(30.0, (0 / 255.0, 191 / 255.0, 255 / 255.0)), # Electric Blue
|
| 820 |
+
(50.0, (0 / 255.0, 191 / 255.0, 255 / 255.0)), # Electric Blue (same band)
|
| 821 |
+
(70.0, (92 / 255.0, 124 / 255.0, 250 / 255.0)), # Blue Violet
|
| 822 |
+
(90.0, (154 / 255.0, 77 / 255.0, 255 / 255.0)), # Intense Violet
|
| 823 |
+
(110.0, (214 / 255.0, 51 / 255.0, 132 / 255.0)), # Fuchsia
|
| 824 |
+
(130.0, (255 / 255.0, 77 / 255.0, 109 / 255.0)), # Strong Pink
|
| 825 |
+
]
|
| 826 |
+
SPEED_COLOR_ABOVE_MAX = (255 / 255.0, 162 / 255.0, 0 / 255.0) # Neon Orange
|
| 827 |
+
|
| 828 |
|
| 829 |
def _maybe_upscale_for_display(image: Image.Image) -> Image.Image:
|
| 830 |
if image is None:
|
|
|
|
| 947 |
focus_mask = np.maximum(focus_mask, mask_np)
|
| 948 |
|
| 949 |
ghost_mask = _build_ball_trail_mask(state, frame_idx)
|
| 950 |
+
ring_result = _build_ball_ring_mask(state, frame_idx)
|
| 951 |
|
| 952 |
if len(masks) != 0:
|
| 953 |
if remove_bg:
|
|
|
|
| 968 |
out_img = Image.fromarray(result_np)
|
| 969 |
else:
|
| 970 |
overlay_masks = masks
|
| 971 |
+
if (ghost_mask is not None or ring_result is not None) and BALL_OBJECT_ID in masks:
|
| 972 |
overlay_masks = {oid: mask for oid, mask in masks.items() if oid != BALL_OBJECT_ID}
|
| 973 |
if overlay_masks:
|
| 974 |
out_img = overlay_masks_on_frame(out_img, overlay_masks, state.color_by_obj, alpha=0.65)
|
|
|
|
| 1000 |
base_np = focus_alpha * orig_np + (1.0 - focus_alpha) * base_np
|
| 1001 |
out_img = Image.fromarray(np.clip(base_np * 255.0, 0, 255).astype(np.uint8))
|
| 1002 |
|
| 1003 |
+
if ring_result is not None:
|
| 1004 |
+
ring_presence, ring_color_map = ring_result
|
| 1005 |
+
ring_presence = np.clip(ring_presence.astype(np.float32), 0.0, 1.0)
|
| 1006 |
+
ring_color_map = np.clip(ring_color_map.astype(np.float32), 0.0, 1.0)
|
| 1007 |
if current_union_mask is not None:
|
| 1008 |
+
mask_keep = np.clip(1.0 - current_union_mask, 0.0, 1.0)
|
| 1009 |
+
ring_presence = ring_presence * mask_keep
|
| 1010 |
+
ring_color_map = ring_color_map * mask_keep[..., None]
|
| 1011 |
+
if ring_presence.max() > FX_EPS and ring_color_map.max() > FX_EPS:
|
| 1012 |
base_np = np.array(out_img).astype(np.float32) / 255.0
|
| 1013 |
alpha_val = getattr(state, "fx_ring_alpha", BALL_RING_ALPHA)
|
| 1014 |
+
added_light = np.clip(ring_color_map * alpha_val, 0.0, 1.0)
|
| 1015 |
base_np = np.clip(base_np + added_light, 0.0, 1.0)
|
| 1016 |
|
| 1017 |
if focus_mask is not None:
|
|
|
|
| 1224 |
return trail_mask
|
| 1225 |
|
| 1226 |
|
| 1227 |
+
def _build_ball_ring_mask(
|
| 1228 |
+
state: AppState, frame_idx: int
|
| 1229 |
+
) -> tuple[np.ndarray, np.ndarray] | None:
|
| 1230 |
if (
|
| 1231 |
state is None
|
| 1232 |
or not state.fx_ball_ring_enabled
|
| 1233 |
or state.masks_by_frame is None
|
| 1234 |
):
|
| 1235 |
return None
|
| 1236 |
+
|
| 1237 |
+
if state.kick_frame is not None:
|
| 1238 |
+
kick_candidate = state.kick_frame
|
| 1239 |
+
elif state.yolo_kick_frame is not None:
|
| 1240 |
+
kick_candidate = state.yolo_kick_frame
|
| 1241 |
+
else:
|
| 1242 |
return None
|
| 1243 |
|
| 1244 |
start_idx = max(int(kick_candidate) + 1, int(frame_idx) + 1)
|
|
|
|
| 1252 |
if start_idx >= end_idx:
|
| 1253 |
return None
|
| 1254 |
|
| 1255 |
+
ring_presence: np.ndarray | None = None
|
| 1256 |
+
ring_color_map: np.ndarray | None = None
|
| 1257 |
+
fps = state.video_fps if state.video_fps and state.video_fps > 0 else 25.0
|
| 1258 |
+
distance_m = state.goal_distance_m if state.goal_distance_m and state.goal_distance_m > 0 else 16.5
|
| 1259 |
|
| 1260 |
# Iterate in REVERSE order so that later frames (further in time/distance) are drawn first,
|
| 1261 |
# and earlier frames (closer in time/distance) are drawn on top.
|
|
|
|
| 1275 |
mask_np = np.clip(mask_np, 0.0, 1.0)
|
| 1276 |
if mask_np.max() <= FX_EPS:
|
| 1277 |
continue
|
| 1278 |
+
if ring_presence is None:
|
| 1279 |
+
ring_presence = np.zeros_like(mask_np, dtype=np.float32)
|
| 1280 |
+
if ring_presence.shape != mask_np.shape:
|
|
|
|
| 1281 |
continue
|
| 1282 |
centroid = _compute_mask_centroid(mask_np)
|
| 1283 |
if centroid is None:
|
|
|
|
| 1293 |
radius = float(max(radius_x, radius_y))
|
| 1294 |
if radius <= 1.5:
|
| 1295 |
continue
|
|
|
|
| 1296 |
# Use dynamic parameters from state if available, else defaults
|
| 1297 |
thick_val = getattr(state, "fx_ring_thickness", BALL_RING_THICKNESS_PX)
|
| 1298 |
+
|
| 1299 |
center = (int(round(cx)), int(round(cy)))
|
| 1300 |
radius_int = max(1, int(round(radius)))
|
| 1301 |
|
| 1302 |
+
delta_frames = max(1, idx - int(kick_candidate))
|
| 1303 |
+
time_s = max(delta_frames / fps, 1.0 / fps)
|
| 1304 |
+
speed_kmh = max(0.0, (distance_m / time_s) * 3.6)
|
| 1305 |
+
color_vec = np.array(_speed_to_ring_color(speed_kmh), dtype=np.float32)
|
| 1306 |
+
|
| 1307 |
+
if ring_color_map is None:
|
| 1308 |
+
h, w = mask_np.shape
|
| 1309 |
+
ring_color_map = np.zeros((h, w, 3), dtype=np.float32)
|
| 1310 |
+
|
| 1311 |
+
ring_local = np.zeros_like(mask_np, dtype=np.float32)
|
| 1312 |
+
|
| 1313 |
# Multi-pass drawing for neon effect: Glow -> Mid -> Core
|
| 1314 |
# 1. Outer Glow
|
| 1315 |
t_glow = max(1, int(round(thick_val * 4.0)))
|
| 1316 |
+
cv2.circle(ring_local, center, radius_int, 0.3, thickness=t_glow)
|
| 1317 |
+
|
| 1318 |
# 2. Inner Glow
|
| 1319 |
t_mid = max(1, int(round(thick_val * 2.0)))
|
| 1320 |
+
cv2.circle(ring_local, center, radius_int, 0.6, thickness=t_mid)
|
| 1321 |
|
| 1322 |
# 3. Core (Sharp)
|
| 1323 |
t_core = max(1, int(round(thick_val)))
|
| 1324 |
+
cv2.circle(ring_local, center, radius_int, 1.0, thickness=t_core)
|
| 1325 |
|
|
|
|
| 1326 |
feather_val = getattr(state, "fx_ring_feather", BALL_RING_FEATHER_SIGMA)
|
| 1327 |
+
ring_local = cv2.GaussianBlur(ring_local, (0, 0), sigmaX=feather_val, sigmaY=feather_val)
|
| 1328 |
+
if ring_local.max() <= FX_EPS:
|
|
|
|
| 1329 |
continue
|
| 1330 |
+
|
| 1331 |
gamma_val = getattr(state, "fx_ring_gamma", BALL_RING_INTENSITY_GAMMA)
|
| 1332 |
if abs(gamma_val - 1.0) > 1e-6:
|
| 1333 |
+
ring_local = np.power(np.clip(ring_local, 0.0, 1.0), gamma_val)
|
| 1334 |
+
|
| 1335 |
+
ring_local = np.clip(ring_local, 0.0, 1.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1336 |
|
| 1337 |
+
ring_presence = np.maximum(ring_presence, ring_local)
|
| 1338 |
+
ring_color_map += ring_local[..., None] * color_vec
|
| 1339 |
|
| 1340 |
+
if (
|
| 1341 |
+
ring_presence is None
|
| 1342 |
+
or ring_color_map is None
|
| 1343 |
+
or ring_presence.max() <= FX_EPS
|
| 1344 |
+
):
|
| 1345 |
return None
|
| 1346 |
|
| 1347 |
+
ring_color_map = np.clip(ring_color_map, 0.0, 1.0)
|
| 1348 |
+
ring_presence = np.clip(ring_presence, 0.0, 1.0)
|
| 1349 |
+
|
| 1350 |
+
return ring_presence, ring_color_map
|
| 1351 |
|
| 1352 |
|
| 1353 |
def _ensure_color_for_obj(state: AppState, obj_id: int):
|