From cdc38d31f0f371ec270dbef4e1af4a3a72d2434f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20Sp=C3=B6rk?= Date: Sun, 28 Jan 2024 13:04:16 +0100 Subject: [PATCH] Add grace for goal detection to avoid false positives False positive cases: * flaky goal bounding box detection * shots that jump right back on the field --- const.py | 1 + foosball/__init__.py | 40 ++++++++++++++---------- foosball/tracking/__init__.py | 4 +-- foosball/tracking/analyze.py | 59 ++++++++++++++++++++++++++++------- 4 files changed, 75 insertions(+), 29 deletions(-) diff --git a/const.py b/const.py index 534b823..15ab634 100644 --- a/const.py +++ b/const.py @@ -14,6 +14,7 @@ XPAD = "xpad" YPAD = "ypad" SCALE = "scale" +GOAL_GRACE_PERIOD = "goalGracePeriod" VERBOSE = "verbose" HEADLESS = "headless" OFF = "off" diff --git a/foosball/__init__.py b/foosball/__init__.py index 28599d2..e390e89 100644 --- a/foosball/__init__.py +++ b/foosball/__init__.py @@ -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 @@ -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, @@ -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 diff --git a/foosball/tracking/__init__.py b/foosball/tracking/__init__.py index dd7aadc..f7df2b4 100644 --- a/foosball/tracking/__init__.py +++ b/foosball/tracking/__init__.py @@ -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 @@ -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 diff --git a/foosball/tracking/analyze.py b/foosball/tracking/analyze.py index 5158077..82b7ad5 100644 --- a/foosball/tracking/analyze.py +++ b/foosball/tracking/analyze.py @@ -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 @@ -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]): @@ -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() @@ -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()