Initial commit
This commit is contained in:
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"python-envs.defaultEnvManager": "ms-python.python:system"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1580
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
import threading
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from config import (
|
||||
CAMERA_FROZEN_DIFF_THRESHOLD,
|
||||
CAMERA_FROZEN_SECONDS,
|
||||
CAMERA_CONNECT_RETRY_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
class Camera:
|
||||
"""
|
||||
Wrapper around cv2.VideoCapture for an HTTP/MJPEG camera stream,
|
||||
with a few exhibition-day survival features:
|
||||
|
||||
- Frame grabbing runs on its own background thread that reads as
|
||||
fast as the stream delivers and keeps only the SINGLE most
|
||||
recent frame. cv2.VideoCapture's own buffering on network
|
||||
streams often ignores CAP_PROP_BUFFERSIZE; if the consumer is
|
||||
ever briefly slower than the stream, frames queue up upstream
|
||||
and every later read() returns an ever-staler frame ("the game
|
||||
gets slower and slower"). Draining continuously and handing
|
||||
out only the newest frame makes that impossible.
|
||||
|
||||
- Connecting also happens on that thread and never blocks the
|
||||
caller: the game window comes up immediately (and stays
|
||||
responsive to the keyboard) even if the phone/app isn't
|
||||
streaming yet. It keeps retrying forever, both at startup and
|
||||
after a mid-show drop.
|
||||
|
||||
- Every frame gets an increasing id (see read_with_id), so the
|
||||
vision thread can skip a frame it has already processed
|
||||
instead of redoing the same work on it.
|
||||
|
||||
- Frozen-frame detection: apps that pause in the background
|
||||
often keep re-sending the last JPEG, which cv2.VideoCapture
|
||||
happily "reads" forever. We track whether the image content
|
||||
actually changes and expose is_frozen() so callers can refuse
|
||||
to trust a feed that looks alive but isn't.
|
||||
"""
|
||||
|
||||
MAX_CONSECUTIVE_FAILURES = 5
|
||||
RECONNECT_BACKOFF_SECONDS = 1.0
|
||||
|
||||
def __init__(self, url: str):
|
||||
self.url = url
|
||||
self.cap = None
|
||||
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
self._state_lock = threading.Lock()
|
||||
self._last_thumb = None
|
||||
self._last_change_time = time.monotonic()
|
||||
|
||||
self._frame_lock = threading.Lock()
|
||||
self._latest_frame = None
|
||||
self._latest_id = 0
|
||||
self._latest_error = "Camera not connected yet."
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="camera-reader",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
# ========================================================
|
||||
# Connection
|
||||
# ========================================================
|
||||
|
||||
def _connect_with_retry(self):
|
||||
"""
|
||||
Keeps trying to open the stream until it works or the
|
||||
camera is released. This runs unattended in a booth: the
|
||||
phone/app may not be streaming yet, or wifi may take a
|
||||
moment, and nobody may be there to relaunch anything.
|
||||
"""
|
||||
attempt = 0
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
attempt += 1
|
||||
|
||||
try:
|
||||
self._open()
|
||||
print("[CAMERA] connected.")
|
||||
return
|
||||
except Exception as error:
|
||||
with self._frame_lock:
|
||||
self._latest_error = (
|
||||
f"Camera not connected ({error})"
|
||||
)
|
||||
|
||||
print(
|
||||
f"[CAMERA] connect attempt {attempt} failed "
|
||||
f"({error}) - retrying in "
|
||||
f"{CAMERA_CONNECT_RETRY_SECONDS:.1f}s..."
|
||||
)
|
||||
|
||||
self._stop_event.wait(
|
||||
CAMERA_CONNECT_RETRY_SECONDS
|
||||
)
|
||||
|
||||
def _open(self):
|
||||
if self.cap is not None:
|
||||
try:
|
||||
self.cap.release()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.cap = cv2.VideoCapture(self.url)
|
||||
|
||||
# Cheap extra defense on backends that honor it; the reader
|
||||
# thread is what actually guarantees we never fall behind.
|
||||
try:
|
||||
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not self.cap.isOpened():
|
||||
raise RuntimeError(
|
||||
f"Could not connect to camera stream: {self.url}"
|
||||
)
|
||||
|
||||
# ========================================================
|
||||
# Background thread
|
||||
# ========================================================
|
||||
|
||||
def _run(self):
|
||||
self._connect_with_retry()
|
||||
|
||||
consecutive_failures = 0
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
success, frame = self.cap.read()
|
||||
except Exception as error:
|
||||
success, frame = False, None
|
||||
|
||||
with self._frame_lock:
|
||||
self._latest_error = (
|
||||
f"Camera read error: {error}"
|
||||
)
|
||||
|
||||
if not success or frame is None:
|
||||
consecutive_failures += 1
|
||||
|
||||
with self._frame_lock:
|
||||
self._latest_error = (
|
||||
"Failed to read frame from camera."
|
||||
)
|
||||
|
||||
if (
|
||||
consecutive_failures
|
||||
>= self.MAX_CONSECUTIVE_FAILURES
|
||||
):
|
||||
self._stop_event.wait(
|
||||
self.RECONNECT_BACKOFF_SECONDS
|
||||
)
|
||||
|
||||
self._connect_with_retry()
|
||||
|
||||
consecutive_failures = 0
|
||||
else:
|
||||
# A failed read on some backends returns
|
||||
# instantly; don't spin a core on it.
|
||||
self._stop_event.wait(0.01)
|
||||
|
||||
continue
|
||||
|
||||
consecutive_failures = 0
|
||||
|
||||
self._update_freeze_state(frame)
|
||||
|
||||
# Publish only the newest frame; anything the consumer
|
||||
# hasn't taken yet is simply replaced.
|
||||
with self._frame_lock:
|
||||
self._latest_frame = frame
|
||||
self._latest_id += 1
|
||||
self._latest_error = None
|
||||
|
||||
# ========================================================
|
||||
# Public API
|
||||
# ========================================================
|
||||
|
||||
def read_with_id(self):
|
||||
"""Newest frame and its id; raises RuntimeError if none yet."""
|
||||
with self._frame_lock:
|
||||
frame = self._latest_frame
|
||||
frame_id = self._latest_id
|
||||
error = self._latest_error
|
||||
|
||||
if frame is None:
|
||||
raise RuntimeError(
|
||||
error or "No frame received from camera yet."
|
||||
)
|
||||
|
||||
return frame, frame_id
|
||||
|
||||
def read(self):
|
||||
return self.read_with_id()[0]
|
||||
|
||||
def is_frozen(self):
|
||||
with self._state_lock:
|
||||
last_change = self._last_change_time
|
||||
|
||||
return (
|
||||
time.monotonic() - last_change
|
||||
) > CAMERA_FROZEN_SECONDS
|
||||
|
||||
def release(self):
|
||||
self._stop_event.set()
|
||||
|
||||
# Let the reader thread notice and finish before the
|
||||
# capture object is torn down underneath it.
|
||||
if self._thread.is_alive():
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
if self.cap is not None:
|
||||
try:
|
||||
self.cap.release()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ========================================================
|
||||
# Frozen-frame detection
|
||||
# ========================================================
|
||||
|
||||
def _update_freeze_state(self, frame):
|
||||
# Shrink FIRST (INTER_AREA averages, so it also suppresses
|
||||
# pixel noise) and only then convert to gray - converting a
|
||||
# full frame just to throw almost all of it away is wasted
|
||||
# work on the capture thread.
|
||||
thumb = cv2.cvtColor(
|
||||
cv2.resize(
|
||||
frame,
|
||||
(32, 18),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
),
|
||||
cv2.COLOR_BGR2GRAY,
|
||||
).astype(np.int16)
|
||||
|
||||
now = time.monotonic()
|
||||
|
||||
with self._state_lock:
|
||||
if self._last_thumb is None:
|
||||
self._last_change_time = now
|
||||
else:
|
||||
diff = float(
|
||||
np.mean(np.abs(thumb - self._last_thumb))
|
||||
)
|
||||
|
||||
if diff > CAMERA_FROZEN_DIFF_THRESHOLD:
|
||||
self._last_change_time = now
|
||||
|
||||
self._last_thumb = thumb
|
||||
@@ -0,0 +1,599 @@
|
||||
# config.py
|
||||
|
||||
# ============================================================
|
||||
# Screen
|
||||
# ============================================================
|
||||
|
||||
SCREEN_WIDTH = 1530
|
||||
SCREEN_HEIGHT = 920
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Camera
|
||||
# ============================================================
|
||||
|
||||
# CAMERA_URL = "http://172.30.2.62:8080/video"
|
||||
# CAMERA_URL = "http://172.30.1.216:4747/video"
|
||||
CAMERA_URL = "http://192.168.1.194:4747/video"
|
||||
|
||||
# How long to wait between attempts to (re)connect to the camera
|
||||
# when it is unreachable - both on startup (phone app not open yet)
|
||||
# and whenever a reconnect is needed mid-show. The game keeps
|
||||
# retrying forever instead of crashing, since this runs unattended
|
||||
# in a booth and nobody may be there to relaunch it.
|
||||
CAMERA_CONNECT_RETRY_SECONDS = 2.0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Detection intervals
|
||||
# ============================================================
|
||||
|
||||
# Both of these run on a background vision thread (see vision.py),
|
||||
# never on the render loop, so they can no longer cause frame
|
||||
# hitches. Obstacle detection works on a downscaled image (see
|
||||
# DETECTION_SCALE) and is cheap enough to run 4x per second, which
|
||||
# makes moving a sticker feel responsive.
|
||||
MONITOR_DETECTION_INTERVAL = 1.0
|
||||
OBSTACLE_DETECTION_INTERVAL = 0.25
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Calibration - screen border pattern
|
||||
# ============================================================
|
||||
#
|
||||
# Instead of four small corner markers, the monitor draws a
|
||||
# magenta/pink outer ring with a white inner ring around the edge
|
||||
# of the screen, permanently (during both calibration and normal
|
||||
# gameplay - see Renderer.draw_calibration_border). This is much
|
||||
# more robust in a busy, noisy exhibition hall than four small
|
||||
# markers:
|
||||
# - it's a single large, high-contrast shape instead of four
|
||||
# tiny independent ones, so a person briefly walking in front
|
||||
# of one corner doesn't break detection the way losing one out
|
||||
# of four markers would;
|
||||
# - requiring BOTH the pink AND the white ring to be present
|
||||
# (in the right relative position) makes it very unlikely that
|
||||
# a random pink or white object/reflection in the room could
|
||||
# be mistaken for the screen.
|
||||
|
||||
# Colors drawn on the monitor itself (RGB, used by pygame).
|
||||
#
|
||||
# Why GREEN instead of magenta/pink: the outer ring only has to be
|
||||
# the single easiest possible color for a CAMERA (not a human eye)
|
||||
# to pick out of a crowded, oddly-lit exhibition hall, and green is
|
||||
# the best pick available for three concrete reasons:
|
||||
# 1. Every consumer camera sensor uses a Bayer filter with TWICE
|
||||
# as many green photosites as red or blue (the pattern a human
|
||||
# eye is most sensitive to), so the green channel is always
|
||||
# the least noisy, highest-resolution channel the camera has -
|
||||
# this is exactly why film/broadcast green-screens are green
|
||||
# and not some other color.
|
||||
# 2. Pink/magenta sits close to skin tones and to a lot of common
|
||||
# clothing under warm (incandescent/halogen) exhibition
|
||||
# lighting, which white-balance algorithms can shift toward
|
||||
# orange/red - pushing it out of a fixed HSV window entirely.
|
||||
# Saturated green has no such close neighbour in a crowd of
|
||||
# people, and survives white-balance shifts far better (most
|
||||
# auto white balance implementations anchor their correction
|
||||
# around making genuinely neutral/gray areas look neutral,
|
||||
# which leaves a strongly green-dominant pixel green-dominant
|
||||
# under almost any color temperature).
|
||||
# 3. It pairs well with the detection method below: "is the green
|
||||
# channel clearly stronger than red AND blue" is a RELATIVE
|
||||
# test, so it survives brightness/exposure changes (someone's
|
||||
# shadow, a spotlight) that would otherwise shift an absolute
|
||||
# HSV threshold out of range.
|
||||
CALIBRATION_PATTERN_OUTER_COLOR = (40, 255, 60) # saturated green
|
||||
CALIBRATION_PATTERN_INNER_COLOR = (255, 255, 255) # white
|
||||
|
||||
# Thickness of each ring, in screen pixels (at the SCREEN_WIDTH x
|
||||
# SCREEN_HEIGHT design resolution above).
|
||||
CALIBRATION_OUTER_BORDER_SIZE = 40
|
||||
CALIBRATION_INNER_BORDER_SIZE = 26
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Target-color detection
|
||||
# ------------------------------------------------------------
|
||||
#
|
||||
# The exact color the camera records for CALIBRATION_PATTERN_OUTER_
|
||||
# COLOR depends on the camera's white balance/exposure and the
|
||||
# room's lighting, and can drift over the course of a show (the
|
||||
# hall's lighting changing, the phone's camera re-adjusting
|
||||
# exposure, etc.) - a single fixed HSV box, tuned once, is exactly
|
||||
# what used to make detection "work great at setup time, then get
|
||||
# flaky an hour into the exhibition". So the mask that finds the
|
||||
# outer ring is now built from two cooperating, auto-adapting
|
||||
# pieces instead of one fixed threshold (see Calibration.
|
||||
# _make_target_mask for the implementation):
|
||||
#
|
||||
# 1. A wide, loose hue pre-filter (this section) that only rules
|
||||
# out colors nowhere near green at all (reds, blues, skin,
|
||||
# gray) - it does NOT try to pin down the exact shade.
|
||||
# 2. Within whatever passes that pre-filter, a per-frame Otsu
|
||||
# threshold on how green-dominant each pixel is (how far G
|
||||
# exceeds R and B) - Otsu finds the split point that best
|
||||
# separates "clearly green" from "not" IN THAT FRAME, so it
|
||||
# re-adapts itself to the current lighting automatically,
|
||||
# frame by frame, with no fixed brightness/saturation number
|
||||
# to go stale.
|
||||
#
|
||||
# CALIBRATION_TARGET_HUE_DEGREES/TOLERANCE describe the loose
|
||||
# pre-filter in standard 0-360 degree hue (green = 120 degrees).
|
||||
CALIBRATION_TARGET_HUE_DEGREES = 120
|
||||
CALIBRATION_TARGET_HUE_TOLERANCE_DEGREES = 45
|
||||
|
||||
# Loose floor for saturation/brightness in the pre-filter - just
|
||||
# enough to exclude flat gray/black/white areas, not to pin an
|
||||
# exact shade (that's Otsu's job).
|
||||
CALIBRATION_MIN_TARGET_SATURATION = 50
|
||||
CALIBRATION_MIN_TARGET_VALUE = 35
|
||||
|
||||
# Once locked on, the system also WATCHES the hue it is actually
|
||||
# seeing inside the confirmed ring and slowly re-centers the
|
||||
# pre-filter on it (see Calibration._update_learned_hue). This is
|
||||
# what lets it track a slow lighting drift over the course of a
|
||||
# multi-hour show instead of needing to be re-tuned on-site.
|
||||
# ADAPTIVE_MAX_DRIFT_DEGREES bounds how far that learned center may
|
||||
# ever wander from the true CALIBRATION_TARGET_HUE_DEGREES, so a
|
||||
# run of bad detections can never "learn" its way onto some
|
||||
# unrelated color.
|
||||
CALIBRATION_ADAPTIVE_HUE_ENABLED = True
|
||||
CALIBRATION_ADAPTIVE_HUE_BLEND = 0.08
|
||||
CALIBRATION_ADAPTIVE_MAX_DRIFT_DEGREES = 30
|
||||
|
||||
# The white inner ring is found the same way detection.py finds
|
||||
# white stickers (see config's "White obstacle detection" section
|
||||
# for the full reasoning): a LOCAL adaptive brightness test rather
|
||||
# than one fixed floor for the whole frame, and LAB chroma rather
|
||||
# than HSV saturation for "is this actually neutral". A fixed HSV
|
||||
# box here was just as fragile under real exhibition lighting as the
|
||||
# old fixed box was for stickers.
|
||||
CALIBRATION_WHITE_ADAPTIVE_OFFSET = 8
|
||||
CALIBRATION_WHITE_MIN_ABSOLUTE_LIGHTNESS = 50
|
||||
|
||||
# One-sided: the only other bright thing nearby is the green ring
|
||||
# itself, so it's enough to exclude clearly GREEN-dominant pixels
|
||||
# rather than pin an exact "how white" number - see the comment in
|
||||
# Calibration._make_white_mask for why a two-sided chroma ceiling
|
||||
# can't reliably cover every possible lighting cast but this can.
|
||||
CALIBRATION_WHITE_MAX_GREEN_DOMINANCE = 50
|
||||
|
||||
# The white inner ring also serves as a live "gray card": its mean
|
||||
# LAB (a*, b*) as the camera currently records it is continuously
|
||||
# learned (slow exponential blend) and handed to detection.py, which
|
||||
# judges a candidate sticker's whiteness relative to THAT instead of
|
||||
# absolute neutral - see Calibration.white_reference(). Colored
|
||||
# ambient light shifts a genuinely white/neutral surface's recorded
|
||||
# color for physical reasons no fixed threshold can see around; this
|
||||
# is what lets the sticker detector stay correct anyway, and track
|
||||
# the room's lighting changing over a long show without re-tuning.
|
||||
CALIBRATION_WHITE_REFERENCE_BLEND = 0.05
|
||||
|
||||
# How far (LAB a/b units) the learned white point may ever drift
|
||||
# from true neutral, so a run of bad samples can never teach the
|
||||
# sticker detector a wildly wrong idea of white.
|
||||
CALIBRATION_WHITE_REFERENCE_MAX_OFFSET = 40.0
|
||||
|
||||
# The detected quad's area, as a fraction of the full camera frame,
|
||||
# must fall in this range. Filters out both tiny false positives
|
||||
# (reflections, noise) and implausibly large ones.
|
||||
CALIBRATION_MIN_SCREEN_AREA_RATIO = 0.10
|
||||
CALIBRATION_MAX_SCREEN_AREA_RATIO = 0.90
|
||||
|
||||
# How much of the sampled outer/inner ring band must actually match
|
||||
# its expected color for a candidate to be accepted.
|
||||
CALIBRATION_MIN_OUTER_TARGET_RATIO = 0.55
|
||||
CALIBRATION_MIN_INNER_WHITE_RATIO = 0.40
|
||||
|
||||
# Combined quality/color score (0-1) required before a detection is
|
||||
# even considered as a calibration candidate.
|
||||
CALIBRATION_MIN_QUALITY = 0.50
|
||||
|
||||
# While NOT yet calibrated, the whole screen is filled solid green
|
||||
# (Renderer.draw_full_calibration_screen) instead of just the thin
|
||||
# border ring. This gives the very first lock-on the simplest,
|
||||
# most unambiguous target possible: one huge solid-color rectangle
|
||||
# with nothing else on screen. This is the fraction of that
|
||||
# candidate quad's own interior that must actually be green for it
|
||||
# to count - it naturally stays low while someone is standing in
|
||||
# front of part of the monitor, so a person walking through frame
|
||||
# during calibration just delays the lock instead of causing a bad
|
||||
# one.
|
||||
CALIBRATION_MIN_FULL_TARGET_RATIO = 0.85
|
||||
|
||||
# Temporal stability: how many consecutive matching detections are
|
||||
# required before (re)locking calibration, and how far (as a
|
||||
# fraction of the camera frame's diagonal) the corners are allowed
|
||||
# to drift between frames and still count as "the same" detection.
|
||||
# This is what lets the system ignore a person briefly walking
|
||||
# through frame instead of resetting calibration.
|
||||
CALIBRATION_REQUIRED_STABLE_FRAMES = 5
|
||||
CALIBRATION_MAX_CORNER_JUMP_RATIO = 0.035
|
||||
|
||||
# Exponential smoothing applied to the accepted corner positions on
|
||||
# every successful re-detection once already calibrated, so small
|
||||
# frame-to-frame jitter doesn't make the transform wobble.
|
||||
CALIBRATION_SMOOTHING_ALPHA = 0.25
|
||||
|
||||
# Morphological closing kernel (pixels) used to bridge small gaps
|
||||
# in the color mask caused by glare/reflections on the monitor
|
||||
# glass. Bigger = more tolerant of glare, but also more likely to
|
||||
# merge in nearby unrelated green/white blobs - tune together with
|
||||
# the hue settings above.
|
||||
CALIBRATION_MASK_CLOSE_KERNEL = 17
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Camera feed health
|
||||
# ============================================================
|
||||
#
|
||||
# If the raw camera feed barely changes for this long, treat it as
|
||||
# a stuck/cached/frozen frame (phone screen locked, streaming app
|
||||
# paused in the background, stale network buffer, dead wifi
|
||||
# re-serving the last JPEG, lens covered, etc.) rather than a live
|
||||
# view, and don't trust it for calibration or obstacle detection.
|
||||
CAMERA_FROZEN_DIFF_THRESHOLD = 0.6
|
||||
CAMERA_FROZEN_SECONDS = 2.0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Rendering
|
||||
# ============================================================
|
||||
|
||||
BACKGROUND_COLOR = (0, 0, 0)
|
||||
|
||||
TARGET_FPS = 144
|
||||
|
||||
# Only repaint (and push to the display) the small screen regions
|
||||
# that actually changed since the last frame, instead of redrawing
|
||||
# and re-uploading all 1920x1080 pixels 144 times a second. On most
|
||||
# machines this is the single biggest rendering speed-up. If you
|
||||
# ever see stale "ghost" pixels on the monitor (a driver that
|
||||
# ignores partial updates), set this to False - everything still
|
||||
# works, just with more CPU/GPU load.
|
||||
USE_DIRTY_RECTS = True
|
||||
|
||||
# Safety net for USE_DIRTY_RECTS: force one complete repaint every
|
||||
# this many seconds, so even a misbehaving driver can't leave a
|
||||
# smudge on the screen for long.
|
||||
FULL_REPAINT_SECONDS = 10.0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Visual effects (purely cosmetic - never affect physics)
|
||||
# ============================================================
|
||||
#
|
||||
# Master switch. Everything below is drawn ONLY with saturated
|
||||
# colors (never white blobs) and only in small, dim glows, because
|
||||
# the camera is looking at this same screen and must never mistake
|
||||
# an effect for a white sticker. If detection ever misbehaves and
|
||||
# you suspect the visuals, flip this to False first to find out.
|
||||
VISUAL_EFFECTS_ENABLED = True
|
||||
|
||||
# Each ball gets its own hue (degrees, 0-360) from this range. The
|
||||
# default skips magenta/pink (roughly 280-350) on purpose: that is
|
||||
# the calibration color, and it must stay unique to the calibration
|
||||
# border so the camera never confuses the two.
|
||||
BALL_HUE_MIN_DEGREES = 15
|
||||
BALL_HUE_MAX_DEGREES = 255
|
||||
|
||||
# Every time a ball bounces off a sticker its hue shifts by this
|
||||
# many degrees (staying inside the range above) - a small, very
|
||||
# visible "the sticker reacted to me" cue that people love.
|
||||
BALL_HUE_SHIFT_ON_BOUNCE = 22
|
||||
|
||||
# Fading comet tail behind each ball: how many past positions it
|
||||
# remembers, and how often (seconds) a new one is recorded.
|
||||
BALL_TRAIL_LENGTH = 16
|
||||
BALL_TRAIL_SAMPLE_SECONDS = 1.0 / 60.0
|
||||
|
||||
# Soft colored glow around each ball (brighter the faster it moves).
|
||||
BALL_GLOW_ENABLED = True
|
||||
|
||||
# Springy squash-and-stretch: balls elongate along their direction
|
||||
# of travel with speed, and squash flat for a moment on a hard hit.
|
||||
BALL_SQUASH_STRETCH_ENABLED = True
|
||||
BALL_SQUASH_DURATION_SECONDS = 0.16
|
||||
|
||||
# Sparks + expanding ring on a bounce whose impact speed (px/s)
|
||||
# exceeds this; gentler taps and resting contact produce nothing,
|
||||
# so a ball lying still on a sticker stays calm.
|
||||
IMPACT_EFFECT_MIN_SPEED = 160.0
|
||||
MAX_PARTICLES = 260
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Detected obstacles (what is painted back onto the monitor)
|
||||
# ============================================================
|
||||
#
|
||||
# When True, every obstacle the camera currently sees is also
|
||||
# painted white on the game's own screen (i.e. on the physical
|
||||
# monitor itself), where it was detected - as the real tilted
|
||||
# rectangle, not an axis-aligned box. When False, nothing extra is
|
||||
# drawn for it. Either way this is purely visual - balls always
|
||||
# collide with every currently-detected obstacle regardless of this
|
||||
# flag; it never affects physics.
|
||||
SHOW_DETECTED_OBSTACLES = False
|
||||
|
||||
DETECTED_OBSTACLE_COLOR = (255, 255, 255)
|
||||
DETECTED_OBSTACLE_BORDER_WIDTH = 0
|
||||
|
||||
# The painted white is deliberately drawn a little SMALLER than the
|
||||
# sticker (this fraction of the sticker's short side is trimmed off
|
||||
# each edge). Reason: the camera looks at this very screen. If the
|
||||
# painted white were exactly sticker-sized, then after someone
|
||||
# removes the sticker the camera would keep seeing the painted
|
||||
# white and "detect" the (now nonexistent) sticker forever. Painting
|
||||
# inset keeps it hidden under the real sticker, and the detector
|
||||
# additionally blanks out exactly the area that was painted before
|
||||
# looking for stickers (see detection.py), so a removed sticker
|
||||
# really disappears.
|
||||
DETECTED_OBSTACLE_PAINT_INSET_RATIO = 0.22
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Ball physics
|
||||
# ============================================================
|
||||
|
||||
BALL_RADIUS = 15
|
||||
|
||||
# Each ball's radius is randomly scaled by up to +/- this fraction
|
||||
# (bigger balls are also heavier in ball-vs-ball collisions).
|
||||
BALL_RADIUS_VARIATION = 0.25
|
||||
|
||||
BALL_GRAVITY = 900
|
||||
|
||||
# Fraction of normal speed kept after bouncing off a sticker or a
|
||||
# screen edge (1.0 = perfectly elastic: with gravity, a ball just
|
||||
# keeps bouncing at the same energy forever and never settles down,
|
||||
# which is the default here). Lower it (e.g. ~0.75) for a "real
|
||||
# rubber ball" feel that loses a little height on every bounce
|
||||
# instead. Very slow contact against a STICKER is still always
|
||||
# treated as fully inelastic (BALL_REST_SPEED) so a ball resting on
|
||||
# one doesn't buzz at the pixel level; screen edges always bounce at
|
||||
# the full BALL_RESTITUTION regardless of speed.
|
||||
BALL_RESTITUTION = 1.0
|
||||
BALL_REST_SPEED = 55.0
|
||||
|
||||
# Fraction of sliding (tangential) speed lost per bounce off a
|
||||
# sticker - what makes a ball roll/slide along a tilted sticker
|
||||
# instead of skating on ice. 0 = no energy lost this way either (the
|
||||
# default, paired with BALL_RESTITUTION = 1.0 above); raise it for
|
||||
# more of a "rolling to a stop on a ramp" feel.
|
||||
BALL_FRICTION = 0.0
|
||||
|
||||
# Safety cap on speed (px/s), so a freak event can never launch a
|
||||
# ball fast enough to tunnel through a sticker.
|
||||
BALL_MAX_SPEED = 2000.0
|
||||
|
||||
# Balls collide with each other: overlapping balls push apart and
|
||||
# exchange momentum (heavier balls push lighter ones around). 1.0 =
|
||||
# no energy lost in the collision either, matching the walls/
|
||||
# stickers above.
|
||||
BALL_BALL_COLLISION_ENABLED = True
|
||||
BALL_BALL_RESTITUTION = 1.0
|
||||
|
||||
# Physics runs in equal slices of at most this many seconds, so
|
||||
# nothing tunnels even on a slow frame.
|
||||
PHYSICS_MAX_STEP_SECONDS = 1.0 / 240.0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Ball spawning and stuck balls
|
||||
# ============================================================
|
||||
#
|
||||
# The two settings that matter most, kept together and right up
|
||||
# front so they're easy to find and tune:
|
||||
#
|
||||
# A new ball drops in every BALL_SPAWN_INTERVAL_SECONDS, on its own
|
||||
# clock, completely independent of what earlier balls are doing or
|
||||
# whether they've disappeared yet.
|
||||
BALL_SPAWN_INTERVAL_SECONDS = 2.5
|
||||
|
||||
# ============================================================
|
||||
# Launcher configuration
|
||||
# ============================================================
|
||||
#
|
||||
# Attached to top-center of the screen.
|
||||
# Angles are in degrees relative to straight down (0 = straight down,
|
||||
# negative = left, positive = right).
|
||||
LAUNCHER_MIN_ANGLE = -70.0
|
||||
LAUNCHER_MAX_ANGLE = 70.0
|
||||
|
||||
# Dimensions of the launcher tube in screen pixels.
|
||||
LAUNCHER_LENGTH = 75.0
|
||||
LAUNCHER_WIDTH = 26.0
|
||||
|
||||
# Angular rotation speed (controls continuous smooth movement).
|
||||
LAUNCHER_ROTATION_SPEED = 35.0
|
||||
|
||||
# Initial ball speed when exiting the nozzle (px/s).
|
||||
LAUNCHER_BALL_SPAWN_SPEED = 280.0
|
||||
|
||||
# Every ball is removed (with a little pop effect, see effects.py)
|
||||
# this many seconds after it was spawned, whatever else is
|
||||
# happening to it. 0 = balls live forever (until/unless the stuck-
|
||||
# timeout below removes them instead).
|
||||
BALL_MAX_LIFETIME_SECONDS = 20.0
|
||||
|
||||
# --- Everything below is secondary detail for the two settings above ---
|
||||
|
||||
# Hard ceiling on balls alive at once - only a safety net so trapped
|
||||
# balls can never accumulate without limit over a long show.
|
||||
MAX_SIMULTANEOUS_BALLS = 12
|
||||
|
||||
# Random horizontal drop position. With BALL_SPAWN_RANDOM_X = False
|
||||
# every ball drops from the middle of the screen (the old
|
||||
# behavior). Margin keeps drops away from the side walls.
|
||||
BALL_SPAWN_RANDOM_X = True
|
||||
BALL_SPAWN_MARGIN = 120
|
||||
BALL_SPAWN_VELOCITY_JITTER = 60.0
|
||||
|
||||
# A ball is "stuck" if it has stayed within BALL_STUCK_DISTANCE
|
||||
# pixels of one spot for BALL_STUCK_TIMEOUT_SECONDS - usually a
|
||||
# pocket between stickers, but with every screen edge now a solid
|
||||
# wall (see physics.py) a ball resting on an empty floor with no
|
||||
# obstacles nearby counts too. A stuck ball stays put for that long
|
||||
# (so visitors can see it) and is then removed with a small pop,
|
||||
# freeing its slot, same as the lifetime limit above. 0 = never
|
||||
# remove stuck balls (BALL_MAX_LIFETIME_SECONDS still applies).
|
||||
BALL_STUCK_DISTANCE = 30.0
|
||||
BALL_STUCK_TIMEOUT_SECONDS = 8.0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# White obstacle detection
|
||||
# ============================================================
|
||||
|
||||
# The camera image is warped to screen space at this fraction of
|
||||
# the real screen resolution before looking for stickers. 0.5 makes
|
||||
# detection ~4x cheaper with no meaningful loss of precision (a
|
||||
# sticker is still tens of pixels wide). All the pixel values below
|
||||
# stay in real screen pixels - the code converts.
|
||||
DETECTION_SCALE = 0.5
|
||||
|
||||
# Real sticker size limits, in screen pixels squared.
|
||||
MIN_OBSTACLE_AREA = 1500
|
||||
MAX_OBSTACLE_AREA = 200000
|
||||
|
||||
# --- Finding candidate blobs (deliberately loose) -------------
|
||||
# Pass 1 finds anything vaguely bright and unsaturated. It is meant
|
||||
# to be generous; the strict checks below decide what is a sticker.
|
||||
#
|
||||
# Both "bright" and "unsaturated" are judged in CIELAB, not HSV (see
|
||||
# imaging.py): L (lightness) and chroma (how colorful a pixel is)
|
||||
# are far more independent of each other there than HSV's V and S,
|
||||
# which is what makes the two tests below behave consistently
|
||||
# whether a sticker sits in a shadowed corner or under a spotlight.
|
||||
#
|
||||
# "Bright" is no longer one fixed brightness floor for the whole
|
||||
# frame, either. A single global number means a sticker in dimmer
|
||||
# ambient light can fall below it and never even reach the per-blob
|
||||
# refinement step below - which is exactly what made otherwise
|
||||
# identical stickers seem to be "detected by force" some of the
|
||||
# time. Instead, pass 1 asks a strictly LOCAL question - "is this
|
||||
# pixel clearly brighter than the small neighborhood around it?"
|
||||
# (cv2.adaptiveThreshold) - the same question a human eye asks when
|
||||
# picking out a pale sticker on a dim part of a black screen, and it
|
||||
# holds regardless of the overall exposure level that frame happens
|
||||
# to have. OBSTACLE_ADAPTIVE_OFFSET is how much brighter than that
|
||||
# local neighborhood a pixel must be; OBSTACLE_MIN_ABSOLUTE_LIGHTNESS
|
||||
# is a sanity floor underneath it, so camera sensor noise sitting in
|
||||
# a totally dark area can never be mistaken for "locally brighter".
|
||||
OBSTACLE_ADAPTIVE_OFFSET = 14
|
||||
OBSTACLE_MIN_ABSOLUTE_LIGHTNESS = 55
|
||||
|
||||
# The neighborhood size (screen px) the adaptive threshold above
|
||||
# looks at is tied to the sticker's own size - large enough that the
|
||||
# neighborhood is mostly black screen around the sticker rather than
|
||||
# the sticker's own bright pixels (which would otherwise drag the
|
||||
# local average up and make the sticker fail to stand out against
|
||||
# ITSELF). Used as a multiple of the short side once a size is
|
||||
# known; OBSTACLE_ADAPTIVE_DEFAULT_SHORT_SIDE is only the starting
|
||||
# guess used before any sticker has been seen yet.
|
||||
OBSTACLE_ADAPTIVE_BLOCK_SPAN = 2.4
|
||||
OBSTACLE_ADAPTIVE_DEFAULT_SHORT_SIDE = 90
|
||||
|
||||
# Loose chroma ceiling for pass 1 (and for the per-blob edge
|
||||
# refinement below) - just needs to exclude obviously colored
|
||||
# objects (clothing, skin, colored lighting splashed on the floor),
|
||||
# not pin an exact "how white" number (that is MAX_OBSTACLE_CHROMA
|
||||
# below).
|
||||
OBSTACLE_COARSE_CHROMA_MAX = 45
|
||||
|
||||
# Each blob's edge is then re-drawn at the "half-way" LIGHTNESS
|
||||
# between the sticker's own paper level and the dark screen next to
|
||||
# it. That makes a dim sticker in a shadowy corner and an identical
|
||||
# sticker under a spotlight come out exactly the same size, which is
|
||||
# what makes all stickers look and behave alike.
|
||||
OBSTACLE_EDGE_LEVEL = 0.5
|
||||
|
||||
# --- Deciding whether a blob really is a sticker --------------
|
||||
# Average lightness/chroma INSIDE the blob (CIELAB - see above).
|
||||
MIN_OBSTACLE_BRIGHTNESS = 130
|
||||
MAX_OBSTACLE_CHROMA = 28
|
||||
|
||||
# Fraction of the blob's own best-fit rotated rectangle it fills.
|
||||
# A disc (a ball's glow, a bulb) scores 0.785, so anything above
|
||||
# ~0.84 rejects round things outright.
|
||||
MIN_OBSTACLE_RECTANGULARITY = 0.86
|
||||
|
||||
# Fraction of its convex hull the blob fills (rejects L-shapes,
|
||||
# blobs with bites out of them).
|
||||
MIN_OBSTACLE_SOLIDITY = 0.90
|
||||
|
||||
# Longest side / shortest side. Squares up to index-card shapes.
|
||||
MAX_OBSTACLE_ASPECT_RATIO = 4.0
|
||||
|
||||
# Paper is a uniform, diffuse surface. Reflections and hot-spots
|
||||
# vary a lot in brightness across their surface.
|
||||
MAX_OBSTACLE_BRIGHTNESS_STD = 30.0
|
||||
|
||||
# A real sticker sits on a black screen: this fraction of the ring
|
||||
# of pixels just outside it must be clearly darker than the sticker.
|
||||
MIN_OBSTACLE_DARK_SURROUND_RATIO = 0.55
|
||||
|
||||
# Only look for stickers inside the calibration border, and never
|
||||
# closer than this many extra screen pixels to it (a sticker
|
||||
# touching the bright border would merge with it).
|
||||
OBSTACLE_BORDER_MASK_MARGIN = 4
|
||||
OBSTACLE_EDGE_MARGIN = 5
|
||||
|
||||
# --- All stickers are identical: use that -------------------
|
||||
# Once a few stickers have been seen the system learns their size
|
||||
# and rejects blobs that don't match (within this fraction) - this
|
||||
# is what kills "forced" detections of things that merely look
|
||||
# roughly rectangular. Set OBSTACLE_EXPECTED_SIZE = (long, short)
|
||||
# in screen pixels to pin the size instead of learning it (the
|
||||
# learned size is printed to the console once it has settled).
|
||||
OBSTACLE_SIZE_TOLERANCE = 0.22
|
||||
OBSTACLE_EXPECTED_SIZE = None
|
||||
|
||||
# Two or three identical stickers glued edge to edge form one
|
||||
# bigger rectangle; accept whole multiples of the sticker size.
|
||||
# (For physics a merged rectangle behaves exactly like the parts.)
|
||||
OBSTACLE_ALLOW_MERGED_STICKERS = True
|
||||
|
||||
# Draw every sticker at exactly the learned size when the measured
|
||||
# size is within ~10% of it, so identical stickers give identical
|
||||
# obstacles instead of wobbling by a pixel or two.
|
||||
OBSTACLE_SNAP_TO_EXPECTED_SIZE = True
|
||||
|
||||
# Forget the learned size after this many seconds with no stickers
|
||||
# at all, so a different sticker size can be used later.
|
||||
OBSTACLE_SIZE_FORGET_SECONDS = 30.0
|
||||
|
||||
# Tracks that are already established are kept with slightly
|
||||
# relaxed rules (this fraction of the strict thresholds) so a real
|
||||
# sticker never flickers in and out on borderline frames, while NEW
|
||||
# stickers still have to pass the full strict tests.
|
||||
OBSTACLE_RELAXED_FACTOR = 0.90
|
||||
|
||||
# --- Following stickers over time (each one tracked separately) ---
|
||||
# Consecutive detections needed before a NEW sticker counts.
|
||||
OBSTACLE_REQUIRED_STABLE_FRAMES = 2
|
||||
|
||||
# An established sticker survives this many passes without being
|
||||
# seen (someone's hand in the way). At 0.25 s per pass, 12 = 3 s.
|
||||
OBSTACLE_MAX_MISSING_FRAMES = 12
|
||||
|
||||
# After this many passes without seeing an established sticker, the
|
||||
# game stops PAINTING its white patch (and, a moment later, stops
|
||||
# blanking that area for the detector). Without this, a sticker that
|
||||
# was moved would be hidden by its own old painted patch until it
|
||||
# timed out. If it was moved, it is found at the new spot within a
|
||||
# pass or two; if it was removed, it is dropped at
|
||||
# OBSTACLE_MAX_MISSING_FRAMES as before. Physics keeps using it
|
||||
# either way until then.
|
||||
OBSTACLE_STALE_MISSES = 3
|
||||
|
||||
# Not-yet-confirmed candidates are dropped after this many misses.
|
||||
OBSTACLE_PENDING_MAX_MISSING_FRAMES = 1
|
||||
|
||||
# Max distance (screen px) a sticker may move between two passes
|
||||
# and still be "the same" sticker.
|
||||
OBSTACLE_TEMPORAL_MATCH_DISTANCE = 80
|
||||
|
||||
# Kernel sizes (screen pixels) for cleaning up the blob mask.
|
||||
OBSTACLE_MASK_OPEN_KERNEL = 3
|
||||
OBSTACLE_MASK_CLOSE_KERNEL = 5
|
||||
+1132
File diff suppressed because it is too large
Load Diff
+271
@@ -0,0 +1,271 @@
|
||||
# effects.py
|
||||
#
|
||||
# Everything cosmetic that reacts to the simulation: comet tails,
|
||||
# colour changes on bounce, squash-and-stretch, sparks and rings.
|
||||
# Pure state + math (no pygame, no drawing) - rendering.py draws it.
|
||||
# None of it ever feeds back into physics.
|
||||
#
|
||||
# Driven by two things only: each ball's own state (position,
|
||||
# velocity) and the events physics.py emits (kind, x, y, normal_x,
|
||||
# normal_y, speed, ball) with kind "obstacle", "ball" or "pop".
|
||||
#
|
||||
# Camera safety: rendering only ever draws these with saturated
|
||||
# colours in small dim glows, so the camera looking at this same
|
||||
# monitor never mistakes an effect for a white sticker. Set
|
||||
# VISUAL_EFFECTS_ENABLED = False in config.py to switch it all off.
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
from config import (
|
||||
VISUAL_EFFECTS_ENABLED,
|
||||
BALL_HUE_MIN_DEGREES,
|
||||
BALL_HUE_MAX_DEGREES,
|
||||
BALL_HUE_SHIFT_ON_BOUNCE,
|
||||
BALL_TRAIL_LENGTH,
|
||||
BALL_TRAIL_SAMPLE_SECONDS,
|
||||
BALL_SQUASH_DURATION_SECONDS,
|
||||
IMPACT_EFFECT_MIN_SPEED,
|
||||
MAX_PARTICLES,
|
||||
)
|
||||
|
||||
|
||||
_PARTICLE_GRAVITY = 520.0
|
||||
_PARTICLE_DRAG = 2.2
|
||||
_MAX_RINGS = 24
|
||||
|
||||
# Impact speed (px/s) at which effects reach full strength.
|
||||
_FULL_STRENGTH_SPEED = 1400.0
|
||||
|
||||
|
||||
class Effects:
|
||||
def __init__(self):
|
||||
# Sparks: [x, y, vx, vy, age, life, hue, size]
|
||||
self.particles = []
|
||||
|
||||
# Expanding rings: [x, y, age, life, hue, max_radius]
|
||||
self.rings = []
|
||||
|
||||
self._random = random.Random()
|
||||
|
||||
def clear(self):
|
||||
self.particles = []
|
||||
self.rings = []
|
||||
|
||||
# ========================================================
|
||||
# Update
|
||||
# ========================================================
|
||||
|
||||
def update(self, delta_time, balls, events):
|
||||
if not VISUAL_EFFECTS_ENABLED:
|
||||
return
|
||||
|
||||
delta_time = max(0.0, min(delta_time, 0.05))
|
||||
|
||||
for event in events:
|
||||
self._on_event(event)
|
||||
|
||||
for ball in balls:
|
||||
self._update_ball(ball, delta_time)
|
||||
|
||||
self._update_particles(delta_time)
|
||||
self._update_rings(delta_time)
|
||||
|
||||
# ========================================================
|
||||
# Per-ball state
|
||||
# ========================================================
|
||||
|
||||
def _update_ball(self, ball, dt):
|
||||
if ball.squash > 0.0:
|
||||
ball.squash = max(
|
||||
0.0,
|
||||
ball.squash
|
||||
- dt / max(BALL_SQUASH_DURATION_SECONDS, 1e-3),
|
||||
)
|
||||
|
||||
if BALL_TRAIL_LENGTH <= 0:
|
||||
ball.trail = []
|
||||
return
|
||||
|
||||
ball.trail_timer += dt
|
||||
|
||||
# More than one sample can be due on a slow frame; keep the
|
||||
# tail evenly spaced in TIME regardless of frame rate.
|
||||
while ball.trail_timer >= BALL_TRAIL_SAMPLE_SECONDS:
|
||||
ball.trail_timer -= BALL_TRAIL_SAMPLE_SECONDS
|
||||
|
||||
if ball.dying:
|
||||
# A popping ball's tail drains away.
|
||||
if ball.trail:
|
||||
ball.trail.pop(0)
|
||||
else:
|
||||
ball.trail.append((ball.x, ball.y))
|
||||
|
||||
excess = len(ball.trail) - BALL_TRAIL_LENGTH
|
||||
|
||||
if excess > 0:
|
||||
del ball.trail[:excess]
|
||||
|
||||
@staticmethod
|
||||
def _shift_hue(hue, amount):
|
||||
span = BALL_HUE_MAX_DEGREES - BALL_HUE_MIN_DEGREES
|
||||
|
||||
return (
|
||||
BALL_HUE_MIN_DEGREES
|
||||
+ (hue - BALL_HUE_MIN_DEGREES + amount) % span
|
||||
)
|
||||
|
||||
# ========================================================
|
||||
# Events -> sparks, rings, squash, colour change
|
||||
# ========================================================
|
||||
|
||||
def _on_event(self, event):
|
||||
kind, x, y, nx, ny, speed, ball = event
|
||||
|
||||
if kind == "pop":
|
||||
self._burst(x, y, ball.hue, 16, 80.0, 260.0)
|
||||
self._ring(x, y, ball.hue, ball.radius * 3.0, 0.45)
|
||||
return
|
||||
|
||||
if kind == "launch":
|
||||
self._sparks(x, y, nx, ny, ball.hue, 12, 0.8)
|
||||
self._ring(x, y, ball.hue, ball.radius * 2.2, 0.25)
|
||||
return
|
||||
|
||||
strength = min(1.0, speed / _FULL_STRENGTH_SPEED)
|
||||
|
||||
ball.squash = 1.0
|
||||
ball.squash_nx = nx
|
||||
ball.squash_ny = ny
|
||||
|
||||
if kind == "obstacle":
|
||||
ball.hue = self._shift_hue(
|
||||
ball.hue, BALL_HUE_SHIFT_ON_BOUNCE
|
||||
)
|
||||
|
||||
self._sparks(
|
||||
x,
|
||||
y,
|
||||
nx,
|
||||
ny,
|
||||
ball.hue,
|
||||
int(4 + 10 * strength),
|
||||
strength,
|
||||
)
|
||||
|
||||
self._ring(
|
||||
x,
|
||||
y,
|
||||
ball.hue,
|
||||
ball.radius * (1.6 + 1.6 * strength),
|
||||
0.32,
|
||||
)
|
||||
else:
|
||||
self._burst(
|
||||
x,
|
||||
y,
|
||||
ball.hue,
|
||||
int(3 + 6 * strength),
|
||||
60.0,
|
||||
140.0 + 200.0 * strength,
|
||||
)
|
||||
|
||||
self._ring(
|
||||
x,
|
||||
y,
|
||||
ball.hue,
|
||||
ball.radius * (1.3 + 1.0 * strength),
|
||||
0.25,
|
||||
)
|
||||
|
||||
def _sparks(self, x, y, nx, ny, hue, count, strength):
|
||||
rng = self._random
|
||||
|
||||
base = math.atan2(ny, nx)
|
||||
|
||||
for _ in range(count):
|
||||
angle = base + rng.uniform(-1.25, 1.25)
|
||||
|
||||
speed = rng.uniform(
|
||||
140.0,
|
||||
260.0 + 420.0 * strength,
|
||||
)
|
||||
|
||||
self._add_particle(
|
||||
x,
|
||||
y,
|
||||
math.cos(angle) * speed,
|
||||
math.sin(angle) * speed,
|
||||
rng.uniform(0.35, 0.8),
|
||||
hue + rng.uniform(-14.0, 14.0),
|
||||
rng.uniform(2.5, 5.5),
|
||||
)
|
||||
|
||||
def _burst(self, x, y, hue, count, speed_min, speed_max):
|
||||
rng = self._random
|
||||
|
||||
for _ in range(count):
|
||||
angle = rng.uniform(0.0, 2.0 * math.pi)
|
||||
speed = rng.uniform(speed_min, speed_max)
|
||||
|
||||
self._add_particle(
|
||||
x,
|
||||
y,
|
||||
math.cos(angle) * speed,
|
||||
math.sin(angle) * speed,
|
||||
rng.uniform(0.3, 0.7),
|
||||
hue + rng.uniform(-18.0, 18.0),
|
||||
rng.uniform(2.5, 5.0),
|
||||
)
|
||||
|
||||
def _add_particle(self, x, y, vx, vy, life, hue, size):
|
||||
if len(self.particles) >= MAX_PARTICLES:
|
||||
# Drop the oldest so a busy moment never grows without
|
||||
# bound.
|
||||
del self.particles[0]
|
||||
|
||||
self.particles.append(
|
||||
[x, y, vx, vy, 0.0, life, hue, size]
|
||||
)
|
||||
|
||||
def _ring(self, x, y, hue, max_radius, life):
|
||||
if len(self.rings) >= _MAX_RINGS:
|
||||
del self.rings[0]
|
||||
|
||||
self.rings.append([x, y, 0.0, life, hue, max_radius])
|
||||
|
||||
# ========================================================
|
||||
# Ageing
|
||||
# ========================================================
|
||||
|
||||
def _update_particles(self, dt):
|
||||
alive = []
|
||||
|
||||
drag = max(0.0, 1.0 - _PARTICLE_DRAG * dt)
|
||||
|
||||
for particle in self.particles:
|
||||
particle[4] += dt
|
||||
|
||||
if particle[4] >= particle[5]:
|
||||
continue
|
||||
|
||||
particle[3] += _PARTICLE_GRAVITY * dt
|
||||
particle[2] *= drag
|
||||
particle[3] *= drag
|
||||
particle[0] += particle[2] * dt
|
||||
particle[1] += particle[3] * dt
|
||||
|
||||
alive.append(particle)
|
||||
|
||||
self.particles = alive
|
||||
|
||||
def _update_rings(self, dt):
|
||||
alive = []
|
||||
|
||||
for ring in self.rings:
|
||||
ring[2] += dt
|
||||
|
||||
if ring[2] < ring[3]:
|
||||
alive.append(ring)
|
||||
|
||||
self.rings = alive
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
# geometry.py
|
||||
#
|
||||
# Small, shared helpers for working with ROTATED rectangles (an
|
||||
# "OBB" - oriented bounding box) in screen space: a rectangle
|
||||
# stickers can be glued on crooked, so an obstacle's position is no
|
||||
# longer just an axis-aligned (x, y, width, height) - it also has
|
||||
# an angle. This module is the single place that knows how to:
|
||||
#
|
||||
# - turn a raw cv2.minAreaRect() result into a STABLE, canonical
|
||||
# (center, long side, short side, angle) representation that
|
||||
# doesn't jitter or jump between frames (see normalize_angle
|
||||
# and detection.py for why that's needed),
|
||||
# - blend/interpolate that angle smoothly across frames
|
||||
# (blend_angle),
|
||||
# - convert between world space and the rectangle's own local,
|
||||
# axis-aligned frame (world_to_local / local_to_world) - this
|
||||
# is what lets both collision detection AND collision response
|
||||
# be written as plain axis-aligned math that "just works" for
|
||||
# any rotation, by doing the rotation once at the boundary
|
||||
# instead of re-deriving rotated formulas everywhere,
|
||||
# - compute the 4 corners of a rectangle for drawing
|
||||
# (obb_corners),
|
||||
# - pull all of the above out of a detected-obstacle dict in one
|
||||
# place, with one shared definition of what counts as a valid
|
||||
# obstacle (extract_obb), so detection/physics/rendering can
|
||||
# never disagree about it.
|
||||
#
|
||||
# Angle convention: an un-rotated rectangle (long side horizontal)
|
||||
# has angle 0. Positive angles rotate the long side counter-
|
||||
# clockwise in standard math coordinates - visually CLOCKWISE on
|
||||
# screen, since screen y grows downward. Angles are always kept in
|
||||
# the canonical range (-90, 90], because a rectangle looks
|
||||
# identical after a 180-degree rotation (it has no "arrow" printed
|
||||
# on it), so anything outside that range is a redundant duplicate
|
||||
# of an angle already inside it. Collapsing to one canonical range
|
||||
# is what keeps blend_angle's shortest-path interpolation well
|
||||
# defined instead of it occasionally spinning the "wrong" way.
|
||||
|
||||
import math
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Angle canonicalization
|
||||
# ============================================================
|
||||
|
||||
def normalize_angle(angle_degrees, period=180.0):
|
||||
"""
|
||||
Fold an orientation angle into the canonical (-period/2,
|
||||
period/2] range.
|
||||
|
||||
A rectangle has 180-degree rotational symmetry, so any angle is
|
||||
equivalent to one inside a 180-degree-wide window (period=180).
|
||||
A (nearly) SQUARE sticker is even more symmetric - it looks the
|
||||
same after every 90 degrees - so for those callers pass
|
||||
period=90. Always folding into the SAME window is what makes
|
||||
two angles describing the same physical orientation compare and
|
||||
blend as equal instead of looking like they are far apart.
|
||||
"""
|
||||
half = period / 2.0
|
||||
|
||||
angle = angle_degrees % period
|
||||
|
||||
if angle > half:
|
||||
angle -= period
|
||||
|
||||
return angle
|
||||
|
||||
|
||||
def blend_angle(old_angle, new_angle, alpha, period=180.0):
|
||||
"""
|
||||
Move old_angle toward new_angle by fraction alpha along the
|
||||
SHORTEST path, for orientations that repeat every `period`
|
||||
degrees.
|
||||
|
||||
A naive `old + alpha * (new - old)` breaks near the wrap-around
|
||||
point: 89 degrees and -89 degrees are really only 2 degrees
|
||||
apart for a rectangle, but the naive subtraction sees 178 and
|
||||
blends the wrong way (and for a near-square sticker, averaging
|
||||
across its 90-degree ambiguity would even produce a completely
|
||||
wrong in-between angle). Wrapping the raw difference into
|
||||
(-period/2, period/2] first avoids both.
|
||||
"""
|
||||
half = period / 2.0
|
||||
|
||||
diff = ((new_angle - old_angle + half) % period) - half
|
||||
|
||||
return normalize_angle(old_angle + alpha * diff, period)
|
||||
|
||||
|
||||
def paint_dimensions(rect_long, rect_short, inset_ratio):
|
||||
"""
|
||||
Size of the white patch painted on the monitor for a detected
|
||||
sticker: the sticker trimmed by inset_ratio * short side on every
|
||||
edge. Shared by rendering (to draw it) and detection (to blank
|
||||
exactly that area out before looking for stickers) so the two can
|
||||
never disagree about what was painted.
|
||||
"""
|
||||
inset = inset_ratio * rect_short
|
||||
|
||||
return (
|
||||
max(1.0, rect_long - 2.0 * inset),
|
||||
max(1.0, rect_short - 2.0 * inset),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# World <-> local (rectangle-aligned) frame conversion
|
||||
# ============================================================
|
||||
|
||||
def world_to_local(x, y, angle_degrees):
|
||||
"""
|
||||
Rotate a world-space vector (x, y) by -angle_degrees, i.e. into
|
||||
the rectangle's own local frame, where its long side lies along
|
||||
the local x-axis and its short side along the local y-axis.
|
||||
|
||||
This takes a VECTOR, not a point - to transform a point, first
|
||||
subtract the rectangle's center, call this, and the result is
|
||||
the point's position relative to the rectangle in its own
|
||||
frame. The same function (with no subtraction) also correctly
|
||||
rotates direction-only quantities like velocity, since rotation
|
||||
doesn't care about position.
|
||||
"""
|
||||
theta = math.radians(angle_degrees)
|
||||
|
||||
cos_t = math.cos(theta)
|
||||
sin_t = math.sin(theta)
|
||||
|
||||
return (
|
||||
x * cos_t + y * sin_t,
|
||||
-x * sin_t + y * cos_t,
|
||||
)
|
||||
|
||||
|
||||
def local_to_world(x, y, angle_degrees):
|
||||
"""
|
||||
The exact inverse of world_to_local: rotate a local-frame
|
||||
vector back by +angle_degrees into world space.
|
||||
"""
|
||||
theta = math.radians(angle_degrees)
|
||||
|
||||
cos_t = math.cos(theta)
|
||||
sin_t = math.sin(theta)
|
||||
|
||||
return (
|
||||
x * cos_t - y * sin_t,
|
||||
x * sin_t + y * cos_t,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Corners (for drawing / debugging)
|
||||
# ============================================================
|
||||
|
||||
def obb_corners(center_x, center_y, rect_long, rect_short, angle_degrees):
|
||||
"""
|
||||
Return the 4 corners of a rotated rectangle, in order around
|
||||
its perimeter, as world-space (x, y) tuples. Safe to hand
|
||||
straight to pygame.draw.polygon or cv2.polylines.
|
||||
"""
|
||||
half_long = rect_long / 2.0
|
||||
half_short = rect_short / 2.0
|
||||
|
||||
local_corners = (
|
||||
(half_long, half_short),
|
||||
(half_long, -half_short),
|
||||
(-half_long, -half_short),
|
||||
(-half_long, half_short),
|
||||
)
|
||||
|
||||
corners = []
|
||||
|
||||
for local_x, local_y in local_corners:
|
||||
world_dx, world_dy = local_to_world(
|
||||
local_x,
|
||||
local_y,
|
||||
angle_degrees,
|
||||
)
|
||||
|
||||
corners.append(
|
||||
(
|
||||
center_x + world_dx,
|
||||
center_y + world_dy,
|
||||
)
|
||||
)
|
||||
|
||||
return corners
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Obstacle dict -> OBB extraction
|
||||
# ============================================================
|
||||
|
||||
def extract_obb(obstacle):
|
||||
"""
|
||||
Pull (center_x, center_y, rect_long, rect_short, angle) out of
|
||||
a detected-obstacle dict, or return None if the obstacle is
|
||||
missing/malformed/degenerate. This is the ONE place that
|
||||
decides what counts as a usable oriented rectangle, so physics,
|
||||
rendering, and validity-checking can never disagree with each
|
||||
other about it.
|
||||
|
||||
Falls back to treating the obstacle as an axis-aligned
|
||||
(angle=0) rectangle built from x/y/width/height if it has no
|
||||
"center"/"rect_long"/"rect_short"/"angle" fields, so any code
|
||||
that still hands in a plain axis-aligned obstacle dict keeps
|
||||
working exactly as before.
|
||||
"""
|
||||
if not isinstance(obstacle, dict):
|
||||
return None
|
||||
|
||||
try:
|
||||
if "center" in obstacle:
|
||||
center_x, center_y = obstacle["center"]
|
||||
else:
|
||||
center_x = (
|
||||
obstacle["x"] + obstacle["width"] / 2.0
|
||||
)
|
||||
center_y = (
|
||||
obstacle["y"] + obstacle["height"] / 2.0
|
||||
)
|
||||
|
||||
rect_long = obstacle.get(
|
||||
"rect_long",
|
||||
obstacle.get("width"),
|
||||
)
|
||||
rect_short = obstacle.get(
|
||||
"rect_short",
|
||||
obstacle.get("height"),
|
||||
)
|
||||
angle = obstacle.get("angle", 0.0)
|
||||
|
||||
center_x = float(center_x)
|
||||
center_y = float(center_y)
|
||||
rect_long = float(rect_long)
|
||||
rect_short = float(rect_short)
|
||||
angle = float(angle)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if not all(
|
||||
math.isfinite(value)
|
||||
for value in (
|
||||
center_x,
|
||||
center_y,
|
||||
rect_long,
|
||||
rect_short,
|
||||
angle,
|
||||
)
|
||||
):
|
||||
return None
|
||||
|
||||
if rect_long <= 0 or rect_short <= 0:
|
||||
return None
|
||||
|
||||
return center_x, center_y, rect_long, rect_short, angle
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# imaging.py
|
||||
#
|
||||
# Small, shared OpenCV helpers for illumination-robust color work,
|
||||
# used by both calibration.py (finding the monitor) and
|
||||
# detection.py (finding the white stickers).
|
||||
#
|
||||
# Both work in CIELAB rather than HSV, for one concrete reason: LAB
|
||||
# separates brightness (L) from true color (a, b) far more cleanly
|
||||
# than HSV does. A glare highlight, a shadow, a dimmer corner of the
|
||||
# hall, or the camera's own auto white-balance drifting under
|
||||
# exhibition lighting - all of these move L a lot while barely
|
||||
# moving a/b. That is exactly the property needed to answer "is
|
||||
# this green?" or "is this a neutral white/gray?" in a way that
|
||||
# stays true regardless of how bright, dim, or unevenly lit that
|
||||
# particular pixel happens to be right now - which is the real
|
||||
# source of "the camera doesn't quite see what the monitor is
|
||||
# actually showing": most of that gap is a brightness/exposure
|
||||
# effect, not a pure color-chemistry one, and LAB is built to shrug
|
||||
# that off.
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def to_lab(frame_bgr):
|
||||
"""
|
||||
(L, a, b), each a 2D uint8 array in OpenCV's 0-255 LAB
|
||||
encoding: L is lightness (0=black, 255=white), a is the
|
||||
green(low)-red(high) axis centered at 128, b is the
|
||||
blue(low)-yellow(high) axis centered at 128.
|
||||
"""
|
||||
lab = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2LAB)
|
||||
|
||||
return lab[:, :, 0], lab[:, :, 1], lab[:, :, 2]
|
||||
|
||||
|
||||
def chroma(a_channel, b_channel, reference_a=128.0, reference_b=128.0):
|
||||
"""
|
||||
How colorful a pixel is, independent of how bright or dark it
|
||||
is - near 0 at the reference point, larger for anything further
|
||||
away from it in LAB's roughly perceptually-even a/b plane.
|
||||
Unlike HSV saturation (a ratio that gets noisy and unstable for
|
||||
dark pixels, since it is normalized by brightness itself), this
|
||||
stays meaningful whether a pixel is in a bright spot or a
|
||||
shadowed corner.
|
||||
|
||||
The reference defaults to (128, 128) - LAB's own neutral gray -
|
||||
but callers that have a measured "what does a KNOWN-white
|
||||
surface look like to this camera right now" point (see
|
||||
Calibration.white_reference) should pass it in instead. Under
|
||||
colored ambient light even genuinely white paper is NOT neutral
|
||||
gray to the camera - that is unavoidable physics, not a flaw in
|
||||
the camera - so testing distance from true neutral would wrongly
|
||||
reject it. Testing distance from "whatever white looks like
|
||||
right now" sidesteps that entirely, the same way a photographer
|
||||
custom white-balances off a gray card instead of trusting a
|
||||
fixed assumption about what "white" means.
|
||||
"""
|
||||
da = a_channel.astype(np.float32) - reference_a
|
||||
db = b_channel.astype(np.float32) - reference_b
|
||||
|
||||
return np.sqrt(da ** 2 + db ** 2)
|
||||
|
||||
|
||||
def green_dominance(a_channel):
|
||||
"""
|
||||
How far into "green" territory a pixel sits on LAB's red-green
|
||||
axis, clipped to [0, 255]. Green is the LOW end of the a
|
||||
channel (a < 128), so this is just how far below 128 the pixel
|
||||
is. Using this one, roughly illumination-independent channel
|
||||
instead of comparing raw R/G/B brightness is what lets the
|
||||
calibration pattern still be found once the camera's own white
|
||||
balance has shifted its recorded color away from what the
|
||||
monitor is actually displaying.
|
||||
"""
|
||||
return np.clip(
|
||||
128 - a_channel.astype(np.int16),
|
||||
0,
|
||||
255,
|
||||
).astype(np.uint8)
|
||||
|
||||
|
||||
def fill_holes(mask):
|
||||
"""
|
||||
Fills any fully-enclosed hole in a binary mask (0/255) - e.g. a
|
||||
glare highlight punching a small gap in the middle of an
|
||||
otherwise solid colored region, or a reflection splitting a
|
||||
sticker's blob in two. Standard flood-fill-from-the-border
|
||||
trick: anything NOT reachable from the mask's own border by
|
||||
walking through zero pixels must be an enclosed hole, so it
|
||||
gets filled in. A real gap that reaches all the way out to the
|
||||
mask's edge (e.g. a person actually standing in front of part
|
||||
of the pattern) stays untouched, since it IS reachable from the
|
||||
border.
|
||||
"""
|
||||
height, width = mask.shape[:2]
|
||||
|
||||
# Padding with a guaranteed-zero 1px border first means the
|
||||
# flood-fill seed point is always background, even if the real
|
||||
# mask happens to already be foreground right at image corner
|
||||
# (0, 0) - otherwise floodFill would treat that corner's own
|
||||
# color as the "background" to flood, filling nothing at all.
|
||||
padded = cv2.copyMakeBorder(
|
||||
mask,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
cv2.BORDER_CONSTANT,
|
||||
value=0,
|
||||
)
|
||||
|
||||
flood = padded.copy()
|
||||
|
||||
flood_fill_mask = np.zeros(
|
||||
(height + 4, width + 4),
|
||||
np.uint8,
|
||||
)
|
||||
|
||||
cv2.floodFill(flood, flood_fill_mask, (0, 0), 255)
|
||||
|
||||
flood = flood[1:-1, 1:-1]
|
||||
|
||||
return mask | cv2.bitwise_not(flood)
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
# launcher.py
|
||||
#
|
||||
# Represents the top-center launcher tube.
|
||||
#
|
||||
# Features:
|
||||
# - Pivot point attached at top-center of the screen (x = width / 2, y = -15),
|
||||
# extending above the screen top so it is clipped by the screen boundary.
|
||||
# - Consists ONLY of a short, elongated solid tube/barrel (no base/tank/turret).
|
||||
# - Continuous smooth rotation between LAUNCHER_MIN_ANGLE and LAUNCHER_MAX_ANGLE.
|
||||
# - Provides collision parameters for PhysicsWorld (_Solid OBB).
|
||||
# - Provides ball spawn transform (position + velocity) outward from nozzle exit.
|
||||
|
||||
import math
|
||||
|
||||
from config import (
|
||||
LAUNCHER_MIN_ANGLE,
|
||||
LAUNCHER_MAX_ANGLE,
|
||||
LAUNCHER_LENGTH,
|
||||
LAUNCHER_WIDTH,
|
||||
LAUNCHER_ROTATION_SPEED,
|
||||
LAUNCHER_BALL_SPAWN_SPEED,
|
||||
BALL_RADIUS,
|
||||
BALL_RADIUS_VARIATION,
|
||||
)
|
||||
|
||||
|
||||
class Launcher:
|
||||
def __init__(self, screen_width, screen_height):
|
||||
self.screen_width = float(screen_width)
|
||||
self.screen_height = float(screen_height)
|
||||
|
||||
# Pivot point at top center. Placed slightly above y=0 (-15px) so the upper part
|
||||
# extends beyond the top of the screen and is clipped by the screen boundary.
|
||||
self.pivot_x = self.screen_width / 2.0
|
||||
self.pivot_y = -15.0
|
||||
|
||||
self.min_angle = float(LAUNCHER_MIN_ANGLE)
|
||||
self.max_angle = float(LAUNCHER_MAX_ANGLE)
|
||||
self.length = float(LAUNCHER_LENGTH)
|
||||
self.tube_width = float(LAUNCHER_WIDTH)
|
||||
self.rotation_speed = float(LAUNCHER_ROTATION_SPEED)
|
||||
self.spawn_speed = float(LAUNCHER_BALL_SPAWN_SPEED)
|
||||
|
||||
self._time = 0.0
|
||||
self.angle = 0.0 # degrees from straight down (0=down, negative=left, positive=right)
|
||||
self._update_angle()
|
||||
|
||||
def update_screen_size(self, width, height):
|
||||
self.screen_width = float(width)
|
||||
self.screen_height = float(height)
|
||||
self.pivot_x = self.screen_width / 2.0
|
||||
|
||||
def update(self, delta_time):
|
||||
self._time += delta_time
|
||||
self._update_angle()
|
||||
|
||||
def _update_angle(self):
|
||||
mid_angle = (self.min_angle + self.max_angle) / 2.0
|
||||
amplitude = (self.max_angle - self.min_angle) / 2.0
|
||||
|
||||
if amplitude <= 0:
|
||||
self.angle = mid_angle
|
||||
return
|
||||
|
||||
# Smooth harmonic oscillation within [min_angle, max_angle]
|
||||
period = max(0.1, (4.0 * amplitude) / max(1.0, self.rotation_speed))
|
||||
omega = (2.0 * math.pi) / period
|
||||
|
||||
sine_val = math.sin(omega * self._time)
|
||||
subtle_wave = 0.85 * sine_val + 0.15 * math.sin(omega * 0.5 * self._time)
|
||||
|
||||
self.angle = mid_angle + amplitude * max(-1.0, min(1.0, subtle_wave))
|
||||
|
||||
def get_solid_params(self):
|
||||
"""
|
||||
Returns (cx, cy, rect_long, rect_short, angle) for creating a _Solid in physics.
|
||||
"""
|
||||
rad = math.radians(self.angle)
|
||||
dir_x = math.sin(rad)
|
||||
dir_y = math.cos(rad)
|
||||
|
||||
cx = self.pivot_x + dir_x * (self.length / 2.0)
|
||||
cy = self.pivot_y + dir_y * (self.length / 2.0)
|
||||
|
||||
# OBB angle in geometry convention: 90 degrees minus launcher angle
|
||||
obb_angle = 90.0 - self.angle
|
||||
|
||||
return cx, cy, self.length, self.tube_width, obb_angle
|
||||
|
||||
def get_spawn_transform(self):
|
||||
"""
|
||||
Returns (spawn_x, spawn_y, dir_x, dir_y) for spawning a ball outward
|
||||
along the nozzle direction, positioned slightly outside the nozzle tip.
|
||||
"""
|
||||
rad = math.radians(self.angle)
|
||||
dir_x = math.sin(rad)
|
||||
dir_y = math.cos(rad)
|
||||
|
||||
max_ball_radius = BALL_RADIUS * (1.0 + BALL_RADIUS_VARIATION)
|
||||
safe_dist = self.length + max_ball_radius + 3.0
|
||||
|
||||
spawn_x = self.pivot_x + dir_x * safe_dist
|
||||
spawn_y = self.pivot_y + dir_y * safe_dist
|
||||
|
||||
return spawn_x, spawn_y, dir_x, dir_y
|
||||
|
||||
def get_points(self):
|
||||
"""
|
||||
Returns the 4 corner points of the launcher tube rectangle.
|
||||
"""
|
||||
rad = math.radians(self.angle)
|
||||
u_x = math.sin(rad)
|
||||
u_y = math.cos(rad)
|
||||
|
||||
v_x = -u_y
|
||||
v_y = u_x
|
||||
|
||||
half_w = self.tube_width / 2.0
|
||||
length = self.length
|
||||
|
||||
p_top_left = (
|
||||
self.pivot_x - v_x * half_w,
|
||||
self.pivot_y - v_y * half_w,
|
||||
)
|
||||
p_top_right = (
|
||||
self.pivot_x + v_x * half_w,
|
||||
self.pivot_y + v_y * half_w,
|
||||
)
|
||||
p_bot_right = (
|
||||
self.pivot_x + u_x * length + v_x * half_w,
|
||||
self.pivot_y + u_y * length + v_y * half_w,
|
||||
)
|
||||
p_bot_left = (
|
||||
self.pivot_x + u_x * length - v_x * half_w,
|
||||
self.pivot_y + u_y * length - v_y * half_w,
|
||||
)
|
||||
|
||||
return [p_top_left, p_top_right, p_bot_right, p_bot_left]
|
||||
@@ -0,0 +1,274 @@
|
||||
# main.py
|
||||
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import pygame
|
||||
|
||||
from camera import Camera
|
||||
from calibration import Calibration
|
||||
from detection import Detector
|
||||
from vision import Vision
|
||||
from physics import PhysicsWorld
|
||||
from effects import Effects
|
||||
from rendering import Renderer
|
||||
|
||||
from config import (
|
||||
CAMERA_URL,
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
BALL_SPAWN_INTERVAL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
# If a spawn attempt fails (too many balls in play, or another ball
|
||||
# is sitting right at the drop point), try again after this short
|
||||
# delay instead of waiting a whole spawn interval.
|
||||
_SPAWN_RETRY_SECONDS = 0.25
|
||||
|
||||
_WARNING_INTERVAL_SECONDS = 5.0
|
||||
|
||||
|
||||
class Game:
|
||||
# ========================================================
|
||||
# Initialization
|
||||
# ========================================================
|
||||
|
||||
def __init__(self):
|
||||
# Connecting happens on the camera's own thread, so the
|
||||
# window below comes up (and stays responsive to the
|
||||
# keyboard) even if the phone isn't streaming yet.
|
||||
self.camera = Camera(CAMERA_URL)
|
||||
|
||||
self.calibration = Calibration()
|
||||
self.detector = Detector()
|
||||
|
||||
# Camera -> calibration -> sticker detection, all on a
|
||||
# background thread; the loop below only ever reads its
|
||||
# latest published result.
|
||||
self.vision = Vision(
|
||||
self.camera,
|
||||
self.calibration,
|
||||
self.detector,
|
||||
)
|
||||
|
||||
self.physics = PhysicsWorld(
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
)
|
||||
|
||||
self.effects = Effects()
|
||||
|
||||
self.renderer = Renderer()
|
||||
|
||||
self.running = True
|
||||
|
||||
self.was_calibrated = False
|
||||
|
||||
# Very negative so the first ball drops the moment
|
||||
# calibration locks on.
|
||||
self.last_ball_spawn = -1e9
|
||||
|
||||
self.last_warning = 0.0
|
||||
|
||||
self.vision.start()
|
||||
|
||||
# ========================================================
|
||||
# Main loop
|
||||
# ========================================================
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
while self.running:
|
||||
self._handle_events()
|
||||
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
# The per-frame pipeline is wrapped defensively:
|
||||
# this runs unattended in a busy exhibition hall,
|
||||
# and one unexpected error must never take the
|
||||
# installation down. Worst case we skip a frame.
|
||||
try:
|
||||
self._run_one_frame()
|
||||
except Exception as error:
|
||||
print(
|
||||
"[ERROR] unexpected failure in main "
|
||||
f"loop, continuing: {error!r}"
|
||||
)
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
def _run_one_frame(self):
|
||||
(
|
||||
calibrated,
|
||||
frozen,
|
||||
obstacles,
|
||||
message,
|
||||
) = self.vision.snapshot()
|
||||
|
||||
current_time = time.monotonic()
|
||||
|
||||
self._maybe_warn(current_time, frozen, message)
|
||||
|
||||
if not calibrated:
|
||||
if self.was_calibrated:
|
||||
print(
|
||||
"[CALIBRATION] lost/reset - "
|
||||
"waiting for the screen to be recognized..."
|
||||
)
|
||||
|
||||
self.was_calibrated = False
|
||||
|
||||
self.renderer.render_calibration()
|
||||
|
||||
# Nothing moves while calibrating; no need to spin.
|
||||
self.renderer.clock.tick(30)
|
||||
|
||||
return
|
||||
|
||||
if not self.was_calibrated:
|
||||
print("[CALIBRATION] locked on - starting game.")
|
||||
|
||||
self.was_calibrated = True
|
||||
|
||||
delta_time = self.renderer.get_delta_time()
|
||||
|
||||
self.physics.update(delta_time, obstacles)
|
||||
|
||||
self._spawn_ball(current_time)
|
||||
|
||||
self.effects.update(
|
||||
delta_time,
|
||||
self.physics.balls,
|
||||
self.physics.events,
|
||||
)
|
||||
|
||||
self.renderer.render(
|
||||
self.physics.balls,
|
||||
obstacles,
|
||||
self.effects,
|
||||
self.physics.launcher,
|
||||
)
|
||||
|
||||
# ========================================================
|
||||
# Warnings
|
||||
# ========================================================
|
||||
|
||||
def _maybe_warn(self, current_time, frozen, message):
|
||||
if (
|
||||
current_time - self.last_warning
|
||||
< _WARNING_INTERVAL_SECONDS
|
||||
):
|
||||
return
|
||||
|
||||
if frozen:
|
||||
print(
|
||||
"[WARNING] Camera feed looks FROZEN "
|
||||
"(no motion detected for a while) - "
|
||||
"check the phone/app/connection. "
|
||||
"Calibration and obstacle detection are "
|
||||
"paused until a live frame is seen again."
|
||||
)
|
||||
elif message:
|
||||
print(f"[WARNING] {message}")
|
||||
else:
|
||||
return
|
||||
|
||||
self.last_warning = current_time
|
||||
|
||||
# ========================================================
|
||||
# Ball spawning
|
||||
# ========================================================
|
||||
|
||||
def _spawn_ball(self, current_time):
|
||||
# Balls arrive on their OWN clock, one every
|
||||
# BALL_SPAWN_INTERVAL_SECONDS, whether or not earlier balls
|
||||
# have left. A ball wedged between stickers just stays
|
||||
# (until the physics removes it as stuck) and the next one
|
||||
# still comes on schedule.
|
||||
if (
|
||||
current_time - self.last_ball_spawn
|
||||
< BALL_SPAWN_INTERVAL_SECONDS
|
||||
):
|
||||
return
|
||||
|
||||
if self.physics.spawn_ball() is not None:
|
||||
self.last_ball_spawn = current_time
|
||||
else:
|
||||
self.last_ball_spawn = (
|
||||
current_time
|
||||
- BALL_SPAWN_INTERVAL_SECONDS
|
||||
+ _SPAWN_RETRY_SECONDS
|
||||
)
|
||||
|
||||
# ========================================================
|
||||
# Events
|
||||
# ========================================================
|
||||
|
||||
def _handle_events(self):
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT:
|
||||
self.running = False
|
||||
|
||||
elif event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_q:
|
||||
self.running = False
|
||||
|
||||
elif event.key in (
|
||||
pygame.K_c,
|
||||
pygame.K_ESCAPE,
|
||||
):
|
||||
self._reset_calibration()
|
||||
|
||||
# ========================================================
|
||||
# Calibration reset
|
||||
# ========================================================
|
||||
|
||||
def _reset_calibration(self):
|
||||
self.vision.request_reset()
|
||||
|
||||
self.physics.clear()
|
||||
|
||||
self.effects.clear()
|
||||
|
||||
self.renderer.force_full_repaint()
|
||||
|
||||
self.last_ball_spawn = -1e9
|
||||
|
||||
# ========================================================
|
||||
# Shutdown
|
||||
# ========================================================
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.vision.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.camera.release()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
cv2.destroyAllWindows()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.renderer.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
game = Game()
|
||||
game.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+747
@@ -0,0 +1,747 @@
|
||||
# physics.py
|
||||
#
|
||||
# Pure simulation - no pygame, no OpenCV, no drawing. Everything the
|
||||
# viewer sees (trails, glow, sparks, squash) is derived from this
|
||||
# module's state and the events it emits, in effects.py.
|
||||
#
|
||||
# What is simulated:
|
||||
# - balls falling under gravity, with slightly different sizes
|
||||
# (bigger = heavier),
|
||||
# - bouncing off detected stickers, which are ROTATED rectangles:
|
||||
# the bounce uses the real surface normal (flat edge or corner)
|
||||
# of the tilted sticker, with restitution (how bouncy) and
|
||||
# friction (so balls roll along slopes instead of skating),
|
||||
# - balls bouncing off each other,
|
||||
# - ALL FOUR screen edges as solid walls - a ball never leaves the
|
||||
# screen, it bounces off the top/bottom/left/right forever,
|
||||
# - by default none of the above bounces lose any energy
|
||||
# (BALL_RESTITUTION = BALL_BALL_RESTITUTION = 1.0, BALL_FRICTION
|
||||
# = 0 in config.py), so with gravity a ball just keeps bouncing
|
||||
# indefinitely like an ideal rubber ball, instead of gradually
|
||||
# settling - lower these if a more "real rubber ball" feel that
|
||||
# loses a little height each bounce is wanted instead,
|
||||
# - a ball is only ever removed in one of two ways, each
|
||||
# independently configurable in config.py and each with its own
|
||||
# little pop effect: it reaches BALL_MAX_LIFETIME_SECONDS after
|
||||
# being spawned, or it is "stuck" (parked in one spot, usually a
|
||||
# pocket between stickers) for BALL_STUCK_TIMEOUT_SECONDS.
|
||||
#
|
||||
# Performance notes (this runs 144 times a second):
|
||||
# - obstacle dicts are converted ONCE per detection update into
|
||||
# lightweight _Solid records with cos/sin already computed,
|
||||
# - each ball first does a cheap circle-vs-circle reject against
|
||||
# each solid's bounding circle before any real collision math,
|
||||
# - time is split into equal slices no longer than
|
||||
# PHYSICS_MAX_STEP_SECONDS (and short enough that a fast ball
|
||||
# can't skip over a sticker), so motion stays smooth and
|
||||
# proportional to real elapsed time.
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
from geometry import extract_obb
|
||||
from launcher import Launcher
|
||||
|
||||
from config import (
|
||||
BALL_RADIUS,
|
||||
BALL_RADIUS_VARIATION,
|
||||
BALL_GRAVITY,
|
||||
BALL_RESTITUTION,
|
||||
BALL_REST_SPEED,
|
||||
BALL_FRICTION,
|
||||
BALL_MAX_SPEED,
|
||||
BALL_BALL_COLLISION_ENABLED,
|
||||
BALL_BALL_RESTITUTION,
|
||||
PHYSICS_MAX_STEP_SECONDS,
|
||||
MAX_SIMULTANEOUS_BALLS,
|
||||
BALL_STUCK_DISTANCE,
|
||||
BALL_STUCK_TIMEOUT_SECONDS,
|
||||
BALL_MAX_LIFETIME_SECONDS,
|
||||
BALL_HUE_MIN_DEGREES,
|
||||
BALL_HUE_MAX_DEGREES,
|
||||
IMPACT_EFFECT_MIN_SPEED,
|
||||
)
|
||||
|
||||
|
||||
# Continuous slow drag (per second) applied to the sliding speed of
|
||||
# a ball that is resting/rolling ON a surface, so it doesn't glide
|
||||
# forever on a slope. Impact friction (BALL_FRICTION) is separate.
|
||||
_ROLLING_DRAG = 0.6
|
||||
|
||||
# How long a removed ("popped") ball takes to shrink away.
|
||||
_DYING_SECONDS = 0.45
|
||||
|
||||
_MAX_EVENTS_PER_FRAME = 64
|
||||
|
||||
|
||||
class Ball:
|
||||
def __init__(
|
||||
self,
|
||||
x,
|
||||
y,
|
||||
radius=15,
|
||||
velocity_x=0.0,
|
||||
velocity_y=0.0,
|
||||
gravity=900.0,
|
||||
hue=200.0,
|
||||
):
|
||||
self.x = float(x)
|
||||
self.y = float(y)
|
||||
|
||||
self.radius = float(radius)
|
||||
|
||||
# Mass grows with area, so a big ball shoves a small one
|
||||
# around instead of bouncing off it like an equal.
|
||||
self.mass = self.radius * self.radius
|
||||
|
||||
self.velocity_x = float(velocity_x)
|
||||
self.velocity_y = float(velocity_y)
|
||||
|
||||
self.gravity = float(gravity)
|
||||
|
||||
self.alive = True
|
||||
|
||||
# True once the ball has fallen down far enough to actually
|
||||
# be on screen (y - radius >= 0). Until then the top wall is
|
||||
# not enforced, so a ball can still drop in from just above
|
||||
# the visible screen the way it always has; from then on,
|
||||
# the top wall is a solid ceiling like every other edge.
|
||||
self.entered = False
|
||||
|
||||
# Dying balls are on their way out (shrinking away): they
|
||||
# no longer collide with anything.
|
||||
self.dying = False
|
||||
self.fade = 1.0
|
||||
|
||||
self.age = 0.0
|
||||
|
||||
# Purely cosmetic state, owned/updated by effects.py.
|
||||
self.hue = float(hue)
|
||||
self.trail = []
|
||||
self.trail_timer = 0.0
|
||||
self.squash = 0.0
|
||||
self.squash_nx = 0.0
|
||||
self.squash_ny = -1.0
|
||||
|
||||
# Stuck detection.
|
||||
self._anchor_x = self.x
|
||||
self._anchor_y = self.y
|
||||
self._stuck_time = 0.0
|
||||
|
||||
@property
|
||||
def speed(self):
|
||||
return math.hypot(self.velocity_x, self.velocity_y)
|
||||
|
||||
|
||||
class _Solid:
|
||||
"""One detected sticker, pre-digested for fast collision tests."""
|
||||
|
||||
__slots__ = (
|
||||
"cx",
|
||||
"cy",
|
||||
"half_long",
|
||||
"half_short",
|
||||
"cos",
|
||||
"sin",
|
||||
"bound",
|
||||
)
|
||||
|
||||
def __init__(self, cx, cy, rect_long, rect_short, angle):
|
||||
theta = math.radians(angle)
|
||||
|
||||
self.cx = cx
|
||||
self.cy = cy
|
||||
self.half_long = rect_long / 2.0
|
||||
self.half_short = rect_short / 2.0
|
||||
self.cos = math.cos(theta)
|
||||
self.sin = math.sin(theta)
|
||||
|
||||
# Radius of the smallest circle containing the whole
|
||||
# rectangle: anything farther away than this (plus a
|
||||
# ball's radius) can't possibly touch it.
|
||||
self.bound = math.hypot(
|
||||
self.half_long,
|
||||
self.half_short,
|
||||
)
|
||||
|
||||
|
||||
class PhysicsWorld:
|
||||
def __init__(self, width, height):
|
||||
self.width = float(width)
|
||||
self.height = float(height)
|
||||
|
||||
self.launcher = Launcher(width, height)
|
||||
|
||||
self.balls = []
|
||||
|
||||
# Impact/pop/launch events produced by the last update(), consumed
|
||||
# by effects.py: (kind, x, y, normal_x, normal_y, speed,
|
||||
# ball). kind is "obstacle", "ball", "pop" or "launch".
|
||||
self.events = []
|
||||
|
||||
self._solids = []
|
||||
self._sticker_solids = []
|
||||
self._solid_source = None
|
||||
|
||||
self._random = random.Random()
|
||||
self._hue_cursor = self._random.uniform(
|
||||
BALL_HUE_MIN_DEGREES,
|
||||
BALL_HUE_MAX_DEGREES,
|
||||
)
|
||||
|
||||
# ========================================================
|
||||
# Balls
|
||||
# ========================================================
|
||||
|
||||
def add_ball(self, ball):
|
||||
if ball is not None:
|
||||
self.balls.append(ball)
|
||||
|
||||
def live_ball_count(self):
|
||||
return sum(
|
||||
1
|
||||
for ball in self.balls
|
||||
if ball.alive and not ball.dying
|
||||
)
|
||||
|
||||
def _next_hue(self):
|
||||
# Golden-ratio hop around the allowed hue range: successive
|
||||
# balls always get clearly different colors.
|
||||
span = BALL_HUE_MAX_DEGREES - BALL_HUE_MIN_DEGREES
|
||||
|
||||
self._hue_cursor = (
|
||||
BALL_HUE_MIN_DEGREES
|
||||
+ (
|
||||
self._hue_cursor
|
||||
- BALL_HUE_MIN_DEGREES
|
||||
+ span * 0.618034
|
||||
)
|
||||
% span
|
||||
)
|
||||
|
||||
return self._hue_cursor
|
||||
|
||||
def spawn_ball(self):
|
||||
"""
|
||||
Spawns a new ball emerging from the launcher nozzle.
|
||||
Returns the ball, or None if blocked at nozzle exit or max balls reached.
|
||||
"""
|
||||
if self.live_ball_count() >= MAX_SIMULTANEOUS_BALLS:
|
||||
return None
|
||||
|
||||
spawn_x, spawn_y, dir_x, dir_y = self.launcher.get_spawn_transform()
|
||||
|
||||
rng = self._random
|
||||
|
||||
radius = BALL_RADIUS * (
|
||||
1.0
|
||||
+ rng.uniform(
|
||||
-BALL_RADIUS_VARIATION,
|
||||
BALL_RADIUS_VARIATION,
|
||||
)
|
||||
)
|
||||
|
||||
min_distance = (radius + BALL_RADIUS * (1.0 + BALL_RADIUS_VARIATION)) * 1.05
|
||||
|
||||
for other in self.balls:
|
||||
if not other.alive or other.dying:
|
||||
continue
|
||||
|
||||
if math.hypot(spawn_x - other.x, spawn_y - other.y) < min_distance:
|
||||
return None
|
||||
|
||||
vel_x = dir_x * self.launcher.spawn_speed
|
||||
vel_y = dir_y * self.launcher.spawn_speed
|
||||
|
||||
ball = Ball(
|
||||
x=spawn_x,
|
||||
y=spawn_y,
|
||||
radius=radius,
|
||||
velocity_x=vel_x,
|
||||
velocity_y=vel_y,
|
||||
gravity=BALL_GRAVITY,
|
||||
hue=self._next_hue(),
|
||||
)
|
||||
ball.entered = True
|
||||
|
||||
self.balls.append(ball)
|
||||
|
||||
if len(self.events) < _MAX_EVENTS_PER_FRAME:
|
||||
self.events.append(
|
||||
(
|
||||
"launch",
|
||||
spawn_x,
|
||||
spawn_y,
|
||||
dir_x,
|
||||
dir_y,
|
||||
self.launcher.spawn_speed,
|
||||
ball,
|
||||
)
|
||||
)
|
||||
|
||||
return ball
|
||||
|
||||
# ========================================================
|
||||
# Obstacles
|
||||
# ========================================================
|
||||
|
||||
def set_obstacles(self, obstacles):
|
||||
# The vision thread publishes a NEW list object whenever
|
||||
# something changed; rebuild sticker solids only then.
|
||||
if obstacles is not self._solid_source:
|
||||
self._solid_source = obstacles
|
||||
|
||||
solids = []
|
||||
|
||||
for obstacle in obstacles or ():
|
||||
obb = extract_obb(obstacle)
|
||||
|
||||
if obb is None:
|
||||
continue
|
||||
|
||||
solids.append(_Solid(*obb))
|
||||
|
||||
self._sticker_solids = solids
|
||||
|
||||
# Combine sticker solids + launcher tube solid
|
||||
self._solids = list(self._sticker_solids)
|
||||
if self.launcher is not None:
|
||||
self._solids.append(_Solid(*self.launcher.get_solid_params()))
|
||||
|
||||
# ========================================================
|
||||
# Update
|
||||
# ========================================================
|
||||
|
||||
def update(self, delta_time, obstacles=None):
|
||||
self.events = []
|
||||
|
||||
if delta_time <= 0:
|
||||
return
|
||||
|
||||
delta_time = min(delta_time, 0.05)
|
||||
|
||||
if self.launcher is not None:
|
||||
self.launcher.update(delta_time)
|
||||
|
||||
self.set_obstacles(obstacles)
|
||||
|
||||
movers = [
|
||||
ball
|
||||
for ball in self.balls
|
||||
if ball.alive and not ball.dying
|
||||
]
|
||||
|
||||
if movers:
|
||||
fastest = max(ball.speed for ball in movers)
|
||||
|
||||
smallest = min(ball.radius for ball in movers)
|
||||
|
||||
# Enough slices that (a) none is longer than the
|
||||
# configured maximum and (b) no ball travels more than
|
||||
# half its own radius in one slice - which is what
|
||||
# guarantees it can't skip through a thin sticker.
|
||||
steps = max(
|
||||
math.ceil(
|
||||
delta_time / PHYSICS_MAX_STEP_SECONDS
|
||||
),
|
||||
math.ceil(
|
||||
fastest * delta_time
|
||||
/ (0.5 * smallest)
|
||||
),
|
||||
1,
|
||||
)
|
||||
|
||||
steps = min(steps, 10)
|
||||
|
||||
step = delta_time / steps
|
||||
|
||||
for _ in range(steps):
|
||||
self._step(movers, step)
|
||||
|
||||
self._housekeeping(delta_time)
|
||||
|
||||
def _step(self, movers, h):
|
||||
for ball in movers:
|
||||
ball.velocity_y += ball.gravity * h
|
||||
|
||||
speed = ball.speed
|
||||
|
||||
if speed > BALL_MAX_SPEED:
|
||||
scale = BALL_MAX_SPEED / speed
|
||||
ball.velocity_x *= scale
|
||||
ball.velocity_y *= scale
|
||||
|
||||
ball.x += ball.velocity_x * h
|
||||
ball.y += ball.velocity_y * h
|
||||
|
||||
if BALL_BALL_COLLISION_ENABLED and len(movers) > 1:
|
||||
self._collide_balls(movers)
|
||||
|
||||
for ball in movers:
|
||||
# Up to 3 passes so a ball squeezed between two
|
||||
# stickers (or a sticker and another ball's push) ends
|
||||
# up somewhere consistent instead of half inside one.
|
||||
for _ in range(3):
|
||||
touched = False
|
||||
|
||||
for solid in self._solids:
|
||||
if self._collide_solid(ball, solid, h):
|
||||
touched = True
|
||||
|
||||
if not touched:
|
||||
break
|
||||
|
||||
self._keep_inside_walls(ball)
|
||||
|
||||
# ========================================================
|
||||
# Ball vs. sticker (oriented rectangle)
|
||||
# ========================================================
|
||||
|
||||
def _collide_solid(self, ball, solid, h):
|
||||
radius = ball.radius
|
||||
|
||||
dx = ball.x - solid.cx
|
||||
dy = ball.y - solid.cy
|
||||
|
||||
reach = solid.bound + radius
|
||||
|
||||
# Cheap reject: outside the sticker's bounding circle.
|
||||
if dx * dx + dy * dy > reach * reach:
|
||||
return False
|
||||
|
||||
cos_t = solid.cos
|
||||
sin_t = solid.sin
|
||||
|
||||
# Ball center in the sticker's own un-rotated frame, where
|
||||
# this is plain circle-vs-axis-aligned-box.
|
||||
local_x = dx * cos_t + dy * sin_t
|
||||
local_y = -dx * sin_t + dy * cos_t
|
||||
|
||||
half_long = solid.half_long
|
||||
half_short = solid.half_short
|
||||
|
||||
closest_x = max(-half_long, min(local_x, half_long))
|
||||
closest_y = max(-half_short, min(local_y, half_short))
|
||||
|
||||
diff_x = local_x - closest_x
|
||||
diff_y = local_y - closest_y
|
||||
|
||||
distance_sq = diff_x * diff_x + diff_y * diff_y
|
||||
|
||||
if distance_sq > radius * radius:
|
||||
return False
|
||||
|
||||
if distance_sq > 1e-9:
|
||||
# Center outside the box: (diff) already points from the
|
||||
# nearest surface point to the ball - the true surface
|
||||
# normal for both a flat face hit and a corner hit.
|
||||
distance = math.sqrt(distance_sq)
|
||||
|
||||
normal_x = diff_x / distance
|
||||
normal_y = diff_y / distance
|
||||
|
||||
penetration = radius - distance
|
||||
|
||||
local_x += normal_x * penetration
|
||||
local_y += normal_y * penetration
|
||||
else:
|
||||
# Center already inside the box (very fast ball / thin
|
||||
# sticker): leave through whichever side is nearest.
|
||||
overlap_x = half_long + radius - abs(local_x)
|
||||
overlap_y = half_short + radius - abs(local_y)
|
||||
|
||||
if overlap_x < overlap_y:
|
||||
normal_x = 1.0 if local_x >= 0 else -1.0
|
||||
normal_y = 0.0
|
||||
local_x = normal_x * (half_long + radius)
|
||||
else:
|
||||
normal_x = 0.0
|
||||
normal_y = 1.0 if local_y >= 0 else -1.0
|
||||
local_y = normal_y * (half_short + radius)
|
||||
|
||||
ball.x = solid.cx + local_x * cos_t - local_y * sin_t
|
||||
ball.y = solid.cy + local_x * sin_t + local_y * cos_t
|
||||
|
||||
local_vx = (
|
||||
ball.velocity_x * cos_t
|
||||
+ ball.velocity_y * sin_t
|
||||
)
|
||||
local_vy = (
|
||||
-ball.velocity_x * sin_t
|
||||
+ ball.velocity_y * cos_t
|
||||
)
|
||||
|
||||
normal_speed = local_vx * normal_x + local_vy * normal_y
|
||||
|
||||
# Only react if the ball is actually moving INTO the
|
||||
# surface. (Reflecting a ball that is already leaving is
|
||||
# what makes bounces flip-flop and jitter.)
|
||||
if normal_speed < 0:
|
||||
impact = -normal_speed
|
||||
|
||||
tangent_x = -normal_y
|
||||
tangent_y = normal_x
|
||||
|
||||
tangent_speed = (
|
||||
local_vx * tangent_x + local_vy * tangent_y
|
||||
)
|
||||
|
||||
if impact >= BALL_REST_SPEED:
|
||||
bounce = BALL_RESTITUTION
|
||||
tangent_speed *= 1.0 - BALL_FRICTION
|
||||
else:
|
||||
# Gentle contact = resting/rolling: no bounce, just
|
||||
# a little rolling resistance.
|
||||
bounce = 0.0
|
||||
tangent_speed *= max(
|
||||
0.0,
|
||||
1.0 - _ROLLING_DRAG * h,
|
||||
)
|
||||
|
||||
new_normal_speed = -normal_speed * bounce
|
||||
|
||||
local_vx = (
|
||||
new_normal_speed * normal_x
|
||||
+ tangent_speed * tangent_x
|
||||
)
|
||||
local_vy = (
|
||||
new_normal_speed * normal_y
|
||||
+ tangent_speed * tangent_y
|
||||
)
|
||||
|
||||
ball.velocity_x = local_vx * cos_t - local_vy * sin_t
|
||||
ball.velocity_y = local_vx * sin_t + local_vy * cos_t
|
||||
|
||||
if (
|
||||
impact >= IMPACT_EFFECT_MIN_SPEED
|
||||
and len(self.events) < _MAX_EVENTS_PER_FRAME
|
||||
):
|
||||
world_nx = normal_x * cos_t - normal_y * sin_t
|
||||
world_ny = normal_x * sin_t + normal_y * cos_t
|
||||
|
||||
self.events.append(
|
||||
(
|
||||
"obstacle",
|
||||
ball.x - world_nx * ball.radius,
|
||||
ball.y - world_ny * ball.radius,
|
||||
world_nx,
|
||||
world_ny,
|
||||
impact,
|
||||
ball,
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
# ========================================================
|
||||
# Ball vs. ball
|
||||
# ========================================================
|
||||
|
||||
def _collide_balls(self, movers):
|
||||
count = len(movers)
|
||||
|
||||
for i in range(count - 1):
|
||||
first = movers[i]
|
||||
|
||||
for j in range(i + 1, count):
|
||||
second = movers[j]
|
||||
|
||||
reach = first.radius + second.radius
|
||||
|
||||
dx = second.x - first.x
|
||||
dy = second.y - first.y
|
||||
|
||||
if abs(dx) >= reach or abs(dy) >= reach:
|
||||
continue
|
||||
|
||||
distance_sq = dx * dx + dy * dy
|
||||
|
||||
if distance_sq >= reach * reach:
|
||||
continue
|
||||
|
||||
distance = math.sqrt(distance_sq)
|
||||
|
||||
if distance > 1e-6:
|
||||
normal_x = dx / distance
|
||||
normal_y = dy / distance
|
||||
else:
|
||||
normal_x = 0.0
|
||||
normal_y = 1.0
|
||||
|
||||
total_mass = first.mass + second.mass
|
||||
|
||||
# Push apart, the lighter ball moving more.
|
||||
penetration = reach - distance
|
||||
|
||||
first_share = penetration * (second.mass / total_mass)
|
||||
second_share = penetration * (first.mass / total_mass)
|
||||
|
||||
first.x -= normal_x * first_share
|
||||
first.y -= normal_y * first_share
|
||||
second.x += normal_x * second_share
|
||||
second.y += normal_y * second_share
|
||||
|
||||
closing = (
|
||||
(second.velocity_x - first.velocity_x) * normal_x
|
||||
+ (second.velocity_y - first.velocity_y) * normal_y
|
||||
)
|
||||
|
||||
if closing >= 0:
|
||||
continue
|
||||
|
||||
impact = -closing
|
||||
|
||||
bounce = (
|
||||
BALL_BALL_RESTITUTION
|
||||
if impact >= BALL_REST_SPEED
|
||||
else 0.0
|
||||
)
|
||||
|
||||
impulse = (
|
||||
-(1.0 + bounce)
|
||||
* closing
|
||||
/ (1.0 / first.mass + 1.0 / second.mass)
|
||||
)
|
||||
|
||||
first.velocity_x -= impulse / first.mass * normal_x
|
||||
first.velocity_y -= impulse / first.mass * normal_y
|
||||
second.velocity_x += impulse / second.mass * normal_x
|
||||
second.velocity_y += impulse / second.mass * normal_y
|
||||
|
||||
if (
|
||||
impact >= IMPACT_EFFECT_MIN_SPEED
|
||||
and len(self.events) < _MAX_EVENTS_PER_FRAME
|
||||
):
|
||||
self.events.append(
|
||||
(
|
||||
"ball",
|
||||
first.x + normal_x * first.radius,
|
||||
first.y + normal_y * first.radius,
|
||||
normal_x,
|
||||
normal_y,
|
||||
impact,
|
||||
first,
|
||||
)
|
||||
)
|
||||
|
||||
# ========================================================
|
||||
# Walls
|
||||
# ========================================================
|
||||
#
|
||||
# All four screen edges are solid: a ball bounces off every one
|
||||
# of them forever and never leaves the screen (the only ways a
|
||||
# ball ever disappears are the lifetime/stuck-timeout removal in
|
||||
# _housekeeping below, each with its own little pop effect).
|
||||
# With BALL_RESTITUTION left at its lossless default (1.0) these
|
||||
# bounces never bleed off energy, so together with gravity a
|
||||
# ball just keeps bouncing indefinitely, exactly like a perfect
|
||||
# rubber ball in a box, until its time is up.
|
||||
|
||||
def _keep_inside_walls(self, ball):
|
||||
radius = ball.radius
|
||||
|
||||
if ball.x - radius < 0:
|
||||
ball.x = radius
|
||||
|
||||
if ball.velocity_x < 0:
|
||||
ball.velocity_x *= -BALL_RESTITUTION
|
||||
|
||||
elif ball.x + radius > self.width:
|
||||
ball.x = self.width - radius
|
||||
|
||||
if ball.velocity_x > 0:
|
||||
ball.velocity_x *= -BALL_RESTITUTION
|
||||
|
||||
if not ball.entered:
|
||||
if ball.y - radius < 0:
|
||||
# Still on its way in from above the visible
|
||||
# screen - leave the top wall unenforced for now.
|
||||
return
|
||||
|
||||
ball.entered = True
|
||||
|
||||
if ball.y - radius < 0:
|
||||
ball.y = radius
|
||||
|
||||
if ball.velocity_y < 0:
|
||||
ball.velocity_y *= -BALL_RESTITUTION
|
||||
|
||||
elif ball.y + radius > self.height:
|
||||
ball.y = self.height - radius
|
||||
|
||||
if ball.velocity_y > 0:
|
||||
ball.velocity_y *= -BALL_RESTITUTION
|
||||
|
||||
# ========================================================
|
||||
# Lifetime, stuck balls, removal
|
||||
# ========================================================
|
||||
|
||||
def _housekeeping(self, delta_time):
|
||||
for ball in self.balls:
|
||||
if not ball.alive:
|
||||
continue
|
||||
|
||||
if ball.dying:
|
||||
ball.fade -= delta_time / _DYING_SECONDS
|
||||
|
||||
if ball.fade <= 0.0:
|
||||
ball.alive = False
|
||||
|
||||
continue
|
||||
|
||||
ball.age += delta_time
|
||||
|
||||
if BALL_STUCK_TIMEOUT_SECONDS > 0:
|
||||
moved = math.hypot(
|
||||
ball.x - ball._anchor_x,
|
||||
ball.y - ball._anchor_y,
|
||||
)
|
||||
|
||||
if moved > BALL_STUCK_DISTANCE:
|
||||
ball._anchor_x = ball.x
|
||||
ball._anchor_y = ball.y
|
||||
ball._stuck_time = 0.0
|
||||
else:
|
||||
ball._stuck_time += delta_time
|
||||
|
||||
if ball._stuck_time >= BALL_STUCK_TIMEOUT_SECONDS:
|
||||
self._start_dying(ball)
|
||||
continue
|
||||
|
||||
if (
|
||||
BALL_MAX_LIFETIME_SECONDS > 0
|
||||
and ball.age >= BALL_MAX_LIFETIME_SECONDS
|
||||
):
|
||||
self._start_dying(ball)
|
||||
|
||||
self.balls = [
|
||||
ball for ball in self.balls if ball.alive
|
||||
]
|
||||
|
||||
def _start_dying(self, ball):
|
||||
ball.dying = True
|
||||
ball.fade = 1.0
|
||||
|
||||
if len(self.events) < _MAX_EVENTS_PER_FRAME:
|
||||
self.events.append(
|
||||
(
|
||||
"pop",
|
||||
ball.x,
|
||||
ball.y,
|
||||
0.0,
|
||||
-1.0,
|
||||
0.0,
|
||||
ball,
|
||||
)
|
||||
)
|
||||
|
||||
# ========================================================
|
||||
# Reset
|
||||
# ========================================================
|
||||
|
||||
def clear(self):
|
||||
self.balls = []
|
||||
self.events = []
|
||||
|
||||
def reset(self):
|
||||
self.clear()
|
||||
+965
@@ -0,0 +1,965 @@
|
||||
# rendering.py
|
||||
#
|
||||
# Draws the game onto the monitor.
|
||||
#
|
||||
# Speed: with USE_DIRTY_RECTS the screen is split into a grid of
|
||||
# small cells. Only cells where something was drawn last frame or is
|
||||
# drawn now get repainted (background, then the sticker patches in
|
||||
# them, then the effects in them) and only those cells are pushed to
|
||||
# the display. A few balls touch a few dozen cells instead of all
|
||||
# 2 million pixels. The calibration border and black interior are
|
||||
# pre-rendered ONCE into a background surface and just copied in.
|
||||
#
|
||||
# Camera safety: the camera watches this very monitor for WHITE
|
||||
# stickers. So everything here except the required sticker patch is
|
||||
# drawn in saturated colour, with dim additive glows (never near
|
||||
# white) and only tiny pastel highlights. The sticker patch is drawn
|
||||
# slightly smaller than the sticker (see geometry.paint_dimensions)
|
||||
# and the detector blanks it out, so a removed sticker can't be
|
||||
# "seen" in its own leftover paint.
|
||||
|
||||
import colorsys
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
|
||||
import pygame
|
||||
|
||||
from geometry import (
|
||||
obb_corners,
|
||||
extract_obb,
|
||||
paint_dimensions,
|
||||
)
|
||||
|
||||
from config import (
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
BACKGROUND_COLOR,
|
||||
CALIBRATION_PATTERN_OUTER_COLOR,
|
||||
CALIBRATION_PATTERN_INNER_COLOR,
|
||||
CALIBRATION_OUTER_BORDER_SIZE,
|
||||
CALIBRATION_INNER_BORDER_SIZE,
|
||||
TARGET_FPS,
|
||||
USE_DIRTY_RECTS,
|
||||
FULL_REPAINT_SECONDS,
|
||||
VISUAL_EFFECTS_ENABLED,
|
||||
BALL_GLOW_ENABLED,
|
||||
BALL_SQUASH_STRETCH_ENABLED,
|
||||
SHOW_DETECTED_OBSTACLES,
|
||||
DETECTED_OBSTACLE_COLOR,
|
||||
DETECTED_OBSTACLE_BORDER_WIDTH,
|
||||
DETECTED_OBSTACLE_PAINT_INSET_RATIO,
|
||||
)
|
||||
|
||||
|
||||
# Side length (px) of one dirty-repaint cell.
|
||||
_CELL = 96
|
||||
|
||||
# Additive glow strengths (fraction of the full hue colour at the
|
||||
# glow's centre). Deliberately dim: see "camera safety" above.
|
||||
_GLOW_LEVELS = (0.16, 0.30, 0.48, 0.66)
|
||||
|
||||
# Layers, drawn in this order inside every repainted cell.
|
||||
_LAYER_GLOW = 0
|
||||
_LAYER_RING = 1
|
||||
_LAYER_BODY = 2
|
||||
|
||||
|
||||
class Renderer:
|
||||
# ========================================================
|
||||
# Initialization
|
||||
# ========================================================
|
||||
|
||||
def __init__(self):
|
||||
# Ask SDL to place the (borderless) window at the top-left
|
||||
# corner so it lines up exactly with the screen.
|
||||
os.environ.setdefault("SDL_VIDEO_WINDOW_POS", "0,0")
|
||||
|
||||
pygame.init()
|
||||
|
||||
pygame.display.set_caption("Monitor Ball")
|
||||
|
||||
# A borderless window sized to the full screen ("fake
|
||||
# fullscreen"/kiosk mode) instead of pygame.FULLSCREEN.
|
||||
# Exclusive SDL fullscreen needs a real videofullscreen-mode switch,
|
||||
# which is unreliable on several X11/Wayland/XWayland driver
|
||||
# combinations (blank frames, crashes on exit).
|
||||
self.screen = pygame.display.set_mode(
|
||||
(SCREEN_WIDTH, SCREEN_HEIGHT),
|
||||
pygame.NOFRAME,
|
||||
)
|
||||
|
||||
# pygame.mouse.set_visible(False)
|
||||
|
||||
# try:
|
||||
# pygame.event.set_grab(True)
|
||||
# except Exception:
|
||||
# pass
|
||||
|
||||
# SDL2 turns "text input mode" on by default, which on
|
||||
# systems with an active IME can swallow certain keys
|
||||
# before they reach us as a normal KEYDOWN.
|
||||
try:
|
||||
pygame.key.stop_text_input()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.clock = pygame.time.Clock()
|
||||
|
||||
self._screen_rect = pygame.Rect(
|
||||
0, 0, SCREEN_WIDTH, SCREEN_HEIGHT
|
||||
)
|
||||
|
||||
self._cols = (SCREEN_WIDTH + _CELL - 1) // _CELL
|
||||
self._rows = (SCREEN_HEIGHT + _CELL - 1) // _CELL
|
||||
|
||||
self._background = self._build_background()
|
||||
|
||||
self._mode = None
|
||||
self._need_full = True
|
||||
self._last_full = 0.0
|
||||
|
||||
# Sticker patches, rebuilt only when the detector publishes
|
||||
# a new list.
|
||||
self._obstacle_source = None
|
||||
self._obstacle_polys = []
|
||||
self._obstacle_cells = {}
|
||||
self._obstacle_dirty = set()
|
||||
|
||||
self._prev_cells = set()
|
||||
|
||||
self._glow_cache = {}
|
||||
self._color_cache = {}
|
||||
|
||||
def _build_background(self):
|
||||
surface = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))
|
||||
surface.fill(BACKGROUND_COLOR)
|
||||
return surface.convert()
|
||||
|
||||
# ========================================================
|
||||
# Calibration screens
|
||||
# ========================================================
|
||||
|
||||
def render_calibration(self):
|
||||
"""
|
||||
Solid pink over the whole screen: the simplest possible
|
||||
target for the camera's first lock-on. It never changes, so
|
||||
it is painted once (and again every FULL_REPAINT_SECONDS as
|
||||
a safety net) instead of 144 times a second.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
|
||||
if (
|
||||
self._mode != "calibration"
|
||||
or now - self._last_full >= FULL_REPAINT_SECONDS
|
||||
):
|
||||
self.draw_full_calibration_screen()
|
||||
|
||||
pygame.display.flip()
|
||||
|
||||
self._mode = "calibration"
|
||||
self._last_full = now
|
||||
|
||||
self._need_full = True
|
||||
self._prev_cells = set()
|
||||
|
||||
def draw_full_calibration_screen(self):
|
||||
self.screen.set_clip(None)
|
||||
|
||||
self.screen.fill(CALIBRATION_PATTERN_OUTER_COLOR)
|
||||
|
||||
def draw_calibration_border(self):
|
||||
# Border ring + black interior (pre-rendered).
|
||||
self.screen.set_clip(None)
|
||||
|
||||
self.screen.blit(self._background, (0, 0))
|
||||
|
||||
def force_full_repaint(self):
|
||||
self._need_full = True
|
||||
|
||||
# ========================================================
|
||||
# Main rendering
|
||||
# ========================================================
|
||||
|
||||
def render(self, balls, obstacles, effects=None, launcher=None):
|
||||
now = time.monotonic()
|
||||
|
||||
self._sync_obstacles(obstacles)
|
||||
|
||||
items = self._collect_items(balls, effects, launcher)
|
||||
|
||||
cell_items = {}
|
||||
|
||||
for layer, bounds, draw in items:
|
||||
self._add_to_cells(
|
||||
cell_items,
|
||||
bounds,
|
||||
(layer, draw),
|
||||
)
|
||||
|
||||
full = (
|
||||
not USE_DIRTY_RECTS
|
||||
or self._need_full
|
||||
or self._mode != "game"
|
||||
or now - self._last_full >= FULL_REPAINT_SECONDS
|
||||
)
|
||||
|
||||
if full:
|
||||
self._paint_full(cell_items, now)
|
||||
else:
|
||||
self._paint_dirty(cell_items)
|
||||
|
||||
self._prev_cells = set(cell_items)
|
||||
|
||||
def _paint_full(self, cell_items, now):
|
||||
screen = self.screen
|
||||
|
||||
screen.set_clip(None)
|
||||
|
||||
screen.blit(self._background, (0, 0))
|
||||
|
||||
for poly in self._obstacle_polys:
|
||||
self._draw_obstacle(poly)
|
||||
|
||||
everything = []
|
||||
|
||||
for entries in cell_items.values():
|
||||
everything.extend(entries)
|
||||
|
||||
# An item spanning several cells appears once per cell;
|
||||
# drawing it once per cell is wasteful when nothing is
|
||||
# clipped, so de-duplicate by identity.
|
||||
seen = set()
|
||||
ordered = []
|
||||
|
||||
for entry in everything:
|
||||
key = id(entry[1])
|
||||
|
||||
if key in seen:
|
||||
continue
|
||||
|
||||
seen.add(key)
|
||||
ordered.append(entry)
|
||||
|
||||
ordered.sort(key=lambda entry: entry[0])
|
||||
|
||||
for _layer, draw in ordered:
|
||||
self._draw_item(draw)
|
||||
|
||||
pygame.display.flip()
|
||||
|
||||
self._need_full = False
|
||||
self._mode = "game"
|
||||
self._last_full = now
|
||||
self._obstacle_dirty = set()
|
||||
|
||||
def _paint_dirty(self, cell_items):
|
||||
screen = self.screen
|
||||
background = self._background
|
||||
|
||||
dirty = (
|
||||
set(cell_items)
|
||||
| self._prev_cells
|
||||
| self._obstacle_dirty
|
||||
)
|
||||
|
||||
self._obstacle_dirty = set()
|
||||
|
||||
rects = []
|
||||
|
||||
for cell in dirty:
|
||||
rect = self._cell_rect(cell)
|
||||
|
||||
if rect.width <= 0 or rect.height <= 0:
|
||||
continue
|
||||
|
||||
rects.append(rect)
|
||||
|
||||
screen.set_clip(rect)
|
||||
|
||||
screen.blit(background, rect.topleft, rect)
|
||||
|
||||
for index in self._obstacle_cells.get(cell, ()):
|
||||
self._draw_obstacle(self._obstacle_polys[index])
|
||||
|
||||
entries = cell_items.get(cell)
|
||||
|
||||
if entries:
|
||||
entries.sort(key=lambda entry: entry[0])
|
||||
|
||||
for _layer, draw in entries:
|
||||
self._draw_item(draw)
|
||||
|
||||
screen.set_clip(None)
|
||||
|
||||
if rects:
|
||||
pygame.display.update(rects)
|
||||
|
||||
# ========================================================
|
||||
# Grid helpers
|
||||
# ========================================================
|
||||
|
||||
def _cell_rect(self, cell):
|
||||
column, row = cell
|
||||
|
||||
return pygame.Rect(
|
||||
column * _CELL,
|
||||
row * _CELL,
|
||||
_CELL,
|
||||
_CELL,
|
||||
).clip(self._screen_rect)
|
||||
|
||||
def _cells_for(self, rect):
|
||||
left = max(0, rect.left // _CELL)
|
||||
top = max(0, rect.top // _CELL)
|
||||
|
||||
right = min(self._cols - 1, (rect.right - 1) // _CELL)
|
||||
bottom = min(self._rows - 1, (rect.bottom - 1) // _CELL)
|
||||
|
||||
if rect.right <= 0 or rect.bottom <= 0:
|
||||
return ()
|
||||
|
||||
if rect.left >= SCREEN_WIDTH or rect.top >= SCREEN_HEIGHT:
|
||||
return ()
|
||||
|
||||
return [
|
||||
(column, row)
|
||||
for row in range(top, bottom + 1)
|
||||
for column in range(left, right + 1)
|
||||
]
|
||||
|
||||
def _add_to_cells(self, cell_items, bounds, entry):
|
||||
for cell in self._cells_for(bounds):
|
||||
cell_items.setdefault(cell, []).append(entry)
|
||||
|
||||
# ========================================================
|
||||
# Sticker patches
|
||||
# ========================================================
|
||||
|
||||
def _sync_obstacles(self, obstacles):
|
||||
if obstacles is self._obstacle_source:
|
||||
return
|
||||
|
||||
self._obstacle_source = obstacles
|
||||
|
||||
# Cells that used to show a patch must be repainted, as
|
||||
# must cells that now do.
|
||||
self._obstacle_dirty |= set(self._obstacle_cells)
|
||||
|
||||
polys = []
|
||||
cells = {}
|
||||
|
||||
if SHOW_DETECTED_OBSTACLES:
|
||||
for obstacle in obstacles or ():
|
||||
if not obstacle.get("painted", True):
|
||||
continue
|
||||
|
||||
obb = extract_obb(obstacle)
|
||||
|
||||
if obb is None:
|
||||
continue
|
||||
|
||||
cx, cy, long_side, short_side, angle = obb
|
||||
|
||||
long_side, short_side = paint_dimensions(
|
||||
long_side,
|
||||
short_side,
|
||||
DETECTED_OBSTACLE_PAINT_INSET_RATIO,
|
||||
)
|
||||
|
||||
if long_side <= 0 or short_side <= 0:
|
||||
continue
|
||||
|
||||
corners = obb_corners(
|
||||
cx,
|
||||
cy,
|
||||
long_side,
|
||||
short_side,
|
||||
angle,
|
||||
)
|
||||
|
||||
points = [
|
||||
(int(round(px)), int(round(py)))
|
||||
for px, py in corners
|
||||
]
|
||||
|
||||
xs = [p[0] for p in points]
|
||||
ys = [p[1] for p in points]
|
||||
|
||||
bounds = pygame.Rect(
|
||||
min(xs) - 2,
|
||||
min(ys) - 2,
|
||||
max(xs) - min(xs) + 4,
|
||||
max(ys) - min(ys) + 4,
|
||||
)
|
||||
|
||||
index = len(polys)
|
||||
|
||||
polys.append(points)
|
||||
|
||||
for cell in self._cells_for(bounds):
|
||||
cells.setdefault(cell, []).append(index)
|
||||
|
||||
self._obstacle_polys = polys
|
||||
self._obstacle_cells = cells
|
||||
|
||||
self._obstacle_dirty |= set(cells)
|
||||
|
||||
def _draw_obstacle(self, points):
|
||||
if DETECTED_OBSTACLE_BORDER_WIDTH <= 0:
|
||||
pygame.draw.polygon(
|
||||
self.screen,
|
||||
DETECTED_OBSTACLE_COLOR,
|
||||
points,
|
||||
)
|
||||
else:
|
||||
pygame.draw.polygon(
|
||||
self.screen,
|
||||
DETECTED_OBSTACLE_COLOR,
|
||||
points,
|
||||
int(DETECTED_OBSTACLE_BORDER_WIDTH),
|
||||
)
|
||||
|
||||
# ========================================================
|
||||
# Collecting what to draw this frame
|
||||
# ========================================================
|
||||
#
|
||||
# Everything is turned into a flat list of
|
||||
# (layer, bounding_rect, draw_tuple) ONCE per frame; the
|
||||
# per-cell painting then only replays those tuples.
|
||||
|
||||
def _collect_items(self, balls, effects, launcher=None):
|
||||
items = []
|
||||
|
||||
if launcher is not None:
|
||||
self._collect_launcher(items, launcher)
|
||||
|
||||
effects_on = (
|
||||
VISUAL_EFFECTS_ENABLED and effects is not None
|
||||
)
|
||||
|
||||
if effects_on:
|
||||
for particle in effects.particles:
|
||||
self._collect_particle(items, particle)
|
||||
|
||||
for ring in effects.rings:
|
||||
self._collect_ring(items, ring)
|
||||
|
||||
for ball in balls or ():
|
||||
if ball.alive:
|
||||
self._collect_ball(items, ball, effects_on)
|
||||
|
||||
return items
|
||||
|
||||
def _collect_launcher(self, items, launcher):
|
||||
points = launcher.get_points()
|
||||
|
||||
xs = [p[0] for p in points]
|
||||
ys = [p[1] for p in points]
|
||||
|
||||
bounds = pygame.Rect(
|
||||
int(min(xs)) - 3,
|
||||
int(min(ys)) - 3,
|
||||
int(max(xs) - min(xs)) + 6,
|
||||
int(max(ys) - min(ys)) + 6,
|
||||
)
|
||||
|
||||
items.append((_LAYER_BODY, bounds, ("launcher", launcher)))
|
||||
|
||||
# ---- particles / rings -------------------------------------
|
||||
|
||||
def _collect_particle(self, items, particle):
|
||||
x, y, _vx, _vy, age, life, hue, size = particle
|
||||
|
||||
fraction = 1.0 - age / life
|
||||
|
||||
level = min(
|
||||
len(_GLOW_LEVELS) - 1,
|
||||
int(fraction * len(_GLOW_LEVELS)),
|
||||
)
|
||||
|
||||
radius = max(2, int(round(size * (0.6 + 0.6 * fraction))) + 2)
|
||||
|
||||
sprite = self._glow(hue, radius, level)
|
||||
|
||||
position = (int(x) - radius, int(y) - radius)
|
||||
|
||||
bounds = pygame.Rect(
|
||||
position[0],
|
||||
position[1],
|
||||
radius * 2,
|
||||
radius * 2,
|
||||
)
|
||||
|
||||
items.append(
|
||||
(
|
||||
_LAYER_GLOW,
|
||||
bounds,
|
||||
("sprite", sprite, position),
|
||||
)
|
||||
)
|
||||
|
||||
def _collect_ring(self, items, ring):
|
||||
x, y, age, life, hue, max_radius = ring
|
||||
|
||||
fraction = age / life
|
||||
|
||||
radius = max(2, int(max_radius * (0.25 + 0.75 * fraction)))
|
||||
|
||||
fade = (1.0 - fraction) ** 1.5
|
||||
|
||||
color = self._hue_rgb(hue, 0.85, 0.9 * fade)
|
||||
|
||||
width = max(1, int(3 * (1.0 - fraction)) + 1)
|
||||
|
||||
bounds = pygame.Rect(
|
||||
int(x) - radius - 2,
|
||||
int(y) - radius - 2,
|
||||
radius * 2 + 4,
|
||||
radius * 2 + 4,
|
||||
)
|
||||
|
||||
items.append(
|
||||
(
|
||||
_LAYER_RING,
|
||||
bounds,
|
||||
(
|
||||
"ring",
|
||||
color,
|
||||
(int(x), int(y)),
|
||||
radius,
|
||||
width,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# ---- balls --------------------------------------------------
|
||||
|
||||
def _collect_ball(self, items, ball, effects_on):
|
||||
fade = max(0.0, ball.fade)
|
||||
|
||||
radius = ball.radius * fade
|
||||
|
||||
if radius < 1.0:
|
||||
return
|
||||
|
||||
if effects_on:
|
||||
self._collect_trail(items, ball, radius)
|
||||
|
||||
if BALL_GLOW_ENABLED:
|
||||
self._collect_glow(items, ball, radius, fade)
|
||||
|
||||
points, angle_bounds = self._ball_polygon(
|
||||
ball,
|
||||
radius,
|
||||
effects_on,
|
||||
)
|
||||
|
||||
body = self._hue_rgb(ball.hue, 0.85, 1.0)
|
||||
rim = self._hue_rgb(ball.hue, 0.95, 0.72)
|
||||
|
||||
# A small pastel highlight (saturated enough that the
|
||||
# camera can never count it as "white").
|
||||
highlight_color = self._hue_rgb(ball.hue, 0.42, 1.0)
|
||||
|
||||
highlight_radius = max(1, int(round(radius * 0.24)))
|
||||
|
||||
highlight_position = (
|
||||
int(round(ball.x - radius * 0.32)),
|
||||
int(round(ball.y - radius * 0.34)),
|
||||
)
|
||||
|
||||
items.append(
|
||||
(
|
||||
_LAYER_BODY,
|
||||
angle_bounds,
|
||||
(
|
||||
"body",
|
||||
points,
|
||||
body,
|
||||
rim,
|
||||
highlight_position,
|
||||
highlight_radius,
|
||||
highlight_color,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def _ball_polygon(self, ball, radius, effects_on):
|
||||
# Ellipse approximated by a polygon: stretched along the
|
||||
# direction of travel with speed, flattened against the
|
||||
# surface for a moment after a hit.
|
||||
semi_a = radius
|
||||
semi_b = radius
|
||||
angle = 0.0
|
||||
|
||||
if effects_on and BALL_SQUASH_STRETCH_ENABLED:
|
||||
speed = ball.speed
|
||||
|
||||
if speed > 30.0:
|
||||
stretch = min(0.35, speed / 4000.0)
|
||||
|
||||
semi_a = radius * (1.0 + stretch)
|
||||
semi_b = radius / (1.0 + stretch * 0.9)
|
||||
|
||||
angle = math.atan2(
|
||||
ball.velocity_y,
|
||||
ball.velocity_x,
|
||||
)
|
||||
|
||||
weight = ball.squash * ball.squash
|
||||
|
||||
if weight > 0.12:
|
||||
k = weight * 0.38
|
||||
|
||||
# First axis lies along the surface normal.
|
||||
semi_a = radius * (1.0 - k)
|
||||
semi_b = radius * (1.0 + k * 0.7)
|
||||
|
||||
angle = math.atan2(
|
||||
ball.squash_ny,
|
||||
ball.squash_nx,
|
||||
)
|
||||
|
||||
count = 18 if radius < 20 else 26
|
||||
|
||||
cos_a = math.cos(angle)
|
||||
sin_a = math.sin(angle)
|
||||
|
||||
points = []
|
||||
|
||||
for i in range(count):
|
||||
theta = 2.0 * math.pi * i / count
|
||||
|
||||
ex = semi_a * math.cos(theta)
|
||||
ey = semi_b * math.sin(theta)
|
||||
|
||||
points.append(
|
||||
(
|
||||
ball.x + ex * cos_a - ey * sin_a,
|
||||
ball.y + ex * sin_a + ey * cos_a,
|
||||
)
|
||||
)
|
||||
|
||||
xs = [p[0] for p in points]
|
||||
ys = [p[1] for p in points]
|
||||
|
||||
bounds = pygame.Rect(
|
||||
int(min(xs)) - 2,
|
||||
int(min(ys)) - 2,
|
||||
int(max(xs) - min(xs)) + 5,
|
||||
int(max(ys) - min(ys)) + 5,
|
||||
)
|
||||
|
||||
return points, bounds
|
||||
|
||||
def _collect_glow(self, items, ball, radius, fade):
|
||||
glow_radius = int(radius * 2.4)
|
||||
|
||||
level = min(
|
||||
len(_GLOW_LEVELS) - 1,
|
||||
int(ball.speed / 450.0),
|
||||
)
|
||||
|
||||
if fade < 0.6:
|
||||
level = max(0, level - 1)
|
||||
|
||||
sprite = self._glow(ball.hue, glow_radius, level)
|
||||
|
||||
position = (
|
||||
int(round(ball.x)) - glow_radius,
|
||||
int(round(ball.y)) - glow_radius,
|
||||
)
|
||||
|
||||
bounds = pygame.Rect(
|
||||
position[0],
|
||||
position[1],
|
||||
glow_radius * 2,
|
||||
glow_radius * 2,
|
||||
)
|
||||
|
||||
items.append(
|
||||
(
|
||||
_LAYER_GLOW,
|
||||
bounds,
|
||||
("sprite", sprite, position),
|
||||
)
|
||||
)
|
||||
|
||||
def _collect_trail(self, items, ball, radius):
|
||||
trail = ball.trail
|
||||
|
||||
count = len(trail)
|
||||
|
||||
if count < 2:
|
||||
return
|
||||
|
||||
sprites = []
|
||||
|
||||
min_x = min_y = 10 ** 9
|
||||
max_x = max_y = -(10 ** 9)
|
||||
|
||||
biggest = 0
|
||||
|
||||
for index, (tx, ty) in enumerate(trail):
|
||||
fraction = (index + 1) / count
|
||||
|
||||
sprite_radius = max(
|
||||
2,
|
||||
int(radius * (0.35 + 0.6 * fraction)),
|
||||
)
|
||||
|
||||
level = min(
|
||||
len(_GLOW_LEVELS) - 1,
|
||||
int(fraction * len(_GLOW_LEVELS)),
|
||||
)
|
||||
|
||||
position = (
|
||||
int(round(tx)) - sprite_radius,
|
||||
int(round(ty)) - sprite_radius,
|
||||
)
|
||||
|
||||
sprites.append(
|
||||
(
|
||||
self._glow(ball.hue, sprite_radius, level),
|
||||
position,
|
||||
)
|
||||
)
|
||||
|
||||
biggest = max(biggest, sprite_radius)
|
||||
|
||||
min_x = min(min_x, tx)
|
||||
max_x = max(max_x, tx)
|
||||
min_y = min(min_y, ty)
|
||||
max_y = max(max_y, ty)
|
||||
|
||||
bounds = pygame.Rect(
|
||||
int(min_x) - biggest - 1,
|
||||
int(min_y) - biggest - 1,
|
||||
int(max_x - min_x) + biggest * 2 + 3,
|
||||
int(max_y - min_y) + biggest * 2 + 3,
|
||||
)
|
||||
|
||||
items.append(
|
||||
(
|
||||
_LAYER_GLOW,
|
||||
bounds,
|
||||
("trail", sprites),
|
||||
)
|
||||
)
|
||||
|
||||
# ========================================================
|
||||
# Drawing one collected item
|
||||
# ========================================================
|
||||
|
||||
def _draw_item(self, draw):
|
||||
kind = draw[0]
|
||||
|
||||
screen = self.screen
|
||||
|
||||
if kind == "sprite":
|
||||
screen.blit(
|
||||
draw[1],
|
||||
draw[2],
|
||||
special_flags=pygame.BLEND_RGB_ADD,
|
||||
)
|
||||
|
||||
elif kind == "trail":
|
||||
for sprite, position in draw[1]:
|
||||
screen.blit(
|
||||
sprite,
|
||||
position,
|
||||
special_flags=pygame.BLEND_RGB_ADD,
|
||||
)
|
||||
|
||||
elif kind == "ring":
|
||||
_kind, color, center, radius, width = draw
|
||||
|
||||
pygame.draw.circle(
|
||||
screen,
|
||||
color,
|
||||
center,
|
||||
radius,
|
||||
width,
|
||||
)
|
||||
|
||||
elif kind == "body":
|
||||
(
|
||||
_kind,
|
||||
points,
|
||||
body,
|
||||
rim,
|
||||
highlight_position,
|
||||
highlight_radius,
|
||||
highlight_color,
|
||||
) = draw
|
||||
|
||||
pygame.draw.polygon(screen, body, points)
|
||||
|
||||
# Anti-aliased outline in the rim colour smooths the
|
||||
# jagged polygon fill edge.
|
||||
pygame.draw.aalines(screen, rim, True, points)
|
||||
|
||||
pygame.draw.circle(
|
||||
screen,
|
||||
highlight_color,
|
||||
highlight_position,
|
||||
highlight_radius,
|
||||
)
|
||||
|
||||
elif kind == "launcher":
|
||||
launcher = draw[1]
|
||||
points = launcher.get_points()
|
||||
|
||||
# Solid metallic tube fill
|
||||
pygame.draw.polygon(screen, (48, 55, 66), points)
|
||||
|
||||
# Highlight & Shadow lines along the tube
|
||||
rad = math.radians(launcher.angle)
|
||||
u_x = math.sin(rad)
|
||||
u_y = math.cos(rad)
|
||||
v_x = -u_y
|
||||
v_y = u_x
|
||||
|
||||
half_w = launcher.tube_width / 2.0
|
||||
length = launcher.length
|
||||
|
||||
# Highlight line along left inner edge
|
||||
hl_start = (
|
||||
launcher.pivot_x - v_x * (half_w * 0.4),
|
||||
launcher.pivot_y - v_y * (half_w * 0.4),
|
||||
)
|
||||
hl_end = (
|
||||
launcher.pivot_x + u_x * length - v_x * (half_w * 0.4),
|
||||
launcher.pivot_y + u_y * length - v_y * (half_w * 0.4),
|
||||
)
|
||||
pygame.draw.line(
|
||||
screen,
|
||||
(115, 126, 142),
|
||||
hl_start,
|
||||
hl_end,
|
||||
width=max(2, int(half_w * 0.45)),
|
||||
)
|
||||
|
||||
# Shadow line along right inner edge
|
||||
sh_start = (
|
||||
launcher.pivot_x + v_x * (half_w * 0.5),
|
||||
launcher.pivot_y + v_y * (half_w * 0.5),
|
||||
)
|
||||
sh_end = (
|
||||
launcher.pivot_x + u_x * length + v_x * (half_w * 0.5),
|
||||
launcher.pivot_y + u_y * length + v_y * (half_w * 0.5),
|
||||
)
|
||||
pygame.draw.line(
|
||||
screen,
|
||||
(26, 30, 38),
|
||||
sh_start,
|
||||
sh_end,
|
||||
width=max(2, int(half_w * 0.35)),
|
||||
)
|
||||
|
||||
# Dark nozzle rim at tip
|
||||
p_bot_right = points[2]
|
||||
p_bot_left = points[3]
|
||||
rim_points = [
|
||||
(
|
||||
launcher.pivot_x + u_x * (length - 6.0) - v_x * half_w,
|
||||
launcher.pivot_y + u_y * (length - 6.0) - v_y * half_w,
|
||||
),
|
||||
(
|
||||
launcher.pivot_x + u_x * (length - 6.0) + v_x * half_w,
|
||||
launcher.pivot_y + u_y * (length - 6.0) + v_y * half_w,
|
||||
),
|
||||
p_bot_right,
|
||||
p_bot_left,
|
||||
]
|
||||
pygame.draw.polygon(screen, (32, 36, 44), rim_points)
|
||||
|
||||
# Crisp metallic outline
|
||||
pygame.draw.polygon(screen, (78, 88, 102), points, width=2)
|
||||
pygame.draw.aalines(screen, (95, 108, 124), True, points)
|
||||
|
||||
# ========================================================
|
||||
# Colours and glow sprites
|
||||
# ========================================================
|
||||
|
||||
def _hue_rgb(self, hue, saturation, value):
|
||||
key = (
|
||||
int(hue) % 360,
|
||||
int(saturation * 50),
|
||||
int(value * 50),
|
||||
)
|
||||
|
||||
color = self._color_cache.get(key)
|
||||
|
||||
if color is None:
|
||||
r, g, b = colorsys.hsv_to_rgb(
|
||||
(key[0] % 360) / 360.0,
|
||||
saturation,
|
||||
max(0.0, min(1.0, value)),
|
||||
)
|
||||
|
||||
color = (
|
||||
int(r * 255),
|
||||
int(g * 255),
|
||||
int(b * 255),
|
||||
)
|
||||
|
||||
self._color_cache[key] = color
|
||||
|
||||
return color
|
||||
|
||||
def _glow(self, hue, radius, level):
|
||||
# Soft radial gradient, cached by (hue bucket, size, level).
|
||||
radius = max(2, min(int(radius), 96))
|
||||
|
||||
bucket = int(hue // 6) % 60
|
||||
|
||||
key = (bucket, radius, level)
|
||||
|
||||
sprite = self._glow_cache.get(key)
|
||||
|
||||
if sprite is None:
|
||||
color = self._hue_rgb(bucket * 6 + 3, 0.9, 1.0)
|
||||
|
||||
gain = _GLOW_LEVELS[level]
|
||||
|
||||
sprite = pygame.Surface((radius * 2, radius * 2))
|
||||
|
||||
sprite.fill((0, 0, 0))
|
||||
|
||||
for i in range(radius, 0, -1):
|
||||
t = 1.0 - i / radius
|
||||
|
||||
k = (t ** 1.8) * gain
|
||||
|
||||
pygame.draw.circle(
|
||||
sprite,
|
||||
(
|
||||
int(color[0] * k),
|
||||
int(color[1] * k),
|
||||
int(color[2] * k),
|
||||
),
|
||||
(radius, radius),
|
||||
i,
|
||||
)
|
||||
|
||||
sprite = sprite.convert()
|
||||
|
||||
self._glow_cache[key] = sprite
|
||||
|
||||
return sprite
|
||||
|
||||
# ========================================================
|
||||
# Timing and shutdown
|
||||
# ========================================================
|
||||
|
||||
def get_delta_time(self):
|
||||
delta_time = self.clock.tick(TARGET_FPS) / 1000.0
|
||||
|
||||
return max(0.0, min(delta_time, 0.05))
|
||||
|
||||
def close(self):
|
||||
pygame.mouse.set_visible(True)
|
||||
|
||||
pygame.quit()
|
||||
@@ -0,0 +1,228 @@
|
||||
# vision.py
|
||||
#
|
||||
# Runs everything that looks at the camera - screen calibration and
|
||||
# sticker detection - on its own background thread, so the render /
|
||||
# physics loop never waits on OpenCV. (Before this, a detection pass
|
||||
# ran inside the game loop and every pass was a small visible hitch.)
|
||||
# OpenCV releases the GIL while it works, so the two really do run
|
||||
# side by side.
|
||||
#
|
||||
# The game loop only ever calls snapshot(), which is a cheap lock +
|
||||
# a few attribute reads. The obstacle list it returns is the SAME
|
||||
# list object until something actually changed (Detector publishes a
|
||||
# new list only on change), so physics/rendering can detect changes
|
||||
# with a plain identity check.
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from config import (
|
||||
DETECTION_SCALE,
|
||||
MONITOR_DETECTION_INTERVAL,
|
||||
OBSTACLE_DETECTION_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
class Vision:
|
||||
def __init__(self, camera, calibration, detector):
|
||||
self.camera = camera
|
||||
self.calibration = calibration
|
||||
self.detector = detector
|
||||
|
||||
self._lock = threading.Lock()
|
||||
|
||||
self._calibrated = False
|
||||
self._frozen = False
|
||||
self._obstacles = []
|
||||
self._message = "Camera not connected yet."
|
||||
|
||||
# Bumped by request_reset(); a result computed against an
|
||||
# older generation is discarded instead of being published.
|
||||
self._generation = 0
|
||||
self._reset_pending = False
|
||||
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="vision",
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
# ========================================================
|
||||
# Public API (called from the game loop)
|
||||
# ========================================================
|
||||
|
||||
def start(self):
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
|
||||
def request_reset(self):
|
||||
"""Forget calibration and stickers; start over."""
|
||||
with self._lock:
|
||||
self._generation += 1
|
||||
self._reset_pending = True
|
||||
self._calibrated = False
|
||||
self._obstacles = []
|
||||
|
||||
def snapshot(self):
|
||||
"""(calibrated, camera_frozen, obstacles, camera_message)."""
|
||||
with self._lock:
|
||||
return (
|
||||
self._calibrated,
|
||||
self._frozen,
|
||||
self._obstacles,
|
||||
self._message,
|
||||
)
|
||||
|
||||
# ========================================================
|
||||
# Thread
|
||||
# ========================================================
|
||||
|
||||
def _run(self):
|
||||
last_frame_id = None
|
||||
last_monitor_pass = 0.0
|
||||
last_obstacle_pass = 0.0
|
||||
last_error_print = 0.0
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
with self._lock:
|
||||
reset = self._reset_pending
|
||||
self._reset_pending = False
|
||||
generation = self._generation
|
||||
|
||||
if reset:
|
||||
self.calibration.reset()
|
||||
self.detector.reset()
|
||||
|
||||
last_frame_id = None
|
||||
last_monitor_pass = 0.0
|
||||
last_obstacle_pass = 0.0
|
||||
|
||||
try:
|
||||
frame, frame_id = self.camera.read_with_id()
|
||||
except RuntimeError as error:
|
||||
self._publish(
|
||||
generation,
|
||||
message=str(error),
|
||||
)
|
||||
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
|
||||
frozen = self.camera.is_frozen()
|
||||
|
||||
if frame_id == last_frame_id:
|
||||
# Same frame as last time: nothing new to
|
||||
# analyse (the camera delivers ~30 fps, the
|
||||
# loop spins faster). Still keep the frozen
|
||||
# flag fresh in case frames stopped entirely.
|
||||
self._publish(generation, frozen=frozen)
|
||||
|
||||
time.sleep(0.004)
|
||||
continue
|
||||
|
||||
last_frame_id = frame_id
|
||||
|
||||
if frozen:
|
||||
# A stuck feed must not be trusted for
|
||||
# calibration or stickers.
|
||||
self._publish(
|
||||
generation,
|
||||
frozen=True,
|
||||
message="",
|
||||
)
|
||||
continue
|
||||
|
||||
now = time.monotonic()
|
||||
|
||||
calibrated = self.calibration.is_calibrated()
|
||||
|
||||
# Until locked on, look at every new frame; once
|
||||
# locked, only re-verify the monitor occasionally.
|
||||
if (
|
||||
not calibrated
|
||||
or now - last_monitor_pass
|
||||
>= MONITOR_DETECTION_INTERVAL
|
||||
):
|
||||
self.calibration.update(frame)
|
||||
|
||||
last_monitor_pass = now
|
||||
|
||||
calibrated = self.calibration.is_calibrated()
|
||||
|
||||
obstacles = None
|
||||
|
||||
if (
|
||||
calibrated
|
||||
and now - last_obstacle_pass
|
||||
>= OBSTACLE_DETECTION_INTERVAL
|
||||
):
|
||||
small = self.calibration.transform_frame(
|
||||
frame,
|
||||
DETECTION_SCALE,
|
||||
)
|
||||
|
||||
# Keep the sticker detector's idea of "what does
|
||||
# white look like right now" in sync with what
|
||||
# calibration is continuously learning off the
|
||||
# ring - see Calibration.white_reference().
|
||||
self.detector.set_white_reference(
|
||||
self.calibration.white_reference()
|
||||
)
|
||||
|
||||
obstacles = self.detector.detect(small)
|
||||
|
||||
last_obstacle_pass = time.monotonic()
|
||||
|
||||
self._publish(
|
||||
generation,
|
||||
calibrated=calibrated,
|
||||
frozen=False,
|
||||
message="",
|
||||
obstacles=obstacles,
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
# Never let one bad frame kill the thread (and with
|
||||
# it, all vision until the next restart).
|
||||
now = time.monotonic()
|
||||
|
||||
if now - last_error_print > 5.0:
|
||||
print(
|
||||
"[VISION] unexpected error, "
|
||||
f"continuing: {error!r}"
|
||||
)
|
||||
|
||||
last_error_print = now
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
def _publish(
|
||||
self,
|
||||
generation,
|
||||
calibrated=None,
|
||||
frozen=None,
|
||||
message=None,
|
||||
obstacles=None,
|
||||
):
|
||||
with self._lock:
|
||||
if generation != self._generation:
|
||||
# A reset happened while this pass was running; its
|
||||
# results describe the old state.
|
||||
return
|
||||
|
||||
if calibrated is not None:
|
||||
self._calibrated = calibrated
|
||||
|
||||
if frozen is not None:
|
||||
self._frozen = frozen
|
||||
|
||||
if message is not None:
|
||||
self._message = message
|
||||
|
||||
if obstacles is not None:
|
||||
self._obstacles = obstacles
|
||||
Reference in New Issue
Block a user