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

System tray state machine #290

Open
wants to merge 49 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
011d8de
build app.py
jesicasusanto Jun 19, 2023
dcd56b6
Merge branch 'MLDSAI:main' into windows-widget
jesicasusanto Jun 20, 2023
d8c465d
add button
jesicasusanto Jun 20, 2023
4ca59a8
add button
jesicasusanto Jun 20, 2023
dbb9979
implement widget with tkinter
jesicasusanto Jun 23, 2023
cf04900
implement replay inprogress and replay paused
jesicasusanto Jun 23, 2023
323e829
resize image
jesicasusanto Jun 23, 2023
81f6eca
Merge branch 'MLDSAI:main' into windows-widget
jesicasusanto Jun 25, 2023
2f5ecd0
Merge branch 'MLDSAI:main' into windows-widget
jesicasusanto Jun 26, 2023
238ce1d
add kivy widget app
jesicasusanto Jun 26, 2023
ad2c104
position widget above active window for windows
jesicasusanto Jun 26, 2023
b071ec3
add widget macos
jesicasusanto Jun 26, 2023
f765d48
format code with black
jesicasusanto Jun 26, 2023
6096a8f
reusing openadapt.window
jesicasusanto Jun 27, 2023
ecf0e65
split proc to replay_proc and record_proc
jesicasusanto Jun 27, 2023
aa5d1bc
remove unused imports
jesicasusanto Jun 27, 2023
8c99bf3
work in progress
jesicasusanto Jun 27, 2023
59fd3e9
add show_data flag to get_window_state()
jesicasusanto Jun 28, 2023
5e28b26
handle NoneType Error when running stateful
jesicasusanto Jun 28, 2023
6abdee7
Merge branch 'MLDSAI:main' into windows-widget
jesicasusanto Jun 29, 2023
e89ee68
add SCREEN_SCALE to pos widget above active window
jesicasusanto Jun 29, 2023
ca130bc
undo strategies. __init__
jesicasusanto Jun 30, 2023
2d51ff9
make widget move faster
jesicasusanto Jun 30, 2023
23fb1ad
resize logo and delete unsude logo
jesicasusanto Jun 30, 2023
d74385d
fix SHOW_DATA parameter
jesicasusanto Jun 30, 2023
17c545c
fix SHOW_DATA
jesicasusanto Jun 30, 2023
1de4930
fix widget keep moving to top
jesicasusanto Jun 30, 2023
b6d7bc1
remove log
jesicasusanto Jun 30, 2023
895d738
fix widget post on fullscreen & on diff platforms
jesicasusanto Jun 30, 2023
ddead1e
add docstrings and comments
jesicasusanto Jun 30, 2023
ec3f672
add kivy
jesicasusanto Jun 30, 2023
74254f3
fix position_above_active_window args error
jesicasusanto Jun 30, 2023
8d6f8c2
add WIDGET_HEIGHT and WIDGET_WIDTH
jesicasusanto Jun 30, 2023
f962f73
update app1.py with PySide6
jesicasusanto Jul 4, 2023
de071c1
Merge branch 'OpenAdaptAI:main' into windows-widget
jesicasusanto Jul 5, 2023
2da733b
modify tray
jesicasusanto Jul 7, 2023
a2c2d27
Merge branch 'windows-widget' of https://github.com/jesicasusanto/PAT…
jesicasusanto Jul 7, 2023
0c6283b
Merge branch 'OpenAdaptAI:main' into windows-widget
jesicasusanto Jul 12, 2023
1027546
black
jesicasusanto Jul 18, 2023
49d3d32
remove is not None
jesicasusanto Jul 18, 2023
bb45d40
fix merge conflict
jesicasusanto Jul 18, 2023
19afebd
Merge branch 'OpenAdaptAI:main' into windows-widget
jesicasusanto Jul 18, 2023
3a66d88
remove kivy and add pyside6
jesicasusanto Jul 18, 2023
038470e
rename app.py to widget.py
jesicasusanto Jul 20, 2023
e746bc6
add poetry run widget
jesicasusanto Jul 21, 2023
dbed721
black
jesicasusanto Jul 21, 2023
e3afd5a
fix merge conflict
jesicasusanto Jul 25, 2023
443150a
typo
jesicasusanto Jul 25, 2023
7a5e401
fix models
jesicasusanto Aug 2, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 124 additions & 48 deletions openadapt/models.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,38 @@
"""This module defines the models used in the OpenAdapt system."""

from typing import Union
import io

from loguru import logger
from pynput import keyboard
from PIL import Image, ImageChops
from pynput import keyboard
import numpy as np
import sqlalchemy as sa

from openadapt import config, db, utils, window
from openadapt import config, db, window


# https://groups.google.com/g/sqlalchemy/c/wlr7sShU6-k
class ForceFloat(sa.TypeDecorator):
"""Custom SQLAlchemy type decorator for floating-point numbers."""

