この記事はGMOペパボ エンジニア Advent Calendar 2025の23日目です

🎅会場:https://adventar.org/calendars/11929

🎄会場:https://adventar.org/calendars/12190

きっかけ

iPhoneでアラームを設定しても、ちゃんと起きれたことがほぼありません。 平日は特に何度もスヌーズが続きます。 そこで、WWDC 2025で発表されたAlarmKitを使って、オリジナルのアラームを作ってみようじゃないかという試みです。

どんなアラームか

  1. 目覚ましが鳴る
  2. 画面を解除する
  3. 腕立て伏せのカウント画面が開く
  4. ○回クリアしたら目覚ましの音が鳴り止む
  5. そのまま顔を洗うなど1日をはじめる

という、いたってシンプルなアプリです。

使用したフレームワークは以下です。

  • Vision(姿勢検出)
  • AlarmKit(タイマー / アラーム)
  • AppIntents(アラーム解除後のアクション)

デモ

アラームのデモ

腕立て伏せ検知のデモ

まだ、途中ですが、アラームの起動と腕立て伏せのカウントまではできました。 悩み中なのは、アラームを解除してから腕立て伏せの画面を開く動線で、単純に画面遷移を実装しただけではうまくできませんでした。 また画面ロックしていると、解除して終了してまう…まあまだまだ理想の形には遠いです。

Visionとは

Vision は Apple が提供する画像解析フレームワークで、顔検出、テキスト検出、物体検出、人体姿勢検出などを、比較的少ないコードで扱えます。

注意点としては、Info.plistに以下を追加する必要があります。

<key>NSCameraUsageDescription</key>
<string>姿勢検出のためにカメラを使用します</string>

姿勢検出の基本的な流れ

姿勢検出の流れはだいたい次の通りです。

  1. カメラ映像を AVCaptureSession で取得
  2. 各フレームに対して VNDetectHumanBodyPoseRequest を実行 (VNDetectAnimalBodyPoseRequest を使えば、動物骨格検知もできます)
  3. 肩・肘・手首などの関節位置を取得
  4. 腕立ての判定

腕立ての判定はシンプルに考えました。

  • 肩・肘・手首の3点から 肘の角度 を計算すればよさそう
  • 腕を「伸ばした状態」「曲げた状態」をしきい値で判定すればよさそう
  • up → down → up を 1 回としてカウントすればよさそう

という具合にポイントだけ考えて、詳しい計算式はChatGPTに投げました。

チャッピーは正しく実装してくれたみたいですが、僕の考えが間違っていて、見事に不正ができるものになってしまいました…(笑)

private func evaluatePushUpPose(
    _ points: [VNHumanBodyPoseObservation.JointName: VNRecognizedPoint]
) {
    // 必要な関節(肩・肘・手首)がすべて取れているかチェック
    guard let leftShoulder = points[.leftShoulder],
          let rightShoulder = points[.rightShoulder],
          let leftElbow = points[.leftElbow],
          let rightElbow = points[.rightElbow],
          let leftWrist = points[.leftWrist],
          let rightWrist = points[.rightWrist] else {
        // どれか欠けていたら判定できないので終了
        return
    }

    // 検出精度(confidence)が低い点はノイズになりやすいので除外
    guard leftShoulder.confidence > 0.2,
          rightShoulder.confidence > 0.2,
          leftElbow.confidence > 0.2,
          rightElbow.confidence > 0.2,
          leftWrist.confidence > 0.2,
          rightWrist.confidence > 0.2 else {
        // 精度が低いフレームは判定しない
        return
    }

    // 左腕の肘角度を計算(肩→肘→手首の3点から角度を出す)
    let leftAngle = angleBetween(
        CGPoint(x: leftShoulder.x, y: leftShoulder.y), // 左肩の座標
        CGPoint(x: leftElbow.x, y: leftElbow.y),       // 左肘の座標(角度の頂点)
        CGPoint(x: leftWrist.x, y: leftWrist.y)        // 左手首の座標
    )

    // 右腕の肘角度を計算(肩→肘→手首の3点から角度を出す)
    let rightAngle = angleBetween(
        CGPoint(x: rightShoulder.x, y: rightShoulder.y), // 右肩の座標
        CGPoint(x: rightElbow.x, y: rightElbow.y),       // 右肘の座標(角度の頂点)
        CGPoint(x: rightWrist.x, y: rightWrist.y)        // 右手首の座標
    )

    // 左右の肘角度の平均を取って、片腕のブレをならす
    let avgAngle = (leftAngle + rightAngle) / 2

    // 腕を曲げた判定(この角度より小さければ「下」扱い)
    let downThreshold: CGFloat = 100

    // 腕を伸ばした判定(この角度より大きければ「上」扱い)
    let upThreshold: CGFloat = 140

    // 腕立ての状態遷移(waitingForUp → up → down → up で1回カウント)
    switch pushUpState {

    case .waitingForUp:
        // 最初は「腕が伸びた状態」になるまで待つ(準備フェーズ)
        if avgAngle > upThreshold {
            pushUpState = .up // 伸びたので「up」状態へ
        }

    case .up:
        // 「up(伸び)」から「down(曲げ)」へ移行したら下降判定
        if avgAngle < downThreshold {
            pushUpState = .down // 曲がったので「down」状態へ
        }

    case .down:
        // 「down(曲げ)」から「up(伸び)」へ戻ったら1回完了としてカウント
        if avgAngle > upThreshold {
            pushUpState = .up   // 伸びたので「up」状態へ戻す
            pushUpCount += 1    // 腕立て1回分を加算
        }
    }
}

