2026年3月29日 星期日

再戰manga-image-translator

去年學著使用Manga-Imgae-Translator
雖然已經玩到要改程式這步了,但玩完後就沒在使用了
主要還是沒有明確要使用的目標
就沒有什麼動力了
今年有了個目標,就重開要來好好用用
然後對程式的修改就越改越多了...........................................

與去年使用隔了一陣子,這專案應該有更新
在進行了比對目前跟去年的專案後發現
新舊版差異太大,放棄用更新的
畢竟現在多了個新功能MangaStudio之外
套件也整個不一樣了
requirments.txt裡paddle ocr也沒在用了
稍稍查了一下,有ocr字樣的套件多了個manga-ocr,大概是用這個吧
所以就把原本的專案連同虛擬環境砍了,重新下載現在的版本來安裝
那因為新版的沒有paddle ocr的cuda版本限制
安裝pytorch時,就不用理cuda版本,直接更新到比較新一點點的cuda126版了
也同步把這個訊息更新到去年寫的那一篇上面

在正式使用前,有先問過AI一輪,要怎麼設定會讓成果更好
畢竟在文字嵌入上它還是會出錯,想要到修圖就很煩
當然要儘量壓低出錯的機率
那AI也給了我不少建議
其中一項事後補救的建議讓我眼睛為之一亮
那就是將檔案匯出成有分圖層功能的PSD與XCF,方便後製再修改
看起來好像很棒,這功能一定要好好試試
不過還要是耐著性子,先試原本的基礎功能
重裝了現在版本後
發現很在意的文字嵌入功能又更準確了
舊版的準度如果是已達八成五的話,新的大概又拉高到九成五的感受
原本事前問好AI要如何微調的選項,似乎都派不上用途了
而失敗的部分,看起來都比較像OCR失敗所以嵌入才會失敗
字都已經抓錯了,那放回去就很難正常
所以稍稍測試後,就往匯出PSD與XCF版的方向前進,這樣產出會更完美
然後就碰壁卡一個下午了.........

算我沒學到教訓,又沒看說明書
一開始我只看AI建議就下指令要做PSD檔匯出
結果用下去出錯,訊息顯示要裝GIMP才能用
那就下載最新版GIMP 3.2版要來用
這步也是錯的,查下去看到程式碼與說明文件才發現
目前Manga-Image-Translator轉這兩個檔案的功能要靠舊的GIMP 2.X
然後我也不清楚是哪個2.X版,因為我下載了2.9裝上去後還是不行
弄了好一陣子沒成果,我就「狂起來了」(台語)
直接把rendering資料夾下原始碼gimp_render.py給丟上Gemini跟Claude
叫它們直接幫我改成可以支援目前GIMP最新版3.2的程式
幾個來來回回,因為Gemini的都只給我片段
所以弄到一半,就全靠Claude修改了,它會給完整的檔案,直接複製貼上省事 XD
很快地,PSD輸出弄好了
然後用GIMP打開後,發現不如我所願
圖層是都分開了,文字位置也還蠻準的
但PSD的文字圖層,還是圖,所以不能當純文字框直修改處理,要塗消後再輸入
那來轉XCF看看好了

先說結論,轉XCF後,就是我要的功能了
那個圖層是文字型態的圖層,可以直接改文字大小,排列方向,字型與內容
只是沒那麼順利,上面提到卡了一下午,差不多有一半的時間卡在這裡
因為這次AI純靠著錯誤log改不出正確版本,來來回回改了十幾次,同樣的錯誤一直出現
最後是我問AI從哪邊可以查API文件,AI跟我說,我再截圖提供後
才解決了轉3.2版XCF問題[註]
這樣弄出來後,用GIMP打開後,長這樣

這是用現在正紅的間諜家家酒連載這一頁當範例
可以看到文字都正常地在框中
文字圖層的名稱是翻譯前的原文
識別正確的話,就是每一個氣泡框一層


Mask的部分有另外一層


底稿就是原本的圖片
文字圖層就是像Office中的文字框功能一樣
點進去可以很方便地修改其內容


也可以移動,旋轉,放大或縮小框型

有沒有正確識別的文字要蓋掉,並增加嵌字的話
可以先在mask圖層增加塗白的部分

然後看是新增文字圖層還是直接修改舊圖層後就可以解決

太棒了!精修竟然是如此簡單!
只是...一張一張來還是太花時間了 Orz

