star back image
people4
電飾 電飾
moon
astronaut

【Python】自分監視カメラWebアプリ(IoT)

BLOG IoTPHPPythonWEBアプリWEBログ
読了約:89分

IoT(Internet of Things)をやってみたいと前から思っていました。

しかし道具を揃える必要と、生活には必要がないと言う理由がネックで後回しになり、手をつけていませんでした。

要件は以下です。

  • 自分のパソコンのWebカメラで行う。
  • IoTらしくインターネットを経由する。
  • パソコンの前に座っているか監視されたい要求を満たすWebアプリにする。

です。最終的に使用しました道具は以下です。

・パソコン(M1 Mac)
・Webカメラ(Macに付けた中古もの)
・ネット環境(繋がった状況)
・自分のホームページ(Webサーバー/lolipop)
・Adafruit IO(クラウド外部サービス※セキュリティーの為)

IoTは初心者ですが、AIは答えてくれます。

「道具さえ揃っていればできますよ!」と、心強い。

【環境】ローカルでpythonを試そう

目指したかったイメージがあります。
それはamazonなどで見かける監視カメラです。

あの商品の大体にはスマホアプリがあって、インストールすると、ペットや赤ちゃん。玄関先などの映像がスマホで簡単に見ることできます。

使うのは簡単ですが、作るとなるとどうですか。

あなたにはハードルが高くないですか?

(๑•̀ㅂ•́)وがんばる!!

amazonの監視カメラという例えは、私にはややこしかったので整理しました。

スマホ →  パソコン
監視カメラ → パソコンと繋がったWebカメラ
アプリ → パソコンで動かすプログラム

そして監視カメラは、自分の目の前です。

と考えるとわかりやすいと思います。

しかし。今回は映像なし。カメラはセンサー機能としての利用にしました。

どうしました?

「顔は見られたくない」です。恥ずかしいので。

まずはローカル用に作成したファイルは以下です。

/Users/saigamo/Documents/iot_project/
├── venv/
├── camera_test.py
├── face_detection.py
├── web_controller.py
├── chromedriver/
└── test_page.html

chromeブラウザーを使ってテストしたかったのでchromedriverドライバーをダウンロードしてiot_projectへ配置しております。

今使っているchromeブラウザーのバージョンを調べて入れるようです。めんどくさいです。

chromedriverドライバー
https://googlechromelabs.github.io/chrome-for-testing/

Pythonの環境構築(Mac)

ターミナルを使って、iot_projectフォルダを作り、OpenCVなどの必要なプログラムをどんどん入れていきます。

↓pip installなどを使いますのでターミナルの画面で行ってください。

# 作業用フォルダを作成
mkdir iot_project

# 作業用フォルダに移動
cd iot_project

# 仮想環境を作成(venvは標準機能です)
python3 -m venv venv 

# 仮想環境を有効化 (このコマンドを実行すると、ターミナルの行頭に (venv) と表示されます)
source venv/bin/activate

# OpenCV (カメラ画像処理) と NumPy (数値計算) をインストール
pip install opencv-python numpy

# Selenium (Webページ操作) をインストール
pip install selenium

作ったiot_projectフォルダを作り、その場所へ移動しています。

(venv) saigamo@Mac-mini iot_project %

↑頭に(venv)と表示されていると思います。

(venv)があると仮想環境になっていると言うことですね。

仮想環境で動作チェックの方法

Pythonを動かすには仮想環境にするのが一般的だそうです。
私は知らなかったです。常識?

あれ?きのうは動いていたのに、なんでだろうと悩み30分。

昨日は動作確認ができていたのに、翌朝ターミナルでpythonを叩いても動かない。

恐らく多分ですが、仮想環境(venv)にしていないのかも。であるならば、zsh: command not found: python とメッセージが出てpythonが動きません。

Pythonを動かす一般的な手順

1.ターミナルを起動する。
2.プロジェクトフォルダ(iot_project)へ移動する。(cd iot_project)
3.仮想環境を有効化する。(source venv/bin/activate)
4.Pythonプログラムを実行する。(python web_controller.py)

この「仮想環境を有効化する」というひと手間は、将来的に複雑なIoTシステムを開発する際のトラブルを未然に防ぐ、必須の習慣と考えていただければ大丈夫です。

とAIが言うとります。

カメラ映像のテストコード実行

正しくインストールされたか確認するため、カメラ映像を表示する基本的なPythonコードを実行してみましょう。

camera_test.pyというファイルを作成します。

このファイルはpythonが動作するかの確認と、それでカメラが動作するかの確認も兼ねたファイルです。

camera_test.py

import cv2

# カメラの指定 (0は内蔵カメラ、1や2は外部カメラの場合があります)
cap = cv2.VideoCapture(0)

if not cap.isOpened():
    print("カメラを開けませんでした。番号を1などに変えて試してください。")
    exit()

print("カメラ映像表示中... (qキーで終了)")

while True:
    # 1フレームずつキャプチャ
    ret, frame = cap.read()

    # 映像が取得できない場合は終了
    if not ret:
        print("映像を取得できませんでした。")
        break

    # 映像をウィンドウに表示
    cv2.imshow('Camera Feed', frame)

    # 'q' キーが押されたらループを抜ける
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

# キャプチャを終了し、ウィンドウを閉じる
cap.release()
cv2.destroyAllWindows()

ターミナルの画面で打ち込んでenterしてください。

python camera_test.py

カメラが起動してモニターにカメラ映像が表示されたら成功です。

確認のためのだけのファイルなので、今後は使いません。

Webカメラの動作確認ができた後は、(カメラ画面を選択の状態にて)qキーを押して終了してください。動作したのが嬉しくて、ずっと出しておきたいですが、テストが終わったら必ず閉じる必要がありました。

つまり命令が被ると上手くいきません。これから何度もカメラを起動させます。
このファイル自体も消しても良いのですが、私は記念に残します。

