経緯
外国人がやっていた、マインクラフト内でマインクラフトをするというのを真似したくて作ってみた。
概要

このように動画を表示する事ができる
画像の画素に似ている色のブロックを配置することで画像を表示し、連続で行うことで動画を表示する。
最初考えていた方法
はじめは、ブロックを敷き詰める順番を予め計算してテキストファイルなどに保存しておき、1tickごとに読み込んで表示する予定だった。
ステップ1 ファイルからの読み込み
Nukkitのプラグインではコマンドの検知、ブロックの設置ができるのでそれを使い、ファイルの読み込みはJavaのioクラスを使うことにして早速プログラムを書いた。このときブロックの配置は手動でテキストエディタに書き込んでいた。
結果うまく行ったのだが、画像のサイズは4×4程度だったのでまだ問題に気づかなかった。
ステップ2 ファイルの自動生成(言語の選定)
手動でのブロック配置によるテストが終わったので、次はブロック配置ファイルの生成を自動化しようとした。Qiitaでブロックアートに関する記事があったので読んだところ、ブロックの色は画像とのRGBの差で決めるよりもLABで決めるとより色合いが本物らしくなるそうだ。RGBからLABに変換する方法は意外と難しいらしく(逆は簡単)自力で実装するのは諦めた。Pythonのライブラリにそれができるものがあるらしく、Pythonを使って画像からテキストファイルに変換するプログラムを作ることにした。
ステップ3 ファイルの自動生成(実装)
ブロックの用意
画像の色に合わせてブロックを置くのでブロックの色が必要になる。
ブロックの色を取るためにブロックのテクスチャ画像を使用した。使えないブロックを確認してすべて手作業でリネームした。

マインクラフトのブロックはIDだけでなくメタデータによって色が変わるものがある。その場合”_”をつけることで区別するようにした。
次にブロックの色の平均色をとった。やり方は簡単で、画像を1×1にリサイズするだけだ。

表示したい画像はffmpegなどで表示したい解像度で切り出してimagesフォルダの中に入れておく。fpsは固定値で10だった気がする
ffmpeg -i video.mp4 -r 10 -s 128x72 images/%05d.png