所以弄了幾張後,還是受不了差不多每張都要修翻譯的狀況
就沒有精度更高的離線翻譯AI模型嗎?
幾個離線模型弄來弄去
最後還是SakuraLLM在專有名詞可以指定上比translategemma好用
只是翻漫畫的精度因為缺乏有脈絡的上下文與情境詞,時不時會偏很大
SakuraLLM的發行版一直停在1.0,這已經是2024年底的產物,要是有更新就好了...
這點在網友的指正下,才知道其實一直有在更新,只是都不是發行(Release)版
就上官網翻了目前比較新的14B-qwen3-v1.5的量化版來用
用這個,因為不是發行版,SakuraLauncherGUI也不支援
只能自行下載最新版的llama.cpp來配合運作
我使用的命令列如下:
因為顯卡只有6GB可用,所以我塞了24層的模型進VRAM,並把輸入的cache進行壓縮
(真想買新電腦,插張有16GB的顯卡)
然後把它寫成BAT批次檔,下次使用直接點就好
理論上使用V1.5版,翻譯器的sakura.py裡prompt的部分,其實要跟著配合修改
不過不更新,用V1.0的prompt,它也翻得出來就是,懶得改的可以不用動它
當然,有更動的話會更好
在實測下,V1.5用專用的prompt還是翻得更好一點
V1.5的prompt長這樣

不想改太大的話,就把v1.0的prompt替換掉就可以用了,像下面這樣

    _CHAT_SYSTEM_TEMPLATE_010 = (
        #'你是一个轻小说翻译模型,可以流畅通顺地以日本轻小说的风格将日文翻译成简体中文,并联系上下文正确使用人称代词,注意不要擅自添加原文中没有的代词,也不要擅自增加或减少换行。'
        '你是一个日本二次元领域的日语翻译模型,可以流畅通顺地以日本轻小说/漫画/Galgame的风格将日文翻译成简体中文,并联系上下文正确使用人称代词,不擅自添加原文中没有的代词。' #換成v1.5
    )
 

當然它的輸出都還是簡體中文,所以想轉繁體中文的話,還是要改個OpenCC來轉
請參考去年那篇要怎麼改

最後來講講新的MangaStudio功能
簡言之就是之前一直都沒有圖形化介面功能

但目前不是很好用
主要是那個佇列操作很不直觀
要先將右側的所有參數設定完成後

然後將匯入的目錄進行排入佇列,這樣才有導入該有的設定

而要排入佇列後,才能按下開始處理,步驟有點繁瑣
不過測試起來最大的問題,還是特定情況下一次就只能出5張圖
看log上是蠻正常處置,因為記憶體不足,所以分批處理,一次5張
只是5張圖產完後,程式就停了...
疑惑之際,換另一組資料夾圖片就又不會了,這下更疑惑了,特定情況的條件是什麼?
反正這次為了GIMP 3.x都改這麼大了,有bug就繼續修就對啦
不過這邊的程式對我來說有點複雜,又是我沒在用的pipeline功能
所以還是餵AI快一點,這辦法確實也很快
依AI指示加完LOG,然後跑個一輪,抓到新方向後,再加新LOG再跑一輪,答案就出來了
MangaStudio_Data/core/pipeline.py這個程式中
有個功能是抓取開頭為Error:或帶有Traceback (most recent call last)的字串後
就取消接下來的佇列
那解決辦法就很單純了,要嘛加上白名單,忽略己知不會讓程式掛掉的Error
不然就是完全取消這部分的判斷
我自己是走後者啦,修改程式碼如下:
修改第159行

    return return_code == 0 #and not has_failed #註解掉後面has_failed的判斷

搞定!

好了,無情的漫畫翻譯機要運作了
之後總算可以看得輕鬆一點了

註:AI修好的原始碼如下


import tempfile
import subprocess
import math
import cv2
import platform
import glob
import os

from ..utils import Context

# convert alignment/direction to gimp's values
alignment_to_justification = {
    "left": "TEXT-JUSTIFY-LEFT",
    "right": "TEXT-JUSTIFY-RIGHT",
    "center": "TEXT-JUSTIFY-CENTER",
}
direction_to_base_direction = {
    "h": "TEXT-DIRECTION-LTR",
    "v": "TEXT-DIRECTION-TTB-RTL-UPRIGHT",
    "hr": "TEXT-DIRECTION-RTL",
    "vr": "TEXT-DIRECTION-TTB-LTR-UPRIGHT",
}

# GIMP 3: gimp-text-layer-new signature is (image text font size unit)
# - font: now a GimpFont resource object, NOT a string. Must use
#   (car (gimp-font-get-by-name "name")) to convert string to resource (v2 dialect).
# - unit: must be UNIT-PIXEL (was just 0 in GIMP 2).
text_init_template = '( text{n} ( car ( gimp-text-layer-new image "{text}" ( car ( gimp-font-get-by-name "{default_font}" ) ) {text_size} UNIT-PIXEL ) ) )'

