Zion Boggan
repos/Pitch Tracker CV/tools/hsv_probe.py
zionboggan.com ↗
115 lines · python
History for this file →
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())