
こんにちは。AI coordinatorの清水です。
前回の記事で、ラズパイでYOLO11を使った物体検出を出来るようにして、さらにそのラズパイをflaskを使ってサーバー化してスマホからもアクセスできるようにしたので、今回はさらにこのラズパイを監視カメラ化して、ラズパイの電源入れると同時にpythonが起動する方法を紹介します。
前回の記事はこれです。
最新のソースコードはこちらです。物体検出したら画像を補完するようにしています。
https://github.com/ai-coordinator/RaspberryPi_yolo11
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Raspberry Pi 5 + Flask + Ultralytics YOLO11 (NCNN固定, yolo11n-seg)
- 右: 映像(SF風ネオンHUD:bbox+コーナー+ラベル、セグ輪郭)
- 左: サイドバー(FPS/温度/CPU/周波数/メモリ/ディスク/稼働時間/throttled + 閾値スライダー + 検出クラス選択)
- レスポンシブ:PCは左右レイアウト、タブレットは幅縮小、スマホは上=映像(全体表示), 下=情報パネル
- 走査線・中央レティクルなし
- Pi向け最適化: OMP/OpenCVスレッド制御, MJPG入力, 短バッファ, 軽いウォームアップ
- スマホ縦画面で“映像は全体が見える”(object-fit: contain)、下半分に情報&クラス選択
- 検出対象の選択:All(全部) or 単一クラス(プルダウン + 検索)
- 監視カメラ機能:選択クラスを検出したら画像保存(最大100枚、古い順に自動削除)/ブラウザからギャラリー参照
- 電源操作:Web UI から「シャットダウン」「再起動」ボタン(確認ダイアログ+非同期実行)
"""
# ===== スレッド数を制限(NumPy/OMPより前に設定) =====
import os
os.environ.setdefault("OMP_NUM_THREADS", "4")
os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
os.environ.setdefault("MKL_NUM_THREADS", "1")
import cv2
try:
cv2.setNumThreads(1) # OpenCV側は1スレに
except Exception:
pass
if hasattr(cv2, "useOptimized"):
try:
cv2.useOptimized(True)
except Exception:
pass
import time
import shutil
import threading
from collections import deque
from datetime import datetime
from subprocess import check_output, CalledProcessError
from flask import Flask, Response, render_template_string, jsonify, request, send_from_directory
from ultralytics import YOLO
import numpy as np
# ===== 固定設定 =====
MODEL_PT = "yolo11n-seg.pt" # このモデルだけを使う(NCNNエクスポート&読み込み)
IMG_SIZE = 640
CONF = 0.25 # 初期値。左サイドバーのスライダーで変更可(0.00〜1.00)
FPS_TARGET = 30
JPEG_QUALITY = 80
CAM_INDEX = 0
# キャプチャ保存
CAPTURE_DIR = "captures"
CAPTURE_LIMIT = 1000
CAPTURE_MIN_INTERVAL = 2.0 # 連写し過ぎ防止(秒)
# ===== Flask / 状態 =====
app = Flask(__name__)
cap = None
model = None
model_lock = threading.Lock()
# ライブ統計
latest_fps = 0.0
_latest_fps_lock = threading.Lock()
# conf のロック
conf_lock = threading.Lock()
# 検出クラス選択(-1=すべて)
sel_lock = threading.Lock()
SELECTED_CLASS = -1
# キャプチャ状態
_capture_lock = threading.Lock()
_last_capture_ts = 0.0
# ===== HTML(レスポンシブUI:左サイドバー/右映像・スマホは上下分割) =====
HTML = """
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>AI coordinator Raspberry Pi vision</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<style>
:root{
color-scheme:dark;
--bg:#0b0b0f; --panel:#111827; --muted:#93a4bf; --accent:#38bdf8;
--ok:#22c55e; --warn:#f59e0b; --bad:#ef4444;
--bar:#0f172a; --border:#22304d;
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:var(--bg);color:#e8f0ff;font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif}
/* Topbar (mobile) */
.topbar{
display:none;position:sticky;top:0;z-index:50;
background:linear-gradient(180deg, rgba(24,35,55,.85), rgba(11,11,15,.7));
border-bottom:1px solid #1f2a44;backdrop-filter:blur(8px);
padding:10px 14px;align-items:center;gap:10px
}
.burger{width:36px;height:36px;border-radius:10px;border:1px solid var(--border);
background:var(--panel);display:grid;place-items:center;color:#cde9ff;cursor:pointer}
.burger span{width:18px;height:2px;background:#cde9ff;display:block;box-shadow:0 6px 0 #cde9ff, 0 -6px 0 #cde9ff}
.layout{display:flex;min-height:100vh}
.sidebar{
width:360px;padding:16px;position:sticky;top:0;align-self:flex-start;z-index:40;
background:linear-gradient(180deg, rgba(24,35,55,.75), rgba(11,11,15,.6));
border-right:1px solid #1f2a44;backdrop-filter:blur(8px);
transition:transform .25s ease
}
.brand{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.brand .dot{width:10px;height:10px;border-radius:9999px;background:var(--accent);box-shadow:0 0 14px var(--accent)}
h1{font-size:clamp(16px, 1.8vw, 20px);margin:0}
.muted{color:var(--muted);font-size:12px}
.section{margin-top:14px}
.card{
background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:12px;margin-bottom:12px;
box-shadow:0 8px 30px rgba(0,0,0,.35)
}
.row{display:flex;align-items:center;justify-content:space-between;margin:6px 0}
.k{font-size:12px;color:var(--muted)}
.v{font-variant-numeric:tabular-nums}
.bar{height:8px;background:var(--bar);border-radius:9999px;overflow:hidden;border:1px solid #1f2a44}
.bar > span{display:block;height:100%;background:var(--accent);width:0%}
.pill{
display:inline-block;background:#172033;border:1px solid #2b3c66;padding:4px 8px;border-radius:9999px;margin-right:.5rem;font-size:12px
}
.content{flex:1;padding:16px}
.videoWrap{
width:100%;max-width:1400px;margin-inline:auto;display:flex;align-items:center;justify-content:center;
background:radial-gradient(1200px 600px at 70% 30%, rgba(56,189,248,.15), transparent);
border-radius:16px;border:1px solid var(--border);box-shadow:0 8px 30px rgba(0,0,0,.35);
aspect-ratio:16/9; /* 画面比を保つ(デスクトップ/横向き) */
}
img.stream{width:100%;height:100%;object-fit:contain;border-radius:12px}
input[type=range]{
-webkit-appearance:none;appearance:none;width:100%;height:10px;border-radius:9999px;background:var(--bar);border:1px solid var(--border);outline:none
}
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none;appearance:none;width:22px;height:22px;border-radius:50%;
background:var(--accent);box-shadow:0 0 10px var(--accent);cursor:pointer;border:none;margin-top:-6px
}
.rangeHead{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.rangeVal{font-variant-numeric:tabular-nums}
/* Buttons */
.btn{
padding:10px 14px;border-radius:10px;border:1px solid var(--border);
background:#172033;color:#e8f0ff;cursor:pointer;font-weight:600
}
.btn.warn{ background:#3a2f0e;border-color:#5b480f }
.btn.danger{ background:#3a0e14;border-color:#5b0f19 }
.btn:active{ transform:translateY(1px) }
/* ------ Responsive: タブレット/スマホ共通の微調整 ------ */
@media (max-width: 1200px){ .sidebar{width:300px} }
@media (max-width: 980px){ .sidebar{width:280px} .content{padding:12px} }
/* ------ スマホ(下地) ------ */
@media (max-width: 780px){
.topbar{display:flex}
.layout{position:relative}
.sidebar{
position:fixed;left:0;top:52px;height:calc(100dvh - 52px);transform:translateX(-105%);
width:min(88vw, 360px);overflow:auto;border-right:1px solid #1f2a44
}
.sidebar.open{transform:translateX(0)}
.content{padding:12px}
.videoWrap{aspect-ratio:auto}
}
/* ------ モバイル下部情報パネル(デフォ隠し) ------ */
.mobileInfo{display:none}
/* ------ スマホ縦:上半分=映像(全体表示、トリミングなし)/ 下半分=情報 ------ */
@media (max-width:780px) and (orientation:portrait){
.content{padding:0}
.mainGrid{display:grid;grid-template-rows:1fr 1fr; height:calc(100dvh - 52px)}
.videoWrap{
aspect-ratio:auto;
min-height:0;
height:100%;
border-radius:0; border:none; box-shadow:none;
background:transparent;
}
img.stream{object-fit:contain; border-radius:0}
.mobileInfo{display:block; overflow:auto; padding:12px; border-top:1px solid var(--border); background:rgba(11,11,15,.65); backdrop-filter:blur(6px)}
}
/* ------ スマホ横:映像優先、下パネルは非表示のまま ------ */
@media (max-width:780px) and (orientation:landscape){
.mobileInfo{display:none}
}
</style>
</head>
<body>
<!-- Mobile topbar -->
<div class="topbar">
<button class="burger" id="burger" aria-label="Toggle sidebar"><span></span></button>
<div style="display:flex;align-items:center;gap:8px">
<div style="width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px var(--accent)"></div>
<div>
<div style="font-size:14px;font-weight:600">AI coordinator Raspberry Pi vision</div>
<div class="muted">Raspberry Pi 5 / NCNN</div>
</div>
</div>
</div>
<div class="layout">
<aside class="sidebar" id="sidebar">
<div class="brand">
<span class="dot"></span>
<div>
<h1>AI coordinator</h1>
<div class="muted">Raspberry Pi vision</div>
</div>
</div>
<div class="section card">
<div class="row"><div class="k">FPS</div><div class="v" id="fps">—</div></div>
<div class="bar"><span id="fpsbar"></span></div>
<div class="row"><div class="k">Backend</div><div class="v">NCNN</div></div>
<div class="row"><div class="k">Model</div><div class="v">yolo11n-seg</div></div>
<div class="row"><div class="k">Input</div><div class="v">{{img}}px, Q{{jpegq}}, {{fps}} FPS tgt</div></div>
</div>
<div class="section card">
<div class="rangeHead">
<div class="k">Confidence threshold</div>
<div class="v rangeVal"><span id="confv">{{conf}}</span></div>
</div>
<input id="conf" type="range" min="0" max="1" step="0.01" value="{{conf}}"/>
<div class="row"><div class="k">Lower → 検出多/誤検知増</div><div class="k">Higher → 厳しめ</div></div>
</div>
<!-- 検出クラス選択(全部 or 単一)。検索付き -->
<div class="section card">
<div class="rangeHead" style="margin-bottom:8px"><div class="k">Detection target</div><div class="v" id="sel_disp">All</div></div>
<input id="clsSearch" type="search" placeholder="クラス名を検索…" style="width:100%;padding:8px;border-radius:10px;border:1px solid var(--border);background:var(--panel);color:#e8f0ff;margin-bottom:8px"/>
<select id="clsSelect" style="width:100%;padding:10px;border-radius:10px;border:1px solid var(--border);background:var(--panel);color:#e8f0ff"></select>
<div class="k" style="margin-top:6px">※ 「All」で全クラス検出/それ以外は1クラスのみ</div>
<div class="row" style="margin-top:10px"><a href="/captures" target="_blank" class="pill">Open Gallery</a></div>
</div>
<div class="section card">
<div style="margin-bottom:8px" class="k">Hardware Health</div>
<div class="row"><div class="k">Temp</div><div class="v" id="temp">—</div></div>
<div class="bar"><span id="tempbar"></span></div>
<div class="row"><div class="k">CPU %</div><div class="v" id="cpu">—</div></div>
<div class="bar"><span id="cpubar"></span></div>
<div class="row"><div class="k">CPU Freq</div><div class="v" id="freq">—</div></div>
<div class="row"><div class="k">Load (1/5/15)</div><div class="v" id="load">—</div></div>
<div class="row"><div class="k">Mem</div><div class="v" id="mem">—</div></div>
<div class="bar"><span id="membar"></span></div>
<div class="row"><div class="k">Disk</div><div class="v" id="disk">—</div></div>
<div class="bar"><span id="diskbar"></span></div>
<div class="row"><div class="k">Uptime</div><div class="v" id="uptime">—</div></div>
<div class="row"><div class="k">Throttled</div><div class="v" id="throt">—</div></div>
</div>
<div class="section card">
<span class="pill">IMG:{{img}}</span>
<span class="pill">NCNN</span>
<span class="pill">Flask</span>
</div>
<!-- 電源操作 -->
<div class="section card">
<div class="k" style="margin-bottom:8px">Power Control</div>
<button class="btn danger" id="btnShutdown">シャットダウン</button>
<button class="btn warn" id="btnReboot" style="margin-left:8px">再起動</button>
<div class="k" style="margin-top:8px">※ 押すと確認ダイアログ後に即実行(非同期)</div>
</div>
</aside>
<main class="content">
<div class="mainGrid">
<div class="videoWrap">
<img class="stream" src="/video_feed" alt="YOLO stream"/>
</div>
<!-- 下半分:サイドバー情報の要約 + クラス選択 + 電源操作(モバイル) -->
<section class="mobileInfo">
<div class="section card">
<div class="row"><div class="k">FPS</div><div class="v" id="fps_m">—</div></div>
<div class="bar"><span id="fpsbar_m"></span></div>
<div class="row"><div class="k">Input</div><div class="v">{{img}}px, Q{{jpegq}}, {{fps}} FPS tgt</div></div>
<div class="row" style="margin-top:6px"><a href="/captures" target="_blank" class="pill">Open Gallery</a></div>
</div>
<div class="section card">
<div class="rangeHead">
<div class="k">Confidence threshold</div>
<div class="v rangeVal"><span id="confv_m">{{conf}}</span></div>
</div>
<input id="conf_m" type="range" min="0" max="1" step="0.01" value="{{conf}}"/>
</div>
<div class="section card">
<div class="rangeHead" style="margin-bottom:8px"><div class="k">Detection target</div><div class="v" id="sel_disp_m">All</div></div>
<input id="clsSearch_m" type="search" placeholder="クラス名を検索…" style="width:100%;padding:8px;border-radius:10px;border:1px solid var(--border);background:var(--panel);color:#e8f0ff;margin-bottom:8px"/>
<select id="clsSelect_m" style="width:100%;padding:10px;border-radius:10px;border:1px solid var(--border);background:var(--panel);color:#e8f0ff"></select>
<div class="k" style="margin-top:6px">※ 「All」で全クラス検出/それ以外は1クラスのみ</div>
</div>
<div class="section card">
<div style="margin-bottom:8px" class="k">Hardware Health</div>
<div class="row"><div class="k">Temp</div><div class="v" id="temp_m">—</div></div>
<div class="bar"><span id="tempbar_m"></span></div>
<div class="row"><div class="k">CPU %</div><div class="v" id="cpu_m">—</div></div>
<div class="bar"><span id="cpubar_m"></span></div>
<div class="row"><div class="k">CPU Freq</div><div class="v" id="freq_m">—</div></div>
<div class="row"><div class="k">Load (1/5/15)</div><div class="v" id="load_m">—</div></div>
<div class="row"><div class="k">Mem</div><div class="v" id="mem_m">—</div></div>
<div class="bar"><span id="membar_m"></span></div>
<div class="row"><div class="k">Disk</div><div class="v" id="disk_m">—</div></div>
<div class="bar"><span id="diskbar_m"></span></div>
<div class="row"><div class="k">Uptime</div><div class="v" id="uptime_m">—</div></div>
<div class="row"><div class="k">Throttled</div><div class="v" id="throt_m">—</div></div>
</div>
<!-- 電源操作(モバイル下段にも) -->
<div class="section card">
<div class="k" style="margin-bottom:8px">Power Control</div>
<button class="btn danger" id="btnShutdown_m">シャットダウン</button>
<button class="btn warn" id="btnReboot_m" style="margin-left:8px">再起動</button>
<div class="k" style="margin-top:8px">※ 押すと確認ダイアログ後に即実行(非同期)</div>
</div>
</section>
</div>
</main>
</div>
<script>
// Drawer toggle for mobile
const sidebar = document.getElementById("sidebar")
const burger = document.getElementById("burger")
if(burger){
burger.addEventListener("click", ()=> sidebar.classList.toggle("open"))
// 閉じやすいよう、映像クリックでも閉じる
document.querySelector(".videoWrap").addEventListener("click", ()=> sidebar.classList.remove("open"))
}
function pct(x){return Math.max(0, Math.min(100, x))}
function setBar(id,val){const el=document.getElementById(id); if(el) el.style.width = pct(val) + "%"}
function setText(id, val){const el=document.getElementById(id); if(el) el.textContent = val}
function fmtBytes(n){return (n/1048576).toFixed(0) + " MiB"}
function pad(n){return n.toString().padStart(2,"0")}
function fmtUptime(s){const d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);return (d?d+"d ":"")+pad(h)+":"+pad(m)}
// 簡易トースト
function toast(txt){ alert(txt) }
// 閾値スライダー → /set_conf にPOST(デバウンス) (PC/モバイル下段の両方に対応)
const conf = document.getElementById("conf")
const confv = document.getElementById("confv")
const conf_m = document.getElementById("conf_m")
const confv_m = document.getElementById("confv_m")
function syncConfUI(v){ if(conf){conf.value=v} if(confv){confv.textContent=Number(v).toFixed(2)} if(conf_m){conf_m.value=v} if(confv_m){confv_m.textContent=Number(v).toFixed(2)} }
let confTimer = null
function onConfInput(src){
const v = Number(src.value)
syncConfUI(v)
if(confTimer) clearTimeout(confTimer)
confTimer = setTimeout(async ()=>{
try{
await fetch("/set_conf", { method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({conf: v}) })
}catch(e){ console.error(e) }
}, 120)
}
if(conf){ conf.addEventListener("input", ()=> onConfInput(conf)) }
if(conf_m){ conf_m.addEventListener("input", ()=> onConfInput(conf_m)) }
// 省エネ:バックグラウンド時はポーリング遅延
let baseInterval = 1000
let timer = null
async function poll(){
try{
const r = await fetch("/health"); const j = await r.json()
// FPS
setText("fps", j.fps.toFixed(1)); setText("fps_m", j.fps.toFixed(1))
setBar("fpsbar", (j.fps / {{fps}}) * 100); setBar("fpsbar_m", (j.fps / {{fps}}) * 100)
// Temp
setText("temp", j.temp_c ? (j.temp_c.toFixed(1) + " ℃") : "—")
setText("temp_m", j.temp_c ? (j.temp_c.toFixed(1) + " ℃") : "—")
if(j.temp_c!=null){ setBar("tempbar", (j.temp_c/85)*100); setBar("tempbar_m", (j.temp_c/85)*100) }
// CPU
const cpuTxt = (j.cpu_percent!=null ? j.cpu_percent.toFixed(0)+" %" : "—")
setText("cpu", cpuTxt); setText("cpu_m", cpuTxt)
if(j.cpu_percent!=null){ setBar("cpubar", j.cpu_percent); setBar("cpubar_m", j.cpu_percent) }
// Freq
setText("freq", j.cpu_mhz ? (j.cpu_mhz.toFixed(0)+" MHz") : "—")
setText("freq_m", j.cpu_mhz ? (j.cpu_mhz.toFixed(0)+" MHz") : "—")
// Load
const loadTxt = j.load ? j.load.map(x=>x.toFixed(2)).join(" / ") : "—"
setText("load", loadTxt); setText("load_m", loadTxt)
// Mem
const memUsed = j.mem_total - j.mem_avail
setText("mem", fmtBytes(memUsed) + " / " + fmtBytes(j.mem_total))
setText("mem_m", fmtBytes(memUsed) + " / " + fmtBytes(j.mem_total))
if(j.mem_total>0){ setBar("membar", (memUsed/j.mem_total)*100); setBar("membar_m", (memUsed/j.mem_total)*100) }
// Disk
setText("disk", fmtBytes(j.disk_used) + " / " + fmtBytes(j.disk_total))
setText("disk_m", fmtBytes(j.disk_used) + " / " + fmtBytes(j.disk_total))
if(j.disk_total>0){ setBar("diskbar", (j.disk_used/j.disk_total)*100); setBar("diskbar_m", (j.disk_used/j.disk_total)*100) }
// Uptime / Throttled
setText("uptime", fmtUptime(j.uptime)); setText("uptime_m", fmtUptime(j.uptime))
setText("throt", j.throttled || "unknown"); setText("throt_m", j.throttled || "unknown")
// サーバ側conf/クラス選択をUIに同期
if(typeof j.conf === "number"){ syncConfUI(j.conf) }
if(typeof j.sel_class !== "undefined"){ syncClassUI(j.sel_class) }
}catch(e){
console.error(e)
}finally{
const delay = document.hidden ? baseInterval*3 : baseInterval
timer = setTimeout(poll, delay)
}
}
// ==== クラス選択:取得と検索/同期 ====
let classesData = []
function optionLabel(c){ return c.id === -1 ? "All" : `${c.name} (id:${c.id})` }
function renderClassOptions(filterText=""){ const f = (filterText||"").toLowerCase(); const list = classesData.filter(c => c.id===-1 || (c.name||"" ).toLowerCase().includes(f));
const build = (sel) => { if(!sel) return; sel.innerHTML = ""; list.forEach(c=>{ const o=document.createElement("option"); o.value=String(c.id); o.textContent=optionLabel(c); sel.appendChild(o) }) }
build(document.getElementById("clsSelect")); build(document.getElementById("clsSelect_m"));
}
function syncClassUI(id){ const s1=document.getElementById("clsSelect"), s2=document.getElementById("clsSelect_m"); const d1=document.getElementById("sel_disp"), d2=document.getElementById("sel_disp_m");
if(s1){ s1.value = String(id) } if(s2){ s2.value = String(id) }
const found = classesData.find(c=>c.id===id) || {id:-1, name:"All"};
if(d1){ d1.textContent = optionLabel(found) } if(d2){ d2.textContent = optionLabel(found) }
}
async function loadClasses(){ try{ const r = await fetch('/get_classes'); const j = await r.json(); classesData = [{id:-1,name:'All'}, ...(j.classes||[])]; renderClassOptions(""); syncClassUI(j.selected ?? -1);}catch(e){ console.error(e) } }
async function setSelectedClass(id){ try{ await fetch('/set_class', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id})}) }catch(e){ console.error(e) } }
document.addEventListener('input', (ev)=>{
if(ev.target && ev.target.id==='clsSearch'){ renderClassOptions(ev.target.value) }
if(ev.target && ev.target.id==='clsSearch_m'){ renderClassOptions(ev.target.value) }
})
document.addEventListener('change', (ev)=>{
if(ev.target && (ev.target.id==='clsSelect' || ev.target.id==='clsSelect_m')){ const id = parseInt(ev.target.value,10); syncClassUI(id); setSelectedClass(id) }
})
// ===== 電源操作:確認→POST =====
async function postJSON(url){
const r = await fetch(url, {method:"POST", headers:{"Content-Type":"application/json"}});
if(!r.ok) throw new Error("HTTP "+r.status);
return r.json();
}
function bindPower(idBtn, url, doneMsg, confirmMsg){
const el = document.getElementById(idBtn);
if(!el) return;
el.addEventListener("click", async()=>{
if(!confirm(confirmMsg)) return;
try{
await postJSON(url);
toast(doneMsg);
}catch(e){
console.error(e);
toast("エラーが発生しました: " + e.message);
}
});
}
bindPower("btnShutdown", "/shutdown", "シャットダウンを実行しました。数十秒後に電源が切れます。", "本当にシャットダウンしますか?\\n(電源を落とします)");
bindPower("btnReboot", "/reboot", "再起動を実行しました。しばらくすると再接続できます。", "本当に再起動しますか?");
bindPower("btnShutdown_m", "/shutdown", "シャットダウンを実行しました。数十秒後に電源が切れます。", "本当にシャットダウンしますか?\\n(電源を落とします)");
bindPower("btnReboot_m", "/reboot", "再起動を実行しました。しばらくすると再接続できます。", "本当に再起動しますか?");
document.addEventListener("visibilitychange", ()=>{ if(timer){ clearTimeout(timer); timer=null } poll() })
loadClasses();
poll()
</script>
</body>
</html>
"""
# ===== ユーティリティ =====
def ncnn_dir_for(pt_name: str) -> str:
return pt_name.replace(".pt", "_ncnn_model")
def load_ncnn_model(pt_name: str):
ncnn_dir = ncnn_dir_for(pt_name)
if not os.path.isdir(ncnn_dir):
print(f"[INFO] NCNNモデルがありません。エクスポートします... ({pt_name} → {ncnn_dir})")
YOLO(pt_name).export(format="ncnn")
print("[INFO] NCNNエクスポート完了")
print(f"[INFO] NCNNモデル読み込み: {ncnn_dir}")
return YOLO(ncnn_dir)
def open_camera():
global cap
cap = cv2.VideoCapture(CAM_INDEX, cv2.CAP_V4L2)
try:
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
except Exception:
pass
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
cap.set(cv2.CAP_PROP_FPS, FPS_TARGET)
try:
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
except Exception:
pass
if not cap.isOpened():
raise RuntimeError("[ERROR] カメラをオープンできませんでした。CAM_INDEX を確認してください。")
# ---- Health helpers ----
def read_temp_c():
try:
with open("/sys/class/thermal/thermal_zone0/temp") as f:
return int(f.read().strip()) / 1000.0
except Exception:
try:
out = check_output(["vcgencmd","measure_temp"]).decode()
return float(out.split("=")[1].split("'")[0])
except Exception:
return None
def read_cpu_mhz():
try:
with open("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq") as f:
return int(f.read().strip())/1000.0
except Exception:
return None
def read_loadavg():
try:
return list(os.getloadavg())
except Exception:
return None
def read_meminfo():
total=avail=None
try:
with open("/proc/meminfo") as f:
for line in f:
if line.startswith("MemTotal:"):
total = int(line.split()[1])*1024
elif line.startswith("MemAvailable:"):
avail = int(line.split()[1])*1024
if total is not None and avail is not None:
break
except Exception:
pass
return total or 0, avail or 0
def read_uptime_s():
try:
with open("/proc/uptime") as f:
return float(f.read().split()[0])
except Exception:
return 0.0
def read_disk_usage():
try:
du = shutil.disk_usage("/")
return du.total, du.used, du.free
except Exception:
return 0, 0, 0
_last_cpu_total = None
_last_cpu_idle = None
_cpu_state_lock = threading.Lock()
def read_cpu_percent():
global _last_cpu_total, _last_cpu_idle
try:
with open("/proc/stat") as f:
parts = f.readline().split()
if parts[0] != "cpu":
return None
nums = list(map(int, parts[1:]))
user,nice,system,idle,iowait,irq,softirq,steal,guest,guest_nice = (nums+[0]*10)[:10]
idle_all = idle + iowait
non_idle = user + nice + system + irq + softirq + steal
total = idle_all + non_idle
with _cpu_state_lock:
if _last_cpu_total is None:
_last_cpu_total, _last_cpu_idle = total, idle_all
return None
totald = total - _last_cpu_total
idled = idle_all - _last_cpu_idle
_last_cpu_total, _last_cpu_idle = total, idle_all
if totald <= 0:
return None
return max(0.0, min(100.0, (totald - idled) * 100.0 / totald))
except Exception:
return None
# ===== SF風オーバーレイ(走査線/中央レティクルなし) =====
def sci_fi_overlay(frame, result, alpha_glow=0.55, alpha_hud=0.8):
h, w = frame.shape[:2]
base = frame.copy()
glow = np.zeros_like(frame)
hud = np.zeros_like(frame)
palette = [
( 80, 80, 255), # ネオン赤
(255, 255, 80), # シアン寄り
(255, 160, 80), # ブルー
(160, 255, 80), # ミント
( 80, 160, 255), # オレンジ
(255, 80, 255), # マゼンタ
]
boxes = getattr(result, "boxes", None)
masks = getattr(result, "masks", None)
names = getattr(result, "names", {}) or {}
xyxy = confs = clss = None
if boxes is not None and boxes.xyxy is not None:
xyxy = boxes.xyxy.cpu().numpy().astype(int)
confs = (boxes.conf.cpu().numpy() if boxes.conf is not None else np.ones(len(xyxy)))
clss = (boxes.cls.cpu().numpy().astype(int) if boxes.cls is not None else np.zeros(len(xyxy), dtype=int))
if xyxy is not None:
for i, (x1, y1, x2, y2) in enumerate(xyxy):
x1 = max(0, x1); y1 = max(0, y1)
x2 = min(w-1, x2); y2 = min(h-1, y2)
if x2 <= x1 or y2 <= y1:
continue
cls_id = int(clss[i]) if i < len(clss) else 0
p = palette[cls_id % len(palette)]
cv2.rectangle(glow, (x1, y1), (x2, y2), p, thickness=4, lineType=cv2.LINE_AA)
L = max(12, int(min(x2-x1, y2-y1) * 0.18))
th = 2
cv2.line(hud, (x1, y1), (x1+L, y1), p, th, cv2.LINE_AA)
cv2.line(hud, (x1, y1), (x1, y1+L), p, th, cv2.LINE_AA)
cv2.line(hud, (x2, y1), (x2-L, y1), p, th, cv2.LINE_AA)
cv2.line(hud, (x2, y1), (x2, y1+L), p, th, cv2.LINE_AA)
cv2.line(hud, (x1, y2), (x1+L, y2), p, th, cv2.LINE_AA)
cv2.line(hud, (x1, y2), (x1, y2-L), p, th, cv2.LINE_AA)
cv2.line(hud, (x2, y2), (x2-L, y2), p, th, cv2.LINE_AA)
cv2.line(hud, (x2, y2), (x2, y2-L), p, th, cv2.LINE_AA)
conf = float(confs[i]) if i < len(confs) else 0.0
name = names.get(cls_id, f"id:{cls_id}")
text = f"{name} {conf*100:.0f}%"
(tw, th_text), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_DUPLEX, 0.5, 1)
pad = 6
rx1, ry1 = x1, max(0, y1 - (th_text + pad*2 + 6))
rx2, ry2 = min(w-1, x1 + tw + pad*2), y1 - 2
cv2.rectangle(hud, (rx1, ry1), (rx2, ry2), p, -1, cv2.LINE_AA)
cv2.putText(hud, text, (rx1+pad, ry2-pad-2), cv2.FONT_HERSHEY_DUPLEX, 0.5, (0,0,0), 1, cv2.LINE_AA)
if masks is not None and getattr(masks, "xy", None) is not None:
for idx, poly in enumerate(masks.xy):
if poly is None or len(poly) == 0:
continue
pts = np.round(poly).astype(np.int32).reshape(-1, 1, 2)
color = palette[idx % len(palette)]
cv2.polylines(glow, [pts], isClosed=True, color=color, thickness=4, lineType=cv2.LINE_AA)
cv2.polylines(hud, [pts], isClosed=True, color=color, thickness=2, lineType=cv2.LINE_AA)
if glow.sum() > 0:
glow_blur = cv2.GaussianBlur(glow, (0,0), sigmaX=7, sigmaY=7)
base = cv2.addWeighted(base, 1.0, glow_blur, alpha_glow, 0)
out = cv2.addWeighted(base, 1.0, hud, alpha_hud, 0)
return out
# ===== キャプチャ保存関連 =====
def _ensure_capture_dir():
try:
os.makedirs(CAPTURE_DIR, exist_ok=True)
except Exception:
pass
_def_name_cache = None
def _model_names():
global _def_name_cache
if _def_name_cache is not None:
return _def_name_cache
try:
with model_lock:
m = model
names = getattr(m, 'names', None) or getattr(getattr(m, 'model', None), 'names', {}) or {}
if isinstance(names, (list, tuple)):
names = {i: n for i, n in enumerate(names)}
_def_name_cache = names
return names
except Exception:
return {}
def save_capture(image_bgr, sel_class_id, any_count):
"""BGR image を保存(オリジナル)。最大 CAPTURE_LIMIT を超えたら古い順に削除。
sel_class_id: -1=All それ以外=クラスID
any_count: 検出数(表示用)
"""
_ensure_capture_dir()
# 連写しすぎ防止
global _last_capture_ts
now = time.time()
with _capture_lock:
if now - _last_capture_ts < CAPTURE_MIN_INTERVAL:
return
_last_capture_ts = now
ts = datetime.fromtimestamp(now).strftime("%Y%m%d-%H%M%S")
cls_name = "all" if sel_class_id < 0 else str(_model_names().get(sel_class_id, f"id{sel_class_id}"))
fname = f"{ts}_{cls_name}_{any_count}.jpg"
path = os.path.join(CAPTURE_DIR, fname)
try:
cv2.imwrite(path, image_bgr)
except Exception as e:
print(f"[WARN] 画像保存失敗: {e}")
return
# 枚数制限で削除
try:
files = sorted([os.path.join(CAPTURE_DIR, f) for f in os.listdir(CAPTURE_DIR) if f.lower().endswith('.jpg')], key=lambda p: os.path.getmtime(p))
excess = len(files) - CAPTURE_LIMIT
for i in range(excess):
try:
os.remove(files[i])
except Exception:
pass
except Exception as e:
print(f"[WARN] 枚数制限処理失敗: {e}")
# ===== ストリーミング =====
def gen_frames():
global latest_fps
frame_interval = 1.0 / max(1, FPS_TARGET)
fps_ma = deque(maxlen=20)
last = time.time()
while True:
ok, frame = cap.read()
if not ok:
print("[WARN] フレーム取得失敗。再試行します。")
time.sleep(0.01)
continue
with model_lock:
m = model
with conf_lock:
conf_val = float(CONF)
with sel_lock:
sel = int(SELECTED_CLASS)
results = m.predict(
source=frame,
imgsz=IMG_SIZE,
conf=conf_val,
classes=(None if sel < 0 else [sel]),
verbose=False
)
now = time.time()
dt = max(1e-6, now - last)
last = now
fps_ma.append(1.0 / dt)
fps_dyn = sum(fps_ma) / len(fps_ma)
with _latest_fps_lock:
latest_fps = fps_dyn
r0 = results[0]
annotated = sci_fi_overlay(frame, r0)
# --- 検出があれば保存(All=任意、単一選択=そのクラスのみ) ---
try:
n = 0
if getattr(r0, 'boxes', None) is not None and getattr(r0.boxes, 'xyxy', None) is not None:
n = int(r0.boxes.xyxy.shape[0])
if n > 0:
save_capture(frame.copy(), sel, n) # オリジナル保存
except Exception as e:
print(f"[WARN] 保存ロジックで例外: {e}")
# 画面左上にFPS表示(控えめ)
cv2.putText(annotated, f"{fps_dyn:4.1f} FPS", (12, 24),
cv2.FONT_HERSHEY_DUPLEX, 0.55, (240,240,240), 1, cv2.LINE_AA)
ok, buffer = cv2.imencode(".jpg", annotated, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
if not ok:
continue
jpg = buffer.tobytes()
yield (b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + jpg + b"\r\n")
slack = frame_interval - (time.time() - now)
if slack > 0:
time.sleep(slack)
# ===== ルーティング =====
@app.route("/")
def index():
with conf_lock:
conf_val = float(CONF)
_ensure_capture_dir()
return render_template_string(HTML, img=IMG_SIZE, fps=FPS_TARGET, jpegq=JPEG_QUALITY, conf=f"{conf_val:.2f}")
@app.route("/video_feed")
def video_feed():
return Response(gen_frames(), mimetype="multipart/x-mixed-replace; boundary=frame")
@app.route("/set_conf", methods=["POST"])
def set_conf():
data = request.get_json(silent=True) or {}
try:
val = float(data.get("conf", 0.25))
except Exception:
return jsonify({"ok": False, "error": "invalid value"}), 400
val = max(0.0, min(1.0, val))
with conf_lock:
global CONF
CONF = val
return jsonify({"ok": True, "conf": val})
@app.route('/get_classes')
def get_classes():
# モデルの names から {id,name} を返す
with model_lock:
m = model
names = {}
try:
names = getattr(m, 'names', None) or getattr(getattr(m, 'model', None), 'names', {}) or {}
except Exception:
names = {}
# names が list/tuple/dict のどれでも対応
classes = []
if isinstance(names, dict):
try:
classes = [{"id": int(k), "name": str(v)} for k, v in sorted(names.items(), key=lambda kv: int(kv[0]))]
except Exception:
classes = [{"id": int(k), "name": str(v)} for k, v in names.items()]
elif isinstance(names, (list, tuple)):
classes = [{"id": i, "name": str(n)} for i, n in enumerate(names)]
else:
classes = []
with sel_lock:
sel_val = int(SELECTED_CLASS)
return jsonify({"classes": classes, "selected": sel_val})
@app.route('/set_class', methods=['POST'])
def set_class():
data = request.get_json(silent=True) or {}
try:
cid = int(data.get('id', -1))
except Exception:
return jsonify({"ok": False, "error": "invalid id"}), 400
with sel_lock:
global SELECTED_CLASS
SELECTED_CLASS = cid
return jsonify({"ok": True, "id": cid})
@app.route("/health")
def health():
with _latest_fps_lock:
fps_val = latest_fps
temp_c = read_temp_c()
cpu_mhz = read_cpu_mhz()
load = read_loadavg()
mem_total, mem_avail = read_meminfo()
uptime = read_uptime_s()
disk_total, disk_used, disk_free = read_disk_usage()
throttled = read_throttled()
cpu_percent = read_cpu_percent()
with conf_lock:
conf_val = float(CONF)
with sel_lock:
sel_val = int(SELECTED_CLASS)
return jsonify({
"fps": fps_val if fps_val is not None else 0.0,
"temp_c": temp_c,
"cpu_mhz": cpu_mhz,
"load": load,
"mem_total": mem_total,
"mem_avail": mem_avail,
"uptime": uptime,
"disk_total": disk_total,
"disk_used": disk_used,
"disk_free": disk_free,
"throttled": throttled,
"cpu_percent": cpu_percent,
"conf": conf_val,
"sel_class": sel_val
})
# ===== 電源操作ルート =====
@app.route("/reboot", methods=["POST"])
def reboot_pi():
# 202 を返してから非同期で実行
threading.Thread(target=lambda: os.system("sudo /sbin/shutdown -r now")).start()
return jsonify({"ok": True, "action": "rebooting"}), 202
@app.route("/shutdown", methods=["POST"])
def shutdown_pi():
threading.Thread(target=lambda: os.system("sudo /sbin/shutdown -h now")).start()
return jsonify({"ok": True, "action": "shutting down"}), 202
# ===== ギャラリー(ブラウザ参照) =====
GALLERY_HTML = """
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Captures</title>
<style>
body{margin:0;background:#0b0b0f;color:#e8f0ff;font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif}
header{position:sticky;top:0;background:rgba(17,24,39,.8);backdrop-filter:blur(6px);padding:10px 14px;border-bottom:1px solid #22304d}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;padding:12px}
.card{background:#111827;border:1px solid #22304d;border-radius:12px;overflow:hidden}
.card img{width:100%;height:120px;object-fit:cover;display:block}
.meta{padding:8px 10px;font-size:12px;color:#93a4bf;display:flex;justify-content:space-between;gap:8px}
a{color:#9bd0ff;text-decoration:none}
.empty{padding:32px;text-align:center;color:#93a4bf}
</style>
<header>
<b>Captures</b> — 最新100枚(古い順に自動削除) | <a href="/">戻る</a>
</header>
<div class="grid">
{% if files %}
{% for f in files %}
<div class="card">
<a href="/capture_files/{{f}}" target="_blank"><img loading="lazy" src="/capture_files/{{f}}" alt="{{f}}"></a>
<div class="meta"><span>{{f}}</span></div>
</div>
{% endfor %}
{% else %}
<div class="empty">まだ保存画像はありません。</div>
{% endif %}
</div>
"""
@app.route('/captures')
def captures():
_ensure_capture_dir()
try:
files = [f for f in os.listdir(CAPTURE_DIR) if f.lower().endswith('.jpg')]
# 新しい順に並べる
files.sort(key=lambda f: os.path.getmtime(os.path.join(CAPTURE_DIR, f)), reverse=True)
except Exception:
files = []
return render_template_string(GALLERY_HTML, files=files)
@app.route('/capture_files/<path:filename>')
def capture_files(filename):
return send_from_directory(CAPTURE_DIR, filename, as_attachment=False)
# ===== 起動処理 =====
def read_throttled():
try:
out = check_output(["vcgencmd","get_throttled"]).decode().strip()
if "=" in out:
return out.split("=")[1]
return out
except (FileNotFoundError, CalledProcessError):
return None
def load_model():
ncnn_dir = ncnn_dir_for(MODEL_PT)
if not os.path.isdir(ncnn_dir):
print(f"[INFO] NCNNモデルがありません。エクスポートします... ({MODEL_PT} → {ncnn_dir})")
YOLO(MODEL_PT).export(format="ncnn")
print("[INFO] NCNNエクスポート完了")
return YOLO(ncnn_dir)
def main():
global model, cap
print("[INFO] モデル読み込み中 (NCNN固定, yolo11n-seg)...")
model = load_model()
# 軽いウォームアップ
try:
_ = model.predict(source=np.zeros((480, 640, 3), dtype=np.uint8), imgsz=IMG_SIZE, conf=CONF, verbose=False)
except Exception as e:
print(f"[WARN] ウォームアップ失敗: {e}")
print("[INFO] カメラを起動します...")
open_camera()
print("[INFO] 起動しました。ブラウザでこの端末のIP:5000 にアクセスしてください。例: http://raspberrypi.local:5000")
# 保存先ディレクトリを用意
_ensure_capture_dir()
server = threading.Thread(target=lambda: app.run(host="0.0.0.0", port=5000, threaded=True, use_reloader=False))
server.daemon = True
server.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
if cap:
cap.release()
print("\n[INFO] 停止しました。")
if __name__ == "__main__":
main()
systemdを使った自動起動設定
前提条件として仮想環境の起動は
source ~/myenv/bin/activate
で構築しています。
スクリプトのパスは
myenv/test.py
です。
ラズパイのユーザー名は”pi”ではなく”ai”にしています。
1. 実行コマンドの確認
まず、仮想環境を有効化してスクリプトを実行するコマンドを確認します。
仮想環境を使う場合は activate を経由せず、仮想環境内の Python を直接呼び出すのが systemd では安全です。
たとえば:
/home/pi/myenv/bin/python /home/pi/myenv/test.py
2.systemd ユニットファイル作成
/etc/systemd/system/myenv-test.service というファイルを作成します。
sudo nano /etc/systemd/system/myenv-test.service
内容はこんな感じです
[Unit]
Description=Run test.py in myenv at boot
After=network.target
[Service]
Type=simple
User=ai
ExecStart=/home/ai/myenv/bin/python /home/ai/myenv/test.py
WorkingDirectory=/home/ai/myenv
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
さて、ここで保存した際に以下のエラーが発生しました。
sudo: ホスト aicamera01 の名前解決ができません: 名前またはサービスが不明です
Raspberry Pi のホスト名設定と /etc/hosts の内容がずれていると起きるようです。前回の記事でhost名を以下のように変更しているのが原因でした。
sudo hostnamectl set-hostname aicamera01
3.ホスト名解決方法
/etc/hosts を編集しましょー
sudo nano /etc/hosts
中にこういう行があるはずです。
127.0.0.1 localhost
127.0.1.1 raspberrypi
ここで raspberrypi を、先ほど hostname で確認した名前(例: aicamera01)に変更します。
127.0.0.1 localhost
127.0.1.1 aicamera01
編集が終了したら再起動しましょー。
sudo reboot
再起動したらsystemdを反映します。
sudo systemctl daemon-reload
sudo systemctl enable myenv-test.service
sudo systemctl start myenv-test.service
これで自動起動設定完了です。
4.動作確認しましょー
以下のコマンドで動作確認できます。
systemctl status myenv-test.service
こんな感じにエラーが出ていなければ問題なく稼働しています。
ai@aicamera01:~ $ systemctl status myenv-test.service
● myenv-test.service - Run test.py in myenv at boot
Loaded: loaded (/etc/systemd/system/myenv-test.service; enabled; preset: enabled)
Active: active (running) since Sun 2025-08-10 23:25:11 JST; 21min ago
Main PID: 830 (python)
Tasks: 21 (limit: 9568)
CPU: 54min 47.124s
CGroup: /system.slice/myenv-test.service
└─830 /home/ai/myenv/bin/python /home/ai/myenv/test.py
8月 10 23:46:54 aicamera01 python[830]: 192.168.1.190 - - [10/Aug/2025 23:46:54] "GET /health HTTP/1.1" 200 -
8月 10 23:46:55 aicamera01 python[830]: 192.168.1.190 - - [10/Aug/2025 23:46:55] "GET /health HTTP/1.1" 200 -
8月 10 23:46:57 aicamera01 python[830]: 192.168.1.190 - - [10/Aug/2025 23:46:57] "GET /health HTTP/1.1" 200 -
8月 10 23:46:58 aicamera01 python[830]: 192.168.1.190 - - [10/Aug/2025 23:46:58] "GET /health HTTP/1.1" 200 -
8月 10 23:46:59 aicamera01 python[830]: 192.168.1.190 - - [10/Aug/2025 23:46:59] "GET /health HTTP/1.1" 200 -
8月 10 23:47:01 aicamera01 python[830]: 192.168.1.190 - - [10/Aug/2025 23:47:01] "GET /health HTTP/1.1" 200 -
8月 10 23:47:02 aicamera01 python[830]: 192.168.1.190 - - [10/Aug/2025 23:47:02] "GET /health HTTP/1.1" 200 -
8月 10 23:47:03 aicamera01 python[830]: 192.168.1.190 - - [10/Aug/2025 23:47:03] "GET /health HTTP/1.1" 200 -
8月 10 23:47:05 aicamera01 python[830]: 192.168.1.190 - - [10/Aug/2025 23:47:05] "GET /health HTTP/1.1" 200 -
8月 10 23:47:06 aicamera01 python[830]: 192.168.1.190 - - [10/Aug/2025 23:47:06] "GET /health HTTP/1.1" 200 -
自動起動を止める場合は
systemctl stop myenv-test.service
ここまで出来れば、ラズパイの電源を起動すれば、プログラムも自動起動するので、好きな場所でラズパイを設置すれば、あとはスマホで監視カメラとして映像を確認することや、対象の物体を検出したらその画像を補完しておくこともできます。
かなり便利なのでぜひ試してみてください。
LEAVE A REPLY