本記事は生成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)にまとまっています。

そこで本記事は、そのチュートリアルでは薄めの「macOS のローカルに一式を構築する」部分に絞ります。とくに、手元で動かそうとすると引っかかる macOS 固有の事情(sed の違い、localhost の名前解決、Docker のディスク、依存ライブラリの互換性)を具体的に埋めることを主眼にします。個々のコンポーネントの操作方法は、上記チュートリアルを併せて参照してください。

ICP は本来、リモートのサーバに置いてリバースプロキシ経由で公開する構成が想定されています。この記事は「まず手元で全体を動かして挙動を理解する」ことを目的にした、ローカル専用のセットアップです。公開運用にそのまま使うものではありません。

完成イメージ

先に、この手順で最終的に動く画面を示します。題材は NDL(国立国会図書館)デジタルコレクションの浮世絵から顔を検出してまとめた、既存のキュレーション(「江戸の花見」121 点)です。

まず IIIF Curation Viewer でキュレーションを開いたところです。元画像の上に、選択された領域(顔)が矩形で示されます。

IIIF Curation Viewer でキュレーションを開いた画面

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

IIIF Curation Finder のファセット一覧

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

Finder で「性別:女性」を絞り込んだ検索結果

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 版 sedgsed)を入れます。

brew install gnu-sed

そのうえで、setup.sh の中の sed -i -Egsed -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_port9001 にすると、各コンポーネントは次のように割り当てられます。

  • JSONkeeper: 9001
  • Canvas Indexer: 9002
  • Frontend(Viewer/Finder など): 9003

そして、これらの前段に立てるリバースプロキシを 8888 で公開し、http://icp.localhost:8888/cp/... に集約します。

4. リバースプロキシを足す

各コンポーネントは別々のポートに出るので、そのままでは「1 つの URL の下に全部ある」状態になりません。ブラウザ内の JavaScript は保存先や検索先を 1 つの URL 基点で組み立てるため、集約用の軽いリバースプロキシ(nginx)を 1 つ足します。

docker composedocker-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_urlcurationJsonExportUrl など)を書き換え、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.localhost127.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/
Playerhttp://icp.localhost:8888/cp/player/
Editorhttp://icp.localhost:8888/cp/editor/
Managerhttp://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 が返り、@idhttp://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 の画面からユーザーとしてログインして、自分のキュレーションを保存・管理する機能を使うときです。

必要なら次を行います。

  1. Firebase プロジェクトのサービスアカウント鍵を setup/jk/firebase-adminsdk.json に置く。
  2. setup/jk/config.ini[firebase] セクションのコメントを外す。
  3. Viewer / Finder / Manager / Editor それぞれの authFirebase.jsfirebaseConfig を、自分のプロジェクトの 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 -Egsed -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 は「まず既製のもので範囲選択と検索の全体像を掴む」ための、扱いやすい出発点として位置づけるとよさそうです。