Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Label Studio のカスタム UI を作ってみた

こんにちは、研究開発部 Automation グループで研究員をしている李です。この 1 年は、修論に追い込まれて研究室の後輩と GPU サーバーを奪い合ったり、そこそこ通りづらい国際会議に出たり、新卒入社して山登りに行ったり、Bill One の自動化率の改善をやったりと色々ありました。

さて、本題に入ります。最近のタスクで Label Studio というアノテーションツールを触る機会があったので、その使い方などをまとめようと思います。至らない部分もあると思いますが、ご参考になれば幸いです。

なお、本記事は Sansan Advent Calendar 2023 第 24 日目の記事です。 adventar.org

目次

Label Studio とは?

Label Studio は Heartex 社より提供されるオープンソースのラベリング・アノテーションツールです。実際にある機能を一部例として挙げます:

  • 画像、テキスト、音声など、さまざまなデータタイプにサポート
  • データ注釈の進捗管理、タスクの割り当て、リアルタイムなプレビューなど、効率的なデータ注釈のワークフローを提供
  • カスタムアノテーションタイプやプラグインを追加でき、異なるタスクに対応

今回はそのカスタム機能に注目します。

背景など

  • 背景: 画像に対して class ごとの矩形 (Bounding Box、以下、BBox) をアノテーションしたいです。多くの BBox はすでに外部のエンジンによって自動アノテーションされています。
  • 課題: しかし、一部の class で BBox のアノテーション不足があります。
  • 解決案: 各クラスの BBox に不足がないかを確認し、追加で BBox をアノテーションできる UI が欲しいです。これを Label Studio で実現します。
  • Label Studio にこだわる理由: (i). 使い慣れている Python でUI のカスタマイズができます。(ii). Supervisely というコンピュータビジョンに特化したアノテーションツールなど、他にもたくさんありますが、Label Studio は UI が好みです。

作ったもの

とりあえずできたものを先にお見せしますと、↓ こんな感じです。

デモ画像

次の節から ↑ これにたどり着くまでの設計を順番に説明していきます。

作成手順

環境の準備

poetry での環境管理に慣れているので、今回も poetry add から label-studio をインストールすることにしました。なるべく早くインストールするには、poetry を最新バージョンにした方がいい (じゃないと、インストールは何時間もかかります) ので、最新の poetry をインストールしましょう。(2023/12/14 で確認した時点で 1.7.1 までリリースされています。)

ちなみに、pipxpoetry をインストールすると、異なるバージョンの poetry を同時にインストールでき、他の環境と干渉しなくて済むので、おすすめです。*1

必要とされるパッケージは以下に示します:

python = "^3.9"
click = "^8.1.7"
opencv-python = "^4.8.1.78"
label-studio = "1.8.2"
label-studio-sdk = "0.0.32"

label-studio は 2023/12/01 の時点で 1.10 系まで update されましたが、別パッケージとバッティングすることがあるため、今回は 1.8 系を使用します。

Label Studio に接続する

続いて、早速ですが、Label Studio に接続してみましょう。公式の説明を見ると

label-studio start 

を叩くと接続するらしいです。しかし、今回はローカルに置いてある画像を Label Studio に反映したいので、環境変数を設定して、docker から繋ぐことにしました。

docker run -it --user root  -p 8080:8080 -v `pwd`/data:/label-studio/data --env LABEL_STUDIO_LOCAL_FILES_SERVING_ENABLED=true --env LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT=/label-studio/data  heartexlabs/label-studio:latest

↑ こんな感じです。

/label-studio/data は基本固定です。`pwd`/data: は画像が保存されているディレクトリの 1 層上のパスを設定します。

 label-studio start --data-dir /path/to/your/data

でローカルファイルを指定して起動することもできるが、以下の理由で docker で起動しています:

  • アノテーションの結果を表示するサーバーと Label Studio の UI を表示するサーバーを独立して起動したいです。
  • 権限・環境まわりの設定が難しいので、docker を使えば一括で簡単に設定できます。

./dataの下に、↓ このようなファイルが作成されたら、接続成功です。

./data
├── export
├── images
│   ├── 000001.png
│   ├── 012764.png
...
│   └── ...
├── label_studio.sqlite3
├── media
│   └── export
└── test_data

次に、localhost:8080 にアクセスしましょう。初回アクセス時はアカウント作成が必要です。アクセスが成功したら、↓ のような画面が出てくると思います。

(ちょっと関係ない話を挟みます。開発者によると、この動物はオポッサムらしいです。しかも、ちゃんと理由があります。*2 )

新しいプロジェクトを作成する

これからぽちぽちでプロジェクトを作成して、UI 画面と class label を定義して、画像を入れることもできるが、ソースコードを実行するだけで一連の操作ができたら便利ですよね。なので、公開された SDK を参考にして書いていきましょう。

