Skip to content
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

feat: Enhance take_screenshot for multi-monitor support #792

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

onyedikachi-david
Copy link

@onyedikachi-david onyedikachi-david commented Jun 22, 2024

Fixes #766
/claim #766

What kind of change does this PR introduce?

Feature

Summary

This PR introduces support for multi-monitor setups in the take_screenshot function. It includes the following changes:

  • Implemented get_current_monitor to determine the monitor where the cursor is currently located.
  • Modified take_screenshot to use get_current_monitor for capturing the correct monitor.
  • Added comprehensive tests for take_screenshot with multiple monitors.
  • Mocked get_current_monitor and mss.mss in tests to simulate multiple monitor configurations.
  • Ensured take_screenshot correctly handles multiple monitors and returns the expected screenshot.

Checklist

  • My code follows the style guidelines of OpenAdapt
  • I have performed a self-review of my code
  • If applicable, I have added tests to prove my fix is functional/effective
  • I have linted my code locally prior to submission
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (e.g. README.md, requirements.txt)
  • New and existing unit tests pass locally with my changes

How can your code be run and tested?

To run and test the code:

  1. Ensure you have the necessary dependencies installed.
  2. Run the test suite using pytest:
pytest tests/openadapt/test_monitors.py
  1. Verify that all tests pass, including the new tests for multi-monitor support.

Other information

No additional context needed.

@abrichr
Copy link
Member

abrichr commented Jun 22, 2024

Thank you @onyedikachi-david ! What do you think about saving taking a screenshot of all of the available monitors, rather than just the active one? This can provide useful additional context to the model if something changes on one of the screens.

@onyedikachi-david
Copy link
Author

onyedikachi-david commented Jun 22, 2024

Thank you @onyedikachi-david ! What do you think about saving taking a screenshot of all of the available monitors, rather than just the active one? This can provide useful additional context to the model if something changes on one of the screens.

Okay, I'll implement it. just that I was being mindful of how it is being used across the codebase. If there are more than one monitors, then a tuple or list will be returned, which will affect the screenshot function usage across the codebase.

@abrichr
Copy link
Member

abrichr commented Jun 23, 2024

Thank you please do. Maybe keep the current functionality behind a config param.

@onyedikachi-david
Copy link
Author

Thank you please do. Maybe keep the current functionality behind a config param.

New take_screenshot function using config:

def take_screenshot() -> list:
    """Take screenshots of the current monitor or all available monitors.

    Returns:
        list of PIL.Image.Image: A list of screenshot images.
    """
    with mss.mss() as sct:
        monitors = sct.monitors
        screenshots = []
        
        if config.CAPTURE_ALL_MONITORS:
            for monitor in monitors[1:]:  # Skip the first entry which is a union of all monitors
                sct_img = sct.grab(monitor)
                image = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
                screenshots.append(image)
        else:
            current_monitor = get_current_monitor(monitors)
            sct_img = sct.grab(current_monitor)
            image = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
            screenshots.append(image)
        
        return screenshots

If I got you right, I should go ahead and change it to have a list return type, if that is the case, then its current implementation in models.py

 @classmethod
    def take_screenshot(cls: "Screenshot") -> "Screenshot":
        """Capture a screenshot."""
        image = utils.take_screenshot()
        screenshot = Screenshot(image=image)
        return screenshot

will change to this:

@classmethod
    def take_screenshot(cls: "Screenshot") -> list:
        """Capture a screenshot."""
        images = take_screenshot(all_monitors=all_monitors)
        screenshots = [cls(image=image) for image in images]
        return screenshots

And its usage will also change:

screenshots = Screenshot.take_screenshot(all_monitors=True)
for idx, screenshot in enumerate(screenshots):
    screenshot.image.show(title=f"Monitor {idx + 1}")

@abrichr
Copy link
Member

abrichr commented Jun 23, 2024

I think we want to return a single PIL.Image containing all screenshots. Any way to determine relative positioning of the screens?

@onyedikachi-david
Copy link
Author

I think we want to return a single PIL.Image containing all screenshots. Any way to determine relative positioning of the screens?

Yes, it is best to return PIL.Image. Let me think of a way to go about it.

@onyedikachi-david
Copy link
Author

@abrichr I just implemented the requested changes.

@onyedikachi-david
Copy link
Author

@abrichr I don't know if you've found time to review the PR.

Copy link
Member

@abrichr abrichr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for putting this together @onyedikachi-david ! And thank you for your patience.

I left a few comments concerning performance. Because we call take_screenshot in a tight loop during recording, it's important that it is as performant as possible.

Can you please run python -m openadapt.record with CAPTURE_ALL_MONITORS = True and = False (while recording similar behavior, e.g. opening the calculator and clicking 2 x 3), and paste the performance plots here? (The path to the performance plot is logged to stdout at the end of the recording.)

return image
PIL.Image.Image: The screenshot image.
"""
with mss.mss() as sct:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to re-use the global SCT here? Creating a new instance impacts performance; see https://python-mss.readthedocs.io/usage.html#intensive-use

combined_image = Image.new("RGB", (total_width, total_height))

for monitor in monitors[1:]: # Skip the first entry which is a union of all monitors
sct_img = sct.grab(monitor)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to grab once and then recombine? Again the issue is performance.

openadapt/utils.py Show resolved Hide resolved
@onyedikachi-david
Copy link
Author

Thank you for putting this together @onyedikachi-david ! And thank you for your patience.

I left a few comments concerning performance. Because we call take_screenshot in a tight loop during recording, it's important that it is as performant as possible.

Can you please run python -m openadapt.record with CAPTURE_ALL_MONITORS = True and = False (while recording similar behavior, e.g. opening the calculator and clicking 2 x 3), and paste the performance plots here? (The path to the performance plot is logged to stdout at the end of the recording.)

You're welcome. Thanks for the feedback and all. Two issues though:

  1. I don't have an external monitor to test.
  2. This one isn't much of an issue though, I'll see how to run it on my Linux machine (although it didn't work last time I tried); my Mac OS crashed yesterday.

Just give me awhile to implement the requested changes.

@onyedikachi-david
Copy link
Author

Please review @abrichr, just implemented the requested changes, no screenshot yet though. Yet to fix out my Mac OS crash

if config.CAPTURE_ALL_MONITORS:
# Grab all monitors at once
sct_img = SCT.grab(SCT.monitors[0]) # Grab the union of all monitors
full_img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@onyedikachi-david can you please clarify why the rest of this block is necessary? Why not just return full_img directly?


config = Config()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be necessary. Please replace with from openadapt.config import config like it's used elsewhere.

@onyedikachi-david
Copy link
Author

onyedikachi-david commented Jul 16, 2024

@abrichr I'm sorry for keeping a stale PR. I hope it isn't blocking anything. I'm still trying to get my macOS setup together. I was using a Hackintosh (dual-booted with Linux), but it got corrupted while I was reinstalling Docker. I'm still figuring out how to resolve this without making things worse. I don't want to lose my Linux files/partition, which is still functional.

@abrichr
Copy link
Member

abrichr commented Jul 17, 2024

@onyedikachi-david thank you for the update. For now it is not blocking. 🙏

@onyedikachi-david
Copy link
Author

@onyedikachi-david thank you for the update. For now it is not blocking. 🙏

Okay.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support multiple monitors
2 participants