続いて顔認証のファイルを用意していきます。

face_detection.py

import cv2

# -----------------------------------------------------------
# 【重要】顔検出のための学習済みデータ(Haar Cascade)をロード
# -----------------------------------------------------------
# このパスは、OpenCVをpipインストールした場所に依存します。
# もしエラーが出る場合は、このパスを修正する必要があります。
# Macでの一般的なOpenCVのインストールパスを使用しています。
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')

# カメラの指定
cap = cv2.VideoCapture(0)

if not cap.isOpened():
    print("カメラを開けませんでした。")
    exit()

print("顔検出開始中... (qキーで終了)")

# 顔検出のフラグを初期化
face_detected = False

while True:
    ret, frame = cap.read()
    if not ret:
        break

    # 1. 映像をグレースケールに変換(検出を高速化するため)
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

    # 2. 顔を検出
    # scaleFactor: スケールをどれだけ小さくするか (1.1が標準的)
    # minNeighbors: 検出候補の数 (3〜5が標準的)
    faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))

    # 3. 検出結果の処理
    if len(faces) > 0:
        face_detected = True
        print("【検出】顔を認識しました!")
    else:
        face_detected = False
        # print("【未検出】顔が見つかりません。") # ログが大量に出るためコメントアウト

    # 4. 検出した顔の周りに枠を描画(オプション)
    for (x, y, w, h) in faces:
        # 枠線 (frame, 左上座標, 右下座標, 色(BGR), 線の太さ)
        cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
        # テキスト表示
        cv2.putText(frame, 'Face Detected', (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 0, 0), 2)

    # 画面に表示
    cv2.imshow('Face Detection', frame)

    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

cap.release()
cv2.destroyAllWindows()

# 検出結果の最終確認(次のステップへの準備)
if face_detected:
    print("\n--- 最終結果 ---")
    print("顔が検出されました。次のステップでWeb操作のトリガーに使います。")
else:
    print("\n--- 最終結果 ---")
    print("顔は検出されませんでした。")

上の顔検出学習データと、以下のコントロール用のセットで、カメラを顔検出の仕組みが整うイメージです。

web_controller.py

import cv2
import time
from selenium import webdriver
from selenium.webdriver.chrome.service import Service as ChromeService
from selenium.webdriver.chrome.options import Options
from webdriver_manager.chrome import ChromeDriverManager
from selenium.webdriver.common.by import By
from selenium.common.exceptions import WebDriverException
import os
import threading
import http.server # ★標準モジュールなのでそのままimport
import socketserver # ★標準モジュールなのでそのままimport
# --- 設定値 ---
COLOR_DETECTED = "red"      # 顔検出時の色
COLOR_NOT_DETECTED = "green" # 顔非検出時の色

PORT = 8000
TARGET_URL = f'http://127.0.0.1:{PORT}/test_page.html' # ★修正:ローカルサーバーのアドレス

# -----------------------------------------------------------
# 【ローカルサーバーの起動関数】
# -----------------------------------------------------------
def start_server():
    # サーバーはカレントディレクトリ(iot_project)をルートとしてファイルを公開します
    Handler = http.server.SimpleHTTPRequestHandler
    
    # 既存のポートを使用中でエラーにならないよう、TCPServerを使う
    try:
        with socketserver.TCPServer(("", PORT), Handler) as httpd:
            print(f"ローカルサーバー起動中: {TARGET_URL}")
            # このスレッドが終了しないように、サーバーを永続的に実行
            httpd.serve_forever()
    except Exception as e:
        print(f"ローカルサーバー起動エラー: {e}")

# -----------------------------------------------------------
# 【顔検出の準備】 
# -----------------------------------------------------------
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
cap = cv2.VideoCapture(0)

if not cap.isOpened():
    print("カメラを開けませんでした。")
    # capが定義されていないため、ここでexit()する前にドライバーがあれば解放する処理が必要だが、
    # 今回はmain関数の外なので、このまま進める。

# -----------------------------------------------------------
# 【Seleniumの準備】
# -----------------------------------------------------------
try:
    # ★修正ポイント:サーバーを別スレッドで起動★
    server_thread = threading.Thread(target=start_server, daemon=True)
    server_thread.start()
    time.sleep(1.5) # サーバーが立ち上がるのを少し長めに待つ
    
    # WebDriver Managerで起動 (省略)
    service = ChromeService(ChromeDriverManager().install())
    
    # ブラウザオプション (省略)
    chrome_options = Options()
    chrome_options.add_experimental_option("detach", True)
    
    # ブラウザを起動し、テストページを開く
    driver = webdriver.Chrome(service=service, options=chrome_options)
    driver.get(TARGET_URL) # ★修正されたTARGET_URLにアクセス
    print(f"Webページを開きました: {TARGET_URL}")

except WebDriverException as e:
    print("--- WebDriver起動エラー ---")
    print(f"エラーが発生しました。Chromeブラウザが閉じられているか、インストールされていない可能性があります。")
    # print(e) # 詳細なエラー表示を省略
    # エラー時はカメラを解放して終了
    cap.release()
    exit()

# -----------------------------------------------------------
# 【メインループ:検出とWeb操作】
# -----------------------------------------------------------
print("IoT連携開始... (qキーで終了)")

# ★最終修正ポイント:カメラとブラウザが起動した後に3秒待機★
print("システム安定化のため3秒待機します...")
time.sleep(3) 

current_color = None # 現在のWebページの色を追跡

