NTTドコモR&Dの技術ブログです。

タッチ操作できないディスプレイをタッチ操作できるようにした

1. はじめに

本記事はNTTドコモR&D Advent Calendar 2024 の14日目の記事です。

はじめまして。NTTドコモ クロステック開発部、入社2年目の中村匠です。 普段の業務では画像認識AIを使って「自動運転遠隔管制」「生物多様性の定量評価」などに取り組んでいます。

今回の記事の内容は上記業務とはほとんど関係なく、画像認識AIを使って自分の作りたいものを作って勝手に紹介します! それでも良いという方はぜひお読みください!

2. つくったものについて

Z世代ど真ん中の自分はスマホやタブレットが当たり前の環境で育ってきたので、PCのディスプレイでもタッチ操作ができないと不便に感じます。 特にシンプルなタスク(例えば画面上のランダムな位置に表示された1から10の数字を順番にクリックするのをイメージしてください。)においては、マウスやトラックパッドに比べてタッチ操作の方が高速で直感的に操作できると思います。

ですがタッチ操作可能なディスプレイはそうでないものに比べて3~4倍の値段です。自分が使うディスプレイをすべてタッチ操作対応のものに変えるのはコストがかかりすぎてしまいます。 ということでどんなディスプレイでもタッチ操作可能にできる便利アイテムを作ってみました!!

…とはいえ本当にタッチ操作ができるようにディスプレイを改造するのはとても大変なのでそんなことはしてません。(できません)

ではどうするかというと、 スマホの中に映ったディスプレイを画面越しに触ることで疑似的にタッチ操作を実現します。

疑似タッチ操作のイメージ図

3. 実装

3.1. システム概要

「スマホのカメラに映ったディスプレイ」→「実際のディスプレイ」への位置合わせは、ディスプレイの4隅に貼り付けたQRコードを基準として座標変換することで実現します。 今回のような用途でQRコードを使うメリットはそれなりにあると思っていて、例えば

  • ディスプレイ番号などの情報をQRコードに埋め込んでおけば、複数のディスプレイにも簡単に拡張可能
  • 接続先の情報もQRコードに埋め込んでおけば、複数のPC用にも拡張可能

あたりが考えられます。デメリットはディスプレイの見た目がダサくなることです。

システム概要

3.2. ディスプレイの準備

QRコードを印刷してディスプレイの4隅に貼り付けます。 内容はなんでもいいのですが今回はそれぞれ「1」「2」「3」「4」をあらわすように作成しました。 任意の内容のQRコードは下記サイトなどで作成できます。

QRコード(二次元バーコード)作成【無料】

3.3. クライアント(Android)側の実装

Androidアプリを作った経験がなく、AndroidStudioをインストールするところからだったのですがChatGPTの力を借りながらどうにか実装しました。 カメラに映ったQRコードの中心座標とタッチした座標をUDPで送信しています。


QR読み取り、タッチ入力などの処理を行うコードが以下です。(一部を抜粋)

private fun processImageProxy(scanner: BarcodeScanner, imageProxy: ImageProxy) {
    val cameraWidth = imageProxy.width
    val cameraHeight = imageProxy.height
    val scaleX = screenWidth.toFloat() / cameraWidth
    val scaleY = screenHeight.toFloat() / cameraHeight
    val mediaImage = imageProxy.image ?: return
    val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)

    scanner.process(image)
        .addOnSuccessListener { barcodes ->
            val qrDataList = mutableListOf<Pair<PointF, String>>()

            for (barcode in barcodes) {
                if (barcode.format == Barcode.FORMAT_QR_CODE) {
                    val qrCodeBounds = barcode.boundingBox
                    val qrContent = barcode.rawValue

                    if (qrCodeBounds != null && qrContent != null) {
                        val centerX = qrCodeBounds.exactCenterX()
                        val centerY = qrCodeBounds.exactCenterY()
                        //val centerPoint = PointF(centerX, centerY)
                        val centerPoint = PointF(centerX * scaleX, centerY * scaleY)

                        qrDataList.add(centerPoint to qrContent)
                        Log.d("QRCodeScanner", "QR Code Detected: center = $centerPoint, content = $qrContent")
                    }
                }
            }

            if (qrDataList.isNotEmpty()) {
                UdpSender.sendData(qrDataList = qrDataList, touchLocation = lastTouchLocation, isTouching = isTouching)
            }
        }
        .addOnFailureListener {
            Log.e("QRCodeScanner", "Barcode detection failed", it)
        }
        .addOnCompleteListener {
            imageProxy.close()
        }
}

3.4. ホスト(Windows)側の実装

UDPで4つのQRコードの座標とタッチした座標を受け取り、ディスプレイのどの部分を触っているかを計算します。

3.4.1. 座標変換

変換にはnumpyのアフィン変換計算関数を使います。 ディスプレイ上の座標に持ち込むために、変換行列A「4つのQRコードの座標 → (0,0)、(1920,0)、(0,1080)、(1920,1080)」を毎フレーム計算して 、UDPで送られてきたタッチ座標に積算します(ディスプレイサイズは幅1920,高さ1080を想定)。

QRコードの座標はディスプレイの4隅と完全には一致しないので違和感のない座標になるように微調整用の変換行列Bを入れます。 Aの変換だけを入れた状態で一度プログラム実行し、変換行列B「(ディスプレイの左上、右上、左下、右下を普段のスマホ使用時と同じ感覚でタッチしたときの座標4つ)*A → (0,0)、(1920,0)、(0,1080)、(1920,1080)」を計算しておきます。