#coding: utf-8
# author itoHO
from PIL import Image, ImageFilter
import glob
import re
import cv2
import sys,time
###file select
import sys
import os
args = sys.argv
maisuu=0;
filedir="images";
start = time.time()
try:
for i in range(1000000):
if os.path.exists(filedir+'/{0:05d}.png'.format(i+1)):
maisuu+=1
else:
raise ValueError("file not exist")
#print("Count {}" .format(maisuu))
except:
print("")
class block:
def __init__(self,id,meta,lab_l,lab_a,lab_b):
self.id=id
self.meta=meta
self.lab_l=lab_l
self.lab_a=lab_a
self.lab_b=lab_b
def __iter__(self):
return iter(self.lab)
blocks=[]
files = glob.glob("tiles/*.png")
for file in files:
print(file)
ids=re.findall("\d+",file)
b_data = cv2.imread(file)
labdata = cv2.cvtColor(b_data, cv2.COLOR_BGR2LAB)
print(labdata[0,0])
print(ids[0])
if(len(ids)==1):
blocks.append(block(ids[0],999,labdata[0,0][0],labdata[0,0][1],labdata[0,0][2]))
else:
print(ids[1])
blocks.append(block(ids[0],ids[1],labdata[0,0][0],labdata[0,0][1],labdata[0,0][2]))
end = time.time()
print("")
print("Count Time {:4.1f}ms".format((end - start) * 1000) )
im = cv2.imread(filedir+'/{0:05d}.png'.format(1))
width, height =im.shape[1::-1]
print("Size {}x{}".format(width,height))
print("#####################################")
#f.write("{}\n".format(maisuu))
#f.write("Python!\n")
start = time.time()
for i in range(maisuu):
imgdata = cv2.imread(filedir+'/{0:05d}.png'.format(i+1))
#imgdata = cv2.cvtColor(imgdata, cv2.COLOR_BGR2RGB)
labdata = cv2.cvtColor(imgdata, cv2.COLOR_BGR2LAB)
f = open("mca/%05d.mca"%(i), "w")
#width, height =im.size
string=""
print(labdata[3,3][2])
for y in range(height):
for x in range(width):
pixdata = labdata[y,x]#int 0-255
minblock=0
mindiff=1000000
for tiles in blocks:
diff=0
diff+=(pixdata[0]-tiles.lab_l)**2
diff+=(pixdata[1]-tiles.lab_a)**2
diff+=(pixdata[2]-tiles.lab_b)**2
if(diff<mindiff):
minblock=tiles
mindiff=diff
if(minblock.meta==999):
f.write("{}\n".format(minblock.id))
else:
f.write("{} {}\n".format(minblock.id,minblock.meta))
#print("{}:{}:{}".format(pixdata[0],pixdata[1],pixdata[2]))
f.close()
print("processing... : {} / {}" .format(i+1,maisuu))
end = time.time()
print("")
print("time {:4.1f}ms".format((end - start) * 1000) )
print("done!")
このプログラムを実行すると、毎フレームごとに対応したテキストファイルができる、、、、が、とてもおそい!!
私のサブデスクトップの性能が低いとはいえ、1分30秒の動画を変換するのに1時間40分かかった。とても遅くてやってられない。
ステップ4 ファイルの自動生成(実装)(高速化)
どの画素が一番近いか試すときにいちいち計算しているのが悪いのかもしれないと考え、(pythonがそもそも遅いというのをjsが主食の先輩に指摘され、jsの沼に引きづりこもうとされたが、ここまで遅いのはアルゴリズムが悪いからだろう)、あらかじめLAB全通りについてどのブロックを置くべきか計算してjsonファイルに保存しておき、配列を一回参照するだけでブロックの種類を決定できるようにすることにした。(メモ化?と呼ぶらしい)
for tiles in blocks:
diff=0
diff+=(l-tiles.lab_l)**2
diff+=(a-tiles.lab_a)**2
diff+=(b-tiles.lab_b)**2
if(diff<mindiff):
minblock=tiles
mindiff=diff
if(minblock.meta==999):
f.write("{}\n".format(minblock.id))
else:
f.write("{} {}\n".format(minblock.id,minblock.meta))
これを
f.write("{}\n".format(blockdatabase[l][a][b]))
こうすることで(※イメージです)
for文が一つなくなるので、高速化できるということだ。
まずは色ごとのデータベースを作る必要がある。
かなり多くの時間を要する計算なので計算はメインマシンで実行し、さらにメインマシンの能力を最大限発揮できるよう、並列計算できるようにした。
16コアすべて使っても1時間半程かかりました。