try:
    while True:
        # ★カメラフレーム取得時にtry-except★
        try:
            ret, frame = cap.read()
            if not ret:
                print("カメラからフレームが取得できませんでした。")
                break
        except Exception as e:
            # カメラが予期せず切断された場合など
            print(f"カメラフレーム取得エラー: {e}")
            time.sleep(1)
            continue # このフレームはスキップして次へ

        # 顔検出の処理
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        
        # ★追加ポイント:顔検出の処理全体にもtry-exceptを追加★
        try:
            faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
        except Exception as e:
            print(f"顔検出処理エラー: {e}")
            faces = [] # エラー時は顔なしとして扱う
        
        # 検出ステータス
        is_detected = len(faces) > 0
        
        # Webページの背景色を操作
        new_color = COLOR_DETECTED if is_detected else COLOR_NOT_DETECTED
        
        # 色が変わる時だけWeb操作を実行
        if new_color != current_color:
            try:
                # JavaScriptを実行して色を直接変更
                js_code = f"document.getElementById('target-area').style.backgroundColor = '{new_color}';"
                driver.execute_script(js_code)
                current_color = new_color
                print(f"ステータス変更: 顔検出={is_detected} -> 色を {new_color} に変更しました。")

            except WebDriverException:
                # ブラウザが手動で閉じられた場合を想定
                print("Web操作中にエラー: ブラウザが閉じられたため、プログラムを終了します。")
                # driver.quit()を呼ばずに終了するために、ここでは単にbreakのみ
                break
        
        # 検出した顔の周りに枠を描画
        for (x, y, w, h) in faces:
            # 枠線を描画する処理(省略、元のコード参照)
            cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0) if is_detected else (0, 0, 255), 2)

        cv2.imshow('IoT Controller: Camera Feed (q to quit)', frame)

        if cv2.waitKey(1) & 0xFF == ord('q'):
            break

finally:
    # 終了処理
    print("\nプログラムを終了します。リソースを解放中...")
    cap.release()
    cv2.destroyAllWindows()
    
    # driverが起動しているか確認し、ブラウザを閉じないようにする
    # SeleniumのOptionsでdetach=Trueを設定済みのため、ここではdriver.quit()を無効化します
    # ただし、例外処理内でbreakした場合に備え、手動で閉じない限り起動したままにします。
    
    # driver.quit() # ★この行は、ブラウザを強制終了させるため、今回はコメントアウト(または削除)します

    print("完了。")

あれ。なんかうまくいかないので以下もインストール。

(venv) saigamo@Mac-mini iot_project % pip install browser-cookie3

私の環境ではChromedriverがうまく認識してくれず、webdriver-managerでChromeのバージョンを自動で検知し、対応するChromedriver(実行ファイル)を自動的にダウンロードし、管理してもらいました。

Python 3の標準ライブラリに入っていない何かが原因なのか、AIにオススメされるままにインストールしたらうまくいきました。

なんだか正直よくわかりません。

カメラが起動し顔を認識しているのがわかる

カメラ映像が表示されればOK。緑の枠が出ています。
緑枠はface_detection.pyとweb_controller.pyが働いた証拠です。

続いて以下がローカルテスト用のHTMLファイルです。

test_page.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>IoTテストページ</title>
    <style>
        /* ターゲットとなる要素のCSS */
        #target-area {
            width: 400px;
            height: 200px;
            background-color: lightgray; /* 初期の色 */
            border: 5px solid gray;
            margin: 50px;
            padding: 20px;
            text-align: center;
            font-size: 20px;
            line-height: 150px;
            transition: background-color 0.5s; /* 色が滑らかに変わるように */
        }
    </style>
</head>
<body>
    <h1>PC前在席検知 IoTデモ</h1>
    
    <div id="target-area">
        <p>顔が検出されると、この背景色が変わります。</p>
    </div>
    
</body>
</html>
デモ用のHTMLを作成した

テストなので粗末な作りで問題ありません。

これを表示して、ターミナルからpythonを走らせてみましょう。

python web_controller.py
デモ用のHTMLの色が変わった

色が変わりました。感動です。

あんがいチョロくないですか。

しかしこれは、テストをする前のテストです。

【ステップ】Lolipopサーバーで試そう

ローカルPCで達成した連携をインターネット経由で実現するには、「Selenium(ブラウザ自動操作)」の代わりに「HTTP通信」を使います。

Chromedriver(Selenium)は不要になります。

構成・要件は以下です。

  • センサー: PCのカメラとOpenCVによる顔検出。
  • 信号送信: PythonとrequestsによるHTTP通信。
  • データ保存: Lolipopサーバー上のPHPとstatus.txt
  • 表示: Webページ上のJavaScriptによるリアルタイム更新。

センサー(PC側 Python)

/Users/saigamo/Documents/iot_project/
├── venv/
└── iot_sender.py (NEW)

Python + requests ライブラリ(HTTP通信用)
※face_detection.pyとweb_controller.pyは、iot_sender.pyに統合。

サーバー(Lolipop側 PHP/ファイル)

Lolipop/root
├── update_status.php
├── status.txt
└── attendance.html

PHP(PCからのHTTPリクエストを受け付けるスクリプト)

Webページ(Lolipop側 HTML/JavaScript)
https://astrowave.jp/attendance.html
サーバーに保存された最新のstatus.txtステータスを定期的に読み込み、それに従ってWebページの色をリアルタイムで変更するJavaScriptが適用してあります。

ステップ 1: Lolipopサーバー側の準備(PHPとデータファイル)

0と1がリアルタイムで書き換えをするためのファイル、status.txtを作成しUPします。

status.txt

空ファイル
  • ファイル名: status.txt
  • 初期内容: 0
  • 重要: Lolipopの管理画面などで、このファイルに書き込み権限(パーミッション 666 または 777)を設定してください。PHPがこのファイルを更新するために必要です。

update_status.php

<?php
// ★★★ サーバーの書き込み可否を判定するための最終テストコード ★★★
error_reporting(E_ALL);
ini_set('display_errors', 1);

// 1. POSTデータからstatusの値を取得
$status = filter_input(INPUT_POST, 'status', FILTER_VALIDATE_INT);
$status_file = 'status.txt';