# GIMP 3: fonts are now resource objects; use gimp-font-get-by-name to convert
# a font name string into a font resource before passing to gimp-text-layer-set-font.
# In v2 dialect, single return values are wrapped in a list, so use car.
font_template = '( gimp-text-layer-set-font text{n} ( car ( gimp-font-get-by-name "{font}" ) ) )'

angle_template = "( gimp-item-transform-rotate text{n} {angle} TRUE 0 0 )"

# GIMP 3: gimp-image-add-layer is replaced by gimp-image-insert-layer
# which takes (image layer parent position). Use -1 for parent (no parent group)
# and 0 for position (top).
# GIMP 3: gimp-text-layer-set-color still accepts (list R G B) in Script-Fu.
text_template = """
    ( gimp-image-insert-layer image text{n} -1 0 )
    ( gimp-text-layer-set-color text{n} (list {color}) )
    ( gimp-item-set-name text{n} "{name}" )
    ( gimp-text-layer-set-language text{n} "{language}" )
    ( gimp-text-layer-set-letter-spacing text{n} {letter_spacing} )
    ( gimp-text-layer-set-line-spacing text{n} {line_spacing} )
    ( gimp-text-layer-set-base-direction text{n} {base_direction} )
    ( gimp-text-layer-set-justification text{n} {justify} )
    ( gimp-layer-set-offsets text{n} {position} )
    ( gimp-text-layer-resize text{n} {size} )
    {font}
    {angle}
"""

# GIMP 3: Non-XCF save procedures were renamed from file-*-save to file-*-export.
# gimp-file-save requires GimpExportOptions which Script-Fu cannot create.
# gimp-xcf-save: internal procedure, GIMP 3 signature is
#   (gimp-xcf-save image num-drawables drawables file)
#   Use the 'layers' variable (from gimp-image-get-layers) defined in script_template.
# file-psd-export / file-pdf-export: plug-in procedures, use keyword syntax.
save_templates = {
    "xcf": '( gimp-xcf-save RUN-NONINTERACTIVE image "{out_file}" )',
    "psd": '( file-psd-export #:run-mode RUN-NONINTERACTIVE #:image image #:file "{out_file}" )',
    "pdf": '( file-pdf-export #:run-mode RUN-NONINTERACTIVE #:image image #:file "{out_file}" )',
}

# GIMP 3: gimp-file-load-layer uses single filename (GFile)
create_mask = '( inpainting ( car ( gimp-file-load-layer RUN-NONINTERACTIVE image "{mask_file}" ) ) )'

# GIMP 3: gimp-image-insert-layer replaces gimp-image-add-layer
rename_mask = '( gimp-image-insert-layer image inpainting -1 0 ) ( gimp-item-set-name inpainting "mask" )'

# GIMP 3: gimp-file-load uses single filename (GFile, one string instead of two).
# gimp-image-get-layers returns a vector in v3; use (vector-ref layers 0) to get first layer.
# gimp-item-set-lock-content replaced by gimp-item-set-lock-content (still exists in GIMP 3
# but the boolean TRUE should still work in v2 dialect batch mode).
script_template = """
( let* (
            ( image ( car ( gimp-file-load RUN-NONINTERACTIVE "{input_file}" ) ) )
            ( layers ( car ( gimp-image-get-layers image ) ) )
            ( background_layer ( vector-ref layers 0 ) )
            {create_mask}
            {text_init}
        )
    {rename_mask}
    ( gimp-item-set-name background_layer "original image" )
    {text}
    {save}
    ( gimp-quit 0 )                        
)"""


