どうもこんばんわ。セレクトオブリージュ 攻略しました。深夜販売で買ってきたげーむ。
くくるちゃん目的!!!だったけど
会長さんルートも結構良かった。てか買うまで分からんかった・・って気持ちになった。
そのうえ側近ちゃんがまたかわいい。うおおお
ちびきゃらイベントCGがかわいかった。
!!!!!!?!?!!
これすち
妹ちゃんルートは別に妹ちゃんルートでやんなくても、、みたいなシナリオだったかな。
ぴしゃん・・・
!!!!!!
くくるちゃんルートが一番良かった。です
食べキャラかわいいわね
これです。これこれ
ぜひくくるちゃんルートを!
本題
写真管理アプリによくある、 似ている画像や重複している画像を消しますか? ってやつ。あれってどうやって動いてるんだろう?。
機械学習で頑張ってるのかなと思いきや、そこまでのことは必要ないらしく、世の中には画像向けのハッシュ値を求めるアルゴリズムがあるらしい。
ハッシュと言えば、同一データかどうか、少しでも違えば全く違うハッシュを出す、そんなイメージですが、
今回のはそういうのじゃなく、似ていれば似ているハッシュを出します。
画像のハッシュ
いくつかあり、簡単なやつなら私でも一からBitmap
をこねくり回して作れそう。
aHash
average hash
これは、画像を8x8
の大きさに変更した後、モノクロ画像に変換し、ピクセルの色の平均を出します(画像の平均の色)。
そのあと、モノクロ画像の各ピクセルに対してさっき出した平均と比較する。比較結果を何らかの方法で保存する。後述
とか言ってもよく分からんと思うのでFigma
で書いてきました。
dHash
くわしくは
https://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html
difference hash
これは、画像を9x8の大きさに変更します。幅が+1
多いですが後で分かります。そのあと、モノクロ画像に変換します。
次に、差を求めます。8x8
のピクセルに対して行います。この時、比較対象が右隣のピクセルの色になります。
隣と比較するために+1
多くしています。x = 8
の比較も+1
したx = 9
と比べば良くなります。
あとはこの比較結果を何らかの方法で保存すればいいです。後述
とか言ってもやっぱり分からんと思うので、これもついでに書いてきました。
pHash
説明を読んだけど難しくて断念。知らない言葉だらけだ。。。
保存方法は?
比較した結果は、大きいか小さいかしか無いので、0
と1
で表現できます、はい、2進数
。片手で31まで数えられるあれです。
各ピクセルの比較結果は8x8 = 64
なので、64
個ビットを格納できるlong型(int64?)
にちょうど収めることが出来ます。
いや、正しくはAndroid (Java)
の場合、Long
の最上位ビットは正の数か負の数か
を表すために1ビット使われてしまうため、64Bit
フルで使えるULong
型を使う必要があります。多分。
どう並べるかは人それぞれだと思いますが、大抵は、左上(x=0, y=0
)から始まり、一番左(x=0, y=7
)まで来たら、1個下に下がって、左から右へ。
ビッグエンディアンで入れていこうと思うので、2進数から見て一番左が x=0, y=0
、一番右が x=7, y=7
の比較結果になるようにしたいと思います。
まあlong
に押し込めるという理由で8x8
になっているはずで、押し込める理由がなければ16x16
とかでもできると思います。
でもビット演算がやりにくいと思うのでlong
に押し込めておけばいいと思った。
比較方法は?
ハッシュを出すだけじゃなくて比較の話も。
が、こちらはXOR
して立っているビットの数を数えればいいはず?比較したい2つの二進数
をXOR
して、0
と1
が一致していない部分に1
を立てます。
真理値表の01
の組み合わせのあの表のとおりですね。
あとは1
の数を数えて、少なければ少ないほど一致していることになります。
2進数の1
を数える方法ですがcountOneBits()
があるのでそれを使えば良いはず?
作ってみる
aHash
やdHash
を計算する機能は用意されてないので、自分で作る必要があります。多分。でも解説した上2つはそんな難しくないと思う。
環境
なまえ | あたい |
---|
Android Studio | Android Studio Koala 2024.1.1 |
言語 | Kotlin |
端末 | Google Pixel 8 Pro |
適当にプロジェクトを作る
Jetpack Compose
でUI
を作るから!
object ImageHashTool { }
を作ったので、ここに画像のハッシュを出すユーティリティ関数を書いていくことにします。
loadBitmap
いつも画像の読み込みをGlide
とかCoil
とかのライブラリに任せてるからInputStream
から作るの新鮮。
インターネットの画像だとライブラリ使うしか無い。
toMonoChrome
https://stackoverflow.com/questions/9377786/
次にaHash
もdHash
も共通して使う、Bitmap
をモノクロ画像にする関数。
aHash
まずは平均と比べるやつ。
説明通り8x8
にしてモノクロ画像にして平均の色を出します。そしたら8x8=64
個あるピクセル全てと比較する。先述の通り左から右へ、上から下へ。
平均より今の色が大きい場合は1
を立てます。返り値はULong
ビット演算難しい
ビット演算見慣れないのであれですが、この部分は何をしているかと言うと、
(1UL shl bitCount)
- この例だと、
bitCount
の回数だけ右から左へ1
を移動させます。
(1UL shl 4)
とした場合は、10000
という二進数になります。
- どうでもいいですが
CPU
はこの手の計算が得意らしいです。詳しくは知らないですが。
resultBit or ...
- 論理演算の
OR
をしています
1000
と0010
という2つの二進数をOR
した場合は1010
になります。OR
なので。
- 返り値の
ULong
でOR
することで、狙った位置へビットを立てることが出来ます
dHash
同様にdHash
も作ります。
こっちも作るのは難しくないはず。ビット演算してるところはaHash
と同じです。
JetpackCompose で UI をサクッと作る
PhotoPicker
で適当に選んで、さっき作ったaHash / dHash
の計算をできるようにします。
UI
には関係ないですが、toBinaryString()
を用意しました。これはULong
を2進数の文字列
として返してくれるやつです。
1ビット
ずつ比較してるの、どうなの?
なんとなくコルーチンを使ってましたが、まあ全部足しても150ms
くらいなので、最悪メインスレッドでも良いのかも。いや画像のサイズにもよりそうだからやっぱ別スレッドのが正しいかも。
これで実行すると、こんな感じにボタンと000...
の羅列を表示してるText()
があるはず。
ボタンを押して画像を選ぶと、aHash / dHash
が計算されます。比較できるわけじゃないので、だから何?感がある。
比較機能をつける
MainScreen()
にボタンを増やして、二枚目の画像を選ぶボタンと、計算するボタンを置きました。
比較方法はXOR
したあと立っているビットの数を数えて少なければ1f
に近づくようにしました。
できた!!!!!
似ていれば1.0
に近い値が出てくるはずです!!!
ここまでのソースコード
https://github.com/takusan23/AndroidCalcImageHash
いろいろ写真を入れてみる
本当に似ていれば同じなのか!?
半分同じ
回転
モノクロにした
正方形に切り抜いた
写真フォルダを走査して重複画像を探したい
せっかくなので重複画像があれば見つけてくれるアプリを作ってみる。
いうてMediaStore
から画像を全部取ってBitmap
取ってaHash / dHash
取って近いやつを表示すれば。。。
画像を読み取る権限
android.permission.READ_MEDIA_IMAGES
が必要です。Android 12
以下でも動かしたい場合はandroid.permission.READ_EXTERNAL_STORAGE
が必要です。
また、この権限は結構強いのでPlayStore
に出す際は追加の審査があるので、できれば避けるべきです。
写真管理アプリ、みたいな目的だとおそらく通りそうな雰囲気はありますが。→ https://support.google.com/googleplay/android-developer/answer/14115180
今回使う関数たち
今回使う関数たち、画像一覧を取得するとか、Uri
からBitmap
を取るとか。aHash / dHash
取るとか。今回はXOR
する部分も関数にしました。
大量の画像を扱うならGlide
とかのライブラリで画像を読み込んだ方がいいですね。。。
ImageTool.kt
重複してるかも画像を表示する画面を作る
宣言型UI
のおかげで縦スクロールの中に横スクロールを入れるのがめっちゃ簡単なの神神神
今回は面倒なのでやってませんが、画像のハッシュを求めて比較する処理とかは明らかにViewModel
にあるべきですね。
動かしてみた、
今回は並列で処理してないので、少し時間がかかります。もし並列で処理したい場合はメモリを使いすぎないよう並列処理数を減らすとか、
Glide
ってライブラリであらかじめ小さくしてBitmap
を読み込むとかしたほうが良いですね。
うーん、スクリーンショットの中にカメラ越しの写真が含まれちゃってるんだけど、そういうものなのかはたまた正しいのか、よく分からんな。。。
スクリーンショットも似ているレイアウトのアプリを見つけてくれるときが大半なんだけど、なんかぜんぜん違うのも混じってて、なにか間違えた可能性がある。。。
↑ スクショなのに普通の写真が出てきている?なんか間違えたかな、、、
(あと知らない間にLazyColumn { }
ってスクロールスクショに対応したんですね!)
ソースコード
https://github.com/takusan23/AndroidDetectDuplicateImageApp
動画も画像を取り出してハッシュにすれば探せるのでは?
動画も一枚一枚取り出してaHash / dHash
に通して突き合わせれば動画の重複も探せるのではないかという話。
MediaCodec
の検証で同じような動画ばっかり作ってるから消せると嬉しい。似てる動画を消したい!!
ただ、動画の場合は多分かなり時間がかかるので、最初から10秒だけとか、アプリを再起動したら途中から再開できるようRoom
データベースに入れておくとかが必要だと思います。
面倒なので今回は冒頭から10
秒だけ見ます。
動画から画像(フレーム)を取り出す
前書いたこれを使います。
https://takusan.negitoro.dev/posts/android_get_video_frame_mediacodec/
一応いつまで面倒を見るか不明ですがライブラリがあります。
https://github.com/takusan23/AkariDroid/tree/master/akari-core
implementation("io.github.takusan23:akaricore:4.1.1")
また、ライブラリを入れるまでもなくAndroid
にはMediaMetadataRetriever
ってクラスがあって、こいつでも動画からフレーム(Bitmap
)を取り出せるのですが、多分並列化出来ない。。。
並列でスレッドを起動して試してみたけど多分効果ない、、、
動画アクセス権限
動画の場合は、android.permission.READ_MEDIA_VIDEO
が必要です。同様にAndroid 12
以下でも使いたい場合はandroid.permission.READ_EXTERNAL_STORAGE
が必要です。
動画一覧を取得する
queryAllImageUriList
の動画版を作ります。今回はUri
ではなくID
を返そうかなと、というのも、Room
(データベース)に入れる時に文字列よりは数字のほうが良いのかなと。
いや、あんまり変わん気がしてきた、、、
ハッシュを出す関数
は、写真の時に作ったImageTool.kt
を使うことにします。
→ #今回使う関数たち
動画のレイアウトを作る
こちらです。
権限付与ボタンと、処理開始ボタンがあって、結果表示のLazyColumn
があります。
実際にフレームを取り出してハッシュを出すanalyze()
関数はこのあとすぐ。ほんとうはViewModel
とかに書くべきです。
動画からフレーム取ってハッシュを出す
自作ライブラリakari-core
のVideoFrameBitmapExtractor()
を使ってますが、Android
のMediaMetadataRetriever
でBitmap
を取り出してもいいです。
その場合はframeBitmapExtractor.getVideoFrameBitmap()
の部分をMediaMetadataRetriever#getFrameAtTime
とかに差し替えれば良いはず。
同時処理数はSemaphore(8)
で8
個以上VideoFrameBitmapExtractor()
のインスタンスが存在しないようにしたはずですが、なんか例外投げてくる時あるので、catch()
が何個か待ち受けてます。それのせい。
並列数を減らせば良いかも。というか端末がそもそも悪い気がしてきた。
println
は消してください、あと、時間がかかりすぎるのでminOf()
で長くても10
秒まで、1
秒ごとに取り出してハッシュを出すことにします。
ハッシュを出してる箇所と比較してる箇所は動画のそれと変わってないはず。
重複しているかもな動画を見てみた
つかってみた。先頭10
秒までしか見ていないんだけど、解析がちょっとかかる。
うまく動いてるっちゃ動いている気がする、、、
削除機能が欲しい
さて、これは画像ハッシュとか全然関係なく、Android
の話になります。。
Uri
を削除したい。けどややこしいんだわ。。
Android
のUri
周りが複雑な話は、今度やる気があったら話したいと思うのですが、今回は他のアプリが作成したファイルの削除をしたいということで。
ContentResolver#delete
って関数があるんですが、これは自分のアプリがContentResolver#insert
したものに限るはずです。
じゃあ他のアプリが作成した写真は消せないのかと言うとそうではなく、ユーザーに許可を求めることで削除ができます。
https://developer.android.com/training/data-storage/shared/media?hl=ja#update-other-apps-files
というわけでレイアウトをまずは作ります。
動画のサムネイルを押した時にボトムシートを出すようにします。ボトムシートの実装もJetpack Compose
だとバカ簡単になって神。
ModalBottomSheet
はレイヤーが違うので(WindowManager#addView
している。React
のPortal
で全然違うところにコンポーネントを置くみたいな)、
再利用する用のコンポーネントでも呼び出して使うことが出来ます。
ACTION_VIEW
でおそらく動画プレイヤーを開けます。削除はこのあとすぐ!
Uriの削除とゴミ箱に移動
削除するっても、Uri
を削除するか、はたまたゴミ箱に移動するか選べます。
ゴミ箱なんてどこから開けば良いんだよって思ったのですがGoogle フォト
アプリか、File by Google
アプリでゴミ箱の一覧が見れるらしいです。Android初心者です
以下のコードはゴミ箱に移動するコード。
MediaStore.createTrashRequest
でIntent
をつくり、それをユーザーに表示させることでゴミ箱に移動するかを決めてもらうことが出来ます。これの亜種に削除版と更新版(書き込みができる?)があります。
複数のUri
を渡すことが出来ます。プリインアプリに関してはダイアログを出さすに遂行できるので、私達は知らず知らずに使ってたっぽいこれ。
古いバージョンに関してはストレージ書き込み権限で消せるんじゃないかな?(未検証)
というわけで削除機能もつけました!やった!
ゴミ箱に移動なので戻せます!
ちゃんとGoogle フォト
アプリとFiles by Google
アプリのゴミ箱に入ってました。
データベースにいれるようにしてアプリの再起動に耐えれるように
ハッシュを求めた結果をAndroid
のデータベースに入れました。Room
、かわらぬ使いやすさ。Flow<T>
が返せるの、どういう仕組みなんだろう?
さすがにViewModel
で処理を書くべきだったこれ。。。
https://github.com/takusan23/AndroidDetectDuplicateVideoApp/commit/be544a06f9befdd19a0670355ebeb545eb68146e
重複しているかも動画を探すソースコード
はい
https://github.com/takusan23/AndroidDetectDuplicateVideoApp
おわりに
わたしの作りが悪いのか、似てる画像のときと外れてるときが半々くらい。。。。
でも思ったよりは見つけられている。MediaCodec
の検証で作ったおんなじような画像は見つけられてそう(まあほぼおんなじだし)
あとどうでもいいんですけど、2進数で1
の数を数える関数countOneBits()
を今回は使ったわけですが、
Kotlin/JVM
だとjava.lang.Integer.bitCount
というメソッドを呼んでいるそうです。
で、ちらっとJava
のbitCount
実装を見てみたけど、、、、上からビットを数えてるのかと思ったらなんかよく分からんビット演算をして数えてる、、、???
https://stackoverflow.com/questions/44093381/
参考にしました
ありがとうございます!
https://tech.unifa-e.com/entry/2017/11/27/111546
https://nsr-9.hatenablog.jp/entry/2021/08/08/010905
https://qiita.com/mamo3gr/items/b93545a0346d8731f03c