-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Allow window ID to be passed to ImageGrab.grab() on macOS #9070
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
Hi. Just for the record, how would a Python user get the window id? |
|
here is a example script: # pip install pyobjc
# pip install pyobjc-core
# pip install pyobjc-framework-quartz
import Quartz
windows = Quartz.CGWindowListCopyWindowInfo(
Quartz.kCGWindowListExcludeDesktopElements, Quartz.kCGNullWindowID
)
for window in windows:
window_id = window["kCGWindowNumber"]
window_name = window["kCGWindowName"]
window_x = window["kCGWindowBounds"]["X"]
window_y = window["kCGWindowBounds"]["Y"]
window_width = window["kCGWindowBounds"]["Width"]
window_height = window["kCGWindowBounds"]["Height"]
window_owner_name = window["kCGWindowOwnerName"]
window_owner_pid = window["kCGWindowOwnerPID"]user can determine which window id to use there is also a tool: |
src/PIL/ImageGrab.py
Outdated
| im_resized = im.resize((right - left, bottom - top)) | ||
| im.close() | ||
| return im_resized | ||
| # manual crop for windowed mode |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's an interesting problem here.
Originally, we did just crop the image using Pillow. However, that changed in #6152, because #6144 reported that it wasn't accounting for scaling on retina displays.
So if you pick a specific window, how would we know whether it was scaled or not? It might be on a retina display or it might not. If this were Swift, I'd say we want the backingScaleFactor for the window. I'm not convinced AppleScript could easily get information about a window just from the id.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will look into whether there is any information about Retina in the EXIF data of the generated images.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
seems no good solution, may be we should just put this on hold.
However, using the -l parameter when capturing a window does have value, as it can create a complete screenshot of an obscured window compared to taking a full screen capture and then cropping it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought of a less elegant method, using -R0,0,100,100 to sample an image and then calculate the ratio, but I cannot ensure that the window obtained with -l and the image sampled with -R are on the same display.
Another option is to behave like the screencapture command line, ignoring the bbox parameter when passing the window parameter.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I created yankeguo#2
One of the screencapture flags is
-o In window capture mode, do not capture the shadow of the window.
Excluding the shadow changes the size of the window. So if we take two screenshots of a window, we can measure the size of the shadow.
On a retina screen, the shadow is twice as wide as it is on a non-retina screen.
It is possible that a window doesn't have a shadow, but perhaps that can be considered an edge case.
…shot capturing on macOS
Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com>
docs/reference/ImageGrab.rst
Outdated
|
|
||
| :param window: | ||
| HWND, to capture a single window. Windows only. | ||
| Capture a single window. On Windows, this is a HWND. On macOS, it uses windowid. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's windowid? There's no other mention in the docs or code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've pushed a commit to describe it as a CGWindowID instead.
Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com>
Erm, I don't think I requested that... |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This pull request adds support for capturing individual windows on macOS by passing a CGWindowID to the ImageGrab.grab() function. The implementation uses the -l flag with macOS's screencapture command.
Key Changes:
- Added window ID support for macOS using
screencapture -lflag - Implemented Retina display detection when both window and bbox are specified
- Updated documentation to reflect macOS support for window parameter
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| src/PIL/ImageGrab.py | Implements window capture for macOS with -l flag, adds Retina detection logic for window+bbox combination, and manual cropping since -R doesn't work with -l |
| docs/reference/ImageGrab.rst | Documents that window parameter now accepts CGWindowID on macOS and notes version 12.1.0 for macOS support |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ["screencapture", "-l", str(window), "-o", "-x", filepath] | ||
| ) | ||
| with Image.open(filepath) as im_no_shadow: | ||
| retina = im.width - im_no_shadow.width > 100 |
Copilot
AI
Nov 30, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The threshold of 100 pixels to detect Retina mode (im.width - im_no_shadow.width > 100) appears arbitrary and could be fragile. This assumes:
- The shadow is always more than 100 pixels wider in non-Retina mode
- The shadow scaling difference is consistent across different window sizes
Consider documenting why 100 pixels is the appropriate threshold, or using a relative threshold (e.g., percentage difference) instead of an absolute pixel count.
| retina = im.width - im_no_shadow.width > 100 | |
| # Detect Retina mode by checking if the width difference is >30% of the no-shadow image width | |
| retina = (im.width - im_no_shadow.width) / im_no_shadow.width > 0.3 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current comment is
Determine if the window was in Retina mode or not by capturing it without the shadow, and checking how different the width is
I think it's reasonably apparent that we're using an absolute threshold
| if window: | ||
| args += ["-l", str(window)] | ||
| elif bbox: | ||
| left, top, right, bottom = bbox | ||
| args += ["-R", f"{left},{top},{right-left},{bottom-top}"] | ||
| subprocess.call(args + ["-x", filepath]) | ||
| im = Image.open(filepath) | ||
| im.load() | ||
| os.unlink(filepath) | ||
| if bbox: | ||
| im_resized = im.resize((right - left, bottom - top)) | ||
| im.close() | ||
| return im_resized | ||
| if window: | ||
| # Determine if the window was in Retina mode or not | ||
| # by capturing it without the shadow, | ||
| # and checking how different the width is | ||
| fh, filepath = tempfile.mkstemp(".png") | ||
| os.close(fh) | ||
| subprocess.call( | ||
| ["screencapture", "-l", str(window), "-o", "-x", filepath] | ||
| ) | ||
| with Image.open(filepath) as im_no_shadow: | ||
| retina = im.width - im_no_shadow.width > 100 | ||
| os.unlink(filepath) | ||
|
|
||
| # Since screencapture's -R does not work with -l, | ||
| # crop the image manually | ||
| if retina: | ||
| left, top, right, bottom = bbox | ||
| im_cropped = im.resize( | ||
| (right - left, bottom - top), | ||
| box=tuple(coord * 2 for coord in bbox), | ||
| ) | ||
| else: | ||
| im_cropped = im.crop(bbox) | ||
| im.close() | ||
| return im_cropped | ||
| else: | ||
| im_resized = im.resize((right - left, bottom - top)) | ||
| im.close() | ||
| return im_resized |
Copilot
AI
Nov 30, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new macOS window capture functionality lacks test coverage. While Tests/test_imagegrab.py includes a test_grab_invalid_handle test for Windows window capture, there are no corresponding tests for macOS window capture scenarios.
Consider adding tests for:
- Basic window capture on macOS (
ImageGrab.grab(window=<valid_window_id>)) - Window capture with bbox on macOS
- Invalid window IDs on macOS
This is especially important given the complexity of the Retina detection logic introduced in this PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've created #9321
| im_resized = im.resize((right - left, bottom - top)) | ||
| im.close() | ||
| return im_resized |
Copilot
AI
Nov 30, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The variable name im_cropped is used here, while a similar operation at line 82 uses im_resized. For consistency and accuracy, consider using im_cropped in both places since both operations effectively crop/extract a region from the original image (one uses crop() and one uses resize() with a box parameter, but both achieve cropping).
| im_resized = im.resize((right - left, bottom - top)) | |
| im.close() | |
| return im_resized | |
| im_cropped = im.resize((right - left, bottom - top)) | |
| im.close() | |
| return im_cropped |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a resize operation, not a crop operation.
| im_cropped = im.resize( | ||
| (right - left, bottom - top), | ||
| box=tuple(coord * 2 for coord in bbox), | ||
| ) |
Copilot
AI
Nov 30, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] In Retina mode, the code uses resize() with a box parameter to crop. However, resize() is primarily intended for scaling, not cropping. While this works (the box parameter specifies the region to sample from the source), it's unconventional and potentially confusing.
Consider using a two-step approach for clarity:
if retina:
left, top, right, bottom = bbox
im_cropped = im.crop(tuple(coord * 2 for coord in bbox))
im_cropped = im_cropped.resize((right - left, bottom - top))Or document why resize() with box is used instead of the more idiomatic crop() + resize() combination.
| im_cropped = im.resize( | |
| (right - left, bottom - top), | |
| box=tuple(coord * 2 for coord in bbox), | |
| ) | |
| im_cropped = im.crop(tuple(coord * 2 for coord in bbox)) | |
| im_cropped = im_cropped.resize((right - left, bottom - top)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Calling two methods would not be more efficient, from a performance perspective, than one.
| subprocess.call(args + ["-x", filepath]) | ||
| im = Image.open(filepath) | ||
| im.load() | ||
| os.unlink(filepath) | ||
| if bbox: | ||
| im_resized = im.resize((right - left, bottom - top)) | ||
| im.close() | ||
| return im_resized | ||
| if window: | ||
| # Determine if the window was in Retina mode or not | ||
| # by capturing it without the shadow, | ||
| # and checking how different the width is | ||
| fh, filepath = tempfile.mkstemp(".png") | ||
| os.close(fh) | ||
| subprocess.call( | ||
| ["screencapture", "-l", str(window), "-o", "-x", filepath] |
Copilot
AI
Nov 30, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The subprocess.call() at lines 51, 62-63 don't check the return code. If screencapture fails (e.g., invalid window ID, permission denied), the code will proceed to try opening a potentially non-existent or corrupt file, which could raise an unexpected exception.
Consider checking the return code and raising an appropriate error:
returncode = subprocess.call(args + ["-x", filepath])
if returncode != 0:
raise OSError(f"screencapture failed with return code {returncode}")There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've created #9321
| # Determine if the window was in Retina mode or not | ||
| # by capturing it without the shadow, | ||
| # and checking how different the width is | ||
| fh, filepath = tempfile.mkstemp(".png") | ||
| os.close(fh) | ||
| subprocess.call( | ||
| ["screencapture", "-l", str(window), "-o", "-x", filepath] | ||
| ) | ||
| with Image.open(filepath) as im_no_shadow: | ||
| retina = im.width - im_no_shadow.width > 100 | ||
| os.unlink(filepath) | ||
|
|
||
| # Since screencapture's -R does not work with -l, | ||
| # crop the image manually | ||
| if retina: | ||
| left, top, right, bottom = bbox | ||
| im_cropped = im.resize( | ||
| (right - left, bottom - top), |
Copilot
AI
Nov 30, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When both window and bbox are provided, the window is captured 3 times:
- Line 51: Initial capture with shadow
- Line 62-63: Capture without shadow for Retina detection
- Implicitly through the subprocess calls
This is inefficient and could impact performance, especially for larger windows. Consider whether the Retina detection step is necessary, or if there's a way to determine Retina mode without an additional capture.
| # Determine if the window was in Retina mode or not | |
| # by capturing it without the shadow, | |
| # and checking how different the width is | |
| fh, filepath = tempfile.mkstemp(".png") | |
| os.close(fh) | |
| subprocess.call( | |
| ["screencapture", "-l", str(window), "-o", "-x", filepath] | |
| ) | |
| with Image.open(filepath) as im_no_shadow: | |
| retina = im.width - im_no_shadow.width > 100 | |
| os.unlink(filepath) | |
| # Since screencapture's -R does not work with -l, | |
| # crop the image manually | |
| if retina: | |
| left, top, right, bottom = bbox | |
| im_cropped = im.resize( | |
| (right - left, bottom - top), | |
| # Use the first captured image's dimensions to infer Retina mode. | |
| # If the image width is roughly double the bbox width, it's Retina. | |
| left, top, right, bottom = bbox | |
| bbox_width = right - left | |
| retina = im.width >= bbox_width * 2 | |
| # Since screencapture's -R does not work with -l, | |
| # crop the image manually | |
| if retina: | |
| im_cropped = im.resize( | |
| (bbox_width, bottom - top), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The window is captured twice.
Implicitly through the subprocess calls
This isn't an additional time.
Consider whether the Retina detection step is necessary, or if there's a way to determine Retina mode without an additional capture.
See discussion at #9070 (comment)
Changes proposed in this pull request:
-largument toscreencapturefor macOS, allowing capture a window