アダコテック技術ブログ

アダコテックのエンジニアが発信する技術ブログです

Dash + PyInstaller で構築したプロトタイプを、React + Tauri に移行した話

はじめに

アダコテックのエンジニアリングマネージャーの谷口です。

新規でプロダクト開発をする際、プロトタイプで効果検証をした後、正式版をリリースする流れでロードマップを引くことが多いかと思います。

初期段階の開発では、開発速度を優先して技術選定を行うことが多い一方で、機能追加や運用を見据えた段階になると、開発体制や保守性の観点から構成の見直しが必要になることがあります。

特にAIプロダクトの場合、初期段階ではアルゴリズム開発ができるAIエンジニアが一人で担当するというケースとなりやすく、不慣れなGUIのロジックも含めて実装するということがあり得ます。その際に選定されるGUIの技術はPythonベースのものになりやすく、その後実装を引き継いだアプリケーションエンジニアがメンテナンスしづらい設計になりがちです。

本記事では、欠陥分類アプリケーション 「Shiwaketter」 を題材に、Dash + PyInstaller で構築したプロトタイプを、Tauri + React に移行した内容についてまとめます。

※本記事で掲載されているソースコードは、コードベースのイメージをしてもらうことに特化しており、そのまま動くことを保証していません。


【前提】Shiwaketter とは

アダコテックが提供しているShiwaketter は、検査工程で取得された画像を欠陥の種別ごとに分類するためのアプリケーションで、2025年1月にリリースされています。

お客様が検査工程の画像を欠陥ごとに分類したいケースとして、「検査機が異常と判定した画像を種別ごとに分類することで、重要な欠陥と重要ではない欠陥を切り分ける用途」と「欠陥種別ごとの発生傾向を把握することで、欠陥発生原因の特定や工程改善につなげる用途」があります。

アダコテックでは既存のAIプロダクトをクラウドで提供していましたが、製造業界では、現場の画像データをクラウドに出すことに抵抗感を感じるお客様も多く、ShiwaketterはローカルPCで動くWindowsアプリケーションで提供することになりました。


リプレイス前の初期構成について

プロトタイプでは、

  • AIエンジニア一人でも開発ができること
  • お客様のローカルPCで動作すること
  • 学習ロジックについては、処理速度の観点でRustを採用すること

を意識して、以下の構成にしていました。

  • UI:Dash(Python)
    • Plotlyが提供している、データ可視化のアプリケーションを簡易に構築することができるフレームワーク
    • https://dash.plotly.com/
  • 配布:PyInstaller
  • バックエンド:Rust
    • バックエンドフレームワークはAxum、ORMはSeaORMを採用
    • APIはGraphQLで実装
    • APIサーバーはPythonアプリケーション起動と同時にローカルで起動する
  • コア処理(学習/推論):Rust

システム概要図

2024年中旬から上記構成でプロトタイプを開発し、2025年1月に正式リリースをいたしました。


Dash の実装例

Dash のメリットは、PythonでUIと状態更新を完結できる点です。

例えば「スライダーの値を変更して」「表示を更新する」ような画面は以下のように書けます。

Dash:レイアウト部分の実装

from dash import Dash, html, dcc, Input, Output, callback

app = Dash(__name__)

app.layout = html.Div(
    style={"padding": "16px"},
    children=[
        html.H1("Shiwaketter (Prototype)"),
        html.Div(
            style={"marginBottom": "12px"},
            children=[
                html.Div("異常判定の閾値"),
                dcc.Slider(
                    id="threshold",
                    min=0.0,
                    max=1.0,
                    step=0.01,
                    value=0.5,
                    marks={
                        0.0: "0.0",
                        0.5: "0.5",
                        1.0: "1.0",
                    },
                ),
            ],
        ),
        html.Div(id="summary", style={"marginBottom": "12px"}),
        dcc.Graph(id="histogram"),
    ],
)

Dash:callback(状態変更)部分の実装

@callback(
    Output("histogram", "figure"),
    Output("summary", "children"),
    Input("threshold", "value"),
)
def update_histogram(threshold: float) -> tuple[go.Figure, str]:
    fig = build_histogram(threshold)

    # 閾値以上の件数を表示(実際はこの結果で画像リストを絞る想定)
    num_total = len(df)
    num_anomaly = int((df["anomaly_score"] >= threshold).sum())
    summary = f"閾値 {threshold:.2f} 以上: {num_anomaly} / {num_total} 件"

    return fig, summary

