10/31に、AWS主催のイベント「AWS AI Day」が開催されました。
すごくおしゃれなホテルで開催されました!
このイベントの最後に、ハッカソンの決勝戦があり、DNPさんのチームが優勝されました。
デモ動画
ハッカソンの成果物というよりは、 「新しい製品サービスです」というぐらいの完成度 です。
技術的にいくつか興味を持った点があるので、模倣してみました。
技術的に気になった点
今回は、以下のような点を調査しました。
- クリップボードを扱う方法
- デスクトップアプリケーションの作り方
私が一番とっつきやすい、Pythonでの実現方法を調べました。
上記の優勝された作品の実装は不明ですが、模倣する方法を調査しました。
調査1:クリップボードを扱う方法
まず、クリップボードを扱う方法ですが、以下のライブラリーを使用すると実現できました。
- Pyperclip
https://pypi.org/project/pyperclip/ - Pillow
https://pypi.org/project/pillow/
Pyperclipはクリップボードからテキストデータを取得する際に使用し、Pillowはスクリーンショットを取得する際に使用します。
私はWindows環境なのですが、他のOSの場合は追加モジュールが必要のようです。
ドキュメントに以下の記述があります。
On Windows, no additional modules are needed.
On Mac, this module makes use of the pbcopy and pbpaste commands, which should come with the os.
On Linux, this module makes use of the xclip or xsel commands, which should come with the os. Otherwise run “sudo apt-get install xclip” or “sudo apt-get install xsel” (Note: xsel does not always seem to work.)
テキストデータの取得
まず、テキストデータを取得する方法です。
import pyperclip
pyperclip.paste()
なんと、これだけです。
Pyperclipはテキストデータだけしか処理できず、スクリーンショットなどをクリップボードに追加した場合は空文字(""
)が返却されます。
スクリーンショットの取得
次はクリップボードに格納されたスクリーンショットを取得する方法です。
from PIL import Image, ImageGrab
image = ImageGrab.grabclipboard()
imageは、Image.Image型のオブジェクトです。
クリップボードに格納されたものではなく、「スクリーンショット取得処理」もあります。
ImageGrab.grab()
クリップボードの変化を検知する方法
ネット上ではクリップボードが変化するまでブロックする「pyperclip.waitForPaste()」や「pyperclip.waitForNewPaste()」というものが紹介されているのですが、私の環境では動作しませんでした。(そもそも関数が存在しない??)
愚直な感じですが、1秒間隔で値を取得する関数を実装しました。
def clipboard_update():
import time
current_val = None
while True:
val = pyperclip.paste()
if type(val) == str and val == "":
val = ImageGrab.grabclipboard()
if not current_val == val:
if type(val) == str:
# クリップボードの値がテキストの場合の処理
if isinstance(val, Image.Image):
# クリップボードの値がテキストの場合の処理
current_val = val
time.sleep(1)
調査2:デスクトップアプリケーションの作り方
Pythonでデスクトップアプリを簡単に構築できる「Flet」というライブラリーを見つけました。
GitHub: https://github.com/flet-dev/flet
普段、StreamlitやGradioを使うことが多いのですが、同じような感覚でデスクトップアプリが作成できると感じました。
Fletの導入
ライブラリーをインストールします。
pip install flet
READMEで紹介されている以下のコードを記述します。
counter.py
import flet
from flet import IconButton, Page, Row, TextField, icons
def main(page: Page):
page.title = "Flet counter example"
page.vertical_alignment = "center"
txt_number = TextField(value="0", text_align="right", width=100)
def minus_click(e):
txt_number.value = str(int(txt_number.value) - 1)
page.update()
def plus_click(e):
txt_number.value = str(int(txt_number.value) + 1)
page.update()
page.add(
Row(
[
IconButton(icons.REMOVE, on_click=minus_click),
txt_number,
IconButton(icons.ADD, on_click=plus_click),
],
alignment="center",
)
)
flet.app(target=main)
実行します
flet counter.py
デスクトップアプリが起動します。
READMEでは
from flet import IconButton, Page, Row, TextField, icons
のように、コントロールを個別にインポートするスタイルですが、Streamlit的に使いたい場合は、
import flet as ft
txt_number = ft.TextField(value="0", text_align="right", width=100)
のような記述も可能です。ドキュメントもこちらのスタイルで記述されています。
実行ファイルにパッケージする
せっかくデスクトップアプリケーションとして作成したので、実行ファイル(exe)にパッケージしたいと思います。
パッケージする場合はPyInstallerが追加で必要です。
pip install pyinstaller
以下のコマンドで生成できます。
flet pack counter.py
flet build
というコマンドもあるのですが、私の環境ではうまく動作させることができませんでした
完成したのがこちら
ソースコード
汚いですが、いつかの自分のために残します
import asyncio
import boto3
import flet as ft
import pyperclip
from PIL import Image, ImageGrab
def image_to_base64(image: Image.Image) -> str:
import base64
import io
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode("utf-8")
def base64_to_byte(image_base64: str):
import base64
img_data = image_base64.encode()
return base64.b64decode(img_data)
def main(page: ft.Page):
page.title = "Clipboard翻訳アプリ(Flet & Bedrock)"
page.scroll = ft.ScrollMode.ALWAYS
page.theme_mode = ft.ThemeMode.LIGHT
# page.vertical_alignment = ft.MainAxisAlignment.CENTER
# page.horizontal_alignment = ft.MainAxisAlignment.CENTER
async def clipboard_update(lv):
import time
current_val = None
def create_card(content: ft.context):
def translate_fn(e):
content, translated = e.control.data
client = boto3.client(
"bedrock-runtime", region_name="us-west-2"
)
if isinstance(content, ft.Text):
response = client.converse(
modelId="us.anthropic.claude-3-haiku-20240307-v1:0",
messages=[
{
"role": "user",
"content": [
{
"text": content.value,
}
],
}
],
system=[
{
"text": "あなたのタスクは翻訳です。ユーザーの入力を日本語に翻訳してください。\n正確な翻訳が望まれます。\n「はいわかりました」などの出力は嫌われます。"
}
],
)
translated.value = response["output"]["message"]["content"][0][
"text"
]
translated.visible = True
if isinstance(content, ft.Image):
response = client.converse(
modelId="us.anthropic.claude-3-haiku-20240307-v1:0",
messages=[
{
"role": "user",
"content": [
{
"image": {
"format": "png",
"source": {
"bytes": base64_to_byte(
content.src_base64
)
},
}
}
],
}
],
system=[
{
"text": "あなたのタスクは翻訳です。画像の内容を日本語に翻訳してください。\n正確な翻訳が望まれます。\n「はいわかりました」などの出力は嫌われます。"
}
],
)
translated.value = response["output"]["message"]["content"][0][
"text"
]
translated.visible = True
page.update()
translated = ft.Text(None, visible=False)
return ft.Card(
content=ft.Column(
[
content,
translated,
ft.FilledButton(
text="翻訳",
data=(content, translated),
on_click=translate_fn,
),
]
)
)
while True:
val = pyperclip.paste()
if type(val) == str and val == "":
val = ImageGrab.grabclipboard()
if not current_val == val:
if type(val) == str:
lv.controls.append(create_card(ft.Text(val)))
if isinstance(val, Image.Image):
base64_img = image_to_base64(val)
lv.controls.append(
create_card(
ft.Image(src_base64=base64_img, fit=ft.ImageFit.CONTAIN)
)
)
page.update()
current_val = val
time.sleep(1)
lv = ft.ListView(expand=1, spacing=10, padding=20, auto_scroll=True)
page.add(lv)
asyncio.run(clipboard_update(lv))
ft.app(main)