はじめに
昨今AIの進化に伴い私たちの日常生活はより豊かになっていっています。特に顕著な例が医療です。病気の発見や治療がより迅速に行われています。そこで私は新しい発見や革新的な技術だけではなく、現状をよりよくしたいと思いました。
例えば、より患者に負担を掛けない検査などです。眼底検査などで用いられる点眼薬は瞳孔を広げる効果があります。この点眼薬が一部の人には効果の長引きによる視界不良などで日常生活がままならなくなるときがあります。私はこの課題解決の足がかりになればという思いで、形状の変化を追うために瞳孔を検出する機器を今回は制作したいと思います。
概要
今回はspresenseで瞳孔を検出する機器を製作しました。
「カメラで目を認識 ⇒ AIモデルが瞳孔を検出 ⇒ 面積の検出・記録 ⇒ ディスプレイに出力」 という流れで行います。
瞳孔を検出するAIモデルはNNCで構築しました。NNCの特徴にGUIで構築できる点があります。そのため初心者でも挑戦できると考えました。さらにspresenseとの互換性もよいと考えNNCで構築することに決めました。
部品
| 名称 | 数量 |
|---|---|
| Spresense メインボード | 1 |
| Spresense 拡張ボード | 1 |
| Spresense カメラボード | 1 |
| ILI9341搭載2.8インチSPI制御タッチパネル付TFT液晶 MSP2807 | 1 |
苦労したポイント
今回は瞳孔の大きさを追うという点が最重要項目だったので、画像認識の分類ではなくセグメンテーションとしてその領域を検出するのに苦労しました。他の記事も余り少ない上に瞳孔というあまりにも小さく不完全な対象に頭を悩ませました。
制作
データセット
検出モデルの作成から行うため、データセットから用意します。しかし、画像撮影とアノテーションの作業が大変なので今回はrobflowで公開されているオープンデータを使用しました。学習画像数は2100枚、検証画像数は196枚で行います。画像サイズは480×640のRGBとなっています。
前処理
画像サイズの480×540ではメモリが圧迫され処理が重く実行されないため96×96 ⇒ 48×64 の流れを経て、最終的に28×28の画像サイズで行うことになりました。学習データも二値化された白黒で行おうと考えたのですが、入力画像がRGBであることを考慮しRGBで行うことにしました。元のデータセットではNNCで求められてる二値化されたラベルじゃなかったので、Yoloで一度学習を行い二値化ラベルの作成を行う、いわゆる類似イントラベル学習を行いました
NNC
Sony Neural Network Consoleを用いて実装しました。DNNRTを用いてSPRESENSE内で推論を実行します。ネットワークの構造などは
https://github.com/TE-YoshinoriOota/Spresense-LowPower-EdgeAI
こちらを参考にしました。
ネットワークの構造は以下の通りです。
ディスプレイ
| LCD | Spresense |
|---|---|
| VCC | 3.3V |
| GND | GND |
| CS | D10 |
| RESET | D08 |
| DC | D09 |
| SDI(MOSI) | D11 |
| SCK | D13 |
| LED | D12 |
| SDO(MISO) | 3.3V |
コード
認識・推論
#include <Camera.h>
#include "Adafruit_ILI9341.h"
#include <SDHCI.h>
#include <DNNRT.h>
#define TFT_DC 9
#define TFT_CS 10
Adafruit_ILI9341 display = Adafruit_ILI9341(TFT_CS, TFT_DC);
#define OFFSET_X (48)
#define OFFSET_Y (6)
#define CLIP_WIDTH (224)
#define CLIP_HEIGHT (224)
#define DNN_WIDTH (28)
#define DNN_HEIGHT (28)
DNNRT dnnrt;
SDClass SD;
DNNVariable input(DNN_WIDTH*DNN_HEIGHT*3);
void CamCB(CamImage img) {
if (!img.isAvailable()) return;
CamImage small;
CamErr camErr = img.clipAndResizeImageByHW(small
,OFFSET_X ,OFFSET_Y
,OFFSET_X+CLIP_WIDTH-1 ,OFFSET_Y+CLIP_HEIGHT-1
,DNN_WIDTH ,DNN_HEIGHT);
if (!small.isAvailable()) return;
small.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565);
uint16_t* sbuf = (uint16_t*)small.getImgBuff();
float* fbuf_r = input.data();
float* fbuf_g = fbuf_r + DNN_WIDTH*DNN_HEIGHT;
float* fbuf_b = fbuf_g + DNN_WIDTH*DNN_HEIGHT;
for (int i = 0; i < DNN_WIDTH*DNN_HEIGHT; ++i) {
fbuf_r[i] = (float)((sbuf[i] >> 11) & 0x1F)/31.0;
fbuf_g[i] = (float)((sbuf[i] >> 5) & 0x3F)/63.0;
fbuf_b[i] = (float)((sbuf[i]) & 0x1F)/31.0;
}
dnnrt.inputVariable(input, 0);
dnnrt.forward();
DNNVariable output = dnnrt.outputVariable(0);
static uint16_t result_buf[DNN_WIDTH*DNN_HEIGHT];
for (int i = 0; i < DNN_WIDTH * DNN_HEIGHT; ++i) {
uint16_t value = output[i] * 0x3F;
if (value > 0x3F) value = 0x3F;
result_buf[i] = (value << 5);
}
bool err;
int16_t s_sx, s_width;
err = get_sx_and_width_of_region(output, DNN_WIDTH, DNN_HEIGHT, &s_sx, &s_width);
int16_t s_sy, s_height;
int sx, width, sy, height;
sx = width = sy = height = 0;
err = get_sy_and_height_of_region(output, DNN_WIDTH, DNN_HEIGHT, &s_sy, &s_height);
if (!err) {
Serial.println("detection error");
goto disp;
}
if (s_width == 0 || s_height == 0) {
Serial.println("no detection");
goto disp;
}
sx = s_sx * (CLIP_WIDTH/DNN_WIDTH) + OFFSET_X;
width = s_width * (CLIP_WIDTH/DNN_WIDTH);
sy = s_sy * (CLIP_HEIGHT/DNN_HEIGHT) + OFFSET_Y;
height = s_height * (CLIP_HEIGHT/DNN_HEIGHT);
disp:
img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565);
uint16_t* buf = (uint16_t*)img.getImgBuff();
draw_box(buf, sx, sy, width, height);
draw_sideband(buf, OFFSET_X, ILI9341_BLACK);
display.drawRGBBitmap(0, 0, (uint16_t*)sbuf, DNN_HEIGHT, DNN_WIDTH);
display.drawRGBBitmap(320-DNN_WIDTH, 0, result_buf, DNN_WIDTH, DNN_HEIGHT);
display.drawRGBBitmap(0, DNN_HEIGHT, buf, 320, 240-DNN_HEIGHT);
int maskPixelCount = 0;
for (int i = 0; i < DNN_WIDTH * DNN_HEIGHT; ++i) {
float val = output[i];
bool isObject = val > 0.5f;
if (isObject) maskPixelCount++;
}
int scale = (CLIP_WIDTH / DNN_WIDTH) * (CLIP_HEIGHT / DNN_HEIGHT);
int areaPixels = maskPixelCount * scale;
Serial.printf("Detected area: %d pixels\n", areaPixels);
display.setTextColor(ILI9341_GREEN, ILI9341_BLACK);
display.setTextSize(2);
display.fillRect(0, 0, 200, 20, ILI9341_BLACK);
display.setCursor(5, 5);
display.print("Area: ");
display.print(areaPixels);
display.print(" px");
}
void setup() {
Serial.begin(115200);
display.begin();
display.setRotation(3);
display.fillRect(0, 0, 320, 240, ILI9341_BLUE);
File nnbfile = SD.open("model.nnb");
if (!nnbfile) {
Serial.print("nnb not found");
return;
}
Serial.println("DNN initialize");
int ret = dnnrt.begin(nnbfile);
if (ret < 0) {
Serial.print("Runtime initialization failure. ");
Serial.println(ret);
return;
}
theCamera.begin();
theCamera.startStreaming(true, CamCB);
}
void loop() {
}
表示設定
#define IMG_WIDTH (320)
#define IMG_HEIGHT (240)
#define THICKNESS (5)
void draw_sideband(uint16_t* buf, int thickness, int color) {
for (int i = 0; i < IMG_HEIGHT; ++i) {
for (int j = 0; j < thickness; ++j) {
buf[i*IMG_WIDTH + j] = color;
buf[i*IMG_WIDTH + IMG_WIDTH-j-1] = color;
}
}
}
bool draw_box(uint16_t* buf, int sx, int sy, int w, int h) {
const int thickness = 4;
if (sx < 0 || sy < 0 || w < 0 || h < 0) {
Serial.println("draw_box parameter error");
return false;
}
if (sx+w >= OFFSET_X+CLIP_WIDTH)
w = OFFSET_X+CLIP_WIDTH-sx-1;
if (sy+h >= OFFSET_Y+CLIP_HEIGHT)
h = OFFSET_Y+CLIP_HEIGHT-sy;
for (int j = sx; j < sx+w; ++j) {
for (int n = 0; n < thickness; ++n) {
buf[(sy+n)*IMG_WIDTH + j] = ILI9341_RED;
buf[(sy+h-n)*IMG_WIDTH + j] = ILI9341_RED;
}
}
for (int i = sy; i < sy+h; ++i) {
for (int n = 0; n < thickness; ++n) {
buf[i*IMG_WIDTH+sx+n] = ILI9341_RED;
buf[i*IMG_WIDTH+sx+w-n] = ILI9341_RED;
}
}
return true;
}
検出設定
const float threshold = 0.8;
bool get_sx_and_width_of_region(DNNVariable &output,
int w, int h, int16_t* s_sx, int16_t* s_width) {
if (&output == NULL || w <= 0 || h <= 0) {
Serial.println(String(__FUNCTION__) + ": param error");
return false;
}
uint16_t *h_integ = (uint16_t*)malloc(w*h*sizeof(uint16_t));
memset(h_integ, 0, w*h*sizeof(uint16_t));
float sum = 0.0;
for (int i = 0; i < h; ++i) {
int h_val = 0;
for (int j = 0; j < w; ++j) {
if (output[i*w+j] > threshold) ++h_val;
else h_val = 0;
h_integ[i*w+j] = h_val;
}
}
int16_t max_h_val = -1;
int16_t max_h_point = -1;
for (int i = 0; i < h; ++i) {
for (int j = 0; j < w; ++j) {
if (h_integ[i*w+j] > max_h_val) {
max_h_val = h_integ[i*w+j];
max_h_point = j;
}
}
}
*s_sx = max_h_point - max_h_val;
*s_width = max_h_val;
memset(h_integ, NULL, w*h*sizeof(uint16_t));
free (h_integ);
if (*s_sx < 0) return false;
return true;
}
bool get_sy_and_height_of_region(DNNVariable &output,
int w, int h, uint16_t* s_sy, uint16_t* s_height) {
if (&output == NULL || w <= 0 || h <= 0) {
Serial.println(String(__FUNCTION__) + ": param error");
return false;
}
uint16_t *v_integ = (uint16_t*)malloc(w*h*sizeof(uint16_t));
memset(v_integ, 0, w*h*sizeof(uint16_t));
for (int j = 0; j < w; ++j) {
int v_val = 0;
for (int i = 0; i < h; ++i) {
if (output[i*w+j] > threshold) ++v_val;
else v_val = 0;
v_integ[i*w+j] = v_val;
}
}
int max_v_val = -1;
int max_v_point = -1;
for (int j = 0; j < w; ++j) {
for (int i = 0; i < h; ++i) {
if (v_integ[i*w+j] > max_v_val) {
max_v_val = v_integ[i*w+j];
max_v_point = i;
}
}
}
*s_height = max_v_val;
memset(v_integ, NULL, w*h*sizeof(uint16_t));
free(v_integ);
if (*s_sy < 0) return false;
return true;
}
以上のコードもこちらを参考にさせていただきました。
https://github.com/TE-YoshinoriOota/Spresense-LowPower-EdgeAI
こだわった点
今回の目的が瞳孔サイズの変化を追うことです。そのため瞳孔の検出のみならず、そのサイズをピクセル数として表示する機能もコードに追加しました。
実機
固定するものがなかったため一旦即席で眼鏡のレンズをくりぬき固定しました。
今後
課題①「精度向上」
まずひとつはモデル精度の向上です。瞳孔という検出対象があまりにも微細なものであるため、28×28解像度ではやはりに精度に限界があります。しかし解像度を上げすぎても処理落ちしてしまうため、本研究にあったネットワーク構造の見直しを考えています。
課題②「保存」
次に瞳孔のサイズと画像の保存です。検出したのち、それをさらに分析するには保存が必須になってきます。現段階ではテキストでのサイズの保存は実現の見通しは立っています。一方で画像保存は、ただ画像をピクチャすることは可能なのですが、DNNRTで検出したものも含めてのピクチャに頭を悩ませています。なのでjpgファイルじゃなくbmpファイルでの保存や検出領域抜きにしたピクチャを考えています。
課題③「距離の問題」
次に距離の問題です。カメラと瞳孔の距離によりピクセルサイズが変わってしまいます。これに関しては、カメラを固定して一定の距離での測定ができるように実機を整えることで解決できそうです。
おわりに
今回は、Spresense上でセグメンテーションによる瞳孔検出と、面積(ピクセル数)表示までをリアルタイムに実行できることを確認しました。しかし実際の現場で用いられるには足りないものがまだまだあります。本研究は、はじめに述べた眼底検査を目的として開始しました。進めるにつれ瞳孔の変化追うことでもっとなにかに応用できる可能性を感じました。今回は検出のみで終わりましたがもっとさらなる発展を今後努めたいと思います。
参考文献
・iRays, “pupil annotation Dataset,” Roboflow Universe (publisher: Roboflow), Sep 2025.
License: CC BY 4.0. (visited on 2026-01-27)
https://universe.roboflow.com/irays/pupil-annotation-ik7uu
※学習のために前処理(必要に応じてラベル形式変換)を実施。
・TE-YoshinoriOota, “Spresense-LowPower-EdgeAI,” GitHub.
Code: LGPL v2.1 / Bundled data & NNC sample projects: CC BY 4.0.
https://github.com/TE-YoshinoriOota/Spresense-LowPower-EdgeAI


-
AK010
さんが
2026/01/20
に
編集
をしました。
(メッセージ: 初版)
-
AK010
さんが
2026/01/20
に
編集
をしました。
-
AK010
さんが
2026/01/27
に
編集
をしました。
-
AK010
さんが
2026/01/27
に
編集
をしました。
-
AK010
さんが
2026/01/30
に
編集
をしました。
-
AK010
さんが
前の土曜日の12:08
に
編集
をしました。
ログインしてコメントを投稿する