# プロトタイプなので仮データを生成(実際は推論結果などをロード)
np.random.seed(42)
df = pd.DataFrame(
    {
        # 0.0〜1.0 の異常度スコアを想定
        "anomaly_score": np.clip(np.random.normal(loc=0.35, scale=0.18, size=500), 0, 1)
    }
)

def build_histogram(threshold: float) -> go.Figure:
    # 閾値以上を「異常」とみなす例
    df["is_anomaly"] = df["anomaly_score"] >= threshold

    # Plotlyのヒストグラム
    fig = px.histogram(
        df,
        x="anomaly_score",
        nbins=30,
        title="異常度スコアの分布(ヒストグラム)",
    )

    # 閾値ラインを表示
    fig.add_vline(x=threshold)

    fig.update_layout(
        xaxis_title="異常度スコア(0.0〜1.0)",
        yaxis_title="件数",
    )
    return fig

Dash はこのように「callback関数を通じてデータ可視化のUIに関する画面を更新する」体験が非常に簡単で、プロトタイプ開発では強力です。

ただ、プロダクトとして作りこんでいくにあたって以下のような課題が発生しました。

callbackベースの状態管理が増えるほど複雑になりやすい

  • Dashは状態の変更を検知したら、別の状態またはUIを変更していくReactive Programmingを採用しています(※1)。
  • 状態設計とReactive Programmingで言うところのストリーム設計(※2)を正確に作り込めればよいのですが、実態としては状態変更した際のコールバック関数が芋づる式に呼ばれる、いわゆる「コールバック地獄」に陥っていました。
    • 以下は実際のプロダクトのコードの一部です。
    @callback(
      Output(f"{self.page_tag}_distribution_delete_modal", "is_open"),
      Input(f"{self.page_tag}_distribution_delete_btn", "n_clicks"),
      Input(f"{self.page_tag}_distribution_delete_modal_cancel", "n_clicks"),
      Input(f"{self.page_tag}_distribution_delete_modal_approve", "n_clicks"),
      Input(self.ids["discriminant_map"], "selectedData"),
      prevent_initial_call=True,
    )
    @CACHE.memoize()
    def toggle_delete_modal(
      n_clicks: int,
      modal_cancel_clicks: int,
      modal_delete_clicks: int,
      selected_rows: list,
    ) -> bool:
    
    • 「コールバック地獄」の課題として以下の点が挙げられます。
      • Inputとして与えられている変数はどこで変更されているか一目で分からない
      • Outputとして変更された変数がまた別のコールバック関数を呼び出している可能性があり、影響範囲を考慮して修正しなくてはいけない

UIの作り込みをしたい場合には結局React知識が必要になる

  • カスタマイズの要件を満たそうとすると、結局Reactで実装する必要があり、Dashの「AIエンジニア一人でも(Pythonで)開発ができること」というメリットが薄れてしまいます。
  • 以下は一覧表示をする際に使用する画像のサムネイル表示用のコンポーネントになります。
var dagcomponentfuncs = window.dashAgGridComponentFunctions = window.dashAgGridComponentFunctions || {};

dagcomponentfuncs.ImgThumbnail = function (props) {
    const { setData, data } = props;

    function onClick() {
        setData(props.value);
    }

    return React.createElement(
        'div',
        {
            style: {
                width: '100%',
                height: '100%',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
            },
        },
        React.createElement(
            'img',
            {
                onClick,
                style: { width: 'auto', height: '100%', objectFit: 'contain' },
                src: props.value,
            },
        )
    );
};
  • サムネイルをクリックした際に、Dashの状態にクリックした画像のURLを伝えるための処理が含まれています。

          function onClick() {
              setData(props.value);
          }
    
    • このような簡易的な機能の追加であってもカスタムのコンポーネントを用意する必要があります。
  • さらに、既存のUIから大きくレイアウトを変更したい場合にも、Reactのコードを使ってカスタマイズする必要があります。

当初Dashの構成のままリリースはしたものの、上記理由からプロダクトとしてのメンテナンスを継続することはできないという判断になり、プロダクト全体の機能が少ない2025年8月の段階で技術基盤のリプレイスの意思決定をすることになりました。


リプレイス計画: Windowsアプリケーションの技術選定