// 2. 認証は行わず、ファイル書き込みのみを試みる
if ($status === 0 || $status === 1) {
    $data_to_write = (string)$status; 
    
    // 3. 最もシンプルな file_put_contents で書き込みを試行
    if (@file_put_contents($status_file, $data_to_write) !== false) {
        http_response_code(200);
        echo json_encode(['success' => true, 'message' => 'Write successful (Token skipped).']);
        exit;
    } else {
        // file_put_contents が失敗した場合
        http_response_code(500);
        echo json_encode(['success' => false, 'message' => 'Write failed: Server permission denied.']);
        exit;
    }

} else {
    // パラメータエラー
    http_response_code(400);
    echo json_encode(['success' => false, 'message' => 'Invalid status parameter.']);
    exit;
}
?>

ステップ 2: PC側の準備(PythonのHTTP送信)

Seleniumから、より簡単なHTTP通信を行うrequestsライブラリに切り替えます。

(venv) saigamo@Mac-mini iot_project % pip install requests

前回の web_controller.py をベースに、Web操作部分をHTTP通信に置き換えた新しいファイル iot_sender.py を作成します。

iot_sender.py

import cv2
import time
import requests # ★追加
# from selenium import ... (Selenium関連のインポートは削除またはコメントアウト)
# from webdriver_manager.chrome import ...

# --- 設定値 ---
# LolipopサーバーのベースURL (例: 'https://yourdomain.lolipop.jp/')
PHP_SCRIPT_URL = 'https://astrowave.jp/update_status.php'
# ★Lolipopに合わせたURLに修正してください★

# -----------------------------------------------------------
# 【顔検出の準備】 ★このブロックを挿入します★
# -----------------------------------------------------------
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
cap = cv2.VideoCapture(0)

if not cap.isOpened():
    print("カメラを開けませんでした。")
    exit() # カメラがなければ終了

# -----------------------------------------------------------
# 【メインループ:検出とHTTP通信】
# -----------------------------------------------------------
print("IoT送信開始... (qキーで終了)")
last_status = None # 最後の送信ステータスを追跡

try:
    while True:
        # 1. カメラフレームの取得
        try:
            ret, frame = cap.read()
            if not ret:
                print("カメラからフレームが取得できませんでした。")
                break
        except Exception as e:
            # カメラが予期せず切断された場合など
            print(f"カメラフレーム取得エラー: {e}")
            time.sleep(1)
            continue # このフレームはスキップして次へ

        # 2. 顔検出の処理
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        
        try:
            faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
        except Exception as e:
            print(f"顔検出処理エラー: {e}")
            faces = [] # エラー時は顔なしとして扱う
        
        # 検出ステータス (1:在席, 0:離席)
        is_detected = len(faces) > 0
        current_status = 1 if is_detected else 0 # サーバーに送るデータ

        # ステータスが変わった時だけサーバーに送信
        if current_status != last_status:
            try:
                # HTTP POSTリクエストでステータスを送信
                response = requests.post(PHP_SCRIPT_URL, data={'status': current_status})
                response.raise_for_status() # 200 OK以外なら例外を発生させる
                
                last_status = current_status
                
                print(f"【HTTP送信成功】ステータス={current_status} -> サーバーに通知しました。")

            except requests.exceptions.RequestException as e:
                print(f"【HTTP送信エラー】通信失敗またはサーバーエラー: {e}")
                time.sleep(5) # エラー時は少し待機

        # ... (枠線の描画、cv2.imshow、qキーでの終了処理 - 変更なし)
        time.sleep(1) # ★追加: サーバー負荷軽減のため、1秒間隔でチェック

        # 検出した顔の周りに枠を描画(ローカル動作確認用)
        for (x, y, w, h) in faces:
            cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0) if is_detected else (0, 0, 255), 2)

        cv2.imshow('IoT Sender: Camera Feed (q to quit)', frame)

        if cv2.waitKey(1) & 0xFF == ord('q'):
            break

        time.sleep(1) # サーバー負荷軽減のため、1秒間隔でチェック
finally:
    # -----------------------------------------------------------
    # 【終了処理】 ★このブロックでfinallyの中身を置き換えます★
    # -----------------------------------------------------------
    print("\nプログラムを終了します。リソースを解放中...")
    if 'cap' in locals() or 'cap' in globals():
        cap.release()
    cv2.destroyAllWindows()
    print("完了。")

↑コードの46行目
faces = でカメラの顔認証のレベル(感度)を設定しています。

現在使用している OpenCVのdetectMultiScale関数が持っている主要なパラメーターは二つあります。
そのパラメーターを調整することで、「どれだけ顔に似ていれば顔と判定するか」という感度をコントロールできます。

📸 顔認証の感度を設定する主要なパラメーター
Pythonコードの face_cascade.detectMultiScale() で使用しているパラメーターは以下の通りです。

1. scaleFactor (スケールファクター)
これは、顔検出のために画像をどれだけ縮小(スケーリング)していくかを決定するパラメーターです。

・定義: 検出器が画像をスキャンする際に、次のサイズに縮小する際の比率。
・値の範囲: 1.0より大きい値(通常 1.1 ~ 1.4)。
・感度への影響:
値を小さくする(例: 1.05): スキャンがより細かくなり、小さな顔や遠くの顔を見つけやすくなります(感度UP)。ただし、処理速度が低下し、誤検出(ノイズ)が増えます。
値を大きくする(例: 1.3): スキャンが粗くなり、検出速度が向上します。ただし、小さな顔を見逃す可能性が高くなります。

2. minNeighbors (ミニマムネイバーズ)
これは、「顔である」と最終的に判定するために必要な、周囲の類似検出ウィンドウの数を決定するパラメーターです。

