Python で他生物の視覚をシミュレートする
こんにちは!アルダグラムでエンジニアをしている内倉です
今年も、一年が終わろうとしていますね。
私のブログ当番は、これが今年最後の予定なので、また印象に残ったニュースを振り返ってみたいと思います。
今年、個人的に気になったニュースは、だいぶ最近ですが
日本では初めての発見となる、二枚貝と共生するヨコエビ、その名も「ユキミノノマルハサミヨコエビ」の発見です。(なんと風情のあるお名前…)
研究者の方のコメントで、
ミノガイの仲間ですが、調査で甲殻類を探していると海底の岩をどけたときに触手を活発に動かして
蠢いている様子をまれに見ます。
今回はそれを見て『何か寄生していないかな~』と考え貝を持ち帰ったのですが、その後にヨコエビ共生に気が付きました。
(引用元:京都大学「二枚貝と共生するヨコエビの新種を発見 ―共生生物の多様性に迫る―」2025年10月2日)
とあり、これがなぜかとても心に残って、時折思い返していました。
あとは、 Pixnapping Attack (画面に表示された情報を、描画処理の際に漏れてしまう小さな手がかりから復元して盗み取る攻撃)も、気になったのですが
どちらも「やってみた」系の記事にするのは、色々むずかしかったので、以前の記事のおまけをやっていきたいと思います。
以前の記事
昨年末あたりに、**Google Colab + PyTorch + ResNet で擬態の本気度を測定する** という、記事を書きました。
ある捕食者に対して「毒性のある生物(アデヤカミノウミウシ)」「アデに擬態している生物(ゴカイの仲間)」「アデに似せた創作物」の擬態度を数値化してみたという内容です。
この記事には、根本的な問題がありました。
このときの数値化は、あくまで人間目線。人間はゴカイを捕食せず完全な部外者なので、お前に向かって本気出してないし!ということなのです。
そこで今回は、実際の捕食者の視覚特性を簡易的にシミュレーションし、どんなものか見てみることにします。
捕食者の確認
今回も、前回用意した アデヤカミノウミウシ と ケショウシリス の画像を使っていこうと思います。
しかし、ケショウシリスの具体的な捕食者についての情報がぜんぜん見つからなかったので、アデは避けるけど、ゴカイは好むっぽいベラ類を捕食者代表とします。
Cirrhilabrus solorensis(ベラの一種)の見え方に関する数値を測定した論文があったので、画像の変換処理はこちらを参考に行います。
🐠 Cirrhilabrus solorensis(ベラの一種)の視覚特性
✨ 光受容体(色を感じる細胞)の特徴
↓ ベラの視界は、主に 青〜緑系統 みたい
| 光受容体の種類 | λₘₐₓ(nm) | 標準偏差 | 平たく言うと… |
|---|---|---|---|
| 単錐体(Single cone) | 514.1 nm | ± 6.6 nm | 主に緑寄りの光に反応し、他の錐体と組み合わせて色の違いを見分ける。 |
| 双錐体 A(Twin cone member 1) | 497.7 nm | ± 6.3 nm | 二つが対になった錐体の片方。青緑の波長に最も感度が高い。 |
| 双錐体 B(Twin cone member 2) | 532.3 nm | ± 3.0 nm | 二つが対になった錐体の片方。黄緑〜緑寄りの光に反応し、Aと組み合わせて明暗や色の差を検出する。 |
※ λₘₐₓ … 各光受容体が最も強く反応する波長
※ nm … ナノメートル
👀 眼の光フィルター(光の通り方)特性
↓ 人間とわりと近い感じみたいなので、今回は考慮しなくていいかな!
| 要素 | 特性値 |
|---|---|
| 眼媒質カットオフ(短波長側) | 約 360 nm(T₀.₅ ≈ 389 nm) |
※ T₀.₅ ≈ 389 nm は、「眼のレンズなどの透明な組織が、389 nmより短い波長の光(紫外線)を半分以上ブロックしている」の意味。= 紫外線は見えない。
出典:Gerlach, T., Theobald, J., Hart, N.S., Collin, S.P. & Michiels, N.K. (2016).
Fluorescence characterisation and visual ecology of pseudocheilinid wrasses.
ベラフィルターの作成
0. 準備
今回も Google Colab を使います。
Google ドライブに任意のフォルダを作成し、変換する画像を入れておきます。
そして、Google Colab のノートブックで、Google ドライブをマウントして、入出力用のフォルダを定義します。
INPUT_DIR = "/content/drive/MyDrive/VisualSimulation" # 画像置き場
OUTPUT_DIR = "/content/drive/MyDrive/VisualSimulation_out" # 出力
import os, glob, numpy as np, cv2
os.makedirs(OUTPUT_DIR, exist_ok=True)
1. RGB ⇔ 線形RGBの変換
# RGB -> 線形RGB
def srgb_to_linear(x):
x = np.clip(x,0,1); a=0.055
return np.where(x<=0.04045, x/12.92, ((x+a)/(1+a))**2.4)
# 線形RGB -> RGB
def linear_to_srgb(x):
x = np.clip(x,0,1); a=0.055
return np.where(x<=0.0031308, 12.92*x, (1+a)*np.power(x,1/2.4)-a)
ふつうのRGB画像は、人の見え方に合わせて明るさを調整してあります。
そこで、srgb_to_linear() で、光の強さに比例したRGB の値(線形RGB)に直してから、物理的な計算をしていきます。
linear_to_srgb() は、最後にファイル出力するときに、再びふつうのRGBに戻すために使います。
2. ベラの錐体の感度カーブを再現する
# 太陽光に含まれる可視光線の範囲(380 から 700 まで)を 5nm 刻みで並べた配列
WL = np.arange(380,701,5)
# 波長 WL に対して、ピーク mu・幅 sig のガウス分布(感度カーブ)を返す
def gauss(mu, sig): return np.exp(-0.5*((WL - mu)/sig)**2)
# Cirrhilabrus solorensis 近似(文献から取った値)
cones = [(497.7,6.3), (514.1,6.6), (532.3,3.0)]
S = gauss(*cones[0]) # 双錐体 A(Twin cone member 1)
M = gauss(*cones[1]) # 単錐体(Single cone)
L = gauss(*cones[2]) # 双錐体 B(Twin cone member 2)
S/=S.max()
M/=M.max()
L/=L.max()
SML = np.vstack([S,M,L])
文献から、ベラの3種類の錐体の 感度ピーク波長(λmax) と 分布の幅 がわかっています。
そこで、この2つの値をもとにガウス分布(ベル型の曲線)で近似して「ある波長の光に対してどれくらい反応するか(感度)」を数値で再現します。
ここでは、各錐体の感度カーブを380〜700nmの範囲で計算し、 SML という3×Nの行列にまとめました。
これが、ベラの色の感じ方を数値的に扱うための基礎データになります。
3. RGB→ベラ錐体空間の変換行列
# ピーク波長 mu を中心に、ディスプレイの原色光(R/G/B)のスペクトル分布(光の成分の分布)を再現する
def prim(mu):
p = gauss(WL, mu, 40.0)
return p/(p.max()+1e-8)
RGB = np.vstack([prim(610.0), prim(540.0), prim(460.0)])
# ベラの錐体感度(SML)と、ディスプレイRGBの仮想スペクトルを使って、RGB→SML の変換行列を作る
from numpy.linalg import pinv
W = SML @ pinv(RGB)
# さらに、値のスケールを整えるために、最大値が1になるように正規化
W = W/(W.max(axis=1,keepdims=True)+1e-8)
RGBとベラの錐体感度(SML)は、完全に1対1で対応するわけではありません。
そこで「できるだけ誤差が小さくなるように」当てはめる手法(=最小二乗法)を使って、変換行列 W を求めます。
Pythonの numpy.linalg.pinv() は、この最小二乗解を計算してくれる関数です。
4. ベラフィルタの適用
def apply_wrasse_filter(
img_bgr,
blur_sigma,
atten,
dist,
water_color,
haze_strength,
gray_adapt,
contrast_soft,
vis_weights
):
""" ベラの視覚シミュレーションを適用する関数
Args:
img_bgr (ndarray): 入力画像(OpenCVのBGR形式)
blur_sigma (float): 水中ぼけの強さ。大きいほど全体がなめらかになる
atten (tuple[float, float, float]): 各チャンネル(R,G,B)の減衰係数。R>G>B の順で強く減衰
dist (float): 想定する観察距離(大きいほど減衰・かぶりが強くなる)
water_color (tuple[float, float, float]): 水のベース色(線形RGB、青緑系)
haze_strength (float): ベイリングライト(散乱光)の強さ。0〜1の範囲
gray_adapt (float): von Kries適応(簡易ホワイトバランス)の強さ。0〜1の範囲
contrast_soft (float): コントラスト低下の量。大きいほど柔らかくなる
vis_weights (tuple[float, float, float]): 錐体出力(L’,M’,S’)の可視化強度調整
Returns:
ndarray: 変換後の画像(BGR形式)
"""
# 1) 計算のために、RGB を 線形RGB に変換
rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB).astype(np.float32)/255.0
lin = srgb_to_linear(rgb)
# 2) 水中フィルタ(減衰+散乱+ぼかし)
# 2-1) 減衰+ベイリングライトによる光学モデル
# I' = I*exp(-βd) + A*(1-exp(-βd))
t = np.exp(-dist*np.array(atten, dtype=np.float32))
A = np.array(water_color, dtype=np.float32) * haze_strength
lin_w = lin * t[None,None,:] + A[None,None,:] * (1.0 - t[None,None,:])
# 2-2) 暗部マスクでベイリングを抑える(暗いほどマスクを弱く)
lum = 0.2126*lin[...,0] + 0.7152*lin[...,1] + 0.0722*lin[...,2]
mask = 1.0 - np.exp(-4.0 * lum) # 明るい所で 1 に近づく、暗部では 0 に近い
mask = mask[..., None]
lin_w = lin * t[None,None,:] + (A[None,None,:] * (1.0 - t[None,None,:])) * mask
# 2-3) 水中でのぼやけを再現
lin_w = cv2.GaussianBlur(lin_w, (0,0), 1.5)
# 3) RGB → ベラの錐体刺激(S’,M’,L’)空間に変換
H,Wd,_ = lin_w.shape
X = lin_w.reshape(-1,3).T
Y = (W @ X).T.reshape(H,Wd,3) # [S', M', L']
Y = np.clip(Y, 0, 1)
# 4) (任意)簡易von Kries:青かぶりを少し整える
if gray_adapt > 0:
g = Y.mean(axis=(0,1)) + 1e-8 # 各錐体の平均感度を計算
gains = (g.mean()/g) ** gray_adapt # 平均で正規化して補正係数を作る
Y = np.clip(Y * gains[None,None,:], 0, 1)
# 5) “水中ぼけ”+コントラスト低下
ksz = max(3, int(blur_sigma*8)//2*2+1)
Yb = cv2.GaussianBlur(Y, (ksz,ksz), blur_sigma, blur_sigma, borderType=cv2.BORDER_REPLICATE)
if contrast_soft > 0:
low = cv2.GaussianBlur(Yb, (0,0), 2.5) # もう一段ぼかす
Yb = np.clip((1.0-contrast_soft)*Yb + contrast_soft*low, 0, 1)
# 6) 最終的にファイル出力するために、線形RGB → ふつうのRGB に戻す
Yb = Yb * np.array(vis_weights, dtype=np.float32)[None,None,:]
pseudo = linear_to_srgb(np.clip(Yb[..., [2, 1, 0]], 0, 1))
out = (np.clip(pseudo,0,1)*255).astype(np.uint8)
return cv2.cvtColor(out, cv2.COLOR_RGB2BGR)
ステップごとの詳細です。
2) 水中フィルタ(減衰+散乱+ぼかし)
# 2-1) 減衰+ベイリングライトによる光学モデル
t = np.exp(-dist*np.array(atten, dtype=np.float32))
A = np.array(water_color, dtype=np.float32) * haze_strength
lin_w = lin * t[None,None,:] + A[None,None,:] * (1.0 - t[None,None,:])
水中では、光が物体に届く前に、水中の微粒子などに当たって、いろんな方向に散らばります。
このとき、観察者(カメラや魚の眼)に直接届く前に、周囲の光が加わるので、白っぽくぼやけて見えたりします。
その「余計に入ってくる光(=ベイリングライト)」の影響を加味しています。
また、波長によって光の減衰率は異なり、赤い光は特に吸収されやすく、青緑の光が比較的遠くまで届くという特徴があります。
全体として、水中では青緑がかった色合いになります。
# 2-2) 暗部マスクでベイリングを抑える(暗いほどマスクを弱く)
lum = 0.2126*lin[...,0] + 0.7152*lin[...,1] + 0.0722*lin[...,2]
mask = 1.0 - np.exp(-4.0 * lum) # 明るい所で 1 に近づく、暗部では 0 に近い
mask = mask[..., None]
lin_w = lin * t[None,None,:] + (A[None,None,:] * (1.0 - t[None,None,:])) * mask
2-1 のステップでベイリングライトの影響を加味しましたが、水深が深い写真や暗い背景では、全体がグレーっぽく白くかぶって見えることがあります。
そこで、明るさ(輝度)に応じてベイリング効果を調整します。
暗い部分ではマスクを弱めることで、影や背景の黒を保ち、自然なコントラストにします。
そして、2-3 のステップで、水中特有のくぐもった見え方を再現します。
3) RGB → ベラの錐体刺激(S’,M’,L’)空間に変換
H,Wd,_ = lin_w.shape
X = lin_w.reshape(-1,3).T
Y = (W @ X).T.reshape(H,Wd,3) # [S', M', L']
Y = np.clip(Y, 0, 1)
先に作成した変換行列 W を使って、画像を「ベラ視覚空間」で表現できる形にします。
np.clip(Y, 0, 1) は、計算誤差で範囲外に出た値を補正しています。
4) (任意)簡易von Kries:青かぶりを少し整える
if gray_adapt > 0:
g = Y.mean(axis=(0,1)) + 1e-8 # 各錐体の平均感度を計算
gains = (g.mean()/g) ** gray_adapt # 平均で正規化して補正係数を作る
Y = np.clip(Y * gains[None,None,:], 0, 1)
水中フィルタを通したので、画像全体が青緑寄りになって、白や灰色の部分まで青っぽく見える傾向があります。
このステップでは各錐体チャンネルの平均値を揃えることで、色のバランスを取り直し、自然な見え方を保ったまま青かぶりをかるく補正します。
5) “水中のぼやけ”+コントラスト低下
ksz = max(3, int(blur_sigma*8)//2*2+1)
Yb = cv2.GaussianBlur(Y, (ksz,ksz), blur_sigma, blur_sigma, borderType=cv2.BORDER_REPLICATE)
if contrast_soft > 0:
low = cv2.GaussianBlur(Yb, (0,0), 2.5) # もう一段ぼかす
Yb = np.clip((1.0-contrast_soft)*Yb + contrast_soft*low, 0, 1)
2-3 のステップで、光の減衰や散乱による細かいエッジのぼけを再現しました。
しかし、水中ではさらにもう一段階の「広域のぼけ」が起こります。
光があちこちから混ざり合うせいで、明るい部分が暗く・暗い部分が明るくなるという現象です。
結果として、全体のコントラストが柔らかくなります。
このステップでは、その「コントラストの低下」を再現しています。
contrast_soft の値が大きいほど、より濁った水中のような印象になります。
5. 出力
# バッチ処理 & Before/Afterの横並び保存
exts = (".jpg",".jpeg")
for p in sorted(glob.glob(os.path.join(INPUT_DIR,"*"))):
if not p.lower().endswith(exts): continue
bgr = cv2.imread(p, cv2.IMREAD_COLOR)
out = apply_wrasse_filter(
bgr,
atten=(2.0, 0.9, 0.45),
dist=1.2,
water_color=(0.035, 0.20, 0.22),
haze_strength=1.0,
gray_adapt=0.15,
blur_sigma=2.8,
contrast_soft=0.3,
vis_weights=(0.55, 0.95, 1.05)
)
# 横並び比較
h = min(bgr.shape[0], 1080)
scale = h/bgr.shape[0]
def rez(im): return cv2.resize(im, (int(im.shape[1]*scale), h), interpolation=cv2.INTER_AREA)
comp = np.hstack([rez(bgr), rez(out)])
fname = os.path.splitext(os.path.basename(p))[0]
cv2.imwrite(os.path.join(OUTPUT_DIR, f"{fname}_wrasse_filter.jpg"), comp)
apply_wrasse_filter() に渡しているのが、私のおすすめです!
わかりやすいように、元の画像と横並びにして OUTPUT_DIR へ出力します。
出力ファイル
前回用意してた画像は、著作権の都合で貼れないので、代わりにPixelスタジオで生成した画像を使ったものを貼っておきます。
どうしても「アデヤカミノ」が反映されなくて、色んなウミウシのキメラっぽいものになってしまいました。
あとなんだか、この画像は、あんまりぼかしが効いてませんね。前回の画像を使ったやつは、すごくいい感じのベラみだったのに…😢

おわりに
ベラ以外にも、さまざまな生き物の錐体データが公開されているそうです。気軽にXXフィルタ作れそうです。
そういえば先日、ハゼ釣り大会に参加したときに、えさがゴカイだったのですが、小さく切って使うのにすぐに魚が気づくので、もしかしたら視覚以外の情報が大きいのかな、と思いました。
「他のなにか」が似た結果、見た目も似ちゃうこともあるのかもしれないですね。
もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!
株式会社アルダグラムのTech Blogです。 世界中のノンデスクワーク業界における現場の生産性アップを実現する現場DXサービス「KANNA」を開発しています。 採用情報はこちら: herp.careers/v1/aldagram0508/
Discussion