というのも、今回は腕の角度のみで腕立て伏せを判定しているので、腕だけ動かせば判定されてしまいます。 実際に、腕立て伏せの伏せる必要がないことです。

とまあ課題は残りましたが、アラームの実装に取り掛かることにしました。

AlarmKitとは

iOS 26で登場したAlarmKit を使うと、タイマー、アラーム、フルスクリーンのアラームのUIを実装することができます。(お馴染みの画面が割と簡単に作れます)

デバイスの標準システムで動くのがメリットだと思います。

AlarmKitのアラーム機能でカスタマイズできること

今回はアラームに限って触ったので、アラームでどんなカスタマイズができるかを簡単に紹介します。

// アラーム解除時に実行するIntent
let stopIntent = OpenCameraIntent()
stopIntent.alarmID = alarmID.uuidString

let stopButton = AlarmButton(
    text: "さあ、腕立てだ!",
    textColor: .white,
    systemImageName: "checkmark.circle"
)

let repeteButton = AlarmButton(
    text: "リピート",
    textColor: .white,
    systemImageName: "repeat.circle"
)

let alertPresentation = AlarmPresentation.Alert(
    title: "おはよう!🏋️",
    stopButton: stopButton,
    secondaryButton: repeteButton,
    secondaryButtonBehavior: .countdown
)

let attributes = AlarmAttributes<SampleAlarmMetadata>(
    presentation: AlarmPresentation(
        alert: alertPresentation
    ),
    tintColor: .blue
)

let configuration =
    AlarmManager.AlarmConfiguration<SampleAlarmMetadata>.timer(
        duration: timerMinutes * 10,
        attributes: attributes,
        stopIntent: stopIntent,
        sound: .default
    )

上記のような記述で、アラームの見た目や時間を制御できることがわかりました。 SoundのカスタマイズやDynamic Islandでの表示もいろいろ変更できるみたいです。

AppIntentsでアラーム解除後にカメラ画面を開く

AlarmKit のアラームでは、解除時に実行されるクロージャを設定できます。 最初はこのクロージャの中で腕立て伏せカウント画面へ遷移させようとしたけど、うまくいきませんでした。 そこで、WWDCの動画を見返したところ、AppIntentsを使用していたので参考にしました。

アラーム解除後にこのIntentを実行し、

struct OpenCameraIntent: AppIntent {
    func perform() async throws -> some IntentResult {
        AppState.shared.shouldShowCamera = true
        return .result()
    }
}

状態を変更して、navigationDestinationで遷移させます。

.navigationDestination(isPresented: $appState.shouldShowCamera) {
    CameraView()
}

こうすことによって、アラーム画面を解除したのちに、腕立て伏せを判定する画面を開らけるようになりました。

Passと組み合わせられるかも?

去年のアドベントカレンダーで【iOS】WalletのPassを作成してみたを紹介をしました。 例えば、飛行機の搭乗が近づいているときに、1時間前にはアラームを鳴らすなど、組み合わせて活用できないかなあとおもいました。

まずば、年末年始ちゃんとの起きるためにアプリ完成させないとですね(笑)