def gimp_render(out_file, ctx: Context):
    input_file = os.path.join(tempfile.gettempdir(), ".gimp_input.png")
    mask_file = os.path.join(tempfile.gettempdir(), ".gimp_mask.png")

    extension = out_file.split(".")[-1]

    ctx.upscaled.save(input_file)

    # If there is no text on the page, gimp_mask will be None and there is no
    # need to add it as a layer.
    if ctx.gimp_mask is not None:
        cv2.imwrite(mask_file, ctx.gimp_mask)
    else:
        ctx.text_regions = []

    filtered_text_regions = [
        text_region for text_region in ctx.text_regions if text_region.translation != ""
    ]

    text_init = "\n".join(
        [
            text_init_template.format(
                n=n,
                text=text_region.translation.replace('"', '\\"'),
                text_size=text_region.font_size,
                default_font=ctx.gimp_font
                + (" Bold" if text_region.bold else "")
                + (" Italic" if text_region.italic else ""),
            )
            for n, text_region in enumerate(filtered_text_regions)
        ]
    )

    text = "".join(
        [
            text_template.format(
                n=n,
                color=" ".join([str(value) for value in text_region.fg_colors]),
                name=" ".join(text_region.text).replace('"', '\\"'),
                position=str(text_region.xywh[0]) + " " + str(text_region.xywh[1]),
                size=str(text_region.xywh[2]) + " " + str(text_region.xywh[3]),
                justify=alignment_to_justification[text_region.alignment],
                font=font_template.format(n=n, font=text_region.font_family)
                if text_region.font_family != ""
                else "",
                # rotated text is weird in gimp so we don't do it unless it's over 10 degrees
                angle=angle_template.format(n=n, angle=math.radians(text_region.angle))
                if abs(text_region.angle) > 10
                else "",
                language=text_region.target_lang,
                line_spacing=text_region.line_spacing,
                letter_spacing=text_region.letter_spacing,
                base_direction=direction_to_base_direction[text_region.direction],
            )
            for n, text_region in enumerate(filtered_text_regions)
        ]
    )

    # scheme script to be ran by gimp
    full_script = script_template.format(
        input_file=input_file.replace("\\", "\\\\"),
        text_init=text_init,
        text=text,
        extension=extension,
        save=save_templates[extension].format(out_file=out_file.replace("\\", "\\\\")),
        create_mask=(
            create_mask.format(mask_file=mask_file.replace("\\", "\\\\"))
            if ctx.gimp_mask is not None
            else ""
        ),
        rename_mask=(rename_mask if ctx.gimp_mask is not None else ""),
    )

    gimp_batch(full_script)

    # Delete Files
    os.unlink(input_file)

    # Deleting file only if it exists
    if os.path.exists(mask_file):
        os.unlink(mask_file)


def gimp_console_executable():
    """
    Find the GIMP executable.
    GIMP 3 removed gimp-console; use 'gimp' with -i (no interface) instead.
    On Windows, search for gimp-3.x.exe or gimp.exe in standard locations.
    """
    executable = "gimp"
    if platform.system() == "Windows":
        # Try GIMP 3.x locations first
        for env_var in ["LOCALAPPDATA", "ProgramFiles"]:
            base = os.getenv(env_var, "")
            if not base:
                continue
            # GIMP 3 installs to "GIMP 3" directory
            for gimp_dir_name in ["GIMP 3", "GIMP 2"]:
                gimp_dir = os.path.join(base, "Programs", gimp_dir_name, "bin")
                if not os.path.isdir(gimp_dir):
                    gimp_dir = os.path.join(base, gimp_dir_name, "bin")
                if not os.path.isdir(gimp_dir):
                    continue
                # GIMP 3: look for gimp-3.x.exe (no separate console binary)
                executables = glob.glob(os.path.join(gimp_dir, "gimp-3.*.exe"))
                if executables:
                    return executables[0]
                # Fallback: look for gimp-console (GIMP 2 style)
                executables = glob.glob(os.path.join(gimp_dir, "gimp-console-*.exe"))
                if executables:
                    return executables[0]
                # Generic gimp.exe
                generic = os.path.join(gimp_dir, "gimp.exe")
                if os.path.isfile(generic):
                    return generic
        print("error: gimp not found in standard Windows directories")
    return executable


def gimp_batch(script):
    """
    Run a gimp script in batch mode.
    GIMP 3: gimp-console was removed. Use 'gimp -i' (no interface) instead.
    GIMP 3: Must specify --batch-interpreter=plug-in-script-fu-eval explicitly.
    """
    executable = gimp_console_executable()
    if executable is None:
        raise Exception("GIMP executable not found")

    result = subprocess.run(
        [
            executable,
            "-i",                                               # no UI
            "--batch-interpreter=plug-in-script-fu-eval",       # GIMP 3 requires this
            "-b", script,
            "-b", "(gimp-quit 0)",
        ],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        universal_newlines=True,
    )

    print("=== Output")
    print(result.stdout)

    print("=== Error")
    print(result.stderr)

    # GIMP 3 produces harmless GEGL warnings on exit (buffer leaks, cache warnings).
    # Only raise on actual Script-Fu execution errors.
    if result.stderr:
        # Filter out known harmless GIMP 3 warnings
        error_lines = []
        for line in result.stderr.splitlines():
            # Skip known harmless warnings
            if any(skip in line for skip in [
                "GEGL-",
                "GeglBuffer",
                "gegl_tile",
                "GEGL_DEBUG",
                "dbind-WARNING",
                "AT-SPI",
                "LibGimp-WARNING",
                "scriptfu-WARNING",
                "g_queue_is_empty",
            ]):
                continue
            error_lines.append(line)

        # Check remaining lines for actual Script-Fu errors
        filtered_stderr = "\n".join(error_lines)
        if "Error:" in filtered_stderr or "batch command experienced an execution error" in filtered_stderr:
            raise Exception("GIMP Execution error")
 


沒有留言: