Skip to content

Commit

Permalink
Add grace for goal detection to avoid false positives
Browse files Browse the repository at this point in the history
False positive cases:

* flaky goal bounding box detection
* shots that jump right back on the field
  • Loading branch information
DarwinsBuddy committed Jan 28, 2024
1 parent 933548a commit cdc38d3
Show file tree
Hide file tree
Showing 4 changed files with 75 additions and 29 deletions.
1 change: 1 addition & 0 deletions const.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
XPAD = "xpad"
YPAD = "ypad"
SCALE = "scale"
GOAL_GRACE_PERIOD = "goalGracePeriod"
VERBOSE = "verbose"
HEADLESS = "headless"
OFF = "off"
Expand Down
40 changes: 24 additions & 16 deletions foosball/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from const import CALIBRATION_MODE, CALIBRATION_IMG_PATH, CALIBRATION_VIDEO, CALIBRATION_SAMPLE_SIZE, ARUCO_BOARD, \
FILE, CAMERA_ID, FRAMERATE, OUTPUT, CAPTURE, DISPLAY, BALL, XPAD, YPAD, SCALE, VERBOSE, HEADLESS, OFF, \
MAX_PIPE_SIZE, INFO_VERBOSITY, GPU, AUDIO, WEBHOOK, BUFFER, BallPresets, CalibrationMode
MAX_PIPE_SIZE, INFO_VERBOSITY, GPU, AUDIO, WEBHOOK, BUFFER, BallPresets, CalibrationMode, GOAL_GRACE_PERIOD
from foosball.arUcos.calibration import print_aruco_board, calibrate_camera
from foosball.tracking.ai import AI

Expand Down Expand Up @@ -56,12 +56,17 @@ def get_argparse():
io.add_argument("-cap", f"--{CAPTURE}", choices=['cv', 'gear'], default='gear', help="capture backend")
io.add_argument("-d", f"--{DISPLAY}", choices=['cv', 'gear'], default='cv', help="display backend cv=direct display, gear=stream")

tracker = ap.add_argument_group(title="Tracker", description="Options for the ball/goal tracker")
tracker.add_argument("-ba", f"--{BALL}", choices=[BallPresets.YAML, BallPresets.ORANGE, BallPresets.YELLOW], default=BallPresets.YAML,
help="Pre-configured ball color bounds. If 'yaml' is selected, a file called 'ball.yaml' "
"(stored by hitting 's' in ball calibration mode) will be loaded as a preset."
"If no file present fallback to 'yellow'")
tracker.add_argument("-b", f"--{BUFFER}", type=int, default=16, help="max track buffer size")
general = ap.add_argument_group(title="General", description="General options")
general.add_argument("-v", f"--{VERBOSE}", action='store_true', help="Verbose")
general.add_argument("-q", f"--{HEADLESS}", action='store_true', help="Disable visualizations")
general.add_argument("-o", f"--{OFF}", action='store_true', help="Disable ai")
general.add_argument("-p", f"--{MAX_PIPE_SIZE}", type=int, default=128, help="max pipe buffer size")
general.add_argument("-i", f"--{INFO_VERBOSITY}", type=int, help="Verbosity level of gui info box (default: None)",
default=None)
general.add_argument("-g", f"--{GPU}", choices=['preprocess', 'tracker', 'render'], nargs='+', default=["render"],
help="use GPU")
general.add_argument("-A", f"--{AUDIO}", action='store_true', help="Enable audio")
general.add_argument("-W", f"--{WEBHOOK}", action='store_true', help="Enable webhook")

preprocess = ap.add_argument_group(title="Preprocessor", description="Options for the preprocessing step")
preprocess.add_argument("-xp", f"--{XPAD}", type=int, default=50,
Expand All @@ -70,15 +75,18 @@ def get_argparse():
help="Vertical padding applied to ROI detected by aruco markers")
preprocess.add_argument("-s", f"--{SCALE}", type=float, default=0.4, help="Scale stream")

general = ap.add_argument_group(title="General", description="General options")
general.add_argument("-v", f"--{VERBOSE}", action='store_true', help="Verbose")
general.add_argument("-q", f"--{HEADLESS}", action='store_true', help="Disable visualizations")
general.add_argument("-o", f"--{OFF}", action='store_true', help="Disable ai")
general.add_argument("-p", f"--{MAX_PIPE_SIZE}", type=int, default=128, help="max pipe buffer size")
general.add_argument("-i", f"--{INFO_VERBOSITY}", type=int, help="Verbosity level of gui info box (default: None)", default=None)
general.add_argument("-g", f"--{GPU}", choices=['preprocess', 'tracker', 'render'], nargs='+', default=["render"], help="use GPU")
general.add_argument("-A", f"--{AUDIO}", action='store_true', help="Enable audio")
general.add_argument("-W", f"--{WEBHOOK}", action='store_true', help="Enable webhook")
tracker = ap.add_argument_group(title="Tracker", description="Options for the ball/goal tracker")
tracker.add_argument("-ba", f"--{BALL}", choices=[BallPresets.YAML, BallPresets.ORANGE, BallPresets.YELLOW],
default=BallPresets.YAML,
help="Pre-configured ball color bounds. If 'yaml' is selected, a file called 'ball.yaml' "
"(stored by hitting 's' in ball calibration mode) will be loaded as a preset."
"If no file present fallback to 'yellow'")
tracker.add_argument("-b", f"--{BUFFER}", type=int, default=16, help="max track buffer size")

