# 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