移行にあたっての今回のプロジェクトの条件として以下の2点を定義しました。

  • React を使ってネイティブアプリを構築できること
    • アダコテックのフロントエンドエンジニアが得意なのがReactであるため
  • Rust のバックエンドロジックをそのまま転用できること
    • フロントエンドに比べて、バックエンドの設計には保守性に関する課題はなかったため

Windowsアプリケーションを開発できる技術選定の候補としては 弊社でも実績があるElectronやC# も上がりましたが、上記の要件に当てはまるTauri を採用しました。

Tauriとは

TauriはRust製のクロスプラットフォームアプリケーションで、Windows / Linux / macOS / iOS / Android全てのアプリケーションを一つのコードで開発することができます。

https://v2.tauri.app/ja/

フロントエンドはViteを使って開発ができるため、React、Vue、Svelteなど、お好きなフレームワークを採用することができます。

移行方針:概要

以下の図のように、移行対象は色付けされた箇所とし、詳細ロジック及びDBに影響が及ばないようにしました。

変更対象

  • Windowsアプリケーションフレームワーク
    • PyInstaller→Tauri
  • UIロジック
    • Dash→React
  • UIとバックエンドの疎通インターフェース
    • 内部APIサーバーを使ったGraphQL→Tauriが提供しているIPC


移行方針:フロントエンド(React)

Dash は内部的に React を利用しているため(※3)、UI要件自体は React でそのまま再構築できると仮説を立て、具体的な技術調査を行いました。

ReactのPlotly.jsライブラリである、react-plotly.jsは最終コミットが2025年1月と定期的なメンテナンスがされていなかったため、plotly.jsを独自のReactコンポーネントとしてラップする方針にしました。

plotly.jsを使った独自のReactラッパーコンポーネント

import type { PlotlyAcceptanceData } from '@/types/custom-plotly';
import Plotly from 'plotly.js-dist-min';
import {
  forwardRef,
  memo,
  useEffect,
  useImperativeHandle,
  useRef,
} from 'react';
import {
  usePlotlyEvents,
  usePlotlyRenderer,
  usePlotlyResize,
  useModebar,
} from '../hooks';

export const MyPlotly = memo(
  forwardRef<HTMLDivElement, PlotlyAcceptanceData>(
    (
      {
        data,
        layout = {},
        options = {},
        onModebarRestore,
        onClick,
        onHover,
        onUnhover,
        onLegendClick,
      },
      ref,
    ) => {
      // 内部でrefを管理(外部refがない場合のフォールバック)
      const plotlyRef = useRef<HTMLDivElement>(null);
      useImperativeHandle(ref, () => plotlyRef.current as HTMLDivElement, []);

      // レンダリング用フック
      const { renderPlotly, setRestoreActiveStateCallback, plotlyGd } =
        usePlotlyRenderer(plotlyRef);

      // モードバー管理用フック
      const { activeMode, applyActiveClassToButton, restoreActiveState } =
        useModebar();

      // イベント管理用フック
      usePlotlyEvents(plotlyGd, { onClick, onHover, onUnhover, onLegendClick });

      // リサイズ管理用フック(plotDivが存在する場合のみ)
      usePlotlyResize(
        plotlyRef.current as unknown as Plotly.PlotlyHTMLElement,
        { data, layout, options, renderPlotly },
      );

      // プロット作成
      useEffect(() => {
        if (!plotlyRef.current) return;
        renderPlotly(data, layout, options);
      }, [data, layout, options, renderPlotly]);

      // active状態の復元コールバックを設定
      useEffect(() => {
        if (onModebarRestore) {
          setRestoreActiveStateCallback(onModebarRestore);
        } else if (options.displayModeBar !== false) {
          setRestoreActiveStateCallback(restoreActiveState);
        } else {
          setRestoreActiveStateCallback(null);
        }
      }, [
        onModebarRestore,
        options.displayModeBar,
        setRestoreActiveStateCallback,
        restoreActiveState,
      ]);

      // レンダリング後にactive状態を適用
      useEffect(() => {
        if (plotlyGd && activeMode !== 'none') {
          const modebar = plotlyGd.querySelector('.modebar');
          if (modebar) {
            applyActiveClassToButton(activeMode, modebar);
          }
        }
      }, [plotlyGd, activeMode, applyActiveClassToButton]);

      return (
        <div
          ref={plotlyRef}
          className="aspect-[457/572] md:aspect-square lg:aspect-[572/457] w-full h-full overflow-hidden"
        />
      );
    },
  ),
);