analyzer = ap.add_argument_group(title="Analyzer", description="Options for the analyzer")
analyzer.add_argument("-gc", f"--{GOAL_GRACE_PERIOD}", type=float,
help="time in sec for a ball to disappear in a goal to be counted (default: 0.5)",
default=0.5)
return ap


Expand Down
4 changes: 2 additions & 2 deletions foosball/tracking/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def generate_frame_mask(width, height) -> Mask:

class Tracking:

def __init__(self, stream, dims: FrameDimensions, goal_detector: GoalDetector, ball_detector: BallDetector, headless=False, maxPipeSize=128, calibrationMode=None, **kwargs):
def __init__(self, stream, dims: FrameDimensions, goal_detector: GoalDetector, ball_detector: BallDetector, headless=False, maxPipeSize=128, calibrationMode=None, goalGracePeriod=1.0, **kwargs):
super().__init__()
self.calibrationMode = calibrationMode

Expand All @@ -40,7 +40,7 @@ def __init__(self, stream, dims: FrameDimensions, goal_detector: GoalDetector, b
self.preprocessor = PreProcessor(dims, goal_detector, mask=mask, headless=headless, useGPU='preprocess' in gpu_flags,
calibrationMode=calibrationMode, **kwargs)
self.tracker = Tracker(ball_detector, useGPU='tracker' in gpu_flags, calibrationMode=calibrationMode, **kwargs)
self.analyzer = Analyzer(**kwargs)
self.analyzer = Analyzer(goal_grace_period_sec=goalGracePeriod, **kwargs)
self.renderer = Renderer(dims, headless=headless, useGPU='render' in gpu_flags, **kwargs)

self.stream = stream
Expand Down
59 changes: 48 additions & 11 deletions foosball/tracking/analyze.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import multiprocessing
import traceback
from typing import Optional
import datetime as dt

from .. import hooks
from ..hooks import generate_goal_webhook
Expand All @@ -13,20 +14,31 @@ class Analyzer(BaseProcess):
def close(self):
pass

def __init__(self, audio: bool = False, webhook: bool = False, *args, **kwargs):
def __init__(self, audio: bool = False, webhook: bool = False, goal_grace_period_sec: float = 1.0, *args, **kwargs):
super().__init__(name="Analyzer")
self.kwargs = kwargs
self.goal_grace_period_sec = goal_grace_period_sec
self.score = Score()
self.score_reset = multiprocessing.Event()
self.audio = audio
self.webhook = webhook
self.last_track_sighting: dt.datetime | None = None
self.last_track: Optional[Track] = None
self.goal_candidate = None
self.last_track_sighting = None

@staticmethod
def is_track_empty(track: Track):
return len([x for x in track if x is not None]) == 0

@staticmethod
def is_track_about_to_vanish(track: Track):
return len([x for x in track if x is not None]) == 1

def goal_shot(self, goals: Goals, track: Track) -> Optional[Team]:
# current track is empty but last track had one single point left
try:
if len([x for x in track if x is not None]) == 0 and len(
[x for x in self.last_track if x is not None]) == 1:
if self.is_track_empty(track) and self.is_track_about_to_vanish(self.last_track):
if contains(goals.left.bbox, self.last_track[-1]):
return Team.BLUE
elif contains(goals.right.bbox, self.last_track[-1]):
Expand All @@ -48,15 +60,29 @@ def process(self, msg: Msg) -> Msg:
track = track_result.ball_track
frame = track_result.frame
info = track_result.info
if self.score_reset.is_set():
self.score.reset()
self.score_reset.clear()
try:
team: Team = self.goal_shot(goals, track) if None not in [goals, track, self.last_track] else None
self.score.inc(team)
if team is not None:
self.logger.info(f"GOAL Team:{team} - {self.score.red} : {self.score.blue}")
self.call_hooks(team)
self.check_reset_score()
now = dt.datetime.now()

no_track_sighting_in_grace_period = (now - self.last_track_sighting).total_seconds() >= self.goal_grace_period_sec if self.last_track_sighting is not None else None

if not self.is_track_empty(track):
# track is not empty, so we save our state and remove a potential goal (which was wrongly tracked)
# case1: detected goals where not accurate => false positive
# case2: ball jumped right back into field => false positive
self.last_track_sighting = now
self.goal_candidate = None
else:
# let's wait for track (s.a.), or we run out of grace period (down below)
# whatever happens first
if self.goal_candidate is not None:
if self.last_track_sighting is not None and no_track_sighting_in_grace_period:
self.count_goal(self.goal_candidate)
self.goal_candidate = None
else:
# if track is empty, and we have no current goal candidate, check if there is one
self.goal_candidate = self.goal_shot(goals, track) if None not in [goals, track, self.last_track] else None

except Exception as e:
self.logger.error("Error in analyzer ", e)
traceback.print_exc()
Expand All @@ -65,5 +91,16 @@ def process(self, msg: Msg) -> Msg:
return Msg(kwargs={"result": AnalyzeResult(score=self.score, ball=ball, goals=goals, frame=frame, info=info,
ball_track=track)})

def check_reset_score(self):
if self.score_reset.is_set():
self.score.reset()
self.score_reset.clear()

def count_goal(self, team: Team):
self.score.inc(team)
if team is not None:
self.logger.info(f"GOAL Team:{team} - {self.score.red} : {self.score.blue}")
self.call_hooks(team)

def reset_score(self):
self.score_reset.set()

0 comments on commit cdc38d3

Please sign in to comment.