| 1 | """HSV color probe. |
| 2 | |
| 3 | Given a frame PNG and a bounding box (or auto-detect the brightest green cluster), |
| 4 | print the HSV percentile stats so we can pick a tight HSV range that catches the |
| 5 | PCI overlay but not the grass field. |
| 6 | |
| 7 | Usage: |
| 8 | hsv_probe.py <frame.png> |
| 9 | hsv_probe.py <frame.png> --box X Y W H |
| 10 | hsv_probe.py <frame.png> --test-range "50,200,200-70,255,255" |
| 11 | """ |
| 12 | from __future__ import annotations |
| 13 | |
| 14 | import argparse |
| 15 | import sys |
| 16 | from pathlib import Path |
| 17 | |
| 18 | import cv2 |
| 19 | import numpy as np |
| 20 | from rich.console import Console |
| 21 | |
| 22 | console = Console() |
| 23 | ROOT = Path(__file__).resolve().parents[1] |
| 24 | LOG_DIR = ROOT / "logs" |
| 25 | |
| 26 | def auto_pci_box(hsv: np.ndarray) -> tuple[int, int, int, int]: |
| 27 | """Find the brightest, most-saturated green cluster in the central half.""" |
| 28 | h, w = hsv.shape[:2] |
| 29 | mask = cv2.inRange(hsv, np.array([40, 200, 200], np.uint8), np.array([80, 255, 255], np.uint8)) |
| 30 | cx0, cy0 = w // 4, h // 4 |
| 31 | cx1, cy1 = 3 * w // 4, 3 * h // 4 |
| 32 | center = np.zeros_like(mask) |
| 33 | center[cy0:cy1, cx0:cx1] = 255 |
| 34 | mask = cv2.bitwise_and(mask, center) |
| 35 | ys, xs = np.where(mask > 0) |
| 36 | if len(xs) == 0: |
| 37 | return w // 3, h // 3, w // 3, h // 3 |
| 38 | return int(xs.min()), int(ys.min()), int(xs.max() - xs.min()), int(ys.max() - ys.min()) |
| 39 | |
| 40 | def main() -> int: |
| 41 | ap = argparse.ArgumentParser(description="HSV probe.") |
| 42 | ap.add_argument("frame_path") |
| 43 | ap.add_argument("--box", nargs=4, type=int, metavar=("X", "Y", "W", "H")) |
| 44 | ap.add_argument("--test-range", type=str, default=None, |
| 45 | help="'Hlo,Slo,Vlo-Hhi,Shi,Vhi' - save a mask with this range.") |
| 46 | args = ap.parse_args() |
| 47 | |
| 48 | frame = cv2.imread(args.frame_path, cv2.IMREAD_COLOR) |
| 49 | if frame is None: |
| 50 | console.print(f"[red]Cannot read {args.frame_path}[/red]") |
| 51 | return 2 |
| 52 | hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) |
| 53 | |
| 54 | if args.box is None: |
| 55 | x, y, w, h = auto_pci_box(hsv) |
| 56 | console.print(f"[cyan]auto-box: x={x} y={y} w={w} h={h}[/cyan]") |
| 57 | else: |
| 58 | x, y, w, h = args.box |
| 59 | |
| 60 | sub = hsv[y:y + h, x:x + w] |
| 61 | if sub.size == 0: |
| 62 | console.print("[red]Empty box.[/red]") |
| 63 | return 2 |
| 64 | |
| 65 | greens = cv2.inRange(sub, np.array([30, 80, 80], np.uint8), np.array([95, 255, 255], np.uint8)) |
| 66 | pci_pixels = sub[greens > 0] |
| 67 | if len(pci_pixels) == 0: |
| 68 | console.print("[yellow]No green-ish pixels in the box.[/yellow]") |
| 69 | return 2 |
| 70 | |
| 71 | hs = pci_pixels[:, 0] |
| 72 | ss = pci_pixels[:, 1] |
| 73 | vs = pci_pixels[:, 2] |
| 74 | def stats(name, arr): |
| 75 | console.print( |
| 76 | f" {name}: min={arr.min():3d} p5={np.percentile(arr,5):5.1f} " |
| 77 | f"p50={np.percentile(arr,50):5.1f} p95={np.percentile(arr,95):5.1f} max={arr.max():3d}" |
| 78 | ) |
| 79 | console.print(f"[bold]HSV stats of green-ish pixels in box (n={len(pci_pixels)}):[/bold]") |
| 80 | stats("H", hs); stats("S", ss); stats("V", vs) |
| 81 | |
| 82 | suggested_low = (int(np.percentile(hs, 5)) - 2, |
| 83 | max(40, int(np.percentile(ss, 5)) - 20), |
| 84 | max(40, int(np.percentile(vs, 5)) - 20)) |
| 85 | suggested_high = (int(np.percentile(hs, 95)) + 2, |
| 86 | min(255, int(np.percentile(ss, 95)) + 20), |
| 87 | min(255, int(np.percentile(vs, 95)) + 20)) |
| 88 | console.print( |
| 89 | f"[bold green]Suggested tight HSV:[/bold green] low={list(suggested_low)} high={list(suggested_high)}" |
| 90 | ) |
| 91 | |
| 92 | if args.test_range: |
| 93 | lo_str, hi_str = args.test_range.split("-") |
| 94 | lo = np.array([int(x) for x in lo_str.split(",")], np.uint8) |
| 95 | hi = np.array([int(x) for x in hi_str.split(",")], np.uint8) |
| 96 | else: |
| 97 | lo = np.array(suggested_low, np.uint8) |
| 98 | hi = np.array(suggested_high, np.uint8) |
| 99 | |
| 100 | full_mask = cv2.inRange(hsv, lo, hi) |
| 101 | LOG_DIR.mkdir(exist_ok=True) |
| 102 | mask_out = LOG_DIR / f"hsv_probe_mask_{Path(args.frame_path).stem}.png" |
| 103 | cv2.imwrite(str(mask_out), full_mask) |
| 104 | total = int((full_mask > 0).sum()) |
| 105 | console.print(f"Mask with range {list(lo)}..{list(hi)}: {total} pixels -> {mask_out}") |
| 106 | |
| 107 | ann = frame.copy() |
| 108 | cv2.rectangle(ann, (x, y), (x + w, y + h), (255, 0, 255), 2) |
| 109 | ann_out = LOG_DIR / f"hsv_probe_box_{Path(args.frame_path).stem}.png" |
| 110 | cv2.imwrite(str(ann_out), ann) |
| 111 | console.print(f"Annotated box -> {ann_out}") |
| 112 | return 0 |
| 113 | |
| 114 | if __name__ == "__main__": |
| 115 | sys.exit(main()) |