impl = sa.Numeric(10, 2, asdecimal=False)
cache_ok = True

def process_result_value(self, value, dialect):
def process_result_value(
self,
value: int | float | str | None,
dialect: str,
) -> float | None:
"""Convert the result value to float."""
if value is not None:
value = float(value)
return value


class Recording(db.Base):
"""Class representing a recording in the database."""

__tablename__ = "recording"

id = sa.Column(sa.Integer, primary_key=True)
Expand Down Expand Up @@ -51,15 +63,18 @@ class Recording(db.Base):
_processed_action_events = None

@property
def processed_action_events(self):
def processed_action_events(self) -> list:
"""Get the processed action events for the recording."""
from openadapt import events

if not self._processed_action_events:
self._processed_action_events = events.get_events(self)
return self._processed_action_events



class ActionEvent(db.Base):
"""Class representing an action event in the database."""

__tablename__ = "action_event"

id = sa.Column(sa.Integer, primary_key=True)
Expand All @@ -86,16 +101,23 @@ class ActionEvent(db.Base):
children = sa.orm.relationship("ActionEvent")
# TODO: replacing the above line with the following two results in an error:
# AttributeError: 'list' object has no attribute '_sa_instance_state'
#children = sa.orm.relationship("ActionEvent", remote_side=[id], back_populates="parent")
#parent = sa.orm.relationship("ActionEvent", remote_side=[parent_id], back_populates="children")
# children = sa.orm.relationship(
# "ActionEvent", remote_side=[id], back_populates="parent"
# )
# parent = sa.orm.relationship(
# "ActionEvent", remote_side=[parent_id], back_populates="children"
# ) # noqa: E501

recording = sa.orm.relationship("Recording", back_populates="action_events")
screenshot = sa.orm.relationship("Screenshot", back_populates="action_event")
window_event = sa.orm.relationship("WindowEvent", back_populates="action_events")

# TODO: playback_timestamp / original_timestamp

def _key(self, key_name, key_char, key_vk):
def _key(
self, key_name: str, key_char: str, key_vk: str
) -> Union[keyboard.Key, keyboard.KeyCode, str, None]:
"""Helper method to determine the key attribute based on available data."""
if key_name:
key = keyboard.Key[key_name]
elif key_char:
Expand All @@ -108,18 +130,18 @@ def _key(self, key_name, key_char, key_vk):
return key

@property
def key(self):
logger.trace(
f"{self.name=} {self.key_name=} {self.key_char=} {self.key_vk=}"
)
def key(self) -> Union[keyboard.Key, keyboard.KeyCode, str, None]:
"""Get the key associated with the action event."""
logger.trace(f"{self.name=} {self.key_name=} {self.key_char=} {self.key_vk=}")
return self._key(
self.key_name,
self.key_char,
self.key_vk,
)

@property
def canonical_key(self):
def canonical_key(self) -> Union[keyboard.Key, keyboard.KeyCode, str, None]:
"""Get the canonical key associated with the action event."""
logger.trace(
f"{self.name=} "
f"{self.canonical_key_name=} "
Expand All @@ -132,7 +154,8 @@ def canonical_key(self):
self.canonical_key_vk,
)

def _text(self, canonical=False):
def _text(self, canonical: bool = False) -> str | None:
"""Helper method to generate the text representation of the action event."""
sep = config.ACTION_TEXT_SEP
name_prefix = config.ACTION_TEXT_NAME_PREFIX
name_suffix = config.ACTION_TEXT_NAME_SUFFIX
Expand All @@ -157,21 +180,25 @@ def _text(self, canonical=False):
else:
if key_name_attr:
text = f"{name_prefix}{key_attr}{name_suffix}".replace(
"Key.", "",
"Key.",
"",
)
else:
text = key_attr
return text

@property
def text(self):
def text(self) -> str:
"""Get the text representation of the action event."""
return self._text()

@property
def canonical_text(self):
def canonical_text(self) -> str:
"""Get the canonical text representation of the action event."""
return self._text(canonical=True)

