はじめに
世界のウミウシ は、ダイバーや研究者がウミウシの写真を投稿し、種の同定情報を共有するプラットフォームだ。2025年2月現在、約45,000枚の投稿写真が蓄積されている。
運営上の悩みは 同定(種の特定) にかかるコストだった。ユーザーが「チゴミノウミウシ」として投稿した写真が本当にチゴミノウミウシなのか、よく似た別種ではないのか。1枚1枚を目視で確認するのは2,000種を超えるウミウシの知識が必要で、かなりの労力がかかる。
「写真を投げたら似ている写真を返してくれるシステム」があれば、同定の確認作業が大幅に効率化できる。そう考えて、Claude Code と一緒にこのシステムを構築した。
システム全体像
+--------------------------------------------------+
| EC-CUBE (seaslug.world) |
| |
| [Review Edit] [Species Edit] [Bulk Register] |
| | | | |
| | auto embed | ID check | auto embed|
+-------+--------------+---------------+-----------+
| | |
v v v
+--------------------------------------------------+
| FastAPI (Similar Photo API) |
| |
| +-----------+ +----------+ +---------------+ |
| | BioCLIP | | pgvector | | MySQL (ref) | |
| | Encoder | | Search | | Species Map | |
| | 512-dim | | HNSW Idx | | | |
| +-----------+ +----------+ +---------------+ |
| |
| Docker Compose (api + PostgreSQL) |
+--------------------------------------------------+
大きく分けて2つのコンポーネントがある。
- EC-CUBE(既存サイト) — ウミウシの投稿・種管理を行う既存のPHPアプリケーション
- FastAPI(新規構築) — BioCLIP + pgvector による類似画像検索API
EC-CUBE側の管理画面からJavaScriptで類似検索APIを呼び出す形で統合した。投稿が公開されると自動的にembeddingが生成され、管理画面から同定チェックや類似検索が実行できる。
なぜ BioCLIP を選んだのか
モデル選定の経緯
最初は DINOv2(Meta, 768次元)を使っていた。汎用的な画像特徴量モデルとして評判が良く、実際に「同じ種の写真同士は類似度が高い」という基本的な性質は確認できた。
しかし問題があった。別種との分離が甘い。似たような色・形のウミウシ同士の類似度が高すぎて、「この写真はA種にもB種にも似ている」という状態が頻発した。
そこで BioCLIP(imageomics/bioclip, 512次元)に切り替えた。BioCLIPは生物学的な画像に特化してファインチューニングされたCLIPモデルで、iNaturalist等の生物写真データセットで学習されている。
比較結果
| DINOv2 (768次元) | BioCLIP (512次元) | |
|---|---|---|
| 同種投稿同士 avg | 0.75 | 0.77 |
| タイプ標本 vs 同種投稿 avg | 0.70 | 0.75 |
| 別種 vs タイプ標本 | 0.46〜0.55 | 0.17〜0.38 |
注目すべきは別種との類似度。DINOv2では0.46〜0.55だったものが、BioCLIPでは0.17〜0.38まで下がった。別種スコアの分離度が約2.5倍に改善。同種のスコアが上がるよりも、別種のスコアがちゃんと下がることのほうが、同定支援としては重要だ。
技術詳細
BioCLIP Encoder
class BioCLIPEncoder:
_instance = None
def __init__(self):
import open_clip
model, _, preprocess = open_clip.create_model_and_transforms(
"hf-hub:imageomics/bioclip"
)
model.eval()
self.model = model
self.preprocess = preprocess
self.tokenizer = open_clip.get_tokenizer("hf-hub:imageomics/bioclip")
@classmethod
def get(cls) -> "BioCLIPEncoder":
if cls._instance is None:
cls._instance = cls()
return cls._instance
def encode(self, image: Image.Image) -> np.ndarray:
with torch.no_grad():
inputs = self.preprocess(image).unsqueeze(0)
features = self.model.encode_image(inputs)
features = features / features.norm(dim=-1, keepdim=True) # L2正規化
return features.cpu().numpy().flatten()
シングルトンパターンで、APIサーバー起動時にモデルをロードして常駐させる。1枚あたりの推論はCPUでも数百ミリ秒程度。
pgvector によるベクトル検索
ベクトルの保存と検索には PostgreSQL の pgvector 拡張を使った。
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS embeddings (
photo_id TEXT PRIMARY KEY,
embedding vector(512) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_embeddings_hnsw
ON embeddings USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
HNSW(Hierarchical Navigable Small World)インデックス を使用。45,000件規模であれば、コサイン類似度によるTOP-100検索が数十ミリ秒で返ってくる。Pinecone等の専用サービスを使わなくても、このスケールならpgvectorで十分だった。
検索クエリはシンプル:
def search_by_vector(query_vec, top_n=100):
conn = get_pg()
cur = conn.execute(
f"SELECT photo_id, 1 - (embedding <=> %s::vector) AS similarity"
f" FROM {EMBEDDINGS_TABLE}"
f" ORDER BY embedding <=> %s::vector"
f" LIMIT %s",
(query_vec.tolist(), query_vec.tolist(), top_n),
)
return [{"photo_id": row[0], "similarity": float(row[1])} for row in cur]
<=> はpgvectorのコサイン距離演算子。1 - 距離 で類似度に変換している。
種レベルの集計とスコアリング
類似検索の生結果は「photo_id と similarity のリスト」だが、実際に知りたいのは「どの種に最も似ているか」。そこで種レベルの集計を行う。
def aggregate_species(results, species_map):
for r in results:
info = species_map.get(r["photo_id"])
sp = info["name"] if info else "Unknown Species"
w = info["weight"] if info else 1.0 # タイプ標本は weight=3.0
d = species_data[sp]
d["weighted_count"] += w
d["total_wsim"] += r["similarity"] * w
d["max_sim"] = max(d["max_sim"], r["similarity"])
ポイントは タイプ標本の重み付け。管理者がアップロードした正確なタイプ標本写真には weight: 3.0 を設定し、一般ユーザーの投稿写真(weight: 1.0)よりもスコアに大きく寄与するようにした。これにより、たまたま似た別種の写真が多数ヒットしても、タイプ標本が1枚あればそちらの種が上位に来やすくなる。
最終スコアは weighted_count * avg_similarity で算出。上位2種のスコア比率で自信度(高/中/低)を判定する。
Docker Compose 構成
# docker-compose.yml
services:
api:
build: .
ports: ["8900:8000"]
env_file: .env
volumes: ["./app:/app"]
pgvector:
image: pgvector/pgvector:pg16
ports: ["35432:5432"]
volumes: ["./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql"]
API(FastAPI + BioCLIP)と pgvector(PostgreSQL)の2コンテナ構成。種名のマッピングには本番MySQLを参照するため、.env に接続情報を持たせている。
EC-CUBE との統合
管理画面への組み込み
既存のEC-CUBE管理画面に、JavaScriptベースで類似検索UIを追加した。
種の編集ページ(product.tpl) には以下を追加:
– タイプ標本写真の登録・管理UI
– タイプ標本からの類似写真検索
– 同定チェック(全投稿写真を検査して、他種に似た写真を検出)
– 代表写真での類似検索
投稿の編集ページ(review_edit.tpl) には:
– 投稿写真の類似検索結果表示
いずれもAPIのURLは EMBEDDING_API_URL として SC_Initial.php に定数化し、テンプレートからは {$smarty.const.EMBEDDING_API_URL} で参照する。
公開時の自動embedding生成
管理者が投稿を「公開」にしたとき、自動的にembeddingを生成するフックを追加した。
// LC_Page_Admin_Products_ReviewEdit_Ex.php
public function lfRegistReviewData($review_id, $arrValues)
{
// ... レビューデータの更新処理 ...
$objQuery->commit();
// ... コピーライト位置変更処理 ...
// Embedding自動生成: 公開された写真のベクトル化
if (isset($arrValues['status']) && $arrValues['status'] == 1
&& !empty($this->arrForm['photo_id'])) {
$this->requestEmbedding($this->arrForm['photo_id']);
}
}
private function requestEmbedding($photo_id)
{
$url = EMBEDDING_API_URL . '/api/embed/' . urlencode($photo_id);
$cmd = 'curl -s -X POST ' . escapeshellarg($url) . ' > /dev/null 2>&1 &';
exec($cmd);
}
バックグラウンドでcurlを実行するため、管理画面の応答を待たせない。API側では ON CONFLICT DO NOTHING で冪等性を確保しているので、既にembedding済みの写真に対して呼んでも問題ない。
このフックは一括登録(複数投稿をまとめて公開)と個別編集の両方で動作する。両方のフローが同じ lfRegistReviewData() メソッドを通るため、1箇所の変更で済んだ。
同定チェック機能
最も実用的な機能が 同定チェック だ。ある種に登録されている全投稿写真に対して類似検索を実行し、「類似検索の1位が自種ではない写真」を検出する。
def check_identification(product_id, species_map):
photo_entries = [
(pid, info) for pid, info in species_map.items()
if info["product_id"] == product_id
]
suspicious = []
for photo_id, info in photo_entries:
results = search_by_photo_id(photo_id, top_n=30)
ranked = aggregate_species(results, species_map)
top = ranked[0]
if top["product_id"] != product_id:
suspicious.append({
"photo_id": photo_id,
"top_match": top["species"],
})
return {"suspicious": suspicious, "checked": checked}
つまり「この写真、本当にこの種? 画像的にはむしろ別種に近いんだけど」というのを自動検出する。数百枚の投稿がある種でも、ボタン一つで疑わしい写真をピックアップできる。
全件embedding生成
45,000枚の写真をCPU環境でベクトル化するバッチ処理を作成した。
# batch_embed.py
cursor.execute(
"SELECT r.photo_id FROM dtb_review r"
" JOIN dtb_products p ON r.product_id = p.product_id"
" WHERE r.del_flg = 0 AND r.status = 1"
" AND r.photo_id != '' AND p.del_flg = 0 AND p.status = 1"
" ORDER BY r.review_id"
)
公開済みの全レビュー写真をCDNから取得し、BioCLIPでベクトル化してpgvectorに格納する。CPU(Apple Silicon M1)で約3.5時間。チェックポイント機構により、途中で止まっても再開可能。
試したが効果がなかったこと
黒背景標本写真の前処理
タイプ標本写真はスタジオで黒背景で撮影されていることが多い。一方、ユーザー投稿写真は水中で自然光で撮影されている。このドメインギャップを前処理で埋められないか試した。
- ガウシアンブラー、シャープネス調整、メディアンフィルタ → 改善なし
- rembgで背景除去 → 白背景置換は逆に悪化(0.73→0.59)
- rembgで切り出し+青パディング → 微改善(+13%)だが不十分
結論: 黒背景スタジオ撮影と水中写真のライティング差は、ピクセルレベルの前処理では埋められない。水中で撮影されたタイプ標本を使うのが最も効果的。
テキストによる類似検索
BioCLIPはCLIPベースなので、テキストエンコーダーも持っている。原記載(種の初記載論文)に書かれた形態記述から写真を検索できないか試した。
def encode_text(self, text: str) -> np.ndarray:
with torch.no_grad():
tokens = self.tokenizer([text])
features = self.model.encode_text(tokens)
features = features / features.norm(dim=-1, keepdim=True)
return features.cpu().numpy().flatten()
結果:
| クエリ | 結果 |
|---|---|
| 形態記述(英語) | ほぼ無関係な種がヒット |
| 学名(属名) | 属レベルで正しい種がヒット |
| スケッチ画像 | 無関係な種がヒット |
BioCLIPは学名に対してはそれなりに反応する(「Hypselodoris」と入力するとHypselodoris属の写真が上位に来る)が、形態記述やスケッチ画像からの検索は実用レベルに達しなかった。画像同士の類似検索が現時点では最も信頼できる。
fine-tuning
ウミウシ写真データセットでBioCLIPをfine-tuningすることも試みた。LoRAおよび最終ブロック解凍でcontrastive lossによる学習を実装したが、CPU環境では1エポックに数十分かかり実用的ではなかった。GPU環境(Colab / Modal)での実行は今後の課題。
Claude Code の活用
このプロジェクトの開発はほぼ全てClaude Codeで行った。特に効果的だったのは以下の場面:
- 既存コードベースの理解: EC-CUBEの複雑なクラス継承構造(
LC_Page_Admin_Products_Review→ReviewEdit→_Exクラス)を読み解き、一括登録と個別編集の両方が同じメソッドを通ることを特定 - クロスリポジトリの作業: Python(API側)とPHP(EC-CUBE側)の両方を同時に扱い、連携部分を設計・実装
- 試行錯誤の高速化: DINOv2 → BioCLIP の比較、前処理の実験、テキスト検索の実験など、「試してみて効果を測定する」サイクルを高速に回せた
API一覧
GET /api/similar/{photo_id} — photo_idで類似検索
POST /api/similar — 画像ファイルで類似検索
POST /api/similar-text — テキストで類似検索(実験的)
GET /api/similar-selected/{product_id}— 代表写真で類似検索
POST /api/embed/{photo_id} — CDNから取得してembedding生成
POST /api/embed-url — 任意URL画像のembedding
POST /api/embed-upload — アップロード画像のembedding
GET /api/check-identification/{id} — 種の同定チェック
GET /api/stats — DB統計
精度と課題
KNN評価
Leave-one-out交差検証での種名分類精度:
| 種数 | 正答率 |
|---|---|
| 5種 | 93% |
| 10種 | 88% |
| 20種 | 86.5% |
種数が増えると精度は下がるが、「候補を絞る」用途としては十分実用的。最終判断は人間が行うので、TOP-5に正解が入っていればよい。
残っている課題
- GPU環境でのfine-tuning: ウミウシ特化のモデルにすれば精度はさらに上がるはず
- デプロイ: 現在はローカルのDockerで動かしている。ユーザー向けに公開するにはVPSデプロイまたはModal等のサーバーレスGPUが必要
- 投稿時の類似検索: ユーザーが写真を投稿する時点で「似ている種」を表示すれば、同定精度の向上につながる
まとめ
- BioCLIP + pgvector で、45,000枚のウミウシ写真に対する類似画像検索を構築した
- DINOv2からBioCLIPに切り替えることで、別種の分離度が2.5倍に改善
- EC-CUBEの管理画面に統合し、同定チェック・自動embedding生成を実現
- テキスト検索や線画検索は現時点では実用に至らず、画像同士の検索が最も有効
- 全てClaude Codeとの対話で開発。既存のPHPコードベースとPython APIの両方を扱えるのが強力だった

コメント