PSD・PSBのテキストを抽出してAI翻訳につなげてみた
Webtoon翻訳で扱いづらかったPSD・PSB内のテキストをCSV化する
概要
実務で翻訳をしていると、ある程度正規化・構造化されたテキストを扱うことが多いと思います。
ExcelやCSV、CATツール上のSegmentなど、少なくとも「どこからどこまでが1つの翻訳単位なのか」が明確になっているデータです。
こうした形式であれば、
- Translation Memoryを参照する
- 用語集を適用する
- AIで下訳を作る
- 過去の翻訳データを検索する
- 翻訳者がレビューする
といったワークフローを比較的組みやすくなります。
一方、私が携わっていたWebtoon翻訳では、納品方法そのものが少し特殊でした。
大まかな流れは次のようなものです。
PSD・PSBファイルを受領
↓
Photoshopで彩度・明度を調整
↓
JPEGで保存
↓
JPEGを確認しながら翻訳
↓
画像上に新しいテキストレイヤーとして訳文を入力
↓
PSDで納品
つまり、翻訳対象の原文がExcelやCSVとして渡されるのではなく、Photoshopファイルのテキストレイヤーの中に入っている状態です。
もちろん、Excelで原文を受け取る案件もありますが、私が長く担当している案件では、この形式が継続して使われていました。
この形式では、他の翻訳業務で使っていたAIによる下訳や、既存翻訳データの再利用をそのまま持ち込むことができませんでした。
そこで、
PSD・PSBからテキストと座標情報を抽出し、一度CSVへ変換してからAI翻訳につなげる
というワークフローを作りました。
このツールは、Photoshopの中に閉じ込められていたテキストを、AIや既存の翻訳ツールで扱える翻訳データへ変換することを目的としています。
Webtoon翻訳では、そもそもデータの形が違った
一般的な翻訳案件では、原文がある程度構造化された状態で支給されることが多くあります。
例えば、
ID | 原文 | 訳文
001 | こんにちは |
002 | また明日 |
のようなデータであれば、そのままAIへ渡せます。
ところがWebtoonでは、原文はPSD・PSBの内部にありました。
しかも実際の納品フローは、
原本PSD・PSB
↓
画像調整
↓
JPEG
↓
その画像を見ながら翻訳
↓
新しいテキストレイヤーとして訳文を配置
という形式でした。
画像コンテンツを翻訳する方法としては自然なのですが、翻訳データを再利用するという観点では不便があります。
例えば、
- 原文を一覧で確認できない
- 過去の訳文と簡単に照合できない
- AIへ渡せる形にまとめにくい
- 翻訳済みデータを検索しにくい
- どの原文がどの画像に属していたのか管理しにくい
といった問題です。
そのため、まず考えたのが、
Photoshopの中にある原文を、通常の翻訳データと同じように扱える形へ変換できないか?
ということでした。
他の翻訳業務にはAIを入れられたのに、Webtoonでは難しかった
他の案件では、AIによる下訳を比較的簡単に取り入れることができました。
例えば原文がExcelで整理されていれば、
原文
↓
AIによる下訳
↓
翻訳者が修正
↓
完成
という流れを作れます。
ところがPhotoshopの場合、AIを使う前に、
Photoshopを開く
↓
テキストレイヤーを探す
↓
原文をコピー
↓
AIへ入力
↓
訳文をコピー
↓
Photoshopに戻る
↓
訳文を入力
という作業が発生します。
1文だけなら問題ありません。
しかし、数十・数百のテキストレイヤーを扱う場合、AIで翻訳する時間よりも、その前後の操作のほうが負担になってきます。
つまり、AIを使うための入出力コストが高すぎるという状態でした。
さらに、これまで翻訳してきた作品のデータもPhotoshopファイルの中に残っていたため、過去訳を検索したり、AIの参考データとして使ったりすることも簡単ではありませんでした。
そこで、
Photoshop
↓
翻訳可能なデータへ変換
↓
AI / 検索 / 再利用
という中間層を作ることにしました。
これは「最新のAI翻訳ツール」を作った話ではない
先に前提を書いておくと、このワークフローのMVPは最近作ったものではありません。
コードを見て気づく方もいるかもしれませんが、初期版では翻訳モデルとしてgpt-4o-miniを使っています。
このMVP自体は約2年前には一通り形になっており、その後、実際のWebtoon翻訳業務で使い続けてきました。
そのため、この記事では最新のモデルや最新のAIエージェント構成を紹介するというより、
非定型なWebtoon翻訳データを、どうやってAIが扱える形へ持っていったか
という部分を中心に書いています。
また、私は個人で翻訳業務をしており、このツールも自分の作業を効率化するために作ったものです。
そのため、
- 同時アクセス
- チーム単位の権限管理
- 高可用性
- 障害対応保証
- Enterprise規模や高費用のAPI契約
- 大量トラフィックを前提としたコスト最適化
といった構成は最初から想定していません。
あくまで、個人翻訳者が自分のPC上で使う、小規模な業務改善ツールという前提です。
この点は、後述するAPIの呼び出し方やチェックポイントの実装にもかなり影響しています。
今回作ったワークフロー
初期版のGUIはTkinterで作成し、次の3つの処理に分けています。
1) PSD → CSV 抽出
2) CSV → RAG翻訳
3) JSX生成
全体の流れは次のようになります。
ここから、
- PSD・PSBから原文を抽出する
- 必要に応じてRAGを使いながらAIで下訳する
- JSXを使ってPhotoshopへ戻す
という順番で見ていきます。
1. PSD・PSBから原文を取り出す
PSDの読み込みにはpsd-toolsを使用しています。
最初に行うのが、テキストレイヤーから文字列を取得する処理です。
def get_text_from_layer(layer):
...
まずtext_dataからテキストを取得し、取得できない場合にはengine_dict側も確認します。
取得した文章については改行コードなどを簡単に整理します。
翻訳作業として考えると、
Photoshopでレイヤーを1つずつ選択して原文をコピーしていた処理
をまとめて行う部分です。
原文だけではなく位置も保存する
原文だけをCSVに出してしまうと、翻訳後に
この文章は、どの画像のどこにあったのか?
が分からなくなります。
そのため、extract_text_layers()ではテキストと同時にbboxも取得します。
def extract_text_layers(
psd,
results,
work,
psd_file,
root_w,
root_h,
...
):
...
保存する主なデータは次のとおりです。
work
psd_file
order_in_file
text_src
trans_text
bbox_x1
bbox_y1
bbox_x2
bbox_y2
rel_x1
rel_y1
rel_x2
rel_y2
canvas_w
canvas_h
つまり、1つの文章を、
原文
+
元ファイル
+
ファイル内での順番
+
元の位置
という形で保持します。
相対座標も保存する
元のPSBと、実際に翻訳レイヤーを入れるJPEGでは画像サイズが異なる場合があります。
そこで絶対座標だけではなく、
rel_x1 = bbox_x1 / canvas_width
rel_y1 = bbox_y1 / canvas_height
という相対位置も保存します。
例えば、
元画像の幅:1500px
テキストのX座標:300px
なら、
300 / 1500 = 0.2
となります。
その後、幅1000pxのJPEGに配置するなら、
1000 × 0.2 = 200px
として、おおよそ同じ位置へ戻せます。
フォルダ単位でまとめて抽出する
実際の案件では複数ファイルを扱うため、1ファイルずつ指定するのではなくフォルダ単位で処理します。
def run_extraction(...):
...
指定したフォルダから.psdと.psbを検索し、順番に処理します。
GUIでは、
- サブフォルダを含めるか
- Smart Objectをスキップするか
- CSVをどこへ保存するか
- 完了後にCSVを開くか
などを設定できます。
抽出結果は最終的にUTF-8 BOM付きCSVとして保存します。
この抽出処理は、過去翻訳データの回収にも使えた
この処理を作ったことで、もう1つできるようになったことがあります。
これまで自分が作業してきたPSD・PSBから、テキストデータをまとめて回収できるようになったことです。
これまでは過去の翻訳がPhotoshopファイルの中に残っているだけで、検索可能な翻訳資産として扱いにくい状態でした。
テキスト抽出部分を別途利用することで、
過去のPSD・PSB
↓
テキスト抽出
↓
[別ワークフロー]
原文と既存訳を対応付け
↓
正規化・除外処理
↓
RAG用データ
という形で、過去データを再利用できるようになりました。
なお、原文と既存訳の対応付けや、RAG用データの整形・登録は、このGUIとは別の補助ワークフローで行っています。
抽出したデータをそのまますべてRAGへ入れているわけでもありません。
実際には、
- 一定文字数以下の極端に短い文章
- データとして利用しにくい特殊なケース
- ノイズになりやすい文章
などを除外し、表記や改行などをある程度正規化したうえで検索用データとして利用しています。
このデータ整備側のワークフローについては今回の本題から外れるため、この記事では詳しく扱いません。
2. CSVをAIで翻訳する
抽出したCSVは、OpenAI APIを使って韓国語から日本語へ翻訳します。
初期版の処理はかなり単純です。
CSVを上から1行ずつ処理します。
for i in range(total):
text = df.loc[i, "text_src"]
tr = translate_with_rag(
text,
client,
use_rag,
top_k,
min_len_for_rag
)
df.loc[i, "trans_text"] = tr
モデルにはgpt-4o-miniを使用しています。
この初期版ではモデルや翻訳方向を自由に切り替える機能はなく、韓日Webtoon翻訳を前提にしています。
RAGは「自動翻訳を賢くする」ためだけではない
ChromaDBを利用した簡単なRAGも組み込んでいます。
def search_rag_context(text, client, collection, top_k=5):
...
原文をtext-embedding-3-smallでEmbeddingし、事前に用意したChromaDBから似た過去翻訳を検索します。
現在の原文
↓
Embedding
↓
ChromaDB
↓
類似した過去翻訳
翻訳時には、その結果を参考文としてAIへ渡します。
また、RAG用に作成したChromaDBは翻訳処理だけではなく、過去訳を確認したいときの検索元としても利用しています。
例えばWebtoonでは、
- 回想シーンで以前と同じセリフが出てくる
- 過去に登場した表現をもう一度確認したい
- 以前このキャラクターが似た発言をどう訳していたか見たい
といったケースがあります。
そのようなとき、
「この文章、以前どこかで翻訳した気がする」
という状態から過去ファイルを1つずつ開くのではなく、類似文を検索して確認できます。
ただし、この検索用途もこのGUI内に独立した検索画面があるわけではなく、同じChromaDBを別の検索処理から利用しています。
短すぎる文章にはRAGを使わない
Webtoonでは、
응
뭐?
아
...
のような短い文章も多くあります。
こうした短文では、似た文章が大量にヒットしても有効な参考情報にならない場合があります。
そのためRAG用データを作る段階でも短すぎる文章などを除外し、翻訳処理側でも、
use_ctx = use_rag and (len(clean_text) > min_len_for_rag)
という条件を入れています。
指定した長さ以下であればRAG検索を行いません。
GUIからMinLenを変更できます。
RAGはTranslation Memoryではない
このRAGは、Translation MemoryやTerm Baseのように、
「この訳を必ず使用する」
ためのものではありません。
例えば、
今回の原文
그 녀석을 절대로 놓치지 마.
過去の類似翻訳
あいつを絶対に逃がすな。
のような結果が取得された場合、その文章を参考情報としてAIへ渡します。
つまり、
現在の原文
+
過去の類似翻訳
↓
AI
という構造です。
過去訳が現在の場面にも適しているとは限らないため、あくまで参考情報として扱っています。
また、この初期ツール自体にはRAGデータベースを構築する機能はありません。
ChromaDBへのデータ投入や前処理については、先ほど触れた別ワークフローで行っています。
翻訳プロンプト
RAGを利用する場合は、おおむね次のような情報をAIへ渡しています。
韓国語Webtoonのセリフを自然な日本語へ翻訳する
- キャラクターの話し方・感情を維持
- 自然な口語表現
- 擬声語・擬態語を日本語へ置き換える
- 文化的な文脈を考慮
原文
+
RAGで取得した参考文
出力形式はJSONにしています。
{
"translation": "翻訳された日本語"
}
取得したtranslationをCSVのtrans_textへ保存します。
APIエラー時は再試行する
API呼び出しに失敗した場合は、最大3回まで再試行します。
単純化すると、
API呼び出し
↓
失敗
↓
待機して再試行
↓
最大3回
という処理です。
それでも正常な翻訳結果を取得できなかった場合は、空文字や最後に取得した応答が残る場合があります。
かなり簡単なエラー処理ですが、一時的なAPIエラーですぐに全体を止めないためのものです。
チェックポイントを保存する
文章数が多いと、途中でアプリを閉じたり処理をキャンセルしたりする場合もあります。
そこで一定件数ごとに、
xxxx_checkpoint.csv
を保存します。
再度実行したときにチェックポイントが存在すれば、そのCSVを読み込み、すでにtrans_textが入っている行をスキップします。
ただし、この初期版では、
現在の入力CSVや翻訳設定と、そのチェックポイントが本当に同じ作業のものかを厳密には検証していません。
ファイルが存在すればそのまま読み込む、かなり単純な仕組みです。
このあたりも、後から改善した部分の1つです。
……「Batch」なのにバッチ翻訳ではなかった
GUIにはBatchという設定があります。
例えば、
Batch = 10
としていたため、最初は自分でも「10件ずつAPIに送っている」ようなつもりで使っていました。
ところが実装してしばらくしてから、
これ、10件ずつAPIに送っているわけではないな……
と気づきました。
実際には1文ずつAPIを呼び出していて、Batchは、
10行ごとにチェックポイントCSVを保存する
という用途でしか使っていませんでした。
本来であれば、複数文をまとめて送るバッチ翻訳に修正したほうが効率的です。
ただ、Webtoon1話分を翻訳する程度であれば、1件ずつAPIを呼び出したところで「待っていられないほど遅い」というわけではありませんでした。
コスト面でも当時の利用規模ではかなり小さく、5ドル分のAPI利用料で数か月使える程度でした。
そのため、
まあ、そのうち直そう。
と思いながら、そのまま後回しになっていました。
……こういう「一応動いているし、いいか」って部分は、個人開発あるあるだと思います。
結果的には、この素朴な実装のまま約2年間使い続けることになりました。
この点については、次回の記事で改善版と比較しながら改めて触れる予定です。
3. Photoshopへ戻すためのJSXを生成する
翻訳が完了したら、翻訳済みCSVをPhotoshopへ戻します。
ここではPhotoshop用のJSXを利用しています。
JSX内には、
CSV_PATH
IMG_DIR
LOG_PATH
FONT_SIZE
LINE_HEIGHT
FONT_COLOR
などの設定があります。
毎回スクリプトを直接編集するのは面倒なので、GUIから設定値を入力し、その部分だけを書き換えたJSXを生成します。
def patch_jsx_config(...):
...
GUIから変更できるのは、
- 翻訳CSV
- JPEGフォルダ
- ログ出力先
- フォントサイズ
- 行間
- RGB文字色
TEST_MODE- 出力ファイル名のsuffix
などです。
元のPSBへ直接書き戻すわけではない
この初期版では、元のPSD・PSBのテキストレイヤーを書き換えているわけではありません。
先ほど説明した従来の納品フローと同じく、別途用意したJPEGを使用します。
元PSD・PSB
↓
彩度・明度調整
↓
JPEG
JSXではこのJPEGをPhotoshopで開きます。
そこへ翻訳済みCSVの情報を使い、新しいテキストレイヤーを追加します。
ファイル名を照合する
CSVには元のPSD・PSBファイル名が保存されています。
一方、Photoshopで開くのはJPEGです。
そこで両者の名前を正規化して対応付けます。
function normalizeName(name) {
...
}
例えば、
- 拡張子を削除
- 大文字・小文字を統一
- ハイフンをアンダースコアへ変更
- 空白を削除
- URLエンコードされた名前をデコード
といった処理を行います。
相対座標から配置位置を復元する
対応するCSV行を取得したら、先ほど保存した相対座標を使います。
var x = rx1 * docW;
var y = ry1 * docH;
つまり、
PSD上で取得した相対位置
×
現在開いているJPEGのサイズ
↓
訳文を置く位置
という考え方です。
新しいテキストレイヤーを生成する
実際にPhotoshop上へ翻訳文を配置する処理が、
function createTextLayerFixed(...) {
...
}
です。
CSVのtrans_textを取得し、翻訳文が空の場合にはtext_srcへフォールバックします。
その後、
- 座標
- テキスト
- フォント
- フォントサイズ
- 行間
- 文字色
を指定した新しいテキストレイヤーを生成します。
処理後は、テキストレイヤーを保持したPSDとして保存します。
結果としてどう変わったか
従来は、
PSB
↓
JPEG
↓
画像を見ながら手作業で翻訳
という流れでした。
これを、
PSD・PSB
↓
原文 + 座標を抽出
↓
CSV
↓
AI下訳 + 必要に応じてRAG
↓
翻訳済みCSV
↓
JSX
↓
JPEG上に翻訳レイヤーを生成
↓
PSD
という形に変えました。
さらに、テキスト抽出機能を別用途でも利用することで、
過去のPSD・PSB
↓
テキストデータを回収
↓
[別ワークフロー]
原文・訳文を対応付け
↓
整理・正規化
↓
検索可能な過去翻訳データ
という形で、これまでPhotoshopの中に埋もれていた翻訳資産も再利用できるようになりました。
ちなみに、開発経験はほとんどありません
コードの話を長々と書いていますが、私自身は開発を専門にしているわけではありません。
プログラミングについても専門的に学んできたわけではなく、今回のツールも、
実際の翻訳業務で感じていた不便を起点に、生成AIに実装を相談しながらコードを書いていく、いわゆる「バイブコーディング」とネット検索を中心に組み立てたものです。
基本的には、
この作業、自動化できないだろうか?
↓
検索する
↓
使えそうなライブラリを探す
↓
AIに実装案やコードを相談する
↓
実際のPSD・PSBで試す
↓
エラーが出る
↓
また検索する
↓
修正する
という繰り返しでした。
最初からアプリ全体の設計をきれいに決めてから実装したわけではありません。
むしろ、
「まずテキストを取り出したい」
から始まり、
「せっかくCSVにしたならAIへ送れるのでは?」
「座標も取れればPhotoshopへ戻せるのでは?」
「過去のPSDからもデータを取り出せるのでは?」
と、実際に使いながら少しずつ機能が増えていきました。
当然、この初期版にも後から見ると非効率な部分や設計の粗い部分がかなりあります。
先ほどの「Batchなのにバッチ翻訳ではなかった」というのも、その1つです。
ただ、自分が日常的に行っていた業務の問題を具体的に理解していたことで、専門的な開発経験がほとんどなくても、
- 既存ライブラリ
- API
- 検索
- 生成AI
を組み合わせて、実際の仕事で使えるところまで持っていくことはできました。
個人的には、コードそのものよりも、
「翻訳作業のどこを自動化すると、本当に翻訳者の負担が減るのか」
を考えることのほうが、このツールでは重要だったように思います。
作ってみて感じたこと
最初は、
PSDからテキストを取り出してAIへ投げれば終わる
程度に考えていました。
しかし実際に作ってみると、AI翻訳そのものより、
どのファイルの文章なのか
どこに配置されていたのか
どのJPEGに対応するのか
過去の翻訳をどう再利用するのか
途中で止まった場合どうするのか
翻訳後にどこへ戻すのか
といった前後の処理のほうが重要でした。
特に、テキストを一度Photoshopの外へ出したことで、
これまで「画像制作データの一部」だった文章を、「翻訳データ」として扱えるようになった
ことは大きかったと思います。
この初期版の制限
画像化された効果音は抽出できない
この初期版では、PSD・PSB上のテキストレイヤーを抽出対象としています。
そのため、
- ラスタライズされた文字
- 作画へ直接描き込まれた文字
- 画像として配置された文字
- 画像化された効果音
などは取得できません。
漫画やWebtoonでは効果音そのものがデザイン要素になっていることも多いため、この部分を扱うにはOCRや画像認識など別の処理が必要になります。
Smart Objectは限定的
この初期版ではSmart Objectをスキップできるようにしています。
Smart Object内部に存在するテキストを再帰的に取り出す処理までは実装していません。
元のレイアウトは引き継がない
この初期版ではJPEG上に新しいテキストレイヤーを作成します。
元PSBの、
- フォント
- 文字サイズ
- 字間
- 行間
- 段落設定
- 吹き出しに合わせた細かなレイアウト
をそのまま再現するものではありません。
最終的な調整は人間が行う必要があります。
AIモデルと翻訳方向は固定
この初期版ではOpenAIを使用し、モデルはgpt-4o-miniに固定しています。
また、韓国語から日本語へのWebtoon翻訳を前提としています。
汎用翻訳アプリというより、自分の実務上の問題を解決するために作ったプロトタイプです。
RAGも単純な文章検索
この初期版のRAGは、文章単位の類似検索です。
- 前後のセリフ
- 話者
- シーン
- キャラクター
- エピソード
などを考慮して検索しているわけではありません。
そのため、似た文章が見つかったからといって、その過去訳が現在の場面でも正しいとは限りません。
RAGはあくまで、
「過去に似た文章をどう訳していたか確認するための補助」
として使用しています。
チェックポイントも単純
前述のとおり、チェックポイントが現在の入力データや設定と一致しているかを厳密に検証する仕組みはありません。
約2年間使ううえでは大きな問題になりませんでしたが、汎用的なツールとして考えると改善が必要な部分です。
まとめ
今回の処理自体は、複雑な翻訳システムではありません。
中心となる考え方は、
Photoshopに入っている原文
↓
テキストデータとして取り出す
↓
AIや検索DBで扱う
↓
再びPhotoshopへ戻す
というものです。
一般的な翻訳業務では、最初から原文が構造化されているため、あまり意識する必要のない部分かもしれません。
しかしPSD・PSBのように、翻訳データとしてそのまま扱いにくい形式では、
AIを導入する前に、まず原文をAIが扱えるデータへ変換する必要がありました。
そして一度データとして取り出せるようになると、AIによる下訳だけではなく、過去翻訳の収集や検索にも利用できるようになりました。
今回の初期版では、まず
「WebtoonのPSD・PSBに入っている原文を翻訳データとして取り出し、AI翻訳とPhotoshopをつなぐ」
ところまでを実装しました。
開発経験がほとんどなくても、実際の業務上の課題を分解し、検索や生成AIを使いながら試行錯誤することで、少なくとも自分の作業を改善するためのツールまでは作ることができました。
一方で、約2年間使い続けると、
「とりあえず動く」
だけでは済まない部分もかなり見えてきました。
そのあたりは次回の記事(あったら)で、初期版からどのように作り直したかをまとめる予定です。