本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
本記事では ID やドメインをプレースホルダで表記している箇所があります。例:
- Firebase プロジェクト ID:
<firebase-pid>- Firebase Web API キー:
<web-api-key>
この記事のねらい
IIIF(International Image Interoperability Framework、画像相互運用のための国際規格)で画像を公開するところまでは、Omeka S などで到達できたとします。その次に「画像の一部(顔・印章・虫損など)を範囲選択して集め、それを検索できるシステムにしたい」という段階で、何を使えばよいか迷うことがあります。
選択肢はいくつかあります。
- 範囲選択したものをまとめる(キュレーション)ツールをどうするか
- 集めたものを検索するプラットフォームをどうするか(自前でフルスクラッチするか、既製のものを使うか)
- 作成段階でローカル環境に置くのか、クラウドに載せるのか
この記事では、そのうちの一つの選択肢として、CODH(人文学オープンデータ共同利用センター)が公開している IIIF Curation Platform(ICP) を、macOS(Apple Silicon)のローカルに Docker で一式立てる手順を記録します。ICP はキュレーション作成・保存・索引・検索がひとそろいになった既製のツール群なので、検索システムを一から書かずに「範囲選択 → 検索」の全体像を手元で確認できます。
対象読者は、IIIF や Docker を触り始めたくらいの方を想定しています。実際に動かす過程で引っかかった点(BSD 版 sed の非互換、ポート競合、Docker のディスク不足、localhost の名前解決、依存ライブラリの互換性)も、つまずきポイントとして残しておきます。
使い方は既存のチュートリアルに、この記事は「構築」に
ICP の使い方(Finder での検索、キュレーションの作り方、クローラの回し方、ファセットや表示のカスタマイズなど)は、鈴木親彦氏による ICP チュートリアル(ICPT)にまとまっています。
- ICP チュートリアル(ICPT): https://www.ch-suzuki.com/icpt/04.html
そこで本記事は、そのチュートリアルでは薄めの「macOS のローカルに一式を構築する」部分に絞ります。とくに、手元で動かそうとすると引っかかる macOS 固有の事情(sed の違い、localhost の名前解決、Docker のディスク、依存ライブラリの互換性)を具体的に埋めることを主眼にします。個々のコンポーネントの操作方法は、上記チュートリアルを併せて参照してください。
ICP は本来、リモートのサーバに置いてリバースプロキシ経由で公開する構成が想定されています。この記事は「まず手元で全体を動かして挙動を理解する」ことを目的にした、ローカル専用のセットアップです。公開運用にそのまま使うものではありません。
完成イメージ
先に、この手順で最終的に動く画面を示します。題材は NDL(国立国会図書館)デジタルコレクションの浮世絵から顔を検出してまとめた、既存のキュレーション(「江戸の花見」121 点)です。
まず IIIF Curation Viewer でキュレーションを開いたところです。元画像の上に、選択された領域(顔)が矩形で示されます。

次が IIIF Curation Finder です。保存したキュレーションを索引化すると、性別・作品・年・年代・信頼度・検出器といったメタデータがファセット(絞り込みの軸)として並びます。

「性別:女性」で絞り込むと、該当する 71 件の顔がサムネイル(IIIF で切り出した画像)で一覧表示されます。これが「範囲選択したものを検索する」の完成形です。

