こんなものを作りました。
カメラに映した手で、画面上のものをツマむことができます。
KTCFを使うと簡単にできました。
KTCFとは
deepinsightという会社が提供している、非接触GUI開発キット「KAIBER Touchless Controll Framework」です。
カメラに映した指の動きでHTMLを操作できる、タッチレスなアプリを簡単に作れますよって触れ込みです。
Hello World
ついてくるマニュアルに従うと、とりあえず簡単なデモが立ち上がります。
謎のキャラクター「かいばくん」を指でつまんで動かすことができます。かわいいですね。
htmlはこんな感じです。jQueryで書いていますね。
cssは割愛していますが、classのつけ外しでかいばくんの表情を変えているようです。
js部分と実際の動きを見るとおり、click
とかgrab
とかのイベントで駆動しているのですね。
drop
, lost
, grabMove
については、要素に発火される訳ではないようです。ドラッグ&ドロップの実装が強引に感じますが、仕組みは簡単なようです。
<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<link rel="icon" href="images/favicon.ico">
<title>kaibakun</title>
</head>
<body>
<div id="kaibakun_div">
<img class="kaibakun" src="./images/kaibakun_size_trans.png"></img>
</div>
<div id="log_div" class="grabscroll">
</div>
<!-- ================ script ================ -->
<script type="text/javascript" src="/eel.js"></script>
<script type="text/javascript" src="/lib/jquery_3.6.0.js"></script>
<script type="text/javascript" src="/lib/ktcf.js"></script>
<script>
let kaibakun = $(".kaibakun");
let frame = $("#kaibakun_div");
jQuery.prototype.setTmpClass = function (_class, _ms) {
this.addClass(_class);
return setTimeout(
(() => {
this.removeClass(_class);
}).bind(this),
_ms
);
}
function appendLog(_text) {
var logdiv = $("#log_div");
logdiv.append($("<p>" + _text + "</p>"));
logdiv[0].scrollTo(0, logdiv[0].scrollHeight);
}
$(function () {
appendLog("event handlers - pinch, click, hovered, grab, grabMove, drop and lost");
});
// ======= kaibakun ========
kaibakun.on("click", function () {
appendLog("click (kaibakun)");
kaibakun.setTmpClass("click", 500);
});
kaibakun.on("pinch", function () {
appendLog("pinch (kaibakun)");
kaibakun.setTmpClass("pinch", 500);
});
kaibakun.on("hovered", function () {
var split = $("#log_div p:last").html().split(" ");
if (split[0] === "hovered") {
$("#log_div p:last").html(
"hovered ... " + (Number(split[2]) + 1)
);
} else {
appendLog("hovered ... 1");
}
});
kaibakun.on("grab", function (event) {
appendLog("grab pid[" + event.detail.pid + "]");
kaibakun.setTmpClass("grab", 500);
$(this).addClass("grabNow");
var img_offset_x = parseInt(event.detail.x) - parseInt($(this).offset().left);
var img_offset_y = parseInt(event.detail.y) - parseInt($(this).offset().top);
var max_top = frame.height() - $(this).height();
var max_left = frame.width() - $(this).width();
$(this).data({
"pid": event.detail.pid,
"frame_offset_x": parseInt(frame.offset().left),
"frame_offset_y": parseInt(frame.offset().top),
"img_offset_x": img_offset_x,
"img_offset_y": img_offset_y,
"max_top": max_top,
"max_left": max_left
});
});
$("body").on("drop", function (event) {
if (kaibakun.hasClass("grabNow")) {
if (event.detail.pid == kaibakun.data("pid")) {
appendLog("drop pid[" + event.detail.pid + "]");
kaibakun.setTmpClass("drop", 500);
kaibakun.removeClass('grabNow');
kaibakun.removeClass("grabMove");
}
}
});
$(document).on("lost", function (event) {
if (kaibakun.hasClass("grabNow")) {
if (event.detail.pid == kaibakun.data("pid")) {
appendLog("lost pid[" + event.detail.pid + "]");
kaibakun.setTmpClass("lost", 500);
kaibakun.removeClass('grabNow');
kaibakun.removeClass("grabMove");
}
}
});
$(document).on("grabMove", function (event) {
if (kaibakun.hasClass("grabNow")) {
if (event.detail.pid == kaibakun.data("pid")) {
var split = $("#log_div p:last").html().split(" ");
if (split[0] === "grabMove") {
$("#log_div p:last").html(
"grabMove pid[" + event.detail.pid + "] ... " + (Number(split[3]) + 1)
);
} else {
appendLog("grabMove pid[" + event.detail.pid + "] ... 1");
}
kaibakun.addClass("grabMove");
var top = parseInt(event.detail.y) - kaibakun.data("frame_offset_y") - kaibakun.data("img_offset_y");
var left = parseInt(event.detail.x) - kaibakun.data("frame_offset_x") - kaibakun.data("img_offset_x");
if (kaibakun.data("max_top") < top) {
top = kaibakun.data("max_top");
}
if (top < 0) {
top = 0;
}
if (kaibakun.data("max_left") < left) {
left = kaibakun.data("max_left");
}
if (left < 0) {
left = 0;
}
kaibakun.css("top", top);
kaibakun.css("left", left);
}
}
});
</script>
</body>
</html>
何を作るか
このキットは「非接触GUI」なので、みんなが触るものを作りたいです。
また、小さいですがカメラを置くことになるので、どこかに据え置いておく必要もあります。
そこで考えました。「お弁当リクエストアプリ」
- アプリは朝立ち上がる
- お弁当が欲しい人は、自分の名前の駒を、欲しいお弁当の枠に入れておく
- 時間になると、アプリはそれぞれのお弁当が何個必要かslackに書き込んで、終了する
本当はネット注文かお弁当屋さんへのメールかまでを自動化したいけれど、ひとまずslackに結果を書くことにします。
お弁当リクエストアプリを作る
外観を作る
簡単に作ります。名前の駒は画像で作りました。
時計と、11時まで待てないときの強制終了ボタンも用意しました。
<div id="pieces">
<img class="piece" src="images/hujiwara.png"></img>
<img class="piece" src="images/kobayashi.png"></img>
<img class="piece" src="images/mori.png"></img>
<img class="piece" src="images/yanagisawa.png"></img>
</div>
<div id="divs">
<div class="menu">ハンバーグ</div>
<div class="menu">サバ味噌</div>
<div class="menu">サバ塩</div>
<div class="menu">シーフードフライ</div>
<div class="menu">生姜焼き</div>
<div class="menu">幕ノ内</div>
<div class="menu">洋風</div>
<div class="menu">メンチ</div>
</div>
<span id="clock"></span>
<button id="forceEnd">終了</button>
cssをいい感じにつけて、こんな感じになりました。
名前の駒.piece
は、後で動かすのでposition: absolute
にする必要があります。
指定されたjsライブラリを読み込む
eel.js, jquery_3.6.0.js, ktcf.js
指定された位置に配置して、この3つのライブラリを読み込むことで、普通のhtmlをアプリのように動かせるようです。
加えて、自作のindex.js
を作っていきます。
<script type="text/javascript" src="/eel.js"></script>
<script type="text/javascript" src="/lib/jquery_3.6.0.js"></script>
<script type="text/javascript" src="/lib/ktcf.js"></script>
<script type="text/javascript" src="index.js"></script>
ハンドラを作る
このアプリでは主に、駒を動かすための、つまむgrab
->つまんだまま動かすgrabMove
->落っことすdrop/lost
、3種類のイベントを使います。
HelloWorldのかいばくんを動かす方法をほとんどそのまま使いました。
実装するべきハンドラのすべてを列挙すると
- 駒のドラッグ&ドロップ
- つまむ
grab
- つまんだまま動かす
grabMove
- 落っことす
drop/lost
- つまむ
- 終了ボタン
- 終了クリック
click
(KTCFのイベントは、マウスイベントのclick
と同じ名前)
- 終了クリック
- 時計
- 時計を動かすインターバル
- 午前11時の終了
駒のドラッグ&ドロップ
HelloWorldのかいばくんを動かす方法をほぼそのまま使いました。
ただし駒は複数あり、複数の手でそれらを操作する可能性を考えてeach
を使って修正します。
- 指先毎に固有のid
pid
が振られる- ずっとカメラに写っているなら同じ
pid
- 別の手には別の
pid
- 一度ひっこめてまた出てきた手は別の
pid
- ずっとカメラに写っているなら同じ
掴んだ時
- 要素にクラス
grabNow
を与え、IDや必要な値をカスタムデータ属性に保持させる - 以下を考慮して、上限値とオフセットを保持
-
frame
は、駒が動ける範囲 - css width/height は左上の相対座標
- event.detail.x/y はポインタの中央の絶対座標
- 最初に掴んだ位置からの移動分だけ要素を動かしたい
-
let frame = $("body");
$(".piece").on("grab", function (event) {
if (0 != event.detail.index || $(this).hasClass("grabNow")) {
return;
}
$(this).addClass("grabNow");
var img_offset_x =
parseInt(event.detail.x) - parseInt($(this).offset().left);
var img_offset_y =
parseInt(event.detail.y) - parseInt($(this).offset().top);
var max_top = frame.height() - $(this).height();
var max_left = frame.width() - $(this).width();
$(this).data({
pid: event.detail.pid,
frame_offset_x: parseInt(frame.offset().left),
frame_offset_y: parseInt(frame.offset().top),
img_offset_x: img_offset_x,
img_offset_y: img_offset_y,
max_top: max_top,
max_left: max_left,
});
});
掴んだまま動いたとき
- クラス
grabNow
を持っている駒は一つではない可能性があるのでeachする - pidが一致した駒を指先の動きに従って動かす
- ただし上限/下限がある
$(document).on("grabMove", function (event) {
$(".piece.grabNow").each(function (idx, ele) {
if (event.detail.pid == $(ele).data("pid")) {
var top =
parseInt(event.detail.y) -
$(ele).data("frame_offset_y") -
$(ele).data("img_offset_y");
var left =
parseInt(event.detail.x) -
$(ele).data("frame_offset_x") -
$(ele).data("img_offset_x");
if ($(ele).data("max_top") < top) {
top = $(ele).data("max_top");
}
if (top < 0) {
top = 0;
}
if ($(ele).data("max_left") < left) {
left = $(ele).data("max_left");
}
if (left < 0) {
left = 0;
}
$(ele).css("top", top);
$(ele).css("left", left);
}
});
});
離したとき
- 普通に離したとき: drop
- 指先を見失ったとき: lost
- どちらにしても、クラス
grabNow
を破棄するだけ
function removeGrabNow(pid) {
$(".piece.grabNow").each(function (idx, ele) {
if (pid == $(ele).data("pid")) {
$(ele).removeClass("grabNow");
}
});
}
$("body").on("drop", function (event) {
removeGrabNow(event.detail.pid);
});
$(document).on("lost", function (event) {
removeGrabNow(event.detail.pid);
});
時計
時計を更新しつつ、時間が来たら、画面の現状をslackに書き込んで終了する。
- sendSlack(): 引数(object)をslackに書き込む
- getOrderList(): メニューに載っている駒の数をobjectにまとめる
- このキット的には「ブラウザを閉じる==アプリの終了」
function getNow() {
var now = new Date();
var hour = ("00" + now.getHours()).slice(-2);
var min = ("00" + now.getMinutes()).slice(-2);
var sec = ("00" + now.getSeconds()).slice(-2);
return hour + ":" + min + "." + sec;
}
setInterval(() => {
let now = getNow();
$("#clock").html(now);
if ("11:00.00" == now) {
sendToSlack(orderList());
window.close();
}
}, 500);
終了ボタン
- クリックイベントはマウスのものと同じ名前
- 11:00になったときと同じ動きをする
$("#forceEnd").click(() => {
sendToSlack(orderList());
window.close();
});
slackへの書き込み
書き込むだけなら、APIやトークンを使わず、Incoming Webhookだけでできるのです。
curlコマンドなら
curl -X POST -H 'Content-type: application/json' --data '{"text":"Hello, World!"}' <URL of Webhook>
jsに埋め込むなら
const url = <URL of Webhook>;
function sendToSlack(data) {
const xhr = new XMLHttpRequest();
xhr.open("POST", slackHookURL, false);
xhr.send(JSON.stringify(data));
}
※ setRequestHeader("Content-Type", "application/json") について
content-typeをjsonに指定すると、pre-flightでCORS policyにブロックされます。
jsからwebhookにPOSTする場合は、pre-flightを実行しない他のTypeを指定する必要があります。
今回の場合では、敢えて明示せずにPOSTするだけで上手くいきました。
https://stackoverflow.com/questions/45752537/slack-incoming-webhook-request-header-field-content-type-is-not-allowed-by-acce
強制終了ボタンを押したとき/午前11時に、これを実行するように仕込みます。
POSTするdataの作り方
https://api.slack.com/messaging/composing
を参考に。
箇条書きにしたかったので、こんな感じに作ります。
{
"type":"section",
"blocks":[
{
"type":"rich_text",
"elements":[
{
"type":"rich_text_list",
"style":"bullet",
"elements":[
{
"type":"rich_text_section",
"elements":[
{
"type":"text",
"text":"箇条書き1"
}
]
},
{
"type":"rich_text_section",
"elements":[
{
"type":"text",
"text":"箇条書き2"
}
]
},
~~~~~~~
]
}
]
}
]
}
朝の起動
タスクスケジューラで朝10時くらいに起動させます。
コード
キットの指定ディレクトリに置いて、起動パスの設定をすることで置き換えられます。