jsを勧められたのでjsで実装してみた。
const performance = require('perf_hooks').performance;
const fs = require('fs');
const cv = require('opencv4nodejs');
function zeroPadding(num,length){
return ('0000000000' + num).slice(-length);
}
console.log("loading...");
const jsonObject1 = JSON.parse(fs.readFileSync('./memo03.json', 'utf8'));
console.log("json loaded");
//console.log(jsonObject1[255][255][255]["id"]);
let imgMat = cv.imread("images/00001.png");
console.log("width="+imgMat.sizes[1]);
const startTime = performance.now();
fs.existsSync('foo.txt')
let maisuu=0;
for(let i=1;i<10000000;i++){
if(fs.existsSync("images/"+zeroPadding(i,5)+".png")){
}else{
maisuu=i-1;
break;
}
}
console.log("flames="+maisuu);
for (let flamenum=1; flamenum<maisuu+1; flamenum++){
const imgMat = cv.imread("images/"+zeroPadding(flamenum,5)+".png");
let str="";
for (let i = 0; i < imgMat.sizes[0]; i++) {
for (let j = 0; j < imgMat.sizes[1]; j++) {
let color=imgMat.atRaw(i,j);
if(jsonObject1[color[2]][color[1]][color[0]]["meta"]==999){
str+=(jsonObject1[color[2]][color[1]][color[0]]["id"]+"\n");
}else{
str+=(jsonObject1[color[2]][color[1]][color[0]]["id"]+" "+jsonObject1[color[2]][color[1]][color[0]]["meta"]+"\n");
}
}
}
fs.writeFile("mca/"+zeroPadding(flamenum,5)+".mca", str, (err, data) => {
if(err) console.log(err);
});
}
const endTime = performance.now();
console.log("end");
console.log(endTime - startTime+"ms");
動画ひとつ(1分30秒)あたり1時間40分かかってた処理が10数秒で終わるようになった。(jsonの読み込み時間除く)これくらいの速さだとリアルタイムで変換できそう。
ステップ5 実行1
できた設計図ファイルを使って表示してみたら、ファイル読み込みをメインスレッドで行っていたため、Nukkitサーバーが止まった。NukkitRunableを使って実行したところ多少改善されたが、ラグによって頻繁に自分の座標がロールバックで戻され、動画は見れるが操作性がひどくなってしまった。この問題は私の技術力ではどうしようもなかったので、これで完成したことにしようとしていた。
ステップ6 通信方式に切り替え
予定より早く終わったので時間が余った。そこで高速化によって見えてきたリアルタイム動画表示を試してみることにした。具体的には、他のパソコンで実行したマインクラフトをキャプチャし、その場で設計図にし、文字列をソケット通信でサーバーに送信することでリアルタイムでマインクラフトのプレイ動画を表示できるということだ。幸いにしてオシロお絵かきから派生した研究でソケット通信とキャプチャ部分やGUIが代替できていたので、gitの新しい枝を作り、再利用することにした。
ステップ7 クライアントソフトの制作(送信側)
windowsFormでクライアントソフトを制作する。ウェブカメラの画像をキャプチャし、用意したメモファイルを使って高速に設計図形式にし、できたテキストをソケット通信で送信するという機能が必要だ。ソケット通信にはSimpleTCPというナゲットパッケージを使ったのであまり苦労しなかった。できたものがこれだ。

操作方法は、
- ”jsonファイル読み込み” ボタンを押す
- 各種パラメーターを設定(解像度やウェブカメラの番号や接続先アドレスなど)
- 接続ボタンを押す
- キャプチャボタンを押す
ステップ7 プラグインにソケットサーバー機能追加(受信側)
プラグインの中でソケットサーバーを建て、22222ポートでリッスンする。クライアントから送られてきた文字列をもとにブロックを配置するようなプログラムを書いた。また、一度接続が切れた場合ソケットサーバーをリセットして次の接続に備える。ブロックを置く起点は、ソケットサーバーを起動するコマンドが打たれた瞬間のプレイヤー座標から計算している。
ステップ8 実行2
うまく動いた。ファイルを読み込むよりもオーバーヘッドが低いのだろうか。快適に動くことができた。また、クライアントソフトはリアルタイム変換も問題なく動いたが、たまにソケット通信が遅れて止まることがある。そこは目をつむってほしい。マイクラサーバーはローカルホストで動かしたほうが高解像度でも動く。
色の比較方式の見直し
あまり色が正確ではないように見えたのでLABでの比較からRGBの比較に変えた。
感想
マイクラのプラグインを作るだけなのにいろいろな言語や技術を使うことになったが、当初の予定以上のことができたので満足だ。マイクラプラグインでPython,js,C# を使うってなんだよ。
統合版のバージョンアップは激しいので開発中に3回サーバーのバージョンを変える必要があった。(jarを差し替えるだけだが)。統合版のサーバーはめんどくさい。
コメント