ICP を構成するコンポーネント
ICP は複数のコンポーネントが連携して動きます。役割を、冒頭の「迷いどころ」と対応づけて整理します。
| コンポーネント | 役割 | 対応する用途 |
|---|---|---|
| IIIF Curation Viewer | キュレーションの閲覧・範囲選択・編集 | キュレーション作成 |
| IIIF Curation Editor | キュレーションの編集 | キュレーション作成 |
| IIIF Curation Player | マニフェスト/キュレーションの再生表示 | 閲覧 |
| IIIF Curation Manager | 保存済みキュレーションの管理 | 管理 |
| IIIF Curation Finder | ファセット検索の UI | 検索システム |
| JSONkeeper | キュレーション JSON の保存先(HTTP API) | 保存 |
| Canvas Indexer | キュレーションを巡回して索引を作る | 検索の索引化 |
データの流れは次のようになります。
Viewer/Editor で範囲選択してキュレーションを作る
│ 保存
▼
JSONkeeper(保存 + Activity Stream を生成)
│ 巡回(クロール)
▼
Canvas Indexer(キャンバスとメタデータを索引化)
│ ファセット・検索 API
▼
Finder(絞り込み検索の画面)
リポジトリは Docker 用のセットアップスクリプト一式です。
前提環境
この記事の作業環境です。
- macOS(Apple Silicon)
- Docker Desktop(Docker Engine 29 系、Docker Compose v2 系)
- Homebrew
Docker Desktop が起動していることを確認しておきます。
docker version
docker compose version
手順
1. リポジトリを取得する
git clone https://github.com/rois-codh/iiif-curation-platform-docker.git
cd iiif-curation-platform-docker
setup.sh(コンポーネントの取得と設定)、start.sh / stop.sh(起動 / 停止)、docker-compose.yml.dist(Compose のテンプレート)などが入っています。
2. GNU 版 sed を入れる(macOS のつまずき その1)
setup.sh は各種設定ファイルを sed -i -E "..." で書き換えます。これは GNU 版 sed(Linux 標準)の書き方で、macOS 標準の BSD 版 sed ではそのまま動きません。BSD 版は -i の直後にバックアップ拡張子を要求するため、-i -E の並びが壊れてしまいます。
GNU 版 sed(gsed)を入れます。
brew install gnu-sed
そのうえで、setup.sh の中の sed -i -E を gsed -i -E に置き換えます。
gsed -i 's/\bsed -i -E/gsed -i -E/g' setup.sh
setup.shにはsed 's/.../'(-iを伴わない、パイプ経由の置換)も数か所あります。こちらはデフォルト設定では実行されない分岐(カスタム API パスを指定したときだけ通る)なので、今回は触っていません。
3. 接続先 URL とポートを決める(localhost を避ける理由)
setup.sh の先頭に、外部からアクセスする URL(externalurl)とポートの開始番号(start_port)を設定する行があります。初期値が入っているので、次のように書き換えます。
externalurl=http://icp.localhost:8888/cp
start_port=9001
ここでホスト名を localhost ではなく icp.localhost にしているのには理由があります。後述するとおり、この URL は「ブラウザから」だけでなく「Docker コンテナの中(Canvas Indexer)から」も同じ文字列で到達できる必要があります。localhost はコンテナの中では「コンテナ自身」を指してしまい、ホスト側のサービスに届きません。
icp.localhost のような *.localhost は、macOS では自動的に 127.0.0.1 に解決されます。加えて、Docker の extra_hosts 機能でコンテナからは「ホスト側」に向けられます。この二段構えで、同じ URL がブラウザとコンテナの両方から使えるようになります(詳細は手順 8)。
start_port を 9001 にすると、各コンポーネントは次のように割り当てられます。
- JSONkeeper:
9001 - Canvas Indexer:
9002 - Frontend(Viewer/Finder など):
9003
そして、これらの前段に立てるリバースプロキシを 8888 で公開し、http://icp.localhost:8888/cp/... に集約します。
4. リバースプロキシを足す
各コンポーネントは別々のポートに出るので、そのままでは「1 つの URL の下に全部ある」状態になりません。ブラウザ内の JavaScript は保存先や検索先を 1 つの URL 基点で組み立てるため、集約用の軽いリバースプロキシ(nginx)を 1 つ足します。
docker compose は docker-compose.override.yml を自動で読み込むので、そこにプロキシを定義します。setup.sh が生成する docker-compose.yml には手を入れずに済み、setup.sh を再実行しても消えません。
proxy/nginx.conf:
server {
listen 80;
server_name localhost;
client_max_body_size 64m;
# リダイレクト先にホストのポート(:8888)を落とさないよう相対 Location にする
absolute_redirect off;
location = /cp { return 302 /cp/viewer/; }
location = /cp/ { return 302 /cp/viewer/; }
location /cp/curation/ {
proxy_pass http://jsonkeeper:8000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /cp/index/ {
proxy_pass http://canvasindexer:8000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /cp/viewer/ { proxy_pass http://frontend:80/viewer/; proxy_set_header Host $host; }
location /cp/finder/ { proxy_pass http://frontend:80/finder/; proxy_set_header Host $host; }
location /cp/manager/ { proxy_pass http://frontend:80/manager/; proxy_set_header Host $host; }
location /cp/editor/ { proxy_pass http://frontend:80/editor/; proxy_set_header Host $host; }
location /cp/player/ { proxy_pass http://frontend:80/player/; proxy_set_header Host $host; }
}
docker-compose.override.yml:
version: '2'
services:
proxy:
image: nginx:alpine
labels:
- 'curation_platform_9001'
ports:
- '8888:80'
volumes:
- ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- jsonkeeper
- canvasindexer
- frontend
# Canvas Indexer / JSONkeeper からホスト側(プロキシ)に届くようにする
canvasindexer:
extra_hosts:
- 'icp.localhost:host-gateway'
jsonkeeper:
extra_hosts:
- 'icp.localhost:host-gateway'
プロキシは各サービスとおなじ Compose ネットワーク上にいるので、jsonkeeper / canvasindexer / frontend というサービス名でそのまま転送できます。
8888が別のコンテナやアプリで使われていると、プロキシの起動時にBind for 0.0.0.0:8888 failed: port is already allocatedが出ます。lsof -nP -iTCP:8888 -sTCP:LISTENで使用者を確認し、空いているポートに変えてください(externalurlと各設定の8888も合わせて変更します)。
5. コンポーネントを取得してビルドする
setup.sh を実行します。JSONkeeper と Canvas Indexer を GitHub から clone し、Viewer などのフロントエンドを CODH のサイトから取得し、externalurl に合わせて各設定(server_url、curationJsonExportUrl など)を書き換え、docker-compose.yml を生成して、最後にイメージをビルドしコンテナを作成します(この時点ではまだ起動しません)。
その前に、空の Firebase 鍵ファイルを置いておきます。docker-compose.yml は JSONkeeper に鍵ファイルをマウントする設定になっており、ファイルが無いと空のディレクトリが作られてしまうためです。setup/jk/ に置くと setup.sh が JSONkeeper 側へコピーします(Firebase を使う場合は、ここに本物のサービスアカウント鍵を置きます。後述)。
echo '{}' > setup/jk/firebase-adminsdk.json
./setup.sh
ビルド中に
apkのパッケージ展開でI/O errorが出て失敗する場合、Docker Desktop の仮想ディスクが逼迫していることがあります(ホストのディスクに空きがあっても、Docker の仮想ディスク側が満杯だと起きます)。まずはビルドキャッシュを解放してから./setup.shを再実行します。イメージやコンテナ、ボリュームには触れないので安全です。docker builder prune -f docker system df # 空きを確認 ./setup.sh
6. Canvas Indexer を SQLAlchemy 2.0 に対応させる(依存のつまずき)
setup.sh で取得した Canvas Indexer は、そのままでは検索 API(/index/api)が 500 エラーになります。原因は、Canvas Indexer のクエリが古い書き方のまま、固定されている依存が新しい SQLAlchemy 2.0 系(sqlalchemy==2.0.43)だったためです。2.0 では、Query オブジェクトを結合対象に渡したり、暗黙の結合条件を推論したりできなくなりました。
このパッチは、必ず
setup.shを実行した後に当ててください。setup.shは Canvas Indexer を毎回rm -rfしてから clone し直すため、先に当てても上書きで消えます。あとでsetup.shを再実行したときも、このパッチ(と、Firebase を使う場合のauthFirebase.js)は当て直しになります。
Canvas-Indexer/canvasindexer/api/views.py を開き、検索クエリの # filter records から docs = docs.join(assocs).join(terms).all() までのブロックを、次の内容にそっくり置き換えます。ORM のリレーション(Doc.terms → Assoc、Assoc.term → Term)で結合し、フィルタ条件はリストにためて最後にまとめて適用する形です。
# filter records
# ORM のリレーション(Doc.terms -> Assoc, Assoc.term -> Term)で結合し、
# フィルタ条件はリストにためて最後に適用する(SQLAlchemy 2.0 対応)
docs = Doc.query.join(Doc.terms).join(Assoc.term)
assoc_filters = []
term_filters = [not_(Term.term == current_app.cfg.e_term())]
for hidden_term in current_app.cfg.facet_label_hide():
term_filters.append(not_(Term.qualifier == hidden_term))
if vrom not in ['curation,canvas', 'canvas,curation']:
assoc_filters.append(Assoc.metadata_type == vrom)
if where_agent in ['human,machine', 'machine,human']:
pass
elif where_agent == 'human':
assoc_filters.append((Assoc.actor == 'human') |
(Assoc.actor == 'unknown'))
else:
assoc_filters.append(Assoc.actor == where_agent)
if where:
if fuzzy:
term_filters.append(Term.term.ilike('%{}%'.format(where)))
else:
term_filters.append(Term.term == where)
elif where_metadata_label:
if fuzzy:
term_filters.append(Term.term.ilike('%{}%'.format(
where_metadata_value
)))
term_filters.append(Term.qualifier == where_metadata_label)
else:
term_filters.append(Term.term == where_metadata_value)
term_filters.append(Term.qualifier == where_metadata_label)
docs = docs.filter(*assoc_filters).filter(*term_filters).all()
同じファイルのデバッグ専用ルートにも、古い文字列指定の結合 Canvas.query.join('terms', 'term') が残っています。こちらも直します(本番モードでは通らない箇所ですが、同じ理由で 2.0 では動きません)。
canvases = (Canvas.query.join(Canvas.terms)
.join(TermCanvasAssoc.term)
.filter(Term.term == q))
書き換えたら、このコンポーネントだけ作り直します。
docker compose build canvasindexer
7. 起動する
docker compose up -d
docker compose ps
jsonkeeper / canvasindexer / frontend / proxy の 4 つが running になっていれば OK です。
8. なぜ icp.localhost なのか(名前解決のつまずき)
ここがローカルで動かすときの肝です。
保存されたキュレーションには @id(そのキュレーション自身の URL)が埋め込まれ、それは externalurl に基づいて http://icp.localhost:8888/cp/curation/api/... になります。Canvas Indexer は索引を作るとき、この @id を実際にたどって本文を取得します。
- ブラウザからは
icp.localhost→127.0.0.1→ プロキシ(8888)に届きます。 - Canvas Indexer のコンテナからは、手順 4 の
extra_hosts: ['icp.localhost:host-gateway']によってicp.localhost→ ホスト → プロキシ(8888)に届きます。
もしここを localhost にしていると、コンテナの中では localhost がコンテナ自身を指すため、クロール時に Connection refused となり、索引が作れません。だからブラウザ・コンテナの双方から同じ文字列で到達できる icp.localhost を使っています。
到達性は次のように確認できます。
# ブラウザ相当(ホストから)
curl -s -o /dev/null -w '%{http_code}\n' http://icp.localhost:8888/cp/viewer/
# コンテナから
docker compose exec canvasindexer \
python3 -c "import urllib.request as u; print(u.urlopen('http://icp.localhost:8888/cp/viewer/', timeout=6).status)"
9. 動作を確認する
ブラウザで各コンポーネントを開けます。
| 用途 | URL |
|---|---|
| Viewer(閲覧・範囲選択) | http://icp.localhost:8888/cp/viewer/ |
| Finder(検索) | http://icp.localhost:8888/cp/finder/ |
| Player | http://icp.localhost:8888/cp/player/ |
| Editor | http://icp.localhost:8888/cp/editor/ |
| Manager | http://icp.localhost:8888/cp/manager/ |
Viewer は、URL パラメータで外部のキュレーションを読み込めます。
http://icp.localhost:8888/cp/viewer/?curation=<キュレーションJSONのURL>
冒頭の完成イメージは、次の URL を開いたものです(NDL の顔検出キュレーションの例)。
http://icp.localhost:8888/cp/viewer/?curation=https://nakamura196.github.io/ndl-face-finder/curations/works/edonohanami.json
検索を動かす:保存 → クロール → 検索(疎通確認)
Finder の画面自体はすぐ開けますが、検索結果が出るには「キュレーションを保存し、索引化する」ひと手間が要ります。ここでは構築が正しくできているかの最小の疎通確認として、コマンドだけで一巡させます。ブラウザからキュレーションを作って検索する通常の使い方は、前掲の ICPT を参照してください。
キュレーションを保存する
キュレーション JSON を JSONkeeper に保存します。JSONkeeper は、X-Access-Token ヘッダに任意の文字列を付けると「所有者(アクセストークン)付き」の文書として保存し、これが検索対象(Activity Stream)に載る条件になります。
まず題材のキュレーション JSON を手元に取得します。
curl -L -o edonohanami.json \
https://nakamura196.github.io/ndl-face-finder/curations/works/edonohanami.json
保存先に POST します。
curl -X POST http://icp.localhost:8888/cp/curation/api \
-H "Content-Type: application/ld+json" \
-H "X-Access-Token: demo-token-1" \
--data-binary @edonohanami.json
201 Created が返り、@id が http://icp.localhost:8888/cp/curation/api/... に書き換えられて保存されます。Activity Stream が生成されたことは次で確認できます。
curl -s http://icp.localhost:8888/cp/curation/as/collection.json | python3 -m json.tool | head
X-Access-Tokenを付けずに保存(匿名 POST)すると、文書は保存されますが Activity Stream には載りません。JSONkeeper は「所有者付きで、かつ非公開設定でない」文書だけを Activity Stream に流す設計になっているためです。実運用では、ここが Firebase ログインで得たトークンに置き換わります(後述)。
索引化(クロール)する
Canvas Indexer に、Activity Stream を巡回させます。
curl http://icp.localhost:8888/cp/index/crawl
{"message": "done"} が返れば完了です。ファセットが作られたか確認します。
curl -s http://icp.localhost:8888/cp/index/facets | python3 -m json.tool | head -40
性別・作品・年・年代などの集計が返ってくれば索引化できています。あとは Finder(/cp/finder/)を開けば、冒頭のようにファセットが並び、絞り込むと該当する顔の一覧が出ます。
Firebase を使う場合(任意)
ここまでの検索は Firebase なしで動きます。Firebase が要るのは、Finder / Manager の画面からユーザーとしてログインして、自分のキュレーションを保存・管理する機能を使うときです。
必要なら次を行います。
- Firebase プロジェクトのサービスアカウント鍵を
setup/jk/firebase-adminsdk.jsonに置く。 setup/jk/config.iniの[firebase]セクションのコメントを外す。- Viewer / Finder / Manager / Editor それぞれの
authFirebase.jsのfirebaseConfigを、自分のプロジェクトの Web 設定に書き換える。
Web 設定(apiKey など)は Firebase CLI で取り出せます。
firebase apps:sdkconfig WEB --project <firebase-pid>
firebaseConfig はおおむね次の形です(apiKey などは Web に埋め込まれる公開値です)。
var firebaseConfig = {
apiKey: '<web-api-key>',
authDomain: '<firebase-pid>.firebaseapp.com',
projectId: '<firebase-pid>',
storageBucket: '<firebase-pid>.firebasestorage.app',
messagingSenderId: '<messaging-sender-id>'
};
ブラウザのログイン(
signInWithPopup)を使う場合、アクセス元のドメインを Firebase の「承認済みドメイン」に登録する必要があります。localhostは既定で登録されていますが、この記事のようにicp.localhostを使う場合は、Firebase コンソールでicp.localhostを追加してください。ログインを使わず、保存 API(X-Access-Token)だけで検索まで確認するなら、この登録は不要です。
サービスアカウント鍵は秘密情報です。リポジトリにコミットしないよう注意してください(元リポジトリの .gitignore では setup/jk/firebase-adminsdk.json などが除外されています)。
つまずきポイントまとめ
ローカルで動かすときに引っかかった点を一覧にしておきます。
- BSD 版
sed:setup.shは GNU 版sed前提。brew install gnu-sedしてsed -i -Eをgsed -i -Eに置換する。 - ポート競合:
8888(や9001〜)が他で使われていると起動に失敗する。lsofで確認して空きポートに変える。 - Docker の仮想ディスク不足: ビルド中の
I/O errorはディスク逼迫のことがある。docker builder prune -fでビルドキャッシュを解放する。 localhostの名前解決: コンテナの中ではlocalhostは自分自身。ブラウザとコンテナ双方から届くicp.localhost+extra_hosts: host-gatewayにする。- SQLAlchemy 2.0 の非互換: Canvas Indexer の検索クエリが古い書き方。リレーション経由の結合に直す。
- 検索データが空: Finder は保存 → クロールをしないと結果が出ない。
X-Access-Token付きで保存し、/index/crawlを叩く。
選択肢としての位置づけ
冒頭の「範囲選択して検索できるシステムをどうするか」に対して、ICP は次のような選択肢になります。
- 検索システムを一から書かずに、キュレーション作成(Viewer/Editor)・保存(JSONkeeper)・索引(Canvas Indexer)・検索(Finder)までが既製でそろう。
- IIIF に準拠しているので、Omeka S などで作った IIIF マニフェストをそのまま素材に使える。
- まずローカルの Docker で全体を動かして挙動を理解し、その後で公開構成(サーバ+リバースプロキシ)に移す、という段取りが取りやすい。
一方で、UI やファセットの細かなカスタマイズ、独自の検索要件(曖昧検索・多言語・スコアリングなど)を突き詰める場合は、Omeka S のカスタマイズや、検索基盤(Elasticsearch など)を用いた自前構築のほうが柔軟なこともあります。ICP は「まず既製のもので範囲選択と検索の全体像を掴む」ための、扱いやすい出発点として位置づけるとよさそうです。
- 元リポジトリ: https://github.com/rois-codh/iiif-curation-platform-docker
- IIIF Curation Platform(CODH): http://codh.rois.ac.jp/iiif-curation-platform/
- ICP チュートリアル(ICPT・使い方): https://www.ch-suzuki.com/icpt/

コメント
…