Skip to content

Commit af21cfd

Browse files
AvasamAlgomancer
andauthored
Batch fixes (#176)
* pyright update * lint-and-build cover more files * Get IDirect3dDevice from LearningModelDevice Closes #175 Co-authored-by: Algomancer <[email protected]> * Ensure we're using the right camera size And not OpenCV's default 640x480 * Fix rounding error in displayed decimal * Revert "back to windowed" This reverts commit 9553271. * Fix highest similarity for start image * Fix split below treshold when image is not valid * Detect gray frames from OBS-Camera Co-authored-by: Algomancer <[email protected]>
1 parent 4b06065 commit af21cfd

11 files changed

+107
-60
lines changed

.github/workflows/lint-and-build.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ on:
88
- master
99
paths:
1010
- "**.py"
11-
- "**.pyi"
1211
- "**.ui"
12+
- ".github/workflows/lint-and-build.yml"
13+
- "**/requirements.txt"
1314
pull_request:
1415
branches:
1516
- main
@@ -20,6 +21,8 @@ on:
2021
- "**.py"
2122
- "**.pyi"
2223
- "**.ui"
24+
- ".github/workflows/lint-and-build.yml"
25+
- "**/requirements*.txt"
2326

2427
env:
2528
GITHUB_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ This program can be used to automatically start, split, and reset your preferred
8181
The smaller the selected region, the more efficient it is.
8282
- **Windows Graphics Capture** (fast, most compatible, capped at 60fps)
8383
Only available in Windows 10.0.17134 and up.
84-
Due to current technical limitations, it requires having at least one audio or video Capture Device connected and enabled. Even if it won't be used.
84+
Due to current technical limitations, Windows versions below 10.0.0.17763 require having at least one audio or video Capture Device connected and enabled.
8585
Allows recording UWP apps, Hardware Accelerated and Exclusive Fullscreen windows.
8686
Adds a yellow border on Windows 10 (not on Windows 11).
8787
Caps at around 60 FPS.

scripts/build.ps1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
& "$PSScriptRoot/compile_resources.ps1"
22
pyinstaller `
33
--onefile `
4+
--windowed `
45
--additional-hooks-dir=Pyinstaller/hooks `
56
--icon=res/icon.ico `
67
--splash=res/splash.png `

scripts/requirements-dev.txt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,11 @@ flake8-quotes
2121
flake8-simplify
2222
pep8-naming
2323
pylint>=2.14,<3.0.0 # New checks # 3.0 still in pre-release
24-
pyright>=1.1.270 # Typeshed update
24+
pyright>=1.1.276 # Typeshed update
2525
unify
2626
#
2727
# Run `./scripts/designer.ps1` to quickly open the bundled PyQt Designer.
2828
# Can also be downloaded externally as a non-python package
2929
qt6-applications
3030
# Types
31-
types-d3dshot
32-
types-keyboard
33-
types-pyinstaller
34-
types-pywin32
3531
typing-extensions

src/AutoSplit.py

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ class AutoSplit(QMainWindow, design.Ui_MainWindow):
8989
reset_highest_similarity = 0.0
9090

9191
# Ensure all other attributes are defined
92-
start_image_split_below_threshold = False
9392
waiting_for_split_delay = False
9493
split_below_threshold = False
9594
run_start_time = 0.0
@@ -292,7 +291,7 @@ def __load_start_image(self, started_by_button: bool = False, wait_for_delay: bo
292291

293292
self.highest_similarity = 0.0
294293
self.reset_highest_similarity = 0.0
295-
self.start_image_split_below_threshold = False
294+
self.split_below_threshold = False
296295
self.timer_start_image.start(int(1000 / self.settings_dict["fps_limit"]))
297296

298297
QApplication.processEvents()
@@ -312,26 +311,25 @@ def __start_image_function(self):
312311
if start_image_similarity > self.highest_similarity:
313312
self.highest_similarity = start_image_similarity
314313

315-
self.table_current_image_threshold_label.setText(decimal(start_image_threshold))
316314
self.table_current_image_live_label.setText(decimal(start_image_similarity))
317315
self.table_current_image_highest_label.setText(decimal(self.highest_similarity))
316+
self.table_current_image_threshold_label.setText(decimal(start_image_threshold))
318317

319318
# If the {b} flag is set, let similarity go above threshold first, then split on similarity below threshold
320319
# Otherwise just split when similarity goes above threshold
320+
# TODO: Abstract with similar check in split image
321321
below_flag = self.start_image.check_flag(BELOW_FLAG)
322322

323323
# Negative means belove threshold, positive means above
324324
similarity_diff = start_image_similarity - start_image_threshold
325-
if below_flag \
326-
and not self.start_image_split_below_threshold \
327-
and similarity_diff >= 0:
328-
self.start_image_split_below_threshold = True
325+
if below_flag and not self.split_below_threshold and similarity_diff >= 0:
326+
self.split_below_threshold = True
329327
return
330-
if (below_flag and self.start_image_split_below_threshold and similarity_diff < 0) \
331-
or (not below_flag and similarity_diff >= 0):
328+
if (below_flag and self.split_below_threshold and similarity_diff < 0 and is_valid_image(capture)) \
329+
or (not below_flag and similarity_diff >= 0): # pylint: disable=too-many-boolean-expressions
332330

333331
self.timer_start_image.stop()
334-
self.start_image_split_below_threshold = False
332+
self.split_below_threshold = False
335333

336334
# delay start image if needed
337335
if self.start_image.get_delay_time(self) > 0:
@@ -410,6 +408,7 @@ def __check_fps(self):
410408
while count < CHECK_FPS_ITERATIONS:
411409
capture, is_old_image = self.__get_capture_for_comparison()
412410
_ = image.compare_with_capture(self, capture)
411+
# TODO: If is_old_image=true is always returned, this becomes an infinite loop
413412
if not is_old_image:
414413
count += 1
415414

@@ -648,20 +647,22 @@ def __similarity_threshold_loop(self, number_of_split_images: int, dummy_splits_
648647
frame_interval: float = 1 / self.settings_dict["fps_limit"]
649648
wait_delta = int(frame_interval - (time() - start) % frame_interval)
650649

650+
below_flag = self.split_image.check_flag(BELOW_FLAG)
651651
# if the b flag is set, let similarity go above threshold first,
652652
# then split on similarity below threshold.
653653
# if no b flag, just split when similarity goes above threshold.
654+
# TODO: Abstract with similar check in start image
654655
if not self.waiting_for_split_delay:
655656
if similarity >= self.split_image.get_similarity_threshold(self):
656-
if not self.split_image.check_flag(BELOW_FLAG):
657+
if not below_flag:
657658
break
658659
if not self.split_below_threshold:
659660
self.split_below_threshold = True
660661
QTest.qWait(wait_delta)
661662
continue
662663

663664
elif ( # pylint: disable=confusing-consecutive-elif
664-
self.split_image.check_flag(BELOW_FLAG) and self.split_below_threshold
665+
below_flag and self.split_below_threshold and is_valid_image(capture)
665666
):
666667
self.split_below_threshold = False
667668
break
@@ -813,12 +814,15 @@ def __reset_if_should(self, capture: cv2.Mat | None):
813814
return self.__check_for_reset_state_update_ui()
814815

815816
def __update_split_image(self, specific_image: AutoSplitImage | None = None):
816-
# Splitting/skipping when there are no images left or Undoing past the first image
817817
# Start image is expected to be out of range (index 0 of 0-length array)
818-
if (not specific_image or specific_image.image_type != ImageType.START) \
819-
and self.__is_current_split_out_of_range():
820-
self.reset()
821-
return
818+
if not specific_image or specific_image.image_type != ImageType.START:
819+
# need to reset highest_similarity and split_below_threshold each time an image updates.
820+
self.highest_similarity = 0.0
821+
self.split_below_threshold = False
822+
# Splitting/skipping when there are no images left or Undoing past the first image
823+
if self.__is_current_split_out_of_range():
824+
self.reset()
825+
return
822826

823827
# Get split image
824828
self.split_image = specific_image or self.split_images_and_loop_number[0 + self.split_image_number][0]
@@ -835,10 +839,6 @@ def __update_split_image(self, specific_image: AutoSplitImage | None = None):
835839
loop_tuple = self.split_images_and_loop_number[self.split_image_number]
836840
self.image_loop_value_label.setText(f"{loop_tuple[1]}/{loop_tuple[0].loops}")
837841

838-
self.highest_similarity = 0.0
839-
# need to set split below threshold to false each time an image updates.
840-
self.split_below_threshold = False
841-
842842
def closeEvent(self, a0: QtGui.QCloseEvent | None = None):
843843
"""
844844
Exit safely when closing the window

src/capture_method/VideoCaptureDeviceCaptureMethod.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import TYPE_CHECKING
55

66
import cv2
7+
from pygrabber import dshow_graph
78

89
from capture_method.CaptureMethodBase import CaptureMethodBase
910
from error_messages import CREATE_NEW_ISSUE_MESSAGE, exception_traceback
@@ -12,6 +13,19 @@
1213
if TYPE_CHECKING:
1314
from AutoSplit import AutoSplit
1415

16+
OBS_CAMERA_BLANK = [127, 129, 128]
17+
18+
19+
def is_blank(image: cv2.Mat):
20+
# Running np.all on the entire array is extremely slow.
21+
# Because it always converts the entire array to boolean first
22+
# So instead we loop manually to stop early.
23+
for row in image:
24+
for pixel in row:
25+
if all(pixel != OBS_CAMERA_BLANK):
26+
return False
27+
return True
28+
1529

1630
class VideoCaptureDeviceCaptureMethod(CaptureMethodBase):
1731
capture_device: cv2.VideoCapture
@@ -35,7 +49,14 @@ def __read_loop(self, autosplit: AutoSplit):
3549
# STS_ERROR most likely means the camera is occupied
3650
result = False
3751
image = None
38-
self.last_captured_frame = image if result else None
52+
if not result:
53+
image = None
54+
55+
# Blank frame. Reuse the previous one.
56+
if image is not None and is_blank(image):
57+
continue
58+
59+
self.last_captured_frame = image
3960
self.is_old_image = False
4061
except Exception as exception: # pylint: disable=broad-except # We really want to catch everything here
4162
error = exception
@@ -51,8 +72,20 @@ def __read_loop(self, autosplit: AutoSplit):
5172

5273
def __init__(self, autosplit: AutoSplit):
5374
super().__init__()
75+
filter_graph = dshow_graph.FilterGraph()
76+
filter_graph.add_video_input_device(autosplit.settings_dict["capture_device_id"])
77+
width, height = filter_graph.get_input_device().get_current_format()
78+
filter_graph.remove_filters()
79+
5480
self.capture_device = cv2.VideoCapture(autosplit.settings_dict["capture_device_id"])
5581
self.capture_device.setExceptionMode(True)
82+
# Ensure we're using the right camera size. And not OpenCV's default 640x480
83+
try:
84+
self.capture_device.set(cv2.CAP_PROP_FRAME_WIDTH, width)
85+
self.capture_device.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
86+
# Some cameras don't allow changing the resolution
87+
except cv2.error:
88+
pass
5689
self.stop_thread = Event()
5790
self.capture_thread = Thread(target=lambda: self.__read_loop(autosplit))
5891
self.capture_thread.start()

src/capture_method/WindowsGraphicsCaptureMethod.py

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,9 @@
1111
from winsdk.windows.graphics.capture.interop import create_for_window
1212
from winsdk.windows.graphics.directx import DirectXPixelFormat
1313
from winsdk.windows.graphics.imaging import BitmapBufferAccessMode, SoftwareBitmap
14-
from winsdk.windows.media.capture import MediaCapture
1514

1615
from capture_method.CaptureMethodBase import CaptureMethodBase
17-
from utils import WINDOWS_BUILD_NUMBER, is_valid_hwnd
16+
from utils import WINDOWS_BUILD_NUMBER, get_direct3d_device, is_valid_hwnd
1817

1918
if TYPE_CHECKING:
2019
from AutoSplit import AutoSplit
@@ -33,19 +32,10 @@ def __init__(self, autosplit: AutoSplit):
3332
super().__init__(autosplit)
3433
if not is_valid_hwnd(autosplit.hwnd):
3534
return
36-
# Note: Must create in the same thread (can't use a global) otherwise when ran from LiveSplit it will raise:
37-
# OSError: The application called an interface that was marshalled for a different thread
38-
media_capture = MediaCapture()
39-
item = create_for_window(autosplit.hwnd)
40-
41-
async def coroutine():
42-
await (media_capture.initialize_async() or asyncio.sleep(0))
43-
asyncio.run(coroutine())
4435

45-
if not media_capture.media_capture_settings:
46-
raise OSError("Unable to initialize a Direct3D Device.")
36+
item = create_for_window(autosplit.hwnd)
4737
frame_pool = Direct3D11CaptureFramePool.create_free_threaded(
48-
media_capture.media_capture_settings.direct3_d11_device,
38+
get_direct3d_device(),
4939
DirectXPixelFormat.B8_G8_R8_A8_UINT_NORMALIZED,
5040
1,
5141
item.size,

src/capture_method/__init__.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,22 @@
77
from typing import TYPE_CHECKING, TypedDict
88

99
from pygrabber import dshow_graph
10-
from winsdk.windows.media.capture import MediaCapture
1110

1211
from capture_method.BitBltCaptureMethod import BitBltCaptureMethod
1312
from capture_method.CaptureMethodBase import CaptureMethodBase
1413
from capture_method.DesktopDuplicationCaptureMethod import DesktopDuplicationCaptureMethod
1514
from capture_method.ForceFullContentRenderingCaptureMethod import ForceFullContentRenderingCaptureMethod
1615
from capture_method.VideoCaptureDeviceCaptureMethod import VideoCaptureDeviceCaptureMethod
1716
from capture_method.WindowsGraphicsCaptureMethod import WindowsGraphicsCaptureMethod
18-
from utils import WINDOWS_BUILD_NUMBER
17+
from utils import WINDOWS_BUILD_NUMBER, get_direct3d_device
1918

2019
if TYPE_CHECKING:
2120
from AutoSplit import AutoSplit
2221

2322
WGC_MIN_BUILD = 17134
2423
"""https://docs.microsoft.com/en-us/uwp/api/windows.graphics.capture.graphicscapturepicker#applies-to"""
24+
LEARNING_MODE_DEVICE_BUILD = 17763
25+
"""https://learn.microsoft.com/en-us/uwp/api/windows.ai.machinelearning.learningmodeldevice"""
2526

2627

2728
class Region(TypedDict):
@@ -121,8 +122,8 @@ def __getitem__(self, key: CaptureMethodEnum):
121122
short_description="fast, most compatible, capped at 60fps",
122123
description=(
123124
f"\nOnly available in Windows 10.0.{WGC_MIN_BUILD} and up. "
124-
"\nDue to current technical limitations, it requires having at least one "
125-
"\naudio or video Capture Device connected and enabled. Even if it won't be used. "
125+
f"\nDue to current technical limitations, Windows versions below 10.0.0.{LEARNING_MODE_DEVICE_BUILD}"
126+
"\nrequire having at least one audio or video Capture Device connected and enabled."
126127
"\nAllows recording UWP apps, Hardware Accelerated and Exclusive Fullscreen windows. "
127128
"\nAdds a yellow border on Windows 10 (not on Windows 11)."
128129
"\nCaps at around 60 FPS. "
@@ -166,21 +167,18 @@ def __getitem__(self, key: CaptureMethodEnum):
166167
})
167168

168169

169-
def test_for_media_capture():
170-
async def coroutine():
171-
return await (MediaCapture().initialize_async() or asyncio.sleep(0))
170+
def try_get_direct3d_device():
172171
try:
173-
asyncio.run(coroutine())
174-
return True
172+
return get_direct3d_device()
175173
except OSError:
176-
return False
174+
return None
177175

178176

179177
# Detect and remove unsupported capture methods
180178
if ( # Windows Graphics Capture requires a minimum Windows Build
181179
WINDOWS_BUILD_NUMBER < WGC_MIN_BUILD
182-
# Our current implementation of Windows Graphics Capture requires at least one CaptureDevice
183-
or not test_for_media_capture()
180+
# Our current implementation of Windows Graphics Capture does not ensure we can get an ID3DDevice
181+
or not try_get_direct3d_device()
184182
):
185183
CAPTURE_METHODS.pop(CaptureMethodEnum.WINDOWS_GRAPHICS_CAPTURE)
186184

@@ -202,10 +200,12 @@ class CameraInfo():
202200
name: str
203201
occupied: bool
204202
backend: str
203+
size: tuple[int, int]
205204

206205

207206
async def get_all_video_capture_devices() -> list[CameraInfo]:
208-
named_video_inputs = dshow_graph.FilterGraph().get_input_devices()
207+
filter_graph = dshow_graph.FilterGraph()
208+
named_video_inputs = filter_graph.get_input_devices()
209209

210210
async def get_camera_info(index: int, device_name: str):
211211
backend = ""
@@ -225,7 +225,10 @@ async def get_camera_info(index: int, device_name: str):
225225
# else None
226226
# finally:
227227
# video_capture.release()
228-
return CameraInfo(index, device_name, False, backend)
228+
filter_graph.add_video_input_device(index)
229+
size = filter_graph.get_input_device().get_current_format()
230+
filter_graph.remove_filters()
231+
return CameraInfo(index, device_name, False, backend, size)
229232

230233
future = asyncio.gather(
231234
*[

src/compare.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def compare_l2_norm(source: cv2.Mat, capture: cv2.Mat, mask: cv2.Mat | None = No
4747
error = cv2.norm(source, capture, cv2.NORM_L2, mask)
4848

4949
# The L2 Error is summed across all pixels, so this normalizes
50-
max_error = (source.size ** 0.5) * MAXBYTE \
50+
max_error: float = (source.size ** 0.5) * MAXBYTE \
5151
if not is_valid_image(mask)\
5252
else (3 * np.count_nonzero(mask) * MAXBYTE * MAXBYTE) ** 0.5
5353

0 commit comments

Comments
 (0)