AWS AI Day ハッカソンの優勝作品が素晴らしかったので、模倣してみました

10/31に、AWS主催のイベント「AWS AI Day」が開催されました。

https://aws.amazon.com/jp/events/ai-day/

:::note
すごくおしゃれなホテルで開催されました！
:::

このイベントの最後に、ハッカソンの決勝戦があり、DNPさんのチームが優勝されました。

https://prtimes.jp/main/html/rd/p/000000715.000069194.html

デモ動画

<iframe width="560" height="315" src="https://www.youtube.com/embed/T2jHNYrHOXw?si=Bqv6vK6sGA_0Jg_6" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

ハッカソンの成果物というよりは、 **「新しい製品サービスです」というぐらいの完成度** です。


技術的にいくつか興味を持った点があるので、模倣してみました。


## 技術的に気になった点

今回は、以下のような点を調査しました。

* クリップボードを扱う方法
* デスクトップアプリケーションの作り方

私が一番とっつきやすい、Pythonでの実現方法を調べました。

:::note
上記の優勝された作品の実装は不明ですが、模倣する方法を調査しました。
:::

## 調査１：クリップボードを扱う方法

まず、クリップボードを扱う方法ですが、以下のライブラリーを使用すると実現できました。

* Pyperclip
    https://pypi.org/project/pyperclip/
* Pillow
    https://pypi.org/project/pillow/

Pyperclipはクリップボードからテキストデータを取得する際に使用し、Pillowはスクリーンショットを取得する際に使用します。

:::note warn
私は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.)
:::


### テキストデータの取得

まず、テキストデータを取得する方法です。

```python
import pyperclip

pyperclip.paste()
```

なんと、これだけです。

Pyperclipはテキストデータだけしか処理できず、スクリーンショットなどをクリップボードに追加した場合は空文字（`""`）が返却されます。

### スクリーンショットの取得

次はクリップボードに格納されたスクリーンショットを取得する方法です。

```python
from PIL import Image, ImageGrab

image = ImageGrab.grabclipboard()
```

imageは、Image.Image型のオブジェクトです。

:::note
クリップボードに格納されたものではなく、「スクリーンショット取得処理」もあります。
[ImageGrab.grab()](https://pillow.readthedocs.io/en/stable/reference/ImageGrab.html#PIL.ImageGrab.grab)
:::

### クリップボードの変化を検知する方法

:::note warn
ネット上ではクリップボードが変化するまでブロックする「pyperclip.waitForPaste()」や「pyperclip.waitForNewPaste()」というものが紹介されているのですが、私の環境では動作しませんでした。（そもそも関数が存在しない？？）
:::

愚直な感じですが、1秒間隔で値を取得する関数を実装しました。

```python
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)
```

## 調査２：デスクトップアプリケーションの作り方

Pythonでデスクトップアプリを簡単に構築できる「Flet」というライブラリーを見つけました。

https://flet.dev/

GitHub: https://github.com/flet-dev/flet

:::note
普段、StreamlitやGradioを使うことが多いのですが、同じような感覚でデスクトップアプリが作成できると感じました。
:::

### Fletの導入

ライブラリーをインストールします。

```shell
pip install flet
```

[README](https://github.com/flet-dev/flet)で紹介されている以下のコードを記述します。

<details><summary>counter.py</summary>

```python: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)
```


</details>



実行します

```shell
flet counter.py
```

デスクトップアプリが起動します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/85762b29-d3d7-8bd0-0535-9e818b0f9666.png)

:::note
READMEでは

```python
from flet import IconButton, Page, Row, TextField, icons
```

のように、コントロールを個別にインポートするスタイルですが、Streamlit的に使いたい場合は、

```python
import flet as ft

txt_number = ft.TextField(value="0", text_align="right", width=100)
```

のような記述も可能です。[ドキュメント](https://flet.dev/docs/)もこちらのスタイルで記述されています。
:::

### 実行ファイルにパッケージする

せっかくデスクトップアプリケーションとして作成したので、実行ファイル（exe）にパッケージしたいと思います。
パッケージする場合は[PyInstaller](https://pypi.org/project/pyinstaller/)が追加で必要です。

```shell
pip install pyinstaller
```

以下のコマンドで生成できます。

```shell
flet pack counter.py
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/3f6d783a-dab6-fa38-1700-19cc0cad5ee0.png)

:::note warn
`flet build`というコマンドもあるのですが、私の環境ではうまく動作させることができませんでした
:::

## 完成したのがこちら

<iframe width="560" height="315" src="https://www.youtube.com/embed/KG6C2YuWdTs?si=JRNQDXg-EI-SuwRZ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


<details><summary>ソースコード</summary>

汚いですが、いつかの自分のために残します

```python
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)
```

</details>