・定義: 同じ場所でどれだけの「顔らしきもの」が連続して検出されたら、最終的に顔として確定するか、という閾値。
・値の範囲: 0以上の整数(通常 3 ~ 6)。
・感度への影響:
値を小さくする(例: 3): わずかな検出でも顔として判定されるため、感度が上がります。ただし、背景のノイズを顔と誤検出するリスク(誤検出率)が高くなります。
値を大きくする(例: 6): 非常に多くの連続した検出が必要になるため、誤検出が減り、より厳密な判定になります(感度DOWN)。

9行目、LolipopサーバーのURLとupdate_status.phpのパスを入力する箇所があります。正確に設定してください。

ステップ 3: Webページ側の準備(JavaScriptでリアルタイム更新)

Lolipopサーバーに、表示したいホームページとなるattendance.htmlを作成します。JavaScriptを使って、status.txtの内容を定期的に読み込み、色を変更します。

attendance.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Lolipop IoT デモ</title>
    <style>
        #target-area {
            width: 400px;
            height: 200px;
            background-color: lightgray; 
            border: 5px solid gray;
            margin: 50px;
            padding: 20px;
            text-align: center;
            font-size: 20px;
            line-height: 150px;
            transition: background-color 0.5s; /* 色が滑らかに変わるように */
        }
    </style>
</head>
<body>
    <h1>Lolipop IoT 在席ステータス</h1>
    <div id="target-area">
        <p>現在のステータス: サーバーから取得中...</p>
    </div>
    <p id="last-update"></p>

    <script>
        const targetArea = document.getElementById('target-area');
        const updateTime = document.getElementById('last-update');
        const statusFileUrl = 'status.txt'; // サーバーにあるデータファイル

        function updateStatus() {
            // status.txtを非同期で読み込む
            fetch(statusFileUrl)
                .then(response => response.text()) // テキストとして取得
                .then(status => {
                    // データが '1' なら在席 (赤)、'0' なら離席 (緑)
                    if (status.trim() === '1') {
                        targetArea.style.backgroundColor = 'red';
                        targetArea.querySelector('p').textContent = "在席中です (RED)";
                    } else if (status.trim() === '0') {
                        targetArea.style.backgroundColor = 'green';
                        targetArea.querySelector('p').textContent = "離席中です (GREEN)";
                    } else {
                        targetArea.style.backgroundColor = 'gray';
                        targetArea.querySelector('p').textContent = "ステータス不明";
                    }
                    
                    updateTime.textContent = '最終更新: ' + new Date().toLocaleTimeString();
                })
                .catch(error => {
                    console.error('ステータス取得エラー:', error);
                    targetArea.style.backgroundColor = 'yellow';
                    targetArea.querySelector('p').textContent = "通信エラー!";
                });
        }

        // 2秒ごとにステータスを更新
        setInterval(updateStatus, 2000); 

        // ページ読み込み時に一度実行
        updateStatus();
    </script>
</body>
</html>

これらの準備ができましたら、python iot_sender.pyを実行してみてください。

python iot_sender.py

Lolipopサーバー上のWebページの色が変化すれば、IoT連携の成功です。

やりましたね!完成じゃないですか。

完成か!そう思ったのですが、終わりません。
このままだとセキュリティーが不十分だと言うのです。

第三者が悪意を持って update_status.php にアクセスすれば、意図的にステータスを操作できてしまうらしい。

 (^_^;)まじですか。

【問題】セキュリティー対策とサーバー制限

ここまで動作確認が出来ました。正直もうここで終わりにしたい気持ちです。

しかし悪意を持っている者には無防備だというのです。
それはまずいと心の警笛を大きく鳴らす原因が、過去にあります。

サイト改竄をされた経験があります。

エッチなサイトへのリンクをメニューに仕込まれてましたね。

AIから「トークン認証」を付けようと提案され、実行しました。
しかし上手くいかなかったです。

それはLolipopサーバー屋さんのphp制限のようでした。
秘密のトークン認証は不採用。その記録が以下です。

【トークン作業記録】Lolipopサーバーのphpへの制限

秘密鍵の「トークン」を決めて進める流れでしたが頓挫しました。
AIも超簡単だと言っていたのですが残念。

PC側の iot_sender.py で、0 や 1 ステータスと一緒にこの「秘密のトークン」をPOSTリクエストに含めて送信。
Lolipopサーバーの update_status.php で、受信したトークンが正しいかどうかを最優先でチェックし、間違っていれば書き込みを拒否する仕組みです。

仕組みを入れたら動作しなくなってしまいました(・ω・`;)

# --- 設定値 --- の下に追加
SECURITY_TOKEN = 'MySecureIOT2025SecretKey' # ★あなたが決めた秘密の鍵

# ... (中略)

        if current_status != last_status:
            try:
                # HTTP POSTリクエストでステータスとトークンを送信
                payload = {
                    'status': current_status,
                    'token': SECURITY_TOKEN  # ★トークンを追加
                }
                response = requests.post(PHP_SCRIPT_URL, data=payload)
                # ... (後略)
<?php
// 秘密のトークンを設定
define('EXPECTED_TOKEN', 'MySecureIOT2025SecretKey'); // ★Pythonと一致させる

// 1. POSTデータからstatusとtokenの値を取得
$status = filter_input(INPUT_POST, 'status', FILTER_VALIDATE_INT);
$received_token = filter_input(INPUT_POST, 'token', FILTER_SANITIZE_STRING); // トークンを取得
$status_file = 'status.txt';

// ★セキュリティチェック(トークンが一致するか)
if ($received_token !== EXPECTED_TOKEN) {
    header('Content-Type: application/json');
    http_response_code(403); // 403 Forbidden(禁止)を返す
    echo json_encode(['success' => false, 'message' => 'Security token is invalid. Access denied.']);
    exit;
}
// ★トークンが正しければ、以下のファイル書き込み処理に進む
// ... (既存のファイル書き込みロジックが続く)
?>

01 を正常に受信しているけど、Tokenは常に NULL
Tokenは辞めて、01 をクラス名に書き換えるテストもダメでした。

嫌な予感がします。

AIの結論は以下です。

PHPが実行されているWebサーバーのユーザーに、あなたが設定したファイル(status.txt)や、そのディレクトリ全体に対する書き込み権限が一切与えられていないという、Lolipopサーバーの非常に厳格なセキュリティ設定が原因です。FTPで 777 に設定しても、PHPの実行ユーザーは別であるため権限が通りません。

★簡単な01 だけ許されている。(´°д°`)エー

