From 4ccb398ec763b7d4732d30cb1eacabb89006d7f0 Mon Sep 17 00:00:00 2001 From: Ting Rong You Date: Tue, 31 Mar 2026 00:42:27 +0800 Subject: [PATCH] docs: add mathematical and logic explanations --- config.py | 2 +- debugger.py | 5 ++- logs/test_results.csv | 99 +++++++++++++++++++++++++++++++++++++++++++ main.py | 72 ++++++++++++++++--------------- modes.py | 3 ++ player.py | 30 ++++++------- sound.py | 2 +- tracker.py | 17 +++++--- vision.py | 8 ++-- 9 files changed, 175 insertions(+), 63 deletions(-) diff --git a/config.py b/config.py index 6e35a5f..091f0ea 100644 --- a/config.py +++ b/config.py @@ -64,7 +64,7 @@ BUTTON_COLOR = (50, 50, 50) BUTTON_HOVER_COLOR = (150, 150, 150) # Dark Grey BUTTON_TEXT_COLOR = (255, 255, 255) # White -MENU_HIT_THRESHOLD = 1000 # How hard to hit the button +MENU_HIT_THRESHOLD = 2000 # How hard to hit the button GAMEOVER_COOLDOWN = 5.0 # Seconds to wait before button active #== ADAPTIVE DIFFICULTY SETTINGS ==# diff --git a/debugger.py b/debugger.py index 42b04b3..bfc85b8 100644 --- a/debugger.py +++ b/debugger.py @@ -63,6 +63,7 @@ def draw_vision_pipeline(self, main_frame, raw_frame, gray_frame, mask_frame, vi # Loop through and draw each one with a nice green border and label for y, pip, title in zip(y_positions, pips, titles): + # Overwrite pixel blocks to create tiny debug screen main_frame[y:y+pip_h, x_offset:x_offset+pip_w] = pip cv.rectangle(main_frame, (x_offset, y), (x_offset+pip_w, y+pip_h), (0, 255, 0), 2) @@ -75,8 +76,8 @@ def draw_thermal_debug(self, main_frame, visual_motion): if not config.DEBUG_MODE: return # 1. Calculate numerical intensity (Before color mapping) - mean_val = cv.mean(visual_motion)[0] - _, max_val, _, _ = cv.minMaxLoc(visual_motion) + mean_val = cv.mean(visual_motion)[0] # Calculate average pixel intensity of entire matrix + _, max_val, _, _ = cv.minMaxLoc(visual_motion) # Find min and max pixel and their positions # --- UI TWEAK: Master Y variable to slide the whole block --- base_y = 150 diff --git a/logs/test_results.csv b/logs/test_results.csv index 3542fb4..466abb2 100644 --- a/logs/test_results.csv +++ b/logs/test_results.csv @@ -508,3 +508,102 @@ Timestamp,Mode,Player,Target_Dir,Up_Votes,Left_Votes,Right_Votes,Total_Strong,Do 20:08:03,SINGLE,PLAYER 1,LEFT,0,1665,0,1665,LEFT,VALID,85.0,6.7,9.627,0.0077 20:08:06,SINGLE,PLAYER 1,RIGHT,0,0,407,407,RIGHT,VALID,106.0,5.1,4.197,0.0078 20:22:45,SINGLE,PLAYER 1,RIGHT,148,6,843,997,RIGHT,VALID,118.0,30.9,11.478,0.0062 +20:29:31,SINGLE,PLAYER 1,UP,1596,0,812,2408,UP,VALID,129.0,33.0,26.846,0.0074 +20:29:33,SINGLE,PLAYER 1,RIGHT,336,3702,0,4038,LEFT,WRONG DIRECTION!,109.0,10.7,9.928,0.0093 +20:29:33,SINGLE,PLAYER 1,RIGHT,2428,4991,0,7419,LEFT,WRONG DIRECTION!,126.0,13.8,11.538,0.0077 +20:29:34,SINGLE,PLAYER 1,RIGHT,268,658,19,945,LEFT,WRONG DIRECTION!,110.0,36.0,12.079,0.0085 +20:29:47,SINGLE,PLAYER 1,RIGHT,10,1894,0,1904,LEFT,WRONG DIRECTION!,142.0,8.0,4.899,0.0059 +20:29:50,SINGLE,PLAYER 1,RIGHT,216,3740,1216,5172,LEFT,WRONG DIRECTION!,130.0,17.9,28.991,0.0081 +20:29:50,SINGLE,PLAYER 1,RIGHT,371,1772,313,2456,LEFT,WRONG DIRECTION!,165.0,31.2,20.637,0.0075 +20:29:51,SINGLE,PLAYER 1,RIGHT,4,0,1638,1642,RIGHT,VALID,147.0,11.1,7.467,0.0066 +20:29:53,SINGLE,PLAYER 1,LEFT,1913,2183,1402,5498,LEFT,WEAK LEFT PUNCH!,126.0,13.4,11.294,0.0091 +20:30:04,SINGLE,PLAYER 1,LEFT,35,0,1560,1595,RIGHT,WRONG DIRECTION!,169.0,25.9,5.889,0.0067 +20:30:04,SINGLE,PLAYER 1,LEFT,1023,2914,233,4170,LEFT,VALID,69.0,11.5,32.021,0.0071 +00:15:54,SINGLE,PLAYER 1,UP,1213,16,0,1229,UP,VALID,60.0,9.7,6.66,0.0081 +00:15:55,SINGLE,PLAYER 1,UP,775,0,59,834,UP,VALID,153.0,28.1,7.435,0.0073 +00:16:01,SINGLE,PLAYER 1,RIGHT,635,2286,0,2921,LEFT,WRONG DIRECTION!,116.0,24.7,15.585,0.0065 +00:16:02,SINGLE,PLAYER 1,RIGHT,0,3767,0,3767,LEFT,WRONG DIRECTION!,160.0,13.0,8.142,0.0052 +00:16:03,SINGLE,PLAYER 1,RIGHT,136,2888,0,3024,LEFT,WRONG DIRECTION!,95.0,20.3,13.106,0.0055 +00:16:06,SINGLE,PLAYER 1,RIGHT,362,0,652,1014,RIGHT,VALID,125.0,30.8,19.655,0.0065 +00:16:07,SINGLE,PLAYER 1,RIGHT,0,317,102,419,LEFT,WRONG DIRECTION!,94.0,24.9,15.72,0.0074 +00:16:08,SINGLE,PLAYER 1,RIGHT,0,0,1405,1405,RIGHT,VALID,140.0,9.1,2.857,0.0068 +00:16:15,SINGLE,PLAYER 1,RIGHT,227,357,686,1270,RIGHT,VALID,109.0,4.6,10.728,0.0073 +00:16:20,SINGLE,PLAYER 1,LEFT,1043,1,2545,3589,RIGHT,WRONG DIRECTION!,146.0,29.3,13.535,0.0083 +00:16:21,SINGLE,PLAYER 1,LEFT,15,3324,473,3812,LEFT,VALID,137.0,19.7,11.802,0.0074 +00:23:25,MULTI,PLAYER 1,UP,1057,19,1370,2446,RIGHT,WRONG DIRECTION!,139.0,29.1,8.144,0.0038 +00:23:26,MULTI,PLAYER 1,UP,33,2061,1,2095,LEFT,WRONG DIRECTION!,89.0,9.8,4.461,0.0062 +00:23:26,MULTI,PLAYER 2,RIGHT,1226,0,389,1615,UP,WRONG DIRECTION!,120.0,4.8,11.668,0.0068 +00:23:27,MULTI,PLAYER 2,RIGHT,0,2970,0,2970,LEFT,WRONG DIRECTION!,135.0,7.0,5.402,0.0062 +00:23:27,MULTI,PLAYER 1,UP,303,4766,0,5069,LEFT,WRONG DIRECTION!,127.0,24.6,10.319,0.0081 +00:23:27,MULTI,PLAYER 2,RIGHT,0,1095,0,1095,LEFT,WRONG DIRECTION!,91.0,6.2,12.669,0.008 +00:23:28,MULTI,PLAYER 2,RIGHT,129,0,727,856,RIGHT,VALID,173.0,9.3,7.232,0.0079 +00:23:28,MULTI,PLAYER 1,UP,651,0,3139,3790,RIGHT,WRONG DIRECTION!,142.0,14.0,9.097,0.0076 +00:23:29,MULTI,PLAYER 1,UP,956,169,780,1905,UP,VALID,108.0,28.7,10.364,0.0086 +00:23:30,MULTI,PLAYER 1,LEFT,1173,364,0,1537,UP,WRONG DIRECTION!,111.0,24.2,6.871,0.0074 +00:23:31,MULTI,PLAYER 1,LEFT,622,85,784,1491,RIGHT,WRONG DIRECTION!,121.0,17.6,9.4,0.0078 +00:23:32,MULTI,PLAYER 1,LEFT,38,449,619,1106,RIGHT,WRONG DIRECTION!,125.0,13.5,2.614,0.0062 +00:23:32,MULTI,PLAYER 1,LEFT,655,2157,1877,4689,LEFT,VALID,135.0,26.6,5.185,0.0082 +00:23:34,MULTI,PLAYER 1,RIGHT,23,0,400,423,RIGHT,VALID,123.0,3.4,5.846,0.0074 +00:23:34,MULTI,PLAYER 1,UP,664,2817,449,3930,LEFT,WRONG DIRECTION!,163.0,59.4,12.929,0.0066 +00:23:35,MULTI,PLAYER 1,UP,7,0,518,525,RIGHT,WRONG DIRECTION!,146.0,7.8,12.181,0.0077 +00:23:35,MULTI,PLAYER 1,UP,689,2,16,707,UP,VALID,123.0,19.9,2.676,0.0068 +00:23:36,MULTI,PLAYER 1,UP,0,8,1301,1309,RIGHT,WRONG DIRECTION!,112.0,17.2,5.286,0.0065 +00:23:36,MULTI,PLAYER 1,UP,281,53,304,638,RIGHT,WRONG DIRECTION!,145.0,61.2,5.61,0.0043 +00:23:37,MULTI,PLAYER 1,UP,1137,169,1754,3060,RIGHT,WRONG DIRECTION!,145.0,28.0,2.463,0.0064 +00:23:38,MULTI,PLAYER 1,UP,2088,2,201,2291,UP,VALID,95.0,22.9,1.225,0.0073 +00:23:39,MULTI,PLAYER 1,UP,0,0,535,535,RIGHT,WRONG DIRECTION!,92.0,5.6,7.855,0.0066 +00:23:40,MULTI,PLAYER 1,UP,16,466,303,785,LEFT,WRONG DIRECTION!,158.0,37.3,8.584,0.0082 +00:23:42,MULTI,PLAYER 1,UP,1313,211,1989,3513,RIGHT,WRONG DIRECTION!,134.0,54.7,10.449,0.0069 +00:23:43,MULTI,PLAYER 1,UP,1455,408,414,2277,UP,VALID,175.0,34.7,8.015,0.0075 +00:23:44,MULTI,PLAYER 1,UP,908,0,337,1245,UP,VALID,101.0,30.2,1.802,0.006 +00:23:45,MULTI,PLAYER 2,RIGHT,1502,348,55,1905,UP,WRONG DIRECTION!,111.0,12.2,4.152,0.0078 +00:23:46,MULTI,PLAYER 1,LEFT,200,77,538,815,RIGHT,WRONG DIRECTION!,119.0,31.2,10.246,0.0065 +00:23:47,MULTI,PLAYER 1,LEFT,0,894,0,894,LEFT,VALID,144.0,15.7,19.155,0.008 +00:23:48,MULTI,PLAYER 1,UP,124,50,2056,2230,RIGHT,WRONG DIRECTION!,148.0,73.0,4.368,0.0057 +00:23:51,MULTI,PLAYER 1,UP,494,4,1271,1769,RIGHT,WRONG DIRECTION!,128.0,46.4,3.258,0.005 +00:23:51,MULTI,PLAYER 1,UP,211,2105,0,2316,LEFT,WRONG DIRECTION!,167.0,14.9,8.362,0.0083 +00:23:52,MULTI,PLAYER 1,UP,0,623,0,623,LEFT,WRONG DIRECTION!,167.0,21.4,2.123,0.0075 +00:23:54,MULTI,PLAYER 1,UP,666,171,379,1216,UP,VALID,148.0,47.3,3.127,0.0075 +00:31:10,MULTI,PLAYER 1,UP,0,0,857,857,RIGHT,WRONG DIRECTION!,84.0,10.6,6.037,0.0062 +00:31:12,MULTI,PLAYER 1,UP,0,457,0,457,LEFT,WRONG DIRECTION!,148.0,10.7,3.193,0.0068 +00:31:12,MULTI,PLAYER 1,UP,406,35,13,454,UP,VALID,112.0,24.9,5.695,0.0064 +00:31:15,MULTI,PLAYER 1,RIGHT,119,0,708,827,RIGHT,VALID,134.0,9.9,2.191,0.0062 +00:31:19,MULTI,PLAYER 2,RIGHT,0,0,1175,1175,RIGHT,VALID,140.0,11.3,4.239,0.0058 +00:31:19,MULTI,PLAYER 2,LEFT,0,42,525,567,RIGHT,WRONG DIRECTION!,114.0,25.0,10.502,0.0067 +00:31:22,MULTI,PLAYER 2,LEFT,71,1453,0,1524,LEFT,VALID,103.0,23.1,4.786,0.0067 +00:31:24,MULTI,PLAYER 1,UP,1450,543,289,2282,UP,VALID,159.0,30.5,10.016,0.006 +00:31:25,MULTI,PLAYER 1,RIGHT,7,0,4022,4029,RIGHT,VALID,177.0,16.6,18.871,0.0072 +00:31:27,MULTI,PLAYER 1,LEFT,103,805,260,1168,LEFT,VALID,76.0,9.0,42.075,0.0079 +00:31:28,MULTI,PLAYER 2,LEFT,6604,326,0,6930,UP,WRONG DIRECTION!,113.0,16.3,41.288,0.0073 +00:31:29,MULTI,PLAYER 1,RIGHT,0,92,3901,3993,RIGHT,VALID,122.0,12.6,10.429,0.0071 +00:31:30,MULTI,PLAYER 2,LEFT,1716,264,0,1980,UP,WRONG DIRECTION!,121.0,6.6,8.296,0.0074 +00:31:31,MULTI,PLAYER 2,LEFT,139,915,0,1054,LEFT,VALID,138.0,7.8,8.413,0.0072 +00:31:32,MULTI,PLAYER 2,RIGHT,127,0,727,854,RIGHT,VALID,103.0,30.9,2.819,0.0084 +00:31:34,MULTI,PLAYER 1,LEFT,236,244,647,1127,RIGHT,WRONG DIRECTION!,87.0,22.3,7.259,0.0077 +00:31:35,MULTI,PLAYER 1,LEFT,212,197,1225,1634,RIGHT,WRONG DIRECTION!,134.0,35.2,2.443,0.0082 +00:31:36,MULTI,PLAYER 1,LEFT,643,118,3,764,UP,WRONG DIRECTION!,115.0,33.3,8.565,0.0092 +00:31:37,MULTI,PLAYER 1,LEFT,0,2372,0,2372,LEFT,VALID,106.0,22.6,1.844,0.0083 +00:31:38,MULTI,PLAYER 1,LEFT,0,3360,60,3420,LEFT,VALID,165.0,17.0,2.806,0.0086 +00:31:39,MULTI,PLAYER 1,RIGHT,0,0,3761,3761,RIGHT,VALID,182.0,17.0,5.139,0.0074 +00:31:40,MULTI,PLAYER 1,UP,45,5182,0,5227,LEFT,WRONG DIRECTION!,110.0,13.4,8.873,0.0069 +00:31:40,MULTI,PLAYER 2,LEFT,0,4035,0,4035,LEFT,VALID,152.0,10.4,10.591,0.0076 +00:31:41,MULTI,PLAYER 1,UP,367,31,356,754,UP,VALID,71.0,12.7,4.297,0.0066 +00:31:41,MULTI,PLAYER 1,RIGHT,65,275,195,535,LEFT,WRONG DIRECTION!,117.0,18.2,6.455,0.008 +00:31:45,MULTI,PLAYER 1,RIGHT,59,267,2143,2469,RIGHT,VALID,157.0,18.7,11.856,0.0066 +00:31:46,MULTI,PLAYER 2,LEFT,1609,68,0,1677,UP,WRONG DIRECTION!,125.0,19.0,2.022,0.008 +00:31:46,MULTI,PLAYER 1,LEFT,163,0,373,536,RIGHT,WRONG DIRECTION!,137.0,23.2,1.58,0.0065 +00:31:48,MULTI,PLAYER 2,LEFT,645,6,17,668,UP,WRONG DIRECTION!,118.0,11.7,6.568,0.0063 +00:31:48,MULTI,PLAYER 1,LEFT,335,620,15,970,LEFT,VALID,151.0,25.1,2.2,0.0066 +00:32:02,SINGLE,PLAYER 1,LEFT,95,1270,0,1365,LEFT,VALID,131.0,20.3,5.608,0.0063 +00:32:03,SINGLE,PLAYER 1,UP,0,2177,0,2177,LEFT,WRONG DIRECTION!,179.0,17.1,5.017,0.0071 +00:32:04,SINGLE,PLAYER 1,UP,670,104,40,814,UP,VALID,122.0,25.8,1.451,0.0058 +00:32:08,SINGLE,PLAYER 1,LEFT,39,1711,0,1750,LEFT,VALID,140.0,11.6,5.923,0.0062 +00:32:11,SINGLE,PLAYER 1,RIGHT,101,4795,1345,6241,LEFT,WRONG DIRECTION!,146.0,28.2,10.473,0.0067 +00:32:12,SINGLE,PLAYER 1,RIGHT,2,0,1512,1514,RIGHT,VALID,140.0,5.6,4.246,0.005 +00:32:15,SINGLE,PLAYER 1,RIGHT,492,436,5335,6263,RIGHT,VALID,156.0,52.9,9.512,0.0063 +00:32:16,SINGLE,PLAYER 1,RIGHT,0,3700,0,3700,LEFT,WRONG DIRECTION!,193.0,25.2,1.985,0.0064 +00:32:17,SINGLE,PLAYER 1,RIGHT,0,2000,70,2070,LEFT,WRONG DIRECTION!,131.0,19.8,6.652,0.007 +00:32:18,SINGLE,PLAYER 1,RIGHT,0,183,1469,1652,RIGHT,VALID,179.0,36.6,5.824,0.0081 +00:32:21,SINGLE,PLAYER 1,UP,0,0,639,639,RIGHT,WRONG DIRECTION!,141.0,28.8,1.657,0.0062 +00:32:23,SINGLE,PLAYER 1,UP,0,2488,0,2488,LEFT,WRONG DIRECTION!,137.0,10.1,6.7,0.006 +00:32:29,SINGLE,PLAYER 1,UP,72,1494,563,2129,LEFT,WRONG DIRECTION!,114.0,31.5,2.544,0.0052 diff --git a/main.py b/main.py index 6f1d7d3..264cc5d 100644 --- a/main.py +++ b/main.py @@ -11,13 +11,13 @@ #== Initialization ==# fullscreen = False -cv.namedWindow('Thermal Punch', cv.WINDOW_NORMAL) -cap = cv.VideoCapture(0) -time.sleep(1) +cv.namedWindow('Thermal Punch', cv.WINDOW_NORMAL) # Craate resizable window +cap = cv.VideoCapture(0) # Use primary webcam +time.sleep(1) # Give time for camera to adjust auto exposure and white balance before start capturing frames # Create Audio System sound = SoundManager() -sound.play_music('menu') +sound.play_music('menu') # Menu music starts immediatey # Create UI Screens menu_screen = MainMenuScreen() @@ -45,9 +45,10 @@ last_p_press = 0 show_heatmap = True -ret, frame = cap.read() -if not ret: exit() -frame = cv.resize(frame, (config.WIDTH, config.HEIGHT)) +# Capture base frame +ret, frame = cap.read() # Read the frame from video capture object. Ret is bool, to see if the frame was read successfully +if not ret: exit() # Safely shutdown program if webcam failed +frame = cv.resize(frame, (config.WIDTH, config.HEIGHT)) frame = cv.flip(frame, 1) prev_gray = cv.cvtColor(frame, cv.COLOR_BGR2GRAY) @@ -59,20 +60,20 @@ key = cv.waitKey(1) & 0xFF - # 1. Prepare Frame - frame = cv.flip(frame, 1) - frame = cv.resize (frame, (config.WIDTH, config.HEIGHT)) - gray = cv.cvtColor(frame, cv.COLOR_BGR2GRAY) + # 1. Prepare First Frame + frame = cv.flip(frame, 1) # Convert frame horizontally so seems like a mirror + frame = cv.resize (frame, (config.WIDTH, config.HEIGHT)) # Resize to standard resolution to ensure math calculations take exact same amount of time on any computer + gray = cv.cvtColor(frame, cv.COLOR_BGR2GRAY) # Converts BGR frame into greyscale, strips out uncessary data # 2. Image Processing - mask = pipeline.process_frame(gray) + mask = pipeline.process_frame(gray) # Pass clean grayscale frame into vision pipeline. Returns a binary mask of pure black and white pixel, to isolate moving objects from background # Visualization Base - visual_motion = cv.absdiff(gray, prev_gray) # Keep absdiff purely for the visual heatmap as it creates cool fading thermal effect - heatmap = cv.applyColorMap(visual_motion * 5, cv.COLORMAP_JET) + visual_motion = cv.absdiff(gray, prev_gray) # Calculate diff between current and prev frame. If it hasn't change, then black (0), else higher num (closer to255) + heatmap = cv.applyColorMap(visual_motion * 5, cv.COLORMAP_JET) # Apply grayscal color to JET color, visual_motion * 5 to amplify weak differences if config.WARMUP_FRAMES > 0: - config.WARMUP_FRAMES -= 1 + config.WARMUP_FRAMES -= 1 # 60 sec for the camera to adjust auto exposure and white balance, also to let players see the calibration screen and stand still to let MOG2 learn the background better, reducing false positives in the actual game # Show calibration text overlay = heatmap.copy() cv.rectangle(overlay, (0,0), (config.WIDTH, config.HEIGHT), (0,0,0), -1) @@ -87,6 +88,7 @@ break continue + # State architecture to switch between different behaviour in the game # A total of 5 states: Menu(0), Countdown(1), Playing(2), GameOver(3), Paused(4) #== State 0: Menu ==# if game_state == config.STATE_MENU: msg_to_show = info_message if time.time() < info_timer else "" @@ -94,11 +96,11 @@ # Draw Menu menu_screen.draw(heatmap, msg_to_show) - if time.time() > menu_lock_time: + if time.time() > menu_lock_time: # Avoid direct triggering of menu after exiting a game # Check Input action = menu_screen.check_input(mask) - if key == ord('1'): action = "SINGLE" + if key == ord('1'): action = "SINGLE" # Two options for selecting menu, either punching the menu, or use shortcut key elif key == ord('2'): action = "MULTI" else: action = None @@ -117,7 +119,7 @@ start_time = time.time() #== State 1: Countdown ==# - elif game_state == config.STATE_COUNTDOWN: + elif game_state == config.STATE_COUNTDOWN: # Countdown before starting a fight current_mode.draw_ui_only(heatmap) # Calculate which text to show @@ -142,12 +144,12 @@ elif game_state == config.STATE_PLAYING: current_t = time.time() - total_paused_time - # Calculate what percentage of the screen is currently moving - mog_density = (cv.countNonZero(mask) / (config.WIDTH * config.HEIGHT)) * 100 + # Calculate percentage of the screen is currently moving + mog_density = (cv.countNonZero(mask) / (config.WIDTH * config.HEIGHT)) * 100 # Use it to adjust boss difficulty in 1P (Adaptive Difficulty) mode_name = "SINGLE" if isinstance(current_mode, SingleplayerMode) else "MULTI" - # 1. Update Match Logic + # 1. Update Match Logic, Organize variable to pass vision = {'mask': mask, 'gray': gray, 'prev_gray': prev_gray, @@ -170,7 +172,7 @@ sound.play_music('menu') #== State 4: Paused ==# - elif game_state == config.STATE_PAUSED: + elif game_state == config.STATE_PAUSED: # When user pause a game # Calculate exact time paused frozen_t = pause_start_time - total_paused_time @@ -179,7 +181,7 @@ # Draw Pause UI over it pause_screen.draw(heatmap) - if time.time() - pause_start_time > 1.0: + if time.time() - pause_start_time > 1.0: # Give user some time to move away from the pause menu # 1. Check physical Button Punches action = pause_screen.check_input(mask) @@ -263,41 +265,41 @@ debugger.draw_vision_pipeline(display_frame, frame, gray, mask, visual_motion) debugger.draw_thermal_debug(display_frame, visual_motion) - cv.imshow('Thermal Punch', display_frame) - prev_gray = gray + cv.imshow('Thermal Punch', display_frame) # Show window + prev_gray = gray # Save current frame # Key Controls - if key == ord('q'): + if key == ord('q'): # quit break - elif key == ord('h'): + elif key == ord('h'): # heatmap show_heatmap = not show_heatmap - elif key == ord('f'): + elif key == ord('f'): # fullscreen fullscreen = not fullscreen if fullscreen: cv.setWindowProperty('Thermal Punch', cv.WND_PROP_FULLSCREEN, cv.WINDOW_FULLSCREEN) else: cv.setWindowProperty('Thermal Punch', cv.WND_PROP_FULLSCREEN, cv.WINDOW_NORMAL) - elif key == ord('d'): + elif key == ord('d'): # debug mode config.DEBUG_MODE = not config.DEBUG_MODE - elif key == ord('s'): + elif key == ord('s'): # screenshot if not os.path.exists("screenshots"): os.makedirs("screenshots") # Save Images timestamp = time.strftime("%H%M%S") - heatmap_path = os.path.join("screenshots", f"report_heatmap_{timestamp}.png") - mask_path = os.path.join("screenshots", f"report_mask_{timestamp}.png") + heatmap_path = os.path.join("screenshots", f"report_heatmap_{timestamp}.png") # JET color image + mask_path = os.path.join("screenshots", f"report_mask_{timestamp}.png") # MOG image - cv.imwrite(heatmap_path, heatmap) + cv.imwrite(heatmap_path, heatmap) # Save image to file cv.imwrite(mask_path, mask) print(f"Screenshots successfully saved! ({heatmap_path})") sound.play_sfx("screenshot") - elif key == ord('c'): + elif key == ord('c'): # calibrate, in case some bug happen or something # Reset Camera Memory Instantly (MOG2 Background) pipeline = VisionPipeline() config.WARMUP_FRAMES = 30 # Trigger calibration screen to absorb the MOG2 white flash! print("Camera background memory recalibrated!") - elif key == ord('p') or key == 27: # ESC key + elif key == ord('p') or key == 27: # ESC key # pause if time.time() - last_p_press > 0.5: # Prevent rapid toggling if key is held down last_p_press = time.time() # Reset Cooldown if game_state == config.STATE_PLAYING: diff --git a/modes.py b/modes.py index 8eebe90..f59d450 100644 --- a/modes.py +++ b/modes.py @@ -35,6 +35,7 @@ def update(self, vision, current_time): self.p1.update_stamina(mask) self.p2.update_stamina(mask) + # Calculate player total motion based on their side p1_total_motion = cv.countNonZero(mask[:, 0:config.MID_X]) p2_total_motion = cv.countNonZero(mask[:, config.MID_X:config.WIDTH]) @@ -182,6 +183,7 @@ def update(self, mask, player, current_time, sound, floating_texts): # Heat targeting zone_w = config.WIDTH // 3 zone_motions = [] + # Divide screen into 3 zones, calculate motion density of each for i in range(3): x_start = i * zone_w zone_mask = mask[:, x_start:x_start + zone_w] @@ -398,6 +400,7 @@ def update(self, vision, current_time): elif total_motion > config.ADAPTIVE_MED_MOTION: speed_factor = config.ADAPTIVE_SPEED_MED else: speed_factor = config.ADAPTIVE_SPEED_LOW + # Shrink timestampe user are schedule to attack if hasattr(self.boss, 'action_timer'): self.boss.action_timer -= (0.01 * speed_factor) diff --git a/player.py b/player.py index dc4e81a..3a4120a 100644 --- a/player.py +++ b/player.py @@ -14,13 +14,13 @@ def __init__ (self, name, side_start, side_end, colour_ui): # Dunder function, _ self.x_end = side_end self.health = config.MAX_HEALTH self.stamina = config.MAX_STAMINA - self.hud = PlayerHUD(name, is_left_side=(side_start ==0)) + self.hud = PlayerHUD(name, is_left_side=(side_start==0)) self.stats = {"hits": 0, "crits": 0, "misses": 0, "damage": 0, "overheats": 0, "energy": 0} self.overheated = False self.cooldown_end = 0 self.colour_ui = colour_ui # (B, G, R) - # Motion Tracking + # Motion Tracking self.prev_box_motion = 0 # Set Motion History to 0 self.last_hit_time = 0 # Ensure player can punch immediately (no cooldown on first punch) @@ -30,11 +30,11 @@ def __init__ (self, name, side_start, side_end, colour_ui): # Dunder function, _ def spawn_target(self, last_x=None, last_y=None): """Finds a random spot on THIS player's side""" - padding = 50 # Avoid spawming on the middle screen + padding = 50 # Avoid spawning on the middle screen attempts = 0 directions = ['ANY', 'UP', 'LEFT', 'RIGHT'] req_dir = random.choice(directions) - while attempts < 20: + while attempts < 20: # If after 20 time still too near, then just spawn it x = random.randint(self.x_start + padding, self.x_end - config.TARGET_SIZE - padding) # Ensure target spawn within player's side y = random.randint(80, config.HEIGHT - config.TARGET_SIZE - 50) @@ -52,7 +52,7 @@ def update_stamina(self, mask): # 1. Always track motion, even if overheated, moving still burns calories! # Mask is binary # Black(0) = No motion, White(255) = Motion - roi = mask [:, self.x_start:self.x_end] # Region of Interest (ROI) of player's side + roi = mask [:, self.x_start:self.x_end] # Region of Interest (ROI) of player's side (All rows of the screen but only the columns that belong to the player) total_motion = cv.countNonZero(roi) # Count white pixels in the mask (indicating motion) self.stats['energy'] += total_motion @@ -113,23 +113,23 @@ def check_attack(self, vision, opponent, current_time): tx, ty, tw, th, req_dir = self.target # location of the target - pad = 40 - y1 = max(0, ty - pad) + pad = 40 # Player fast punch might be capture as "motion blur" by the webcam, add padding so it feels more forgiving + y1 = max(0, ty - pad) # To avoid accepting punches outside of the screen y2 = min(config.HEIGHT, ty + th + pad) x1 = max(0, tx - pad) x2 = min(config.WIDTH, tx + tw + pad) - roi = mask[y1:y2, x1:x2] # Region of Interest (ROI) of the target + roi = mask[y1:y2, x1:x2] # Region of Interest (ROI) of the target # If a pixel moved? # Calculate raw intensity of the punch! - vm_roi = visual_motion[y1:y2, x1:x2] - prev_roi = prev_gray[y1:y2, x1:x2] + vm_roi = visual_motion[y1:y2, x1:x2] # How hard is the punch + prev_roi = prev_gray[y1:y2, x1:x2] # Raw, unedited camera frames curr_roi = gray[y1:y2, x1:x2] avg_intensity = cv.mean(vm_roi)[0] _, max_intensity, _, _ = cv.minMaxLoc(vm_roi) box_motion = cv.countNonZero(roi) # Count white pixels in the target area - acceleration = box_motion - self.prev_box_motion # Calculate acceleration by deducting current frame with previous frame + acceleration = abs(box_motion - self.prev_box_motion) # Calculate acceleration by deducting current frame with previous frame self.prev_box_motion = box_motion # Cooldown between hits @@ -150,15 +150,15 @@ def check_attack(self, vision, opponent, current_time): return 0, error_msg, None # 3. If they survived the direction check, calculate the damage! - ys, xs = np.where(roi > 0) + ys, xs = np.where(roi > 0) # Find xy coordinate of all white pixels in the target area if len(xs) > 0: - hit_x = int(np.mean(xs)) + tx + hit_x = int(np.mean(xs)) + tx # Find centroid by calculating the mean hit_y = int(np.mean(ys)) + ty else: hit_x, hit_y = tx + tw//2, ty + th//2 - distance = math.sqrt((hit_x - (tx + tw//2))**2 + (hit_y - (ty + th//2))**2) - accuracy_ratio = max(0, 1 - (distance / (tw // 2))) + distance = math.sqrt((hit_x - (tx + tw//2))**2 + (hit_y - (ty + th//2))**2) # Measure the distance in pixels between the player hit and target area center + accuracy_ratio = max(0, 1 - (distance / (tw // 2))) # Converts distance into percentage (0-1), if distance is 0, 100% accuracy, the further the hit is, the lower the ratio if accuracy_ratio > config.ACCURACY_PERFECT: accuracy_text = "PERFECT!" elif accuracy_ratio > config.ACCURACY_GOOD: accuracy_text = "GOOD" diff --git a/sound.py b/sound.py index 96cead3..12210e9 100644 --- a/sound.py +++ b/sound.py @@ -17,7 +17,7 @@ def __init__(self): self.load_sfx("crit_p2", "assets/sfx/crit_p2.mp3") # Crit Impact on P2 self.load_sfx("hurt_p1", "assets/sfx/hurt_p1.mp3") # P1 Grunt self.load_sfx("hurt_p2", "assets/sfx/hurt_p2.mp3") # P2 Grunt - self.load_sfx("button", "assets/sfx/button.mp3") # Reusing hit sound for button if you don't have a specific one, or add button.mp3 if you have it + self.load_sfx("button", "assets/sfx/button.mp3") # Menu button hit self.load_sfx("screenshot", "assets/sfx/screenshot.mp3") # Screenshot sound # === 2. Define Music Tracks === diff --git a/tracker.py b/tracker.py index fe11de2..a3ec355 100644 --- a/tracker.py +++ b/tracker.py @@ -12,10 +12,10 @@ def analyze_punch(self, mask_roi, prev_gray_roi, curr_gray_roi, req_dir, current self.last_votes = (0, 0, 0, 0, 'NONE') # Start frest on every single frame flow_calculated = False - fist_mask = mask_roi > 0 + fist_mask = mask_roi > 0 # Only care where user fist currently located, ignore the background # Unchained Math: Run it every time a fist is in the box - if np.sum(fist_mask) > 15: + if np.sum(fist_mask) > 15: # Calculate the total area in pixel of the moving object, ignore if less than 15 flow = cv.calcOpticalFlowFarneback(prev_gray_roi, curr_gray_roi, None, 0.5, 3, 15, 3, 5, 1.2, 0) dx = flow[..., 0] dy = flow[..., 1] @@ -37,9 +37,11 @@ def analyze_punch(self, mask_roi, prev_gray_roi, curr_gray_roi, req_dir, current fist_dx = dx[fist_mask] fist_dy = dy[fist_mask] - up_votes = np.sum((fist_dy < -1.0) & (np.abs(fist_dy) > np.abs(fist_dx))) - left_votes = np.sum((fist_dx < -1.0) & (np.abs(fist_dx) > np.abs(fist_dy))) - right_votes = np.sum((fist_dx > 1.0) & (np.abs(fist_dx) > np.abs(fist_dy))) + # Calculate for every pixel in the frame, moving up = negative dy, left = negative dx, right = positive dx + # To find out which is the actual moving direction, since not everytime perfect punch, might be diagonal sometime + up_votes = np.sum((fist_dy < -1.0) & (np.abs(fist_dy) > np.abs(fist_dx))) # Is the fist moving up && more vertical than horizontal? + left_votes = np.sum((fist_dx < -1.0) & (np.abs(fist_dx) > np.abs(fist_dy))) # Is the fist moving left && more horizontal than vertical? + right_votes = np.sum((fist_dx > 1.0) & (np.abs(fist_dx) > np.abs(fist_dy))) # Is the fist moving right && more horizontal than vertical? total_strong_pixels = up_votes + left_votes + right_votes @@ -53,7 +55,9 @@ def analyze_punch(self, mask_roi, prev_gray_roi, curr_gray_roi, req_dir, current # Save the votes so debugger can read them self.last_votes = (up_votes, left_votes, right_votes, total_strong_pixels, dominant_dir) - if max_votes < (total_strong_pixels * 0.35): + # Multi-stage Heuristic filtering + # 35% check if it is a punch + if max_votes < (total_strong_pixels * 0.35): # If winning direction doesn't have at least 35% of the strong pixels, then it's not clear return False, "NO CLEAR DIRECTION!" if max_votes < 10: return False, "STRAIGHT PUNCH!" @@ -62,6 +66,7 @@ def analyze_punch(self, mask_roi, prev_gray_roi, curr_gray_roi, req_dir, current # Dominance Validation if req_dir == 'UP': + # 40% check if it is a clear hit for required target if (up_votes / max(total_strong_pixels, 1)) < 0.40: return False, "NOT A VERTICAL PUNCH!" diff --git a/vision.py b/vision.py index f7c2226..a7be1ad 100644 --- a/vision.py +++ b/vision.py @@ -4,20 +4,22 @@ class VisionPipeline: def __init__(self): # Initialize the advanced background learner - self.bg_subtractor = cv.createBackgroundSubtractorMOG2(history=500, varThreshold=20, detectShadows=False) + # Using Gaussian Mixture-based Background/Foreground Segmentation (Separates foreground from static background) + self.bg_subtractor = cv.createBackgroundSubtractorMOG2(history=500, varThreshold=20, detectShadows=False) # Remember 50 frames, Mahalabonis Threshold of 20 (Lowering means more sensitive to small motion), no shadow detection # Create the 5x5 kernel for morphological noise cleanup - self.kernel = cv.getStructuringElement(cv.MORPH_RECT, (5,5)) + self.kernel = cv.getStructuringElement(cv.MORPH_RECT, (5,5)) # Rectangular kernel, size 5x5 self.last_proc_time = 0 def process_frame(self, gray_frame): """Applies MOG2 and Morphological Filtering to output a clean binary mask.""" # Start Timer + # Timer is just for debug incase the game become lag (i.e, heavy math) t_start = time.perf_counter() # 1. Apply MOG2 Background Subtraction raw_mask = self.bg_subtractor.apply(gray_frame) # 2. Morphological Filtering (MORPH_OPEN erodes noise, then dilates back) - clean_mask = cv.morphologyEx(raw_mask, cv.MORPH_OPEN, self.kernel) + clean_mask = cv.morphologyEx(raw_mask, cv.MORPH_OPEN, self.kernel) # Opening, remove salt-and-pepper noice on webcam # Stop Timer self.last_proc_time = time.perf_counter() - t_start