def __str__(self):
def __str__(self) -> str:
"""Return a string representation of the action event."""
attr_names = [
"name",
"mouse_x",
Expand All @@ -183,16 +210,8 @@ def __str__(self):
"text",
"element_state",
]
attrs = [
getattr(self, attr_name)
for attr_name in attr_names
]
attrs = [
int(attr)
if isinstance(attr, float)
else attr
for attr in attrs
]
attrs = [getattr(self, attr_name) for attr_name in attr_names]
attrs = [int(attr) if isinstance(attr, float) else attr for attr in attrs]
attrs = [
f"{attr_name}=`{attr}`"
for attr_name, attr in zip(attr_names, attrs)
Expand All @@ -202,21 +221,31 @@ def __str__(self):
return rval

@classmethod
def from_children(cls, children_dicts):
children = [
ActionEvent(**child_dict)
for child_dict in children_dicts
]
def from_children(cls: list, children_dicts: list) -> "ActionEvent":
"""Create an ActionEvent instance from a list of child event dictionaries.

Args:
children_dicts (list): List of dictionaries representing child events.

Returns:
ActionEvent: An instance of ActionEvent with the specified child events.

"""
children = [ActionEvent(**child_dict) for child_dict in children_dicts]
return ActionEvent(children=children)


class Screenshot(db.Base):
"""Class representing a screenshot in the database."""

__tablename__ = "screenshot"

id = sa.Column(sa.Integer, primary_key=True)
recording_timestamp = sa.Column(sa.ForeignKey("recording.timestamp"))
timestamp = sa.Column(ForceFloat)
png_data = sa.Column(sa.LargeBinary)
png_diff_data = sa.Column(sa.LargeBinary, nullable=True)
png_diff_mask_data = sa.Column(sa.LargeBinary, nullable=True)

recording = sa.orm.relationship("Recording", back_populates="screenshots")
action_event = sa.orm.relationship("ActionEvent", back_populates="screenshot")
Expand All @@ -231,7 +260,8 @@ class Screenshot(db.Base):
_diff_mask = None

@property
def image(self):
def image(self) -> Image:
"""Get the image associated with the screenshot."""
if not self._image:
if self.sct_img:
self._image = Image.frombytes(
Expand All @@ -242,35 +272,49 @@ def image(self):
"BGRX",
)
else:
buffer = io.BytesIO(self.png_data)
self._image = Image.open(buffer)
self._image = self.convert_binary_to_png(self.png_data)
return self._image

@property
def diff(self):
if not self._diff:
assert self.prev, "Attempted to compute diff before setting prev"
self._diff = ImageChops.difference(self.image, self.prev.image)
def diff(self) -> Image:
"""Get the difference between the current and previous screenshot."""
if self.png_diff_data:
return self.convert_binary_to_png(self.png_diff_data)

assert self.prev, "Attempted to compute diff before setting prev"
self._diff = ImageChops.difference(self.image, self.prev.image)
return self._diff

@property
def diff_mask(self):
if not self._diff_mask:
if self.diff:
self._diff_mask = self.diff.convert("1")
def diff_mask(self) -> Image:
"""Get the difference mask between the current and previous screenshot."""
if self.png_diff_mask_data:
return self.convert_binary_to_png(self.png_diff_mask_data)

if self.diff:
self._diff_mask = self.diff.convert("1")
return self._diff_mask

@property
def array(self):
def array(self) -> np.ndarray:
"""Get the NumPy array representation of the image."""
return np.array(self.image)

@classmethod
def take_screenshot(cls):
def take_screenshot(cls: "Screenshot") -> "Screenshot":
"""Capture a screenshot."""
# avoid circular import
from openadapt import utils

sct_img = utils.take_screenshot()
screenshot = Screenshot(sct_img=sct_img)
return screenshot

def crop_active_window(self, action_event):
def crop_active_window(self, action_event: ActionEvent) -> None:
"""Crop the screenshot to the active window defined by the action event."""
# avoid circular import
from openadapt import utils

window_event = action_event.window_event
width_ratio, height_ratio = utils.get_scale_ratios(action_event)

Expand All @@ -282,8 +326,35 @@ def crop_active_window(self, action_event):
box = (x0, y0, x1, y1)
self._image = self._image.crop(box)

def convert_binary_to_png(self, image_binary: bytes) -> Image:
"""Convert a binary image to a PNG image.

Args:
image_binary (bytes): The binary image data.

Returns:
Image: The PNG image.
"""
buffer = io.BytesIO(image_binary)
return Image.open(buffer)

def convert_png_to_binary(self, image: Image) -> bytes:
"""Convert a PNG image to binary image data.

Args:
image (Image): The PNG image.

Returns:
bytes: The binary image data.
"""
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()


class WindowEvent(db.Base):
"""Class representing a window event in the database."""

__tablename__ = "window_event"

id = sa.Column(sa.Integer, primary_key=True)
Expand All @@ -301,11 +372,14 @@ class WindowEvent(db.Base):
action_events = sa.orm.relationship("ActionEvent", back_populates="window_event")

@classmethod
def get_active_window_event(cls):
def get_active_window_event(cls: "WindowEvent") -> "WindowEvent":
"""Get the active window event."""
return WindowEvent(**window.get_active_window_data())


class PerformanceStat(db.Base):
"""Class representing a performance statistic in the database."""

__tablename__ = "performance_stat"

id = sa.Column(sa.Integer, primary_key=True)
Expand All @@ -317,6 +391,8 @@ class PerformanceStat(db.Base):


class MemoryStat(db.Base):
"""Class representing a memory usage statistic in the database."""

__tablename__ = "memory_stat"

id = sa.Column(sa.Integer, primary_key=True)
Expand Down
Loading