labelstud.io

まずはプロジェクトの作成です:

設定した localhost と Access Token を利用して、client を初期化し、新しいプロジェクトを立ち上げます。Access Token は、Label Studio 画面右上のアイコン → Account & Settings から取ってきたものです。

class LabelStudioProject:
    def __init__(
        self,
        label_config: str = "label_config.xml",
        root_dir: str = "data/",
        label_root_dir: str = "label_studio/data/local-files?d=images/",
        label_studio_url: str = "http://localhost:8080",
        label_studio_token: str | None = None,
        title: str = "annotation",
        description: str = "hogehoge",
        images_dir: str = "data/images",
        annotation_bbox_path: str = "data/bbox.json",
    ) -> None:
        # Configuration
        self.label_config = label_config
        self.root_dir = Path(root_dir)
        self.label_root_dir = Path(label_root_dir)
        self.label_studio_url = label_studio_url
        self.label_studio_token = label_studio_token or os.environ.get("LABEL_STUDIO_TOKEN", "")

        # Label Studio Client
        client = Client(url=self.label_studio_url, api_key=self.label_studio_token)
        self.project = client.start_project(
            title=title,
            description=description,
            label_config=Path(self.label_config).read_text(),
            show_collab_predictions=True,
            show_skip_button=False,
            enable_empty_annotation=False,
        )

        current_file_dir = os.path.dirname(os.path.abspath(__file__))
        # 画像の保存先のディレクトリ
        self.images_dir = os.path.join(current_file_dir, images_dir)

        # 既にアノテーションした BBox の情報が書かれた json ファイル
        with open(os.path.join(current_file_dir, annotation_bbox_path)) as f:
            self.annotation = json.load(f)

root_dir, label_root_dir, images_dir, annotation_bbox_path は後で使うので、先に定義しておきます。

UI (label_config) の定義

先ほどの __init__ 内では、label_config を呼び出す処理があるので、その中身の定義を書きます。

今回はまず画像と BBox の表示が必要なので、テンプレートを参考にして、"Bbox object detection" によると、ImageRectangleLabels を使えば実現できます (タグなどは XML とほぼ変わらないのですが、書き方はかなり限定されています):

<View style="display:flex">
    <Image name="image" value="$image" zoom="true" zoomControl="true" rotateControl="true"/>
    <RectangleLabels name="bbox" toName="image" strokeWidth="1" showInline="true">
        <Label value="mouse" background="#FF4B00"/>
        <Label value="cup" background="#005AFF"/>
        <Label value="chair" background="#03AF7A"/>
        <Label value="laptop" background="#4DC4FF"/>
        <Label value="table" background="#F6AA00"/>
        <Label value="plant" background="#FFF100"/>
        <Label value="monitor" background="#FF66FF"/>
        <Label value="keyboard" background="#C6F55E"/>
        <Label value="cat" background="#875C44"/>
        <Label value="dog" background="#BD5EF5"/>
    </RectangleLabels>
</View>

画像データを表示する

続いて作成したプロジェクト内に画像を表示してみましょう。下記のソースコードで示すように、表示したい画像データを集めて、dataset を作成し、タスクを追加します:

class LabelStudioProject:
    def __init__(...):
        ...

    @staticmethod
    def is_image(path: Path) -> bool:
        """Check if the given path is an image."""
        return path.suffix.lower() in [".jpg", ".jpeg", ".png"]

    def create_dataset(self) -> list[dict]:
        image_names = [path.stem for path in sorted(filter(self.is_image, self.root_dir.joinpath("images").glob("*")))]

        # Create dataset
        dataset = []
        for idx, filename in enumerate(image_names, start=1):
            image_path = self.label_root_dir.joinpath(f"{filename}.jpg")

            dataset.append(
                {
                    "data": {
                        "idx": int(idx),
                        "image_id": str(filename),
                        "image": str(image_path),
                    }
                }
            )

        return dataset

    def add_tasks(self) -> None:
        dataset = self.create_dataset()
        self.project.import_tasks(dataset)

↑ 実は、これを実行するだけでは表示できないのです。Label Studio 上でも設定しなきゃいけません。今回はローカルから画像を取得するので、ローカルパスを設定するだけで表示できます。

Project → 作成したプロジェクト → Settings → Cloud Storage に行って、Add Source Storage の以下の設定で storage を追加します。

storage の設定

そうすると以下で示すように画像が表示されます。

作成したプロジェクトの中身 (画像出典:MS COCO *3 )

アノテーション済みの BBox を表示する

次に、下の画像のように、別エンジンでもう既にアノテーションが完了した BBox とその class label を表示したいです。