ファイル書き込みが不可能な環境で、Webページをリアルタイムで更新し続けるには、ファイルシステム以外の方法でデータを保存するしかありません。

この問題を解決するには、以下の2つの方法があります。

選択肢 1: データベース (MySQL) を使う
選択肢 2: Lolipopのマネージドサービスを使う (外部API)

厳しいのが普通なのではないですか。

MySQLを3回推しほど薦められました。
恐らくMySQLでするのがセオリーなのでしょう。

「データベースの接続設定やSQL文の説明」は、初心者読者様にとって非常に高いハードルとなり、本来伝えたい「IoT連携の楽しさ」が伝わりにくくなってしまいます。

あなたがやりたくないだけではないですか。

(゚ー゚;A、ただでさえ長文の記事で、見るのが辛いかなぁ。

「外部サービスを利用する」方針で進めることにしました。

外部APIサービス「Adafruit IO」

人気のあるIoTプラットフォームの有名どころ無料(または低コスト)プラットフォームがで最初にオススメされたのは「ThingSpeak」でした。

しかしThingSpeakは、gmailやyahooなどの個人的な無料メールアドレスはアカウント作成不可でした。
教育機関や企業での利用を主眼に置いているためらしいです。

営業メールがたくさん来る事が想定されます。

無料で使わせてもらうのに、そんな贅沢を言うのですね。

そこで白羽の矢が立ったのが「Adafruit IO」です。こちらも人気らしいです。
「Adafruit IO」はgmailでアカウント登録可能です。

Adafruit IO 無料プランの制約
ブログで公開するIoTプロジェクトには十分な機能が提供されていますが、以下の制限があります。

・フィード数: 最大5つまで(今回は1つだけ使用しますので問題ありません)。
・データ送信頻度: 1分間に最大30データポイントまで。

サンプルのPythonスクリプトは1秒間に1回=1分間に60回送信していますが、この頻度だと制限に引っかかるので、2秒に1回に修正して対応します。

【共有】トップページに完成形として再現したい

Python、OpenCV、HTTP通信、そして外部APIという複数の技術要素を統合し、安全に動作させたいです。

外部APIサービス「Adafruit IO」
https://io.adafruit.com/

略してAIOらしいです。

アカウントを作成したら、IOページからAIOキーの取得をしにいきます。

「Adafruit IO」のAPIキーの場所
「Adafruit IO」のAPIキーを表示してみた状況

続いてFeedを作成して、APIのエンドポイントの情報をゲットします。

「Adafruit IO」のFeedを作成します
「Feed info」ここでAPIのエンドポイントが確認できます

簡単な書き換えなのでFeedを作成するだけでOK。
取得(ゲット)した情報を以下のファイルに適用します。

用意したファイル

Lolipop/root
├── get_status.php(NEW)
├── update_status.php
└── index.php (astrowaveトップページ)

get_status.phpにAIOの内容を追加。
/Users/saigamo/Documents/iot_project/
├── venv/
└── iot_sender.py

iot_sender.pyにAIOの内容を追加。

要件は以下です。

  • クラウドのAPIを(is-present または is-away)を読み込む。
  • そのクラス名をトップページの <div id="chart01" class="column topset_01"> に追加・削除する。
  • CSSの定義に従って、トップページに配置したメッセージが切り替わる。

送り手と受け手のファイルに「Adafruit IO」のAPIキーの設定を追加しました。

iot_sender.py

import cv2
import time
import requests

# --- 設定値 ---
AIO_USERNAME = '●●●●●●'
AIO_KEY = '●●●●●●●●●●●●●●●●●●●' 
# ★★★ 修正!:フィードのKeyをハイフン付きに修正 ★★★
AIO_FEED_KEY = '●●●●●●●●●●●' 

# AIOのデータ送信URLを構築
# AIOの公式エンドポイントに /data を付与します
AIO_API_URL = f'https://io.adafruit.com/api/v2/{AIO_USERNAME}/feeds/{AIO_FEED_KEY}/data'

# -----------------------------------------------------------
# 【顔検出の準備】 ★このブロックを挿入します★
# -----------------------------------------------------------
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
cap = cv2.VideoCapture(0)

if not cap.isOpened():
    print("カメラを開けませんでした。")
    exit() # カメラがなければ終了

# -----------------------------------------------------------
# 【メインループ:検出とHTTP通信】
# -----------------------------------------------------------
print("IoT送信開始... (qキーで終了)")
last_status = None # 最後の送信ステータスを追跡

try:
    while True:
        # 1. カメラフレームの取得
        try:
            ret, frame = cap.read()
            if not ret:
                print("カメラからフレームが取得できませんでした。")
                break
        except Exception as e:
            # カメラが予期せず切断された場合など
            print(f"カメラフレーム取得エラー: {e}")
            time.sleep(1)
            continue # このフレームはスキップして次へ

        # 2. 顔検出の処理
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        
        try:
            faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
        except Exception as e:
            print(f"顔検出処理エラー: {e}")
            faces = [] # エラー時は顔なしとして扱う
        
        # 検出ステータス (1:在席, 0:離席)
        is_detected = len(faces) > 0
        current_status = 1 if is_detected else 0 # サーバーに送るデータ

        # ステータスが変わった時だけサーバーに送信
        if current_status != last_status:
            try:
                # 1. 送信データを準備 (AIOはJSONで 'value' を要求)
                payload_json = {
                    "value": "is-present" if current_status == 1 else "is-away"
                }
                
                headers = {
                    "X-AIO-Key": AIO_KEY,
                    "Content-Type": "application/json"
                }
                
                # 3. HTTP POSTリクエストでデータを送信
                response = requests.post(
                    AIO_API_URL, 
                    headers=headers, # ★認証情報をヘッダーで送信
                    json=payload_json 
                )
                # 4. サーバー応答が成功(200 OK)か確認
                response.raise_for_status() 
                
                last_status = current_status
                
                print(f"【AIO送信成功】データ={payload_json['value']} をフィードに記録しました。")

            except requests.exceptions.HTTPError as e:
                print(f"【AIOエラー】ステータスコード: {response.status_code}")
                print(f"【サーバー応答】: {response.text}")
                time.sleep(5) 
            except Exception as e:
                print(f"【予期せぬエラー】: {e}")
                time.sleep(5)

        # 検出した顔の周りに枠を描画(ローカル動作確認用)
        for (x, y, w, h) in faces:
            cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0) if is_detected else (0, 0, 255), 2)

        cv2.imshow('IoT Sender: Camera Feed (q to quit)', frame)

        # qキーが押されたかチェック (待ち時間を長くする)
        if cv2.waitKey(100) & 0xFF == ord('q'): # ★100ミリ秒に変更
            break

        # ループの末尾で一度だけ待機します
        time.sleep(2)