MyPlotly.displayName = 'MyPlotly';

export default MyPlotly;

React:UI実装例

import React, { useMemo, useState } from "react";
import MyPlotly from '@/components/plotly';

export function AnomalyHistogram() {
  const [threshold, setThreshold]  = // 省略

  const rows  = // 省略
  const scores  = // 省略

  const { total, numAnomaly }  = // 省略

  const data = // 省略
  const layout = // 省略

  return (
    <div style={{ padding: 16 }}>
      <h1>Shiwaketter</h1>

      <div style={{ marginBottom: 12 }}>
        <div style={{ marginBottom: 6 }}>異常判定の閾値</div>
        <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
          <input
            type="range"
            min={0}
            max={1}
            step={0.01}
            value={threshold}
            onChange={(e) => setThreshold(Number(e.target.value))}
            style={{ width: 320 }}
          />
          <div style={{ width: 64 }}>{threshold.toFixed(2)}</div>
        </div>
      </div>

      <div style={{ marginBottom: 12 }}>
        閾値 {threshold.toFixed(2)} 以上: {numAnomaly} / {total}</div>

      <MyPlotly
        data={data}
        layout={layout}
      />
    </div>
  );
}

移行方針:バックエンド(Axum → tauri::command)

リプレイス前のDash版では Rust の Axum API を GraphQL で叩く形でしたが、Tauri では IPC 経由で Rust 関数を直接呼ぶことができます。IPC経由で呼ぶ関数はtauri::commandという属性を付与して実装します。

#[tauri::command]
pub async fn some_command() -> std::result::Result<(), String> {
}

置き換えの方針としては、GraphQLのQueryとMutationのリゾルバ関数を、tauri::commandとして定義し直すという進め方を取りました。

以下は「プロジェクトを取得する」「プロジェクトを作成する」という処理をGraphQLからtauriのコマンドに置き換えた例を提示しています。

Axum+GraphQL例

#[derive(Default)]
pub struct ProjectQuery;

#[Object]
impl ProjectQuery {
    // プロジェクトを取得するリゾルバ関数
    pub async fn read_projects(&self, company_id: i32) -> Result<Vec<Project>> {
        let projects = ProjectRepository::read_by_company_id(&company_id).await?;
        let projects = projects
            .into_iter()
            .map(|project| Project { model: project })
            .collect();
        Ok(projects)
    }
}

#[derive(Default)]
pub struct ProjectMutation;

#[Object]
impl ProjectMutation {
    // プロジェクトを作成するリゾルバ関数
    pub async fn create_project(
        &self,
        company_id: i32,
        name: String,
        description: String,
    ) -> Result<i32> {
        let project_id =
            ProjectRepository::create(&company_id, &name, &description).await?;
        Ok(project_id)
    }
}

#[derive(MergedObject, Default)]
pub struct QueryRoot(
    projects::ProjectQuery,
);

#[derive(MergedObject, Default)]
pub struct MutationRoot(
    projects::ProjectMutation,
);
pub async fn serve_api(port: u16) {
    let app = create_app().await;

    let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port))
        .await
        .unwrap();

    axum::serve(listener, app.into_make_service()).await.unwrap();
}

pub async fn create_app() -> Router {
    let schema = Schema::build(QueryRoot::default(), MutationRoot::default())
        .finish();

    Router::new()
        .route("/", get(graphiql).post_service(GraphQL::new(schema)))
}

Tauri:command 例

// プロジェクトを取得するTauriコマンド
#[tauri::command]
#[specta::specta]
pub async fn read_projects() -> std::result::Result<Vec<Project>, String> {
    let projects = ProjectRepository::read_by_company_id(&COMPANY_ID)
        .await
        .map_err(|e| e.to_string())?;
    let projects = projects
        .into_iter()
        .map(|project| Project { model: project })
        .collect();
    Ok(projects)
}

// プロジェクトを作成するTauriコマンド
#[tauri::command]
#[specta::specta]
pub async fn create_project(
    name: String,
    description: String,
) -> std::result::Result<i32, String> {
    let project_id = ProjectRepository::create(&COMPANY_ID, &name, &description)
        .await
        .map_err(|e| e.to_string())?;
    Ok(project_id)
}

