![見出し画像](https://assets.st-note.com/production/uploads/images/166138803/rectangle_large_type_2_d0bc616f34be9ead8d91d1f9685b5278.jpeg?width=1200)
p2hacksを終えて〜フラッシュ通信技術解説〜
この記事は、FUN Advent Calendar 2024 の18日目の記事です。
想定の5倍くらい書きたい事があって間に合いませんでした…この記事は12/20 12/25ごろまでアップデートされる予定です。
はじめに
未来大1年のこはぜです。12/17 17時現在、noteの会員登録を済ませこの記事を書いています。この記事がアップされているということはこの記事がアップされているということです。
アドカレ初参加で何を書こうか迷ったのですが、p2hacksで面白い技術を作ることができたのでこれについて書こうと思います。
更新情報
2024/12/18 23:59 記事をアップロードしました。
2024/12/25 23:44 サンプルコードを修正しました。
p2hacks
チーム
#TweetAvalanche という1年生5人のチームで参加しました。
私以外のメンバーは uiro, wisteria, のん, もち です。
開発期間中は #TweetAvalanche で色々ツイートしてました、大体悲鳴をあげていたり、バスを逃したりしています。
開発したアプリ
負ラッシュをフラッシュに!、というキャッチコピーで「ぴこるー」というアプリを開発しました。
![](https://assets.st-note.com/img/1734446447-V3JeQURP1ru8TDHhNMk2zsf9.png?width=1200)
今回は技術について語るのでこのアプリについての紹介は割愛します。
フラッシュ通信とはなんぞや?
p2hacks初日のアイデアソンで、「カメラのフラッシュみたいな感じで通信できたら面白くね?」という意見が出て、面白そうだったので実装してみることにしました。
色々試行錯誤した結果、
2台のiPhone(A,B)を利用し、AがBに向けて標準搭載のライトを特定のパターンで点灯・消灯を繰り返す。Bはカメラを使用しその点灯パターンを検知し、受信した値に応じて、データを取得する。
といったシステムになりました。
完成したものがこちらになります。
ここからはこれの技術解説です。
ちょっとだけ真面目なパートになります。
技術解説
1. 要件
スマホAとスマホBの間で、スマホ標準搭載のライトとカメラを利用し、光を検知してデータを交換
スマホA,Bは互いに直射日光の当たらない室内で利用することを想定
転送時間は長くても10秒以下、動作率95%以上を目指す
2. 技術選定
まずデバイスごとの差異をできる限り減らした方がいいということでiPhoneのみをターゲットとしました。
開発言語は、iPhoneの性能を最大限に引き出す必要があったためSwift / SwiftUIで開発を行うことにしました。
OSとかバージョンとかは以下の通りです。
Swift 5
Xcode 16.2
iOS 18.X
3. 実装
実装は送信側と受信側に分けて解説します。
全部を解説する時間はないので、一部の関数だけをピックアップして解説します。
3.0 送受信のデータ形式
ライトの点灯を検知している時を1、ライトの点灯を検知していない時は0として扱います。データは1bitあたり100msで転送します。100msごとにライトを点灯・消灯させることをで値を変えます。
3.1 送信側
送信(ライトの点灯処理)のコードは以下の通りです。
重要なのは非同期処理で実行することです。ライトのON/OFF処理に微妙に時間がかかるため、毎回数ms〜数十msのずれが発生してしまいます。
1bit/100msで転送するこのシステムにおいてこの遅延は致命的なため非同期でできる限り遅延を減らすよう実装します。
// 余計なコードが多いので後日リファクタリングしてアップします
import AVFoundation
class FlashTransmitter: ObservableObject {
// MARK: - Properties
private let device: AVCaptureDevice?
private let stabilizationPattern = [true, true, true, true, false, false]
private let startPattern = [true, false, true, false, true, false, true, false] // 10101010
private let bitDuration: Double = 0.1 // 100ms per bit
private let touchLevel: Float = 0.5
// MARK: - initialization
init() {
self.device = AVCaptureDevice.default(for: .video)
}
// MARK: - Send Data
public func send(data: [Bool]) {
guard let device = device, device.hasTorch else {
print("Device does not have a torch.")
return
}
let sendData = stabilizationPattern + startPattern + data
DispatchQueue.global(qos: .userInitiated).async {
do {
try device.lockForConfiguration()
var currentIndex = 0
var lastBit: Bool? = nil
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .userInitiated))
timer.setEventHandler { [weak self] in
guard let self = self else { return }
if currentIndex < sendData.count {
let bit = sendData[currentIndex]
if bit != lastBit {
do {
if bit {
try device.setTorchModeOn(level: touchLevel)
} else {
device.torchMode = .off
}
} catch {
print("Error while setting torch: \(error)")
}
lastBit = bit
}
currentIndex += 1
} else {
device.torchMode = .off
device.unlockForConfiguration()
timer.cancel()
print("Frame sent successfully.")
}
}
timer.schedule(deadline: .now(), repeating: self.bitDuration)
timer.activate()
} catch {
print("Torch could not be used: \(error)")
}
}
}
// MARK: - Private Methods
private func encode(_ rowBits: [Bool]) -> [Bool] {
var encodedBits: [Bool] = Array(repeating: false, count: 29)
let parityPositions: Set<Int> = [1,2,4,8,16]
var dataIndex = 0
for bitPos in 1...29 {
if parityPositions.contains(bitPos) {
encodedBits[bitPos - 1] = false
} else {
encodedBits[bitPos - 1] = rowBits[dataIndex]
dataIndex += 1
}
}
// パリティ計算
for p in parityPositions {
var parity = false
for i in 1...29 {
if (i & p) != 0 {
parity = parity != encodedBits[i - 1]
}
}
encodedBits[p - 1] = parity
}
return encodedBits
}
}
アプリを起動して初めてライトを起動した最初の数百ms処理が安定しないのでstabilizationPatternを手前に置きます。データの意味はないのでおまじないのようなものです。
startPatternはスタート検知のための値です。詳しくは受信側で解説します。
let sendData = stabilizationPattern + startPattern + data
// stabilizationPatternの中身
private let stabilizationPattern = [true, true, true, true, false, false]
// startPatternの中身
private let startPattern = [true, false, true, false, true, false, true, false]
それ以外の部分の処理は以下のような流れで処理されています。
デバイスの設定を変更できないようにロックをかける
ライトを切り替えるイベントハンドラーをセット
100ms毎に処理するようにスケジュールを設定
データを1つずつ100ms間隔で処理していく
全てのデータ送信が終わったらスケジュールをキャンセルし終了
システム側がライトを自由にコントロールできるということを知ってしまえば比較的簡単です。
3.2 受信側
とりあえず全体像です。
// 余計なコードが多いので後日リファクタリングしてアップします
import AVFoundation
import UIKit
class FlashReceiver: NSObject, ObservableObject, AVCaptureVideoDataOutputSampleBufferDelegate {
// MARK: - Properties
@Published var isSessionRunning = false
@Published var progress: Double = 0.0
@Published var statusText: String = "起動中..."
@Published var fps: Double = 0.0
@Published var processingTime: Double = 0.0
@Published var lastReceivedData: String = ""
@Published var isFinish: Bool = false
@Published var receivedUserData: User?
@Published var isFlash: Bool = false
private let tokenViewModel = TokenViewModel()
// セッション関連
private let session = AVCaptureSession()
private let sessionQueue = DispatchQueue(label: "touch.session.queue")
private var device: AVCaptureDevice?
private let videoDataOutput = AVCaptureVideoDataOutput()
// データ処理用
private let startPattern = [1,0,1,0,1,0,1,0] // 8bit
private let dataBitCount = 29
private let bitDuration: Double = 0.1 // 100ms per bit
private let luminanceThreshold: CGFloat = 0.85
private let framesPerBit: Int = 3
private let initialFrameCount = 30
private var frameCount = 0
private var startFrameBuffer: [Bool] = []
private var isReceivingData = false
private var startAccuracyList: [Double] = [0.0, 0.0, 0.0]
private var dataFrameBuffer: [Bool] = []
private var dataBitBuffer: [Bool] = []
private var maxLuminance: CGFloat = 0.0
private var lastFrameTime: CMTime = CMTime.invalid
// MARK: - Initialization
override init() {
super.init()
configureSession()
}
func configureSession() {
sessionQueue.async {
self.session.beginConfiguration()
self.session.sessionPreset = .low
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
self.session.commitConfiguration()
return
}
self.device = device
do {
try device.lockForConfiguration()
if device.isExposureModeSupported(.custom) {
let exposureDuration = device.activeFormat.minExposureDuration
device.setExposureModeCustom(duration: exposureDuration, iso: device.iso, completionHandler: nil)
}
// フレームレートを30fpsに設定
device.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 30)
device.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: 30)
device.unlockForConfiguration()
} catch {
self.session.commitConfiguration()
return
}
guard let input = try? AVCaptureDeviceInput(device: device) else {
self.session.commitConfiguration()
return
}
if self.session.canAddInput(input) {
self.session.addInput(input)
}
self.videoDataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)]
let videoQueue = DispatchQueue(label: "videoQueue", qos: .userInitiated)
self.videoDataOutput.setSampleBufferDelegate(self, queue: videoQueue)
if self.session.canAddOutput(self.videoDataOutput) {
self.session.addOutput(self.videoDataOutput)
}
self.session.commitConfiguration()
}
}
func startSession() {
sessionQueue.async {
if self.session.isRunning { return }
self.configureSession()
self.session.startRunning()
Task { @MainActor in
self.statusText = "読み込み中..."
self.isSessionRunning = true
}
}
}
func stopSession() {
sessionQueue.async {
if !self.session.isRunning { return }
self.session.stopRunning()
Task { @MainActor in
self.isSessionRunning = false
}
}
}
func getSession() -> AVCaptureSession {
return session
}
func getLuminance() -> CGFloat {
return maxLuminance
}
// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate
func captureOutput(_ captureOutput: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
let startTime = CFAbsoluteTimeGetCurrent()
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
CVPixelBufferLockBaseAddress(imageBuffer, .readOnly)
let width = CVPixelBufferGetWidth(imageBuffer)
let height = CVPixelBufferGetHeight(imageBuffer)
let yStride = CVPixelBufferGetBytesPerRowOfPlane(imageBuffer, 0)
guard let yPlane = CVPixelBufferGetBaseAddressOfPlane(imageBuffer, 0) else { return }
let squareSize = min(width, height)
let xOffset = (width - squareSize) / 2
let yOffset = (height - squareSize) / 2
maxLuminance = 0.0
for y in 0..<squareSize {
let rowPtr = yPlane + (y + yOffset) * yStride
for x in 0..<squareSize {
let pixel = rowPtr.advanced(by: (x + xOffset)).assumingMemoryBound(to: UInt8.self)
let luminance = CGFloat(pixel.pointee) / 255.0
if luminance > maxLuminance {
maxLuminance = luminance
}
}
}
CVPixelBufferUnlockBaseAddress(imageBuffer, .readOnly)
let currentTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
updateFPS(currentTime)
updateProcessingTime(startTime)
DispatchQueue.main.async {
self.processLuminance(self.maxLuminance)
}
}
// MARK: - Data Processing
private func processLuminance(_ luminance: CGFloat) {
frameCount += 1
if frameCount < initialFrameCount {
updateStatusText("初期化中...")
return
}
let isBright = luminance > luminanceThreshold
updateFlash(isBright)
updateStatusText(isReceivingData ? "受信中..." : "スタートパターン検知前")
if !isReceivingData {
startFrameBuffer.append(isBright)
let accuracy = calculateStartPatternAccuracy()
startAccuracyList.removeFirst()
startAccuracyList.append(accuracy)
if startAccuracyList[0] > 0.0 {
let maxIndex = startAccuracyList.firstIndex(of: startAccuracyList.max()!)!
let tmpBuffer = startFrameBuffer.suffix(2 - maxIndex)
dataFrameBuffer.append(contentsOf: tmpBuffer)
isReceivingData = true
startFrameBuffer = []
startAccuracyList = [0.0, 0.0, 0.0]
}
} else {
dataFrameBuffer.append(isBright)
if dataFrameBuffer.count > framesPerBit {
dataFrameBuffer = []
isReceivingData = false
return
}
if dataFrameBuffer.count == framesPerBit {
let brightCount = dataFrameBuffer.filter{$0}.count
let bitPattern = (brightCount > framesPerBit/2)
dataBitBuffer.append(bitPattern)
dataFrameBuffer = []
DispatchQueue.main.async {
self.progress = Double(self.dataBitBuffer.count) / Double(self.dataBitCount)
}
if dataBitBuffer.count == dataBitCount {
isReceivingData = false
let decodedBit = decode(dataBitBuffer)
let hexData = bitToHex(decodedBit)
updateLastReceivedData(hexData)
tokenViewModel.loadToken(token: hexData) {
print("loadToken completion")
if let user = self.tokenViewModel.receivedUser {
print("token user sucsses")
print(user)
self.receivedUserData = user
self.updateFinish()
} else {
print("loadToken")
}
}
dataBitBuffer = []
}
}
}
}
// スタートパターン精度計算
private func calculateStartPatternAccuracy() -> Double {
let startFrameCount = startPattern.count * framesPerBit
if startFrameBuffer.count < startFrameCount { return 0.0 }
startFrameBuffer = Array(startFrameBuffer.suffix(startFrameCount))
var missCount = 0
for i in 0..<startFrameCount {
if i % framesPerBit == 0 {
let targetPattern = startPattern[i / framesPerBit] == 1
let bitBuffer = Array(startFrameBuffer[i..<i+framesPerBit])
let brightCount = bitBuffer.filter{$0}.count
let bitPattern = (brightCount > framesPerBit/2)
if targetPattern != bitPattern { return 0.0 }
for bit in bitBuffer {
if bit != targetPattern {
missCount += 1
}
}
}
}
let accuracy = 1.0 - Double(missCount) / Double(startFrameCount)
return accuracy
}
private func decode(_ encodedBits: [Bool]) -> [Bool] {
var encodedBits = encodedBits
// パリティチェック
let parityPositions: [Int] = [1,2,4,8,16]
var errorPosition = 0
for p in parityPositions {
var parity = false
for i in 1...29 {
if (i & p) != 0 {
parity = parity != encodedBits[i - 1]
}
}
// パリティがずれていれば、そのパリティビット位置をerrorPositionにXOR加算
if parity {
errorPosition ^= p
}
}
// エラー訂正(もしerrorPositionが0でなければ、そのビットを反転)
if errorPosition != 0 && errorPosition <= 29 {
encodedBits[errorPosition - 1].toggle()
}
// 復元するデータビットを抽出
let paritySet = Set(parityPositions)
var decodedBits: [Bool] = []
for i in 1...29 {
if !paritySet.contains(i) {
decodedBits.append(encodedBits[i - 1])
}
}
print("decode: \(decodedBits)")
return decodedBits
}
private func bitToHex(_ bits: [Bool]) -> String {
let binaryString = bits.map { $0 ? "1" : "0" }.joined()
guard let decimal = Int(binaryString, radix: 2) else {
print("Invalid data: \(bits)")
return ""
}
return String(format: "%06X", decimal)
}
// MARK: - Debug / UI Update
func updateFPS(_ currentTime: CMTime) {
if lastFrameTime.isValid {
let duration = CMTimeSubtract(currentTime, lastFrameTime)
let fpsVal = 1.0 / CMTimeGetSeconds(duration)
DispatchQueue.main.async {
self.fps = fpsVal
}
}
lastFrameTime = currentTime
}
func updateProcessingTime(_ startTime: CFAbsoluteTime) {
let endTime = CFAbsoluteTimeGetCurrent()
let processingDuration = endTime - startTime
DispatchQueue.main.async {
self.processingTime = processingDuration
}
}
func updateStatusText(_ text: String) {
DispatchQueue.main.async {
self.statusText = text
}
}
func updateLastReceivedData(_ hexData: String) {
DispatchQueue.main.async {
self.lastReceivedData = hexData
}
}
func updateFlash(_ isBright: Bool) {
DispatchQueue.main.async {
self.isFlash = isBright
}
}
func updateFinish() {
DispatchQueue.main.async {
self.isFinish = true
}
}
}
受信側が必要な基本的な処理は以下の3つです。
100msごとに点灯しているかを判定する
開始を検知する
終了を検知する
まず100msごとに点灯していることを確認していきます。愚直に100msごとにカメラのフレームを切り取って判定するような作りにすると、以下の画像ように送信側と受信側のフレームがズレた際にうまく認識できない可能性が高いです。
![](https://assets.st-note.com/img/1734519412-lWiruYm6LhnM1Dzb4UfTSJVw.png?width=1200)
この問題を解消するために、まずカメラを30fpsで固定し、1フレームずつ点灯を判定していきます。それを3フレーム1bitとして扱い多数派を点灯として扱います。(例: ●●●なら●、○○●なら○、など)
これだけでもかなり精度が改善しますがまだ安定性はありません。スタートのタイミングが一致しないため、ここをできる限り一致させます。
![](https://assets.st-note.com/img/1734525151-dtEowXqOM59GF6PvQR8Sheps.png?width=1200)
2台のスマホ間で直接の通信を行なってしまっては台無しなので、受信側で無理矢理タイミングを合わせる必要があります。そのため、今回はスタートパターンの検知と同時に最も精度が高くなるタイミングを検知します。
毎フレーム、スタートパターンとの正答率を判定し常に最新3フレーム分を変数に記録しておきます。
あとは記録の中で最も古いデータが閾値を超えた場合に、最新の3フレームから最も精度が高かったパターンを取得し、タイミングが合うようにメインデータ取得用の変数に値を入れます。
ここまですればずれは、ズレは大きくても10~20ms程度に抑えられるので、ほぼ100%の精度で安定して検知することができます。
if !isReceivingData {
startFrameBuffer.append(isBright)
let accuracy = calculateStartPatternAccuracy()
startAccuracyList.removeFirst()
startAccuracyList.append(accuracy)
if startAccuracyList[0] > 0.0 {
let maxIndex = startAccuracyList.firstIndex(of: startAccuracyList.max()!)!
let tmpBuffer = startFrameBuffer.suffix(2 - maxIndex)
dataFrameBuffer.append(contentsOf: tmpBuffer)
isReceivingData = true
startFrameBuffer = []
startAccuracyList = [0.0, 0.0, 0.0]
}
}
メインの受信部分です。今回は送るデータのサイズが変わることはないので終了パターンを用意せず、受信したデータの量で終了を検知します。
同じく3フレーム毎に点灯or消灯を判定し、指定されたサイズまでデータが貯まった時点で処理を終了し、受信前に状態を戻します。
else {
dataFrameBuffer.append(isBright)
if dataFrameBuffer.count > framesPerBit {
dataFrameBuffer = []
isReceivingData = false
return
}
if dataFrameBuffer.count == framesPerBit {
let brightCount = dataFrameBuffer.filter{$0}.count
let bitPattern = (brightCount > framesPerBit/2)
dataBitBuffer.append(bitPattern)
dataFrameBuffer = []
if dataBitBuffer.count == dataBitCount {
isReceivingData = false
// ここにデコード処理だったりリクエスト処理だったりを書く
print("Received data: \(dataBitBuffer)")
dataBitBuffer = []
}
}
}
3.3 誤り訂正
ここまでの対策だけでもだいぶ安定して動作するようになりました、でも何か物足りなくないですか?1bitでもデータが破損したら動作しなくなるのはかなり不安ですよね?
ということで簡易的ではありますがハミング符号を実装します。
簡単に説明すると、1bitまで誤りを訂正できる誤り訂正です。有名なものなのでここでは解説を省略し、実装例だけ書いておきます。
送信側(encode)
今回はデータサイズを24bitとして実装しました。
パリティデータは5bitなので、合計24bitを送信します。
private func encode(_ rowBits: [Bool]) -> [Bool] {
var encodedBits: [Bool] = Array(repeating: false, count: 29)
let parityPositions: Set<Int> = [1,2,4,8,16]
var dataIndex = 0
for bitPos in 1...29 {
if parityPositions.contains(bitPos) {
encodedBits[bitPos - 1] = false
} else {
encodedBits[bitPos - 1] = rowBits[dataIndex]
dataIndex += 1
}
}
for p in parityPositions {
var parity = false
for i in 1...29 {
if (i & p) != 0 {
parity = parity != encodedBits[i - 1]
}
}
encodedBits[p - 1] = parity
}
return encodedBits
}
受信側(decode)
受信側は29bitを24bitにしていきます。
private func decode(_ encodedBits: [Bool]) -> [Bool] {
var encodedBits = encodedBits
let parityPositions: [Int] = [1,2,4,8,16]
var errorPosition = 0
for p in parityPositions {
var parity = false
for i in 1...29 {
if (i & p) != 0 {
parity = parity != encodedBits[i - 1]
}
}
if parity {
errorPosition ^= p
}
}
// エラー訂正
if errorPosition != 0 && errorPosition <= 29 {
encodedBits[errorPosition - 1].toggle()
}
// 復元するデータビットを抽出
let paritySet = Set(parityPositions)
var decodedBits: [Bool] = []
for i in 1...29 {
if !paritySet.contains(i) {
decodedBits.append(encodedBits[i - 1])
}
}
print("decode: \(decodedBits)")
return decodedBits
}
ここまですればかなり動作が安定します。
3.4 バックエンドとの連携
今回のシステムの転送速度は10bit/sです。この速度では遅れてもせいぜい50bitくらいです。できる限りたくさんのデータを送りたいため、バックエンド側でトークンを発行し、そのトークンを送信し、受信側はサーバーにそのトークンを問い合わせることでデータの転送を実現しています。
4. サンプルコード
サンプルコードはこちらで試せます。手元にiPhoneが2台ある方はぜひ試してみてください。
p2hacks振り返り
全体の振り返りというよりはこの技術に関する振り返りです。
よかった点
実現可能かを素早く検証できた
ChatGPT o1などを使って、2日目の時点で「とりあえず動く」の状態まで持っていくことができた
動作を安定させるためのアルゴリズムを自力で考えることができた
プロンプトが悪いのかGPTがあまり役に立たなかった…
脅威の安定性
スマホさえ固定できればほぼ100%動く(開発中の失敗は無し)
悪かった点
時間を取られすぎた
ハッカソンなのでもうちょっと妥協してよかったのかもしれない
想定以上に時間が押して他の仕事を他のメンバーに回すことになってしまった…
最終日にSwiftをいきなり学んでくれたuiroくんに心より感謝いたします。
他のViewでカメラを使用する際のバグを治すことが出来なかった
Viewを高速で切り替えられないようにゴリ押しで解決しました
おまけ
「ぴこるー」はCyberAgentさんからCyberAgent賞をいただくことができました!ありがとうございました!
![](https://assets.st-note.com/img/1734532996-9mfBHS1vDKoChYZeq5a3TAFI.jpg?width=1200)
さらにおまけ
なぜかApple製品が2人分ある話とか、開発で目を痛めた話とか、色々書きたいことはありますが現時点(12/18 23:55)で未投稿なため投稿します
来年はもう少し余裕を持って書きたいな…
次回、19日はとみすけくんです。きっと書いてくれていることでしょう、楽しみにしています。
2025/01/30追記
公開されました、バングラデシュに行く話らしいです。面白いのでこちらもぜひ読んでください。
https://tomisuke.com/bangladesh/
ここまで読んでいただきありがとうございました。