finally:
    # -----------------------------------------------------------
    # 【終了処理】 ★最終修正:終了時に離席信号を送信します★
    # -----------------------------------------------------------
    print("\nプログラムを終了します。リソースを解放中...")
    
    # 離席信号を強制送信
    try:
        print("最終ステータス (is-away) を送信中...")
        payload_json = {"value": "is-away"}
        headers = {"X-AIO-Key": AIO_KEY, "Content-Type": "application/json"}
        
        # POSTリクエストを送信
        requests.post(
            AIO_API_URL, 
            headers=headers, 
            json=payload_json,
            timeout=5 # 5秒以内に応答がなければ諦める
        )
        print("最終ステータス送信完了。")
        
    except Exception as e:
        print(f"最終ステータス送信エラー: {e}")

    if 'cap' in locals() or 'cap' in globals():
        cap.release()
    cv2.destroyAllWindows()
    print("完了。")

Adafruit IO 無料プランの制約である、データ送信頻度: 1分間に最大30データポイントに対応するために、time.sleep()を2に指定をしております。

102行目
# ループの末尾で一度だけ待機します
time.sleep(2)

get_status.php

このget_status.phpファイルは、Webページ(JavaScript)からのリクエストを受け取り、内部で秘密の AIO Keyを使ってデータを取りに行くための「橋渡し役」です。

<?php
// ★★★ Adafruit IO 認証ブリッジ (get_status.php) ★★★

// 1. 設定値(このPHPファイル内に秘密の鍵を隠します)
// Webページには公開されないため安全です。
$aio_username = "●●●●●●";
$aio_key = "●●●●●●●●●●●●●●●●●●●"; // ★ご自身のAIO KEYに修正してください
$feed_key = "●●●●●●●●●●●";

// 2. AIOからのデータ取得URLを構築
$api_url = "https://io.adafruit.com/api/v2/{$aio_username}/feeds/{$feed_key}/data/last";

// 3. HTTPリクエストの実行
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $api_url);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
// ★認証:ヘッダーにX-AIO-Keyを設定して送信★
curl_setopt($curl, CURLOPT_HTTPHEADER, array("X-AIO-Key: " . $aio_key));
$response = curl_exec($curl);
$http_code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);

// 4. エラーチェックと応答の整形
if ($http_code === 200) {
    // 応答が成功した場合、JSONをデコード
    $data = json_decode($response, true);
    
    // Webページへ渡す最終データを準備
    // AIOは 'value' と 'created_at' を返します
    $status_value = $data['value'] ?? 'is-away'; // データがなければ is-away をデフォルトとする
    
    header('Content-Type: application/json');
    // Webページ側はJSONとしてこのステータスを受け取ります
    echo json_encode(['status' => $status_value]);
} else {
    // AIOからのエラー応答をそのままJSONで返す
    header('Content-Type: application/json');
    http_response_code($http_code);
    echo $response;
}
?>

index.php(トップページHTML抜粋)

/* --- IoT 在席ステータス表示用 CSS --- */

/* 1. ターゲット要素の準備 */
/* #chart01 はあなたのトップページにある要素のIDです */
#chart01 {
  position: relative; 
}

/* 2. 在席時 (is-present) のスタイル */
#chart01.is-present {
}

/* 3. 離席時 (is-away) のスタイル */
#chart01.is-away {
}

/* 4. メッセージ表示 (::after 擬似要素を使用) */
#chart01::after {
  content: "読み込み中..."; 
  position: absolute;
  top: 0;
  right: 0;
  transform: translate(0%, 0%);
  padding: 4px 14px;
  font-size: 13px;
  font-weight: normal;
  border-radius: 24px;
  background: rgba(0, 0, 0, 0.7);
  color: #fff;
  opacity: 0; /* 通常は非表示 */
  transition: opacity 0.4s;
}

/* 5. 在席中になったらメッセージを表示 */
#chart01.is-present::after {
  content: "🟢 在席中です"; 
  background:rgb(233, 38, 16); 
  opacity: 1; 
}

/* 6. 離席中になったらメッセージを表示 */
#chart01.is-away::after {
  content: "🔴 離席中です"; 
  background: rgba(0, 0, 0, 0.7);
  opacity: 1; 
}