実際に使用する際は、(タッチした座標) * A * B を計算して座標変換することで違和感なくカーソルが表示されるようになります。 Bの方の変換前の座標は今回は事前に確かめた値をコード中に手打ちしていますが、座標調整機能として可変にするのがベターかと思います。

3.4.2. マウス操作

変換したタッチ座標をマウス入力として扱って操作します。

今回マウス操作の部分をPythonのpyautoguiライブラリで実装したのですが、ここが速度のボトルネックになってしまい若干遅延があります。 (使用技術選定ミスです。)
一応下記リンクの手法のようにpyautoguiライブラリ内のsleep()をコメントアウトすることで少し高速化しました。

PyAutoGUIの入力速度の超高速化 | Withcation

ホスト側のコードは以下です。

import socket
import re
import pyautogui
import numpy as np

# 任意の点(x, y)を変換
def transform_point(x, y, A):
    point = np.array([x, y, 1])
    transformed_point = np.dot(point, A)
    return transformed_point

# UDPサーバーの設定
UDP_IP = "0.0.0.0"  # すべてのインターフェースで受信
UDP_PORT = "--<ポート番号>--"    # アプリと同じポート番号に設定

#ディスプレイサイズ
display_width = 1920
display_height = 1080

# ソケットの設定
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((UDP_IP, UDP_PORT))
print(f"Listening on UDP port {UDP_PORT}...")

pre_tap_flag = False
tmp_dic = {}
try:
    while True:
        data, addr = sock.recvfrom(1024)  # 1024バイトまで受信

        data = data.decode('utf-8')
        '''
        UDPで送られてくるデータを処理して以下のように格納
        tap_flag = True or False #タッチされていたらTrue
        tmp = {1:(<qrコード「1」のx座標>,<qrコード「1」のy座標>),2:(<qrコード「2」のx座標>,<qrコード「2」のy座標>)...} #カメラでの座標を格納
        '''

        if tap_flag == "true":
            tap_position = list(map(float,ret[-2].split(" ")[1].split(",")[:2]))

            # 元の4点と変換後の4点を設定
            points_original = np.array([[tmp[i][0], tmp[i][1], 1] for i in range(4)])
            points_transformed = np.array([[0, 0],[display_width, 0],[0, display_height],[display_width, display_height]])
            A, _, _, _ = np.linalg.lstsq(points_original, points_transformed, rcond=None)

            points_original = np.array([[138 ,-328, 1],[1790, -336, 1],[139, 1558, 1],[1797, 1664, 1]]) #微調整用。事前にBをいれずに実行してディスプレイ4隅を触った時の変換後座標を入力
            points_transformed = np.array([[0, 0],[display_width, 0],[0, display_height],[display_width, display_height]])
            B, _, _, _ = np.linalg.lstsq(points_original, points_transformed, rcond=None)

            transformed_coordinates = transform_point(tap_position[0], tap_position[1], A)
            print(transformed_coordinates)
            transformed_coordinates = transform_point(transformed_coordinates[0], transformed_coordinates[1], B)
            print(transformed_coordinates)

            #移動がないときにもずっとmoveToが呼ばれていると重くなるので移動距離判定
            if np.linalg.norm(np.array(pyautogui.position())-np.array(transformed_coordinates)) > 30:
                pyautogui.moveTo(max(1,min(display_width,transformed_coordinates[0])),max(1,min(display_height,transformed_coordinates[1])))

            if pre_tap_flag == False:
                pyautogui.mouseDown()
                pre_tap_flag = True

        else:
            if pre_tap_flag:
                pyautogui.mouseUp()
                pre_tap_flag = False

except KeyboardInterrupt:
    print("\nServer stopped by user (Ctrl+C).")
finally:
    sock.close()
    print("Socket closed.")

4. デモ

というわけで完成したものがこちらです!

画面操作の様子
スマホの画面内に映ったPC画面をタッチすると、実際のPC画面でもその部分にマウスカーソルが移動している様子が確認できます。 ドラッグ、ダブルクリックなどの操作も問題なくできています。 (pycustomで実装してしまったことによる遅延が少々あります。タップ操作では気にならない程度ですが、ドラッグ操作すると0.1秒ほど遅れてカーソルが追従してきます。)

精密な操作が必要な場合は結局マウスに使い勝手が劣りそうですが、ベッドに寝っ転がってPCを操作したいなどライトな用途では使いたくなる場面ありそうです。 またキーボード入力もスマホ側のフリックでできるようにすると入力がスマホ一つで完結してもっと使いどころ増えるかもです。


他の使い道として、ゲーム的なコンテンツとの相性よさそうに感じがしました...。。 ということでUnityで適当にオブジェクトを配置して作ったなんちゃって的当てゲームを遊んでみました。

ゲーム的なアプリケーションでの使用
いい感じです。 タッチ操作ができるのはもちろんですが、スマホ本体を動かして位置調節もできるのでシューティングゲームとの親和性高そうです...! マウス入力としてではなくゲームのプログラムに直接UDPを送れば複数のスマホからの入力も扱えるので、手軽に参加可能なシューティングゲームのようなコンテンツでイベントなどで展示すると盛り上がりそうだなと思いました。

5. おわりに

本記事ではどんなディスプレイでもタッチ操作可能にできる便利アイテムを作りました! カメラに映してタッチするという仕様から精密な操作は難しくなってしまうのですが、ネットサーフィンなどの軽い使い方やゲームでの利用で使えそうな面白いものができたと思います。 それではまた!