Cursor Agent commited on
Commit
5cdc8c7
·
1 Parent(s): ab620ec

Enhance ball rings with neon glow effect (additive blending)

Browse files
Files changed (1) hide show
  1. app.py +77 -56
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 _speed_range_color(speed_kmh: float) -> tuple[float, float, float]:
786
- """Map a speed value (km/h) to the established 6-color palette, returning floats in [0,1]."""
787
- if speed_kmh < 50:
788
- color = (0, 191, 255) # Electric Blue (covers <30 and 30-50)
789
- elif speed_kmh < 70:
790
- color = (92, 124, 250) # Blue Violet
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
- ring_layer = _build_ball_ring_layer(state, frame_idx)
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 ring_mask is not None) and BALL_OBJECT_ID in masks:
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 ring_layer is not None:
1002
- ring_np = np.clip(ring_layer.astype(np.float32), 0.0, 1.0)
 
 
1003
  if current_union_mask is not None:
1004
- ring_np = ring_np * np.clip(1.0 - current_union_mask, 0.0, 1.0)[..., None]
1005
- if ring_np.max() > FX_EPS:
 
 
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 = ring_np * alpha_val
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 _build_ball_ring_layer(state: AppState, frame_idx: int) -> np.ndarray | None:
 
 
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
- kick_candidate = state.kick_frame if state.kick_frame is not None else state.kick_debug_kick_frame
1229
- if kick_candidate is None:
 
 
 
 
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
- ring_layer: np.ndarray | None = None
 
 
 
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 ring_layer is None:
1264
- height, width = mask_np.shape
1265
- ring_layer = np.zeros((height, width, 3), dtype=np.float32)
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(ring_mask, center, radius_int, 0.3, thickness=t_glow)
1293
-
1294
  # 2. Inner Glow
1295
  t_mid = max(1, int(round(thick_val * 2.0)))
1296
- cv2.circle(ring_mask, center, radius_int, 0.6, thickness=t_mid)
1297
 
1298
  # 3. Core (Sharp)
1299
  t_core = max(1, int(round(thick_val)))
1300
- cv2.circle(ring_mask, center, radius_int, 1.0, thickness=t_core)
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
- ring_mask = cv2.GaussianBlur(ring_mask, (0, 0), sigmaX=feather_val, sigmaY=feather_val)
1305
- max_val = float(ring_mask.max())
1306
- if max_val <= FX_EPS:
1307
  continue
1308
- ring_mask = ring_mask / max_val
1309
  gamma_val = getattr(state, "fx_ring_gamma", BALL_RING_INTENSITY_GAMMA)
1310
  if abs(gamma_val - 1.0) > 1e-6:
1311
- ring_mask = np.power(np.clip(ring_mask, 0.0, 1.0), gamma_val)
1312
-
1313
- # Hypothetical speed calculation
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
- ring_layer = np.clip(ring_layer + ring_mask[..., None] * color_rgb, 0.0, 1.0)
 
1325
 
1326
- if ring_layer is None or ring_layer.max() <= FX_EPS:
 
 
 
 
1327
  return None
1328
 
1329
- return np.clip(ring_layer, 0.0, 1.0)
 
 
 
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):