<div id="chart01" class="column topset_01">
    <img class="set01" src="./img/top/info_photo.webp" alt="NWYH Stock Images" width="285" height="358" loading="lazy">
    <h2 class="catchcopy">一緒に宇宙旅行しようじゃないか♪</h2>
    <p id="info_text">早く宇宙船を手に入れてどこまでも続く宇宙を旅したい。<br>その熱い気持ちを胸に秘め、楽しそうな事を空想したりして、業界の発展や自身の人生を少しでも盛り上げたいと、こっそりホームページを立ち上げました。</p>
    <img class="wavebg" src="./img/top/wave-img.webp" alt="" width="336" height="153" loading="lazy">
    <a href="https://neo.astrowave.jp/category/stocks/" class="kabu">
    <picture>
          <source media="(max-width: 480px)" srcset="https://neo.astrowave.jp/wp-content/uploads/2023/09/kabu_bana_side.jpg">
          <img class="kabu_bnner" src="./img/top/kabu_bana.webp" alt="株投資・資金調達" width="506" height="100%" loading="lazy">
        </picture>
    </a>
</div>

<script>
    // パスに合わせて
    const STATUS_API_URL = '●●●サイトドメイン●●●/get_status.php'; // ステップ1で作成したPHPファイル

    const targetElement = document.getElementById('chart01'); 
    const updateInterval = 2000; // 2秒ごとに更新 (AIOの制限遵守)

    function updateStatus() {
        // キャッシュ対策として、常に最新のPHPファイルを呼び出す
        const fetchUrl = STATUS_API_URL + '?' + new Date().getTime(); 

        fetch(fetchUrl)
            .then(response => {
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                return response.json();
            })
            .then(data => {
                const newClass = data.status; // PHPから受け取った 'is-present' または 'is-away'
                
                // クラス切り替えのロジック
                if (newClass) {
                    // 既存のステータスクラスを全て削除
                    targetElement.classList.remove('is-present', 'is-away'); 
                    
                    // 新しいクラスを追加
                    targetElement.classList.add(newClass);
                }
            })
            .catch(error => {
                console.error('IoT Status Error:', error);
                // エラー時は背景を黄色にするなど、視覚的なフィードバックを与えます
                targetElement.classList.remove('is-present', 'is-away');
                targetElement.classList.add('is-error'); 
            });
    }

    // 2秒ごとにステータス更新関数を実行
    setInterval(updateStatus, updateInterval); 

    // ページ読み込み時に一度実行
    updateStatus();
</script>

cssの::after要素を使ってアイコンを表示させて、JavaScriptでget_status.phpからステータスをゲットして適用します。

Pythonスクリプトを実行して確認してください。

(venv) saigamo@Mac-mini iot_project % python iot_sender.py

サイトのトップでアイコンの確認ができます。
https://astrowave.jp/

classが差し代わる簡単な仕様です。

今見たらアイコンの色が変わっているかも!?

なぜ監視されたいのですか?

( -`ω-)アプリが起動していれば、ほぼリアルタイムでアイコンが動作します。

実行後、Adafruit IOのフィード詳細画面(saigamo / Feeds / ito_sender)で、グラフの下に「Value」が記録され、値が is-presentis-away に切り替わっているかを確認できます。

以上です。

記事が長すぎです。

【簡単】アプリケーション化(Mac)

ターミナルを使うのは苦手です。
アイコンをダブルクリックで使えるようにしたいです。

start_iot.shを作成します。

/Users/saigamo/Documents/iot_project/
├── venv/
├── iot_sender.py
└── start_iot.sh(NEW)

start_iot.sh

#!/bin/bash

# 1. スクリプトのディレクトリに移動
# スクリプトがどこから実行されても、venvフォルダを見つけられるようにします
cd "$(dirname "$0")"

# 2. 仮想環境を有効化
if [ -d "venv" ]; then
    source venv/bin/activate
else
    echo "エラー: 仮想環境 'venv' が見つかりません。"
    exit 1
fi

# 3. Pythonスクリプトを実行 (警告表示を抑止し、メインスクリプトを実行)
python -W ignore iot_sender.py

# 4. 実行後、仮想環境を終了
deactivate

# 5. カメラウィンドウが閉じられた後、ターミナルウィンドウがすぐに閉じないよう待機
echo "IoTシステムの実行が完了しました。ウィンドウを閉じるには Enter キーを押してください。"
read -r

実行権限の付与します。

(venv) saigamo@Mac-mini iot_project % chmod +x start_iot.sh

権限付与が完了しましたら、Automator を使ったアプリケーション作成をします。

アプリケーションフォルダの中にいます。

新規書類でアプリケーションを選択します。

「シェルスクリプトを実行」を探して

shファイルまでのパスはiot_projectフォルダ。

/Users/saigamo/Documents/iot_project/start_iot.sh

名前を入れて、保存しましょう。

デスクトップに専用のアイコンが出現します。

できたアプリのアイコン

いつでもダブルクリックしてアプリ起動が可能となりました。

アプリケーション化の最終段階で、Macのセキュリティ設定によるおなじみのエラーが出る事があります。これは、Automator経由でPythonを実行する際によくある現象です。

満足です (っ ´ー` c)=3

【AI】イラストを描いてもらった

今回の記事のキャッチ画像で使わせてもらいます「Google ImageFX」で作成した画像です。誰でもgoogleアカウントでログインして使えます。

この記事にピッタリなイラストのための考えたリクエストは、「古いヨーロッパの街中。暗がりで名探偵コスプレしているピンク色髪のメイドさんが、容疑者を遠くから監視しています。片目が光っています。ダッチアングルのショルダーショット。」です。

メイドさんブームが来てませんか。

星間旅路のメロディ

「宇宙の静けさに包まれながら、漂流する過去の音楽を捜し求め、銀河の奥底でその旋律に耳を傾ける。」

「この電波はどこの星からきたのだろうか。」

なんかすばらしいですねぇ。たまりません。