Tauri の起動側(main.rs)

mod commands;

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            commands::read_projects,
            commands::create_project
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

read_projectsとcreate_project内のロジックにほとんど差異がないことが分かるかと思います。

エンドポイントとしてのインターフェースがGraphQLからIPCに変わっただけなので、このような移行方針を取ることができました。

リゾルバ関数内のロジックはシンプルになっており、詳細のロジックはRepsitory層などの別関数に切り出していることも、移行を容易にした要因となっています。


tauri-specta による型安全な IPC

REST APIの場合にはOpenAPI、GraphQLの場合はGraphQL Code Generatorを使って型定義するように、tauri::commandの場合には、tauri-spectaを使って関数の型定義ファイルを出力することができます。

https://github.com/specta-rs/tauri-specta

Rust側:tauri-specta で型付け

use shiwaketter_lib::create_builder_for_export;
use specta_typescript::Typescript;
use tauri_specta::Builder;

pub fn export_typescript_bindings() {
        let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
             // #[specta::specta]属性を持つ関数を設定
         commands::read_projects,
         commands::create_project
        ];
    builder
        .export(
            Typescript::default().bigint(specta_typescript::BigIntExportBehavior::BigInt),
              // 指定したパスにTypeScriptのコードを出力
            "../src/bindings.ts",
        )
        .expect("Failed to export typescript bindings");
}

TypeScript側:生成された型を使う

(生成された型ファイルの例)

export const commands = {
  async readProjects(): Promise<Result<Project[], string>> {
    try {
      return { status: 'ok', data: await TAURI_INVOKE('read_projects') };
    } catch (e) {
      if (e instanceof Error) throw e;
      else return { status: 'error', error: e as any };
    }
  },
  async createProject(
    name: string,
    description: string,
  ): Promise<Result<number, string>> {
    try {
      return {
        status: 'ok',
        data: await TAURI_INVOKE('create_project', { name, description }),
      };
    } catch (e) {
      if (e instanceof Error) throw e;
      else return { status: 'error', error: e as any };
    }
  },
}  

このように、型を中央集権的に Rust 側で管理できるため、フロントとバックエンドの整合性が取りやすくなります。

リプレイス前ではDash上でPythonを採用していたこともあり、型安全性を担保するのが難しい側面がありましたが、TypeScript+Rustの構成であれば、インターフェース部分の型定義を明確にしやすいというメリットを享受することができました。


プロジェクト成果:2ヶ月で移行完了

このリプレイスプロジェクトの移行作業は、いくつかUI面での改善タスクも含めて、2ヶ月で完了しました。

※【参考】リプレイス前コード行数

  • フロントエンド→9219行
  • バックエンド→10342行

移行を進めやすかった要因として、プロトタイプのころからUIロジックとドメインロジックを分割して実装していたことが挙げられます。
もしUI層にあたるPythonのロジックにドメインロジックが埋め込まれていた場合、フロントエンド刷新に伴ってビジネスロジックの再設計や言語移植が必要になり、移行作業は「作り直し」に近いものになっていたはずです。
特に学習周りの重い処理が Rust 側に集約されていたことで、フロントエンドを置き換えてもコア機能を維持しやすい状態になっていました。


所感

AIプロダクトのプロトタイプ段階においては、DashなどAIエンジニアが簡単にUIを構築できるPythonベースのフレームワークを採用することが最良の選択肢となりえる一方で、正式版に向けて UI の保守性や開発体制を考慮すると、どこかのタイミングでリプレイスを検討する必要があります。

今回のプロジェクト成功要因としては、

  • リプレイスの対象をフロントエンドに絞り込み、バックエンド側の技術資産を転用できる技術選定を行ったこと
  • プロトタイプ時点で、フロントエンドとバックエンドのロジックの切り分けを明確にしており、リプレイス対象の絞り込みができたこと

が挙げられます。

今回のプロトタイプから正式版への技術基盤の移行計画はあくまで一例ではありますが、同じような課題を抱えている方の参考になれば幸いです。


※1 https://dash.plotly.com/basic-callbacks This is called "Reactive Programming" because the outputs react to changes in the inputs automatically.

※2 https://gist.github.com/staltz/868e7e9bc2a7b8c1f754#reactive-programming-is-programming-with-asynchronous-data-streams

※3 https://dash.plotly.com/faqs Dash uses React to render your app on the client browser.