作品概要
お化け屋敷で絶叫した瞬間、自動でシャッターが切られる。終わった後、自分の「最高の表情」をその場でみんなで見て盛り上がる
── そんな体験を作りたくて、このカメラを作りました。
本作品は、人の「悲鳴」や「絶叫」をきっかけに、自動で写真を撮影するSCREAM SHOT カメラ、およびその場でスマートフォンに表示するシステムです。
ユーザーは何も操作する必要がなく、驚いた瞬間の最もリアルな表情が自動的に記録・共有されます。
お化け屋敷や展示イベント、体験型アトラクションなど、感情が大きく動くシーンでの利用を想定しており、特にお化け屋敷での活用をメインに作成しました。
驚いて悲鳴を上げた瞬間、システムがそれを検知し、自動でシャッターが切られ、ボタン操作や意識的なポーズは一切不要です。
そのため、驚きが最高潮に達した瞬間、表情が最も崩れ、最も面白いタイミングを逃さずに写真として残すことができます。
「撮られる」ことを意識しないからこそ、本物の表情が残る体験ができます。暗闘で撮られた驚きの顔を、みんなで見て楽しみませんか?
システム構成
上記で示すように、ローカル環境でシステムを組むことで、お化け屋敷で「驚く → 撮られる → すぐ見る」 という体験の流れを途切れさせない構成としました。
想定する運用としては、お化け屋敷の運営側があらかじめ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 |
音声検知による自動撮影
本作品では、ユーザーは一切操作を行う必要がありません。ただ驚いて声を上げるだけで、その瞬間の表情が自動的に撮影されます。
音声検知は、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を使うことで、通信遅延や回線状況に左右されず、展示会場やお化け屋敷といった閉じた空間でも安定した体験を提供できます。
Spresenseからの画像送信には、HTTPクライアントライブラリではなく、RAW TCPによるHTTP POSTを使用しています。
JPEGデータは 256byte 単位で分割して送信し、送信中も音声処理が停止しないよう、ループ内で audio_drain()](以下に記載) を挿入しています。
while (sent < len) {
audio_drain();
size_t n = min(CHUNK, len - sent);
gs2200.write(cid, data + sent, n);
}
これにより、通信中でも音声FIFOが詰まらず、次の悲鳴検知に即座に戻れる という安定動作を実現しています。
スマホでの閲覧
撮影された写真は、スマートフォンのブラウザから即座に確認できます。
その場で自分の驚いた顔を見ることができるため、「撮られた写真」ではなく「体験の延長」として受け取られます。
また、表示形式がスマートフォン前提であるため、SNSへの共有とも相性が良く、体験がその場限りで終わらない点も特徴です。
工夫した・苦労した点
HDRカメラを使った理由
お化け屋敷や展示空間では、照明条件が急激に変化します。暗所から突然強い光が当たる場面でも、表情を破綻なく撮影する必要がありました。
SpresenseのHDRカメラ機能を有効化することで、
- 白飛びしにくい
- 暗所でも顔の情報が残る
という安定した撮影を実現しています。
音声検知と撮影の両立(リアルタイム性 vs 処理負荷)
音声処理と撮影処理を同一のタイミングで行うと、処理負荷によって音声フレームの取りこぼしが発生します。
そこで本カメラシステムでは、
- 音声検知は常にループ内で回す
- 撮影は「フラグ」で非同期的に要求する
という構造を採用しました。
if (max_val > THRESHOLD) {
g_capture_request = true;
}
この設計により、撮影処理の遅延が音声検知に影響しないようにしています。
WiFi送信と音声処理の競合
WiFi通信は不定期にブロックが発生するため、その間に音声入力を止めると、Audio FIFOが詰まりエラーになります。
通信中であっても音声処理を止めないことが、体験の連続性を保つ上で必須でした。
本作品では、音声を「使わない時間」でも必ず読み続けています。
inline void audio_drain() {
theAudio->readFrames(dummy, sizeof(dummy), &read_size);
}
送信中や保存中は判定を行わず、読み捨てるだけにすることで、
- Audio FIFOを詰まらせない
- 次の検知に即座に復帰できる
- システムを停止させない
という動作を実現しています。
これは単なる回避策ではなく、体験を壊さないための設計判断です。
デモ写真
本記事では悲鳴が検知された瞬間にスマートフォンに表示された画面を掲載します。
わかりやすいように「ぬいぐるみの馬」を被写体として、そして実際に驚いた声で判定するデモを行いました。そのスマホ画像とともに例を以下に示します。
ソースコード
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 "configapp1.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...");
audio_drain();
cid = gs2200.connect(HTTP_SRVR_IP, HTTP_PORT);
audio_drain();
if (cid == ATCMD_INVALID_CID) {
Serial.println("[RAW] connect failed");
return false;
}
audio_drain();
WiFi_InitESCBuffer();
audio_drain();
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");
audio_drain();
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 > 50) {
audio_drain();
Serial.println("[RAW] body write abort");
gs2200.stop(cid);
delay(10);
return false;
}
delay(10);
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);
audio_drain();
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) 送信・保存処理(jpeg_ready 優先)
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);
audio_drain();
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 > 10000)) {
audio_drain();
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");
audio_drain();
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;
}
}
今後の拡張アイデア
本作品は「音で驚いた瞬間を残す」体験を軸に設計していますが、今後は以下のような拡張が考えられます。
-
表情解析
撮影した画像から表情を解析し、驚きの度合いを数値化することで、より客観的な評価やランキング表示が可能になります。簡易的な学習が可能なSpresenseならではのアイデアとなります。 -
複数人対応
グループで体験した際に、複数人の表情を同時に撮影・保存することで、イベント性や盛り上がりをさらに高めることができます。
複数の SCREAM SHOT カメラ を利用することで可能になります。 -
連写機能
驚きの前後を含めて複数枚を撮影することで、「驚く前 → 驚いた瞬間 → 笑う瞬間」までを一連の体験として残すことができます。 -
悲鳴検知の精度向上
特定の周波数や、悲鳴には特定のフォルマント構造になることを利用したより高精度な悲鳴検知が可能となります。
おわりに
本作品では、単に音声検知やカメラ制御といった技術を作ることを目的にするのではなく、驚いた瞬間が自然に残り、その場で楽しめる体験を成立させることを最優先に設計しました。
音声処理・撮影・通信といった複数の要素が同時に動作する中で、体験が途切れないように設計判断を重ねたことが、この作品の最も重要なポイントです。
InstagramやTwitter, TikTokで、お化け屋敷で悲鳴をあげたその瞬間の衝撃画像がバズることを楽しみにしています。


-
shoshosho
さんが
2026/01/31
に
編集
をしました。
(メッセージ: 初版)
-
shoshosho
さんが
2026/01/31
に
編集
をしました。
-
shoshosho
さんが
2026/01/31
に
編集
をしました。
-
shoshosho
さんが
2026/01/31
に
編集
をしました。
-
shoshosho
さんが
2026/01/31
に
編集
をしました。
-
shoshosho
さんが
2026/01/31
に
編集
をしました。
-
shoshosho
さんが
2026/01/31
に
編集
をしました。
-
shoshosho
さんが
2026/01/31
に
編集
をしました。
-
shoshosho
さんが
2026/01/31
に
編集
をしました。
-
shoshosho
さんが
2026/02/02
に
編集
をしました。
-
shoshosho
さんが
2026/02/02
に
編集
をしました。
ログインしてコメントを投稿する