アノテーション済みの BBox を表示してる画面 (画像出典:https://cocodataset.org/#explore?id=12764 [MS COCO] )

JSON ファイルに保存されている情報を Label Studio で表示できる形式に変換します。ここで、注意してほしいこと (ここは特に、完全に理解するまでにかなり時間がかかりました):

  • Label Studio 上の画像表示はパーセント形式なので、座標はそれに合わせて調整する必要があります。
  • 基本 key はもとから定義されているものなので、変えられません。増やすことも、減らすこともできません。
  • label_config.xml で定義している内容がちゃんと表示できるように、to_name, from_name, type の value はそれと一致する必要があります。

コメント文にも注意事項を書いてあるので、参考にしてください。

class LabelStudioProject:
    def __init__(...):
        ...

    def _draw_annotation_bbox(self, filename: str, original_height: int, original_width: int) -> list[dict]:
        """
        Summary:
            annotation 済みの class label の bbox を取得し, label-studio で表示するための形式に変換する

        Args:
            filename (str): 画像のファイル名
            original_height (int): 元の画像の高さ
            original_width (int): 元の画像の幅

        Returns:
            list[dict]: label-studio で表示するための形式に変換した bbox のリスト
        """

        annotation_results = []
        for idx, bbox in enumerate(self.annotation[filename]):
            # label-studio 上座標はパーセント表示のため、BBox 座標を調整する
            rectangle = {
                "x": bbox["bbox"]["left"] / original_width * 100,
                "y": bbox["bbox"]["top"] / original_height * 100,
                "width": (bbox["bbox"]["width"]) / original_width * 100,
                "height": (bbox["bbox"]["height"]) / original_height * 100,
            }

            # key は変えられない
            # 基本全部必要
            bbox = {
                "original_width": original_width,
                "original_height": original_height,
                "image_rotaio": 0,
                "value": {
                    **rectangle,
                    "rotation": 0,
                    "rectanglelabels": [bbox["class_label"]],
                },
                # label_config.xml の toName の値と一致しなければならない
                "to_name": "image",
                # label_config.xml の name の値と一致しなければならない
                "from_name": "rectangle",
                "id": str(idx),
                # <> に書かれてるタグの名前と一致しなければならない
                "type": "rectanglelabels",
            }
            annotation_results.extend([bbox])

        return annotation_results

アノテーション漏れを確認できるチェックリストを作る

デモ画像のような例は簡単に漏れを確認できるのですが、下記を示すように、ものがたくさん表示されている画像はどの class label がアノテーションができて、何がまだできていないのかを確認するのはかなり時間をかかると感じました。

また、マルチラベルの画像分類器が keyboard に高いスコアを出しているにも関わらず、keyboard はまだアノテーションされていない (物体検出器に検出されていない) のような状況だと、チェックリストでアノテーション漏れがないかを確認できると便利ですよね。

そのため、ある程度アノテーションの質と速度を担保するために、その物体は本当にないのかを確認する画面 (画面の右半分) も作成しました。

アノテーションが若干複雑な例 (画像出典:https://github.com/HKBU-HPML/IRS/blob/master/imgs/office.png [Wang+, ICME 2021] *4 )

(質を担保するとか言いつつ、結局人間が 2 回確認しているだけので、本当に効果があるのかは不明です。)

アノテーション済みの class label を取ってきて、Label Studio のチェックボックスで表示できるように変換します。ここも、先と同様に、key と value に注意してください。

class LabelStudioProject:
    def __init__(...):
        ...

    def _draw_annotation_bbox(...):
        ...

    def _build_label_check_list(
        self, filename: str, data: dict[str, dict[str, int | str]], original_height: int, original_width: int
    ) -> list[dict]:
        """
        Summary:
            annotation された class label を label-studio でチェックボックスの形で表示する

        Args:
            filename (str): 画像のファイル名
            data (dict[str, dict[str, int  |  str]]): 画像の名前・ID・パスなどの情報が書かれた辞書
            original_height (int): 画像の元の高さ
            original_width (int): 画像の元の幅

        Returns:
            list[dict]: label-studio のチェックボックスで表示するための形式に変換した class label のリスト
        """
        check_list = []

        annotation_results = self.annotation[filename]
        class_labels = [result["class_label"] for result in annotation_results]
        # 重複を削除
        class_labels = list(dict.fromkeys(class_labels))

        detected_labels = {
            "original_width": original_width,
            "original_height": original_height,
            "image_rotation": 0,
            "value": {
                "choices": class_labels,
            },
            "to_name": "image",
            "from_name": "class_labels",
            "id": str(data["data"]["idx"]),
            "type": "choices",
        }

        check_list.extend([detected_labels])

        return check_list

label_config を更新する

画面の右側に、チェックリストを作成します。テンプレートによると、Choice を使えば、実現できます。また、Customizable Tags によると、<View>style を設定すれば、画面分割が実現できます (ここも XML とほぼ変わりません):

<View style="display:flex">
    <View style="width: 60%">
        <Image name="image" value="$image" zoom="true" zoomControl="true" rotateControl="true"/>
        <RectangleLabels name="bbox" toName="image" strokeWidth="1" showInline="true">
            <Label value="mouse" background="#FF4B00"/>
            <Label value="cup" background="#005AFF"/>
            <Label value="chair" background="#03AF7A"/>
            <Label value="laptop" background="#4DC4FF"/>
            <Label value="table" background="#F6AA00"/>
            <Label value="plant" background="#FFF100"/>
            <Label value="monitor" background="#FF66FF"/>
            <Label value="keyboard" background="#C6F55E"/>
            <Label value="cat" background="#875C44"/>
            <Label value="dog" background="#BD5EF5"/>
        </RectangleLabels>
    </View>

    <View  style="width: 40%;">
        <Header value="以下 ✅ が入っていない label は本当にないのかを確認して!" />
        <Choices name="class_labels" toName="image" choice="multiple">
            <Choice value="mouse" />
            <Choice value="cup" />
            <Choice value="chair" />
            <Choice value="laptop" />
            <Choice value="table" />
            <Choice value="plant" />
            <Choice value="monitor" />
            <Choice value="keyboard" />
            <Choice value="cat" />
            <Choice value="dog" />
        </Choices>
    </View>
</View>

そうしたら、全体画面の右に次のような画面が得られます:

チェックリストの画面

チェックが入っていない class label を 1 個ずつ確認して、画像内に現れなかったもしくは、アノテーションが追加済みだったら、チェックを入れます。全 class label のチェックが入っていたら、そのタスクを完了とします。

一連の作業をつなぐ

今まで説明した内容を以下のように連結します。詳細を説明しますと、 project.get_tasks_ids() を使って、各タスクに対して、BBox とチェックリストの表示処理を行います。 あとは、@click__init__ で定義しているコマンドライン引数を定義して、関数を呼び出せば、デモ画面で示したものが実現できます。

class LabelStudioProject:
    def __init__(...):
        ...

    @staticmethod
    def is_image(...):
        ...

    def create_dataset(...):
        ...

    def create_views(self, dataset: list[dict]) -> None:
        """
        Summary:
            各タスクに対して、annotation された class label の bbox とそのチェックリストを label-studio で表示する

        Args:
            dataset (list[dict]): create_dataset で作成したデータセット
        """

        for task_id, data in zip(self.project.get_tasks_ids(), dataset, strict=True):
            results = []

            image_name = data["data"]["image_id"]
            image = cv2.imread(os.path.join(self.images_dir, f"{image_name}.jpg"))
            original_height, original_width = image.shape[:2]

            annotation_bbox = self._draw_annotation_bbox(image_name, original_height, original_width)
            results.extend(annotation_bbox)

            check_list = self._build_label_check_list(image_name, data, original_height, original_width)
            results.extend(check_list)

            self.project.create_annotation(task_id, result=results)

    def _draw_annotation_bbox(...):
        ...

    def _build_label_check_list(...):
        ...

    def add_tasks(self) -> None:
        dataset = self.create_dataset()
        self.project.import_tasks(dataset)
        self.create_views(dataset)

追加のアノテーションをやってみる

画像の下にある class label をクリックして、物体に BBox を囲むと、新たなアノテーションが追加できます。

画面右下にある update を押すとアノテーション結果が更新されます。タスク一覧の画面で、そのタスクの一番右側にある </> を見ると、 "result" リストの中に、追加された分がちゃんと append されたことを確認できます。

画面右側のチェックリストも同様に、チェックを入れたら、その分のアノテーションが更新されます。

まとめ・NEXT

Label Studio で、もう既に一部アノテーション済みの BBox を表示しながら、その続きのアノテーションができるツールを作りました。

今回は自分用に作ったので、全ての設定はローカルからとしてますが、今後の展開を考えると、他人に共有するための Nginx の設定や、AWS などから直接データを持ってくる設定などもやりたいです。

*1:https://python-poetry.org/docs/#installing-with-pipx

*2:https://labelstud.io/blog/what-s-with-the-label-studio-opossums/

*3:https://cocodataset.org/#home

*4:WANG, Qiang, et al. Irs: A large naturalistic indoor robotics stereo dataset to train deep models for disparity and surface normal estimation. In: 2021 IEEE International Conference on Multimedia and Expo (ICME). IEEE, 2021. p. 1-6.

© Sansan, Inc.