shoshosho が 2026年01月31日19時38分31秒 に編集
コメント無し
本文の変更
# キャッチ(見出しなし・動画) ★ここは 説明しない →「音で驚く → シャッター → スマホに表示」 → 審査員の脳に一発で体験を入れる 悲鳴が上がった、その瞬間。 シャッターが切れ、数秒後にはスマホにその表情が映る。 # 作品概要 *お化け屋敷で絶叫した瞬間、自動でシャッターが切られる。終わった後、自分の「最高の表情」をみんなで見て盛り上がる* ── そんな体験を作りたくて、このカメラを作りました。 本作品は、人の「悲鳴」や「絶叫」をきっかけに、自動で写真を撮影するカメラ、およびその場でスマートフォンに表示するシステムです。 ユーザーは何も操作する必要がなく、驚いた瞬間の最もリアルな表情が自動的に記録・共有されます。 お化け屋敷や展示イベント、体験型アトラクションなど、感情が大きく動くシーンでの利用を想定しており、特に**お化け屋敷**での活用をメインに作成しました。
暗闘で撮られた驚きの顔を、みんなで見て楽しみませんか?
驚いて悲鳴を上げた瞬間、システムがそれを検知し、自動でシャッターが切られ、ボタン操作や意識的なポーズは一切不要です。
そのため、驚きが最高潮に達した瞬間、表情が最も崩れ、最も面白いタイミングを逃さずに写真として残すことができます。 「撮られる」ことを意識しないからこそ、本物の表情が残る体験ができます。暗闘で撮られた驚きの顔を、みんなで見て楽しみませんか?
# システム構成  上記で示すように、ローカル環境でシステムを組むことで、お化け屋敷で「驚く → 撮られる → すぐ見る」 という体験の流れを途切れさせない構成としました。 想定する運用としては、お化け屋敷の運営側があらかじめWi-FiとSpresenseを設置しておき、ユーザーはカメラ付きの懐中電灯を持ってアトラクションを体験し、体験後に、自分や家族・友達・恋人の"驚きの顔"を見て一緒に楽しむことができます。 # 部品表 | 名称 | 数量 | |:---:|:---:| | SPRESENSE メインボード | 1 | | SPRESENSE 拡張ボード| 1 | |SPRESENSE HDRカメラボード| 1 | |SPRESENSE Wi-Fi Add-onボード(iS110B) | 1 | |マイク(Mic&LCD KIT for SPRESENSE 秋月電子)| 1 | |SDカード(Cloudisk 2GB) | 1 |
「選定理由」は1つだけ入れる
なぜSpresenseだったか(HDRカメラ)
# 音声検知による自動撮影
本作品では、ユーザーは一切操作を行う必要がありません。ただ驚いて声を上げるだけで、その瞬間の表情が自動的に撮影されます。
★ここは 体験主語で書く
音声検知は、Spresenseのマイク入力から取得したPCMデータを用いて行っています。
ユーザーは何もしなくていい
音声は FRAME_SIZE = 256 サンプル単位で連続的に取得し、各フレーム内の最大振幅値を計算することで、音量の急激な立ち上がりを検出しています。
驚いた“瞬間”が残る
``` int16_t max_val = 0; for (int i = 0; i < FRAME_SIZE; i++) { int16_t v = abs(pcm_buf[i]); if (v > max_val) max_val = v; } ```
表情が最も面白いタイミングを逃さない
この値があらかじめ設定した THRESHOLD を超えた場合に、「悲鳴・絶叫が発生した」と判断します。
本設計では、FFTなどの周波数解析は行っておりません。 悲鳴検知において重要なのは音の意味ではなく、瞬間的な音圧変化をいかに遅延なく検出できるかだと考えました。 そのため、計算量の少ない振幅ベースの判定を採用し、リアルタイム性を最優先した設計としています。
# 写真のサーバー送信
撮影された写真は、数秒以内にスマートフォンから閲覧できます。「驚いた直後に、自分の顔を確認できる」こと自体が、アクティビティ体験の一部として強い印象を残します。
ローカルWiFiを使う理由
クラウド経由ではなくローカルWiFiを使うことで、通信遅延や回線状況に左右されず、展示会場やお化け屋敷といった閉じた空間でも安定した体験を提供できます。
クラウドを使わなかった理由(体験・即時性)
Spresenseからの画像送信には、HTTPクライアントライブラリではなく、RAW TCPによるHTTP POSTを使用しています。
JPEGデータは 256byte 単位で分割して送信し、送信中も音声処理が停止しないよう、ループ内で [audio_drain()](音声dummy処理という設計判断) を挿入しています。 ``` while (sent < len) { audio_drain(); size_t n = min(CHUNK, len - sent); gs2200.write(cid, data + sent, n); } ``` これにより、通信中でも音声FIFOが詰まらず、次の悲鳴検知に即座に戻れる という安定動作を実現しています。
# スマホでの閲覧 「その場で見られる」ことの価値 SNS共有との相性 # 工夫した・苦労した点 ## HDRカメラを使った理由 ## 音声検知と撮影の両立 → リアルタイム性 vs 処理負荷 ## WiFi送信と音声処理の競合 →通信が遅れると体験が壊れる ## 音声dummy処理という設計判断 → 止めない / 詰まらせない / 体験を守る 👉 ここは 技術的ヒーローポイント # ソースコード ## spresenseコード(Arduino) 本システムを実装したファイル構成とソースコードを以下に示します。  【app.ino】 ``` #include <SDHCI.h> #include <Camera.h> #include <Audio.h> #include <arch/board/board.h> #include <HttpGs2200.h> #include <TelitWiFi.h> #include "configapp.h" // ========================= // Audio (Scream detection) // ========================= #define MIC_GAIN 210 #define SAMPLE_RATE 16000 #define CHANNELS 1 #define FRAME_SIZE 256 #define THRESHOLD 4000 // scream 判定(仮) AudioClass* theAudio; int16_t pcm_buf[FRAME_SIZE]; enum AudioMode { AUDIO_MONITOR, // 普段:音量チェック AUDIO_IGNORE // scream~送信中:PCMは読むが判定しない }; volatile AudioMode audio_mode = AUDIO_MONITOR; // 送信中などに Audio FIFO が詰まらないように読み捨て inline void audio_drain() { int16_t dummy[FRAME_SIZE]; uint32_t read_size = 0; theAudio->readFrames((char*)dummy, sizeof(dummy), &read_size); } // ========================= // Camera (Streaming JPEG) // ========================= SDClass theSD; volatile bool g_capture_request = false; // true のとき CamCB が 1枚だけ取る uint32_t capture_wait_ms = 0; int picture_id = 0; uint8_t jpeg_buf[80 * 1024]; size_t jpeg_size = 0; volatile bool jpeg_ready = false; bool scream_active = false; // ========================= // HTTP // ========================= TelitWiFi gs2200; TWIFI_Params gsparams; HttpGs2200 theHttpGs2200(&gs2200); HTTPGS2200_HostParams hostParams; // ------------------------- // Audio error callback // ------------------------- static void audio_attention_cb(const ErrorAttentionParam* atprm) { if (atprm->error_code >= AS_ATTENTION_CODE_WARNING) { Serial.println("Audio Error!"); } } // ========================= // RAW HTTP POST over TCP (binary JPEG) // ========================= static bool post_jpeg_raw(const uint8_t* data, size_t len) { char cid = ATCMD_INVALID_CID; Serial.println("[RAW] connect TCP..."); cid = gs2200.connect(HTTP_SRVR_IP, HTTP_PORT); if (cid == ATCMD_INVALID_CID) { Serial.println("[RAW] connect failed"); return false; } WiFi_InitESCBuffer(); String header = String("POST ") + HTTP_POST_PATH + " HTTP/1.1\r\n" + "Host: " + String(HTTP_SRVR_IP) + ":" + String(HTTP_PORT) + "\r\n" + "Content-Type: image/jpeg\r\n" + "Content-Length: " + String(len) + "\r\n" + "Connection: close\r\n" + "\r\n"; if (!gs2200.write(cid, (const uint8_t*)header.c_str(), header.length())) { Serial.println("[RAW] header write failed"); gs2200.stop(cid); delay(10); return false; } // --- body --- size_t sent = 0; const size_t CHUNK = 256; int retry = 0; while (sent < len) { audio_drain(); // 送信中も Audio FIFO を捨てて詰まり回避 size_t n = min(CHUNK, len - sent); if (!gs2200.write(cid, data + sent, n)) { retry++; audio_drain(); if (retry > 30) { Serial.println("[RAW] body write abort"); gs2200.stop(cid); delay(50); return false; } delay(20); continue; } retry = 0; sent += n; delay(10); } Serial.println("[RAW] body sent, waiting response..."); // --- response --- uint8_t rx[256]; uint32_t t0 = millis(); while (millis() - t0 < 2000) { audio_drain(); // レスポンス待ち中も捨てる if (gs2200.available()) { int r = gs2200.read(cid, rx, sizeof(rx) - 1); if (r > 0) { rx[r] = 0; Serial.print((char*)rx); if (strstr((char*)rx, "200 OK") || strstr((char*)rx, "HTTP/1.1 200")) { Serial.println("[RAW] HTTP 200 OK"); gs2200.stop(cid); delay(10); return true; } } } delay(10); } Serial.println("[RAW] no response / timeout"); gs2200.stop(cid); delay(10); return false; } // ------------------------- // Camera stream callback (1枚だけ) // ------------------------- void CamCB(CamImage img) { if (!img.isAvailable()) return; if (!g_capture_request) return; if (jpeg_ready) return; // 未処理が残ってたら取らない jpeg_size = img.getImgSize(); memcpy(jpeg_buf, img.getImgBuff(), jpeg_size); jpeg_ready = true; g_capture_request = false; // 1枚だけ撮影 Serial.println("CamCB: JPEG captured"); } // ========================= // setup // ========================= void setup() { Serial.begin(115200); pinMode(LED0, OUTPUT); digitalWrite(LED0, LOW); // ---- SD init ---- while (!theSD.begin()) { Serial.println("Insert SD card."); delay(500); } Serial.println("SD OK"); // ---- Camera init ---- Serial.println("Camera begin..."); CamErr err = theCamera.begin( 2, CAM_VIDEO_FPS_30, CAM_IMGSIZE_QVGA_H, CAM_IMGSIZE_QVGA_V, CAM_IMAGE_PIX_FMT_JPG ); if (err != CAM_ERR_SUCCESS) { Serial.print("Camera begin failed: "); Serial.println((int)err); while (1); } theCamera.setHDR(CAM_HDR_MODE_ON); theCamera.setAutoExposure(true); theCamera.setAutoWhiteBalance(true); theCamera.setAutoWhiteBalanceMode(CAM_WHITE_BALANCE_AUTO); theCamera.setAutoISOSensitivity(false); theCamera.setISOSensitivity(1250 * 1000); theCamera.setJPEGQuality(60); Serial.println("Camera startStreaming..."); err = theCamera.startStreaming(true, CamCB); if (err != CAM_ERR_SUCCESS) { Serial.print("startStreaming failed: "); Serial.println((int)err); while (1); } Serial.println("Camera streaming OK"); // ---- WiFi init ---- Init_GS2200_SPI_type(iS110B_TypeC); gsparams.mode = ATCMD_MODE_STATION; gsparams.psave = ATCMD_PSAVE_DEFAULT; if (gs2200.begin(gsparams)) { Serial.println("GS2200 begin failed"); while (1); } if (gs2200.activate_station(AP_SSID, PASSPHRASE)) { Serial.println("WiFi connect failed"); while (1); } Serial.println("WiFi connected"); // ---- HTTP init ---- hostParams.host = (char*)HTTP_SRVR_IP; hostParams.port = (char*)HTTP_PORT; theHttpGs2200.begin(&hostParams); theHttpGs2200.config(HTTP_HEADER_HOST, HTTP_SRVR_IP); theHttpGs2200.config(HTTP_HEADER_CONTENT_TYPE, "text/plain"); theHttpGs2200.config(HTTP_HEADER_TRANSFER_ENCODING, "identity"); Serial.println("HTTP client ready"); // ---- Audio init ---- theAudio = AudioClass::getInstance(); theAudio->begin(audio_attention_cb); theAudio->setRecorderMode(AS_SETRECDR_STS_INPUTDEVICE_MIC, MIC_GAIN); theAudio->initRecorder(AS_CODECTYPE_WAV, "/mnt/sd0/BIN", SAMPLE_RATE, CHANNELS); theAudio->startRecorder(); Serial.println("Audio ready"); Serial.println("SCREAM detector started"); } // ========================= // loop // ========================= void loop() { // --- 1) 送信・保存処理 if (jpeg_ready) { audio_mode = AUDIO_IGNORE; // 送信中は判定せず読むだけ char filename[32]; sprintf(filename, "SCREAM_%04d.JPG", picture_id++); File f = theSD.open(filename, FILE_WRITE); if (!f) { Serial.println("SD open failed"); jpeg_ready = false; scream_active = false; audio_mode = AUDIO_MONITOR; return; } audio_drain(); f.write(jpeg_buf, jpeg_size); f.close(); audio_drain(); Serial.print("Saved: "); Serial.println(filename); Serial.println("POST JPEG (raw)..."); bool ok = post_jpeg_raw(jpeg_buf, jpeg_size); if (ok) { Serial.println("POST OK -> remove SD"); theSD.remove(filename); } else { Serial.println("POST FAIL (keep SD)"); } jpeg_ready = false; scream_active = false; audio_mode = AUDIO_MONITOR; digitalWrite(LED0, LOW); return; } // --- 2) capture timeout if (g_capture_request && (millis() - capture_wait_ms > 6000)) { Serial.println("Capture timeout -> cancel"); g_capture_request = false; scream_active = false; audio_mode = AUDIO_MONITOR; digitalWrite(LED0, LOW); } // --- 3) Audio フレーム読み uint32_t total_read = 0; while (total_read < FRAME_SIZE) { uint32_t read_size = 0; int ret = theAudio->readFrames( (char*)(pcm_buf + total_read), (FRAME_SIZE - total_read) * sizeof(int16_t), &read_size ); if (ret != AUDIOLIB_ECODE_OK && ret != AUDIOLIB_ECODE_INSUFFICIENT_BUFFER_AREA) { Serial.println("readFrames error"); return; } if (read_size > 0) { total_read += read_size / sizeof(int16_t); } else { delay(1); } } // --- 4) 判定(MONITOR の時だけ) if (audio_mode == AUDIO_MONITOR) { int16_t max_val = 0; for (int i = 0; i < FRAME_SIZE; i++) { int16_t v = abs(pcm_buf[i]); if (v > max_val) max_val = v; } Serial.println(max_val); if (max_val > THRESHOLD && !scream_active && !g_capture_request) { scream_active = true; audio_mode = AUDIO_IGNORE; digitalWrite(LED0, HIGH); Serial.println("SCREAM!"); Serial.println("Capture requested"); g_capture_request = true; capture_wait_ms = millis(); // timeout用 } } } ``` 【configapp.h】 ``` #ifndef _CONFIG_H_ #define _CONFIG_H_ /*-------------------------------------------------------------------------* * Configration *-------------------------------------------------------------------------*/ #define AP_SSID "******" // wifiのSSID #define PASSPHRASE "******" // PASSWORD #define HTTP_SRVR_IP "******" // 接続したいserverのIPアドレス #define HTTP_PORT "8000" // サーバーのport番号 #define HTTP_GET_PATH "/" #define HTTP_POST_PATH "/upload" #endif /*_CONFIG_H_*/ ``` ## スマホ表示コード(Flask) サーバーの構築とspresenseから送信された画像をスマホに表示するため、Flaskで簡易的にアプリケーションを作成しました。ファイル構成と各ファイルのソースコードを以下に示します。  【app.py】 ``` from flask import Flask, request, send_from_directory, render_template import os from datetime import datetime app = Flask(__name__) UPLOAD_DIR = "static/uploads" os.makedirs(UPLOAD_DIR, exist_ok=True) @app.route("/uploads/<filename>") def uploaded_file(filename): return send_from_directory(UPLOAD_DIR, filename) @app.route("/") def index(): files = sorted(os.listdir(UPLOAD_DIR), reverse=True) return render_template("index.html", files=files) @app.route("/upload", methods=["POST"]) def upload(): data = request.data if not data: return "No data", 400 ts = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"scream_{ts}.jpg" path = os.path.join(UPLOAD_DIR, filename) with open(path, "wb") as f: f.write(data) print("Saved:", filename, "bytes:", len(data)) return "OK", 200 if __name__ == "__main__": app.run(host="0.0.0.0", port=8000) ``` 【index.html】 ``` <!doctype html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>Scream Gallery</title> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=DotGothic16&family=Zen+Kaku+Gothic+New:wght@400;500&display=swap" rel="stylesheet"> <link rel="stylesheet" href="{{ url_for('static', filename='css/scream-gallery.css') }}"> </head> <body class="gallery-body"> <div class="gallery-wrapper"> <h1 class="gallery-title">Scream Gallery</h1> <p class="gallery-subtitle">あなたが一番怖かった、その一瞬</p> <div class="gallery-list"> {% for f in files %} <div class="gallery-card"> <img src="{{ url_for('static', filename='uploads/' ~ f) }}" alt="{{ f }}"> <p class="gallery-filename"> {{ f[7:11] }}.{{ f[11:13] }}.{{ f[13:15] }} {{ f[16:18] }}:{{ f[18:20] }}:{{ f[20:22] }} </p> </div> {% endfor %} </div> </div> </body> </html> ``` 【scream-gallery.css】 ``` html, body { margin: 0; padding: 0; height: 100%; } .gallery-body { min-height: 100vh; background: linear-gradient( 180deg, #020617, #0f172a ); display: flex; justify-content: center; font-family: "M PLUS 1", sans-serif; color: #e5e7eb; } .gallery-wrapper { width: 100%; max-width: 780px; padding: 24px 14px 48px; } .gallery-title { font-family: 'DotGothic16', monospace; font-size: 72px; letter-spacing: 0.14em; text-align: center; color: #f9fafb; margin-bottom: 8px; } .gallery-subtitle { font-family: 'Zen Kaku Gothic New', sans-serif; font-size: 32px; font-weight: 400; color: #cbd5f5; /* 白すぎない */ text-align: center; letter-spacing: 0.05em; margin-bottom: 36px; } .gallery-list { display: flex; flex-direction: column; gap: 50px; } .gallery-card { position: relative; background: #111827; border-radius: 22px; padding: 16px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6), 0 0 18px rgba(251, 113, 133, 0.25), 0 0 22px rgba(167, 139, 250, 0.25); } .gallery-card::before { content: ""; position: absolute; inset: -6px; border-radius: 28px; background: linear-gradient( 135deg, #fb7185, /* 明るい赤(rose-400) */ #a78bfa /* 明るい紫(violet-400) */ ); z-index: -1; opacity: 0.9; filter: blur(0.5px); } .gallery-card img { width: 100%; border-radius: 16px; display: block; filter: contrast(1.05) saturate(1.05); } .gallery-card:hover { transform: translateY(-1px); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.8); } .gallery-card:hover::before { opacity: 0.9; } .gallery-filename { margin-top: 12px; font-size: 12px; color: #9ca3af; text-align: center; letter-spacing: 0.04em; } @media (max-width: 480px) { .gallery-wrapper { padding: 16px 10px 32px; } .gallery-title { font-size: 22px; } .gallery-subtitle { font-size: 14px; } } ``` # 今後の拡張アイデア 表情解析 複数人対応 得点化 / 演出 連写機能 # おわりに 「技術を作った」ではなく **「体験を成立させた」**で締める