見出し画像

WindowsローカルでJupyerlabを動かす(後編)

前編からの続きです。前編ではjupyterlabと使用ライブラリのインストールを行いました。

Colab環境との違いに対応するため、ソースを一部変更します。

ソース変更

フォルダ作成

# 使用するnotebookでの実装の都合から、notebook-dirの下にcontentフォルダを作成しておく

rootDir = "C:/Path/To/NotebookDir"
content_dir = f"{rootDir}/content"
os.makedirs(content_dir, exist_ok=True)

# TODO: できればsys.path.append()を使わずにimportのサーチパスを追加したい
sys.path.append(content_dir)

GPU動作の確認

# PyTorchの確認
print(torch.__version__)
print(torch.cuda.is_available())

# 割り当てられたGPUの確認
!nvidia-smi

イベントハンドラから実行される実処理メソッドの書き換え

"""
ipywidgets 8.x 系での処理
"""

# 従来のメソッド。ファイル1個の情報を返す。内部処理のラッパーとする
#  (content, parameter_text) を返す 
# ipywidgets 7.x からの変更点は、tupleからファイル情報を取得
def update_pnginfo(new_value):
  return update_pnginfo_internal(new_value[0])

# rawデータを返す(bytesオブジェクト)
# ipywidgets 7.x からの変更点は、tupleからファイル情報を取得
def fileUploader_load_png(new_value):
  return fileUploader_load_png_internal(new_value[0])


# ファイル情報取得の内部処理
# ipywidgets 7.x からの大きな変更はない

def update_pnginfo_internal(uploaded_file):
  #rawデータを取得
  content = uploaded_file["content"]

  #画像情報取得用にPILのライブラリで画像を開く
  img = Image.open(io.BytesIO(content))
  #画像情報を取得
  parameter_text = img.info["parameters"]
  return (content, parameter_text)

def fileUploader_load_png_internal(uploaded_file):
  #rawデータを取得(bytesオブジェクト)
  return uploaded_file["content"]

preprocess_image(), preprocess_mask()の引数の修正

StableDiffusionPipeline側での仕様変更を反映

# Long Prompt Weighting にマスク操作を追加
class StableDiffusionLPWMaskPipeline(lpw_stable_diffusion.StableDiffusionLongPromptWeightingPipeline):

  # ...

  def prepare_latents(self, image, timestep, batch_size, height, width, dtype, device, generator, latents=None):
      
      # ...
      
      # 4. Preprocess image and mask
      # (2024-09-25) https://github.com/huggingface/diffusers/commit/9965cb50eac12e397473f01535aab43aae76b4ab に対応して引数batch_sizeを追加
      if isinstance(image, PIL.Image.Image):
          #image = lpw_stable_diffusion.preprocess_image(image)
          image = lpw_stable_diffusion.preprocess_image(image, batch_size)
      if image is not None:
          image = image.to(device=self.device, dtype=dtype)
      if isinstance(mask_image, PIL.Image.Image):
          #mask_image = lpw_stable_diffusion.preprocess_mask(mask_image, self.vae_scale_factor)
          mask_image = lpw_stable_diffusion.preprocess_mask(mask_image, batch_size, self.vae_scale_factor)
      if mask_image is not None:
          mask = mask_image.to(device=self.device, dtype=dtype)
          mask = torch.cat([mask] * batch_size * num_images_per_prompt)
      else:
          mask = None
      
      # ...    

その他

deprecatedとなったstrtobool()の自作

#from distutils.util import strtobool
# distutilsがPython3.12以降deprecatedとなったので自作
# 仕様  https://docs.python.org/ja/3.11/distutils/apiref.html#distutils.util.strtobool
# 参考  https://qiita.com/satsukiya/items/0788bade058d03d3edca

def strtobool(str):
    contain_true = str.lower() in ["y", "yes", "t", "true", "on", "1"]
    if contain_true == True:
        return True
    contain_false = str.lower() in ["n", "no", "f", "false", "off", "0"]
    if contain_false == True:
        return False
    #val が上のどれでもない時は ValueError    
    raise(ValueError)

#自作strtobool()のテスト
#strtobool("tRue")
#strtobool("faLse")
#strtobool("yeS")
#strtobool("nO")
#strtobool("0")
#strtobool("1")
#strtobool("2") #ValueError

from_pretrained()のオプション引数名の変更の準備。ただしTrinArtなど旧モデルが対応できなくなるので適用はしない

class BackEnd:
    
  # ...
    
  def setup(self, model, revision, device_to, output_dir, default_width, default_height, attention_slicing, xformers_memory_efficient_attention, safety_checker):
    
    # ...
    
    # オプション引数が変更されているのを反映(2024-09-16~)
    # https://huggingface.co/docs/diffusers/v0.30.2/en/api/pipelines/overview#diffusers.DiffusionPipeline.from_pretrained
    self.pipe = StableDiffusionLPWMaskPipeline.from_pretrained(
      self.model,
      torch_dtype=torch.float16,
    
      # 2024-09-25現在のデフォルト値
      #variant=self.revision,
      #use_safetensors=True
      
      #TrinArtなど旧モデルは引数が異なる
      revision=self.revision,
      use_safetensors=False
      
    ).to(self.device_to)
    
    # ...

詳細

IPyWidgetsのバージョンによるイベントハンドラ仕様の変更

IPyWidgetsの最新版は記事の時点で8.x系です。一方、GoogleColab上のIPyWidgetsは7.x系がインストールされています。
残念なことに、とても残念なことに、IPyWidgetsのAPIは7.x系と8.xとでイベントハンドラの仕様が一部異なっています。そのため、Colab上で動くノートブックをローカルで動かすためにはノートブックのコードを一部変更する必要があります。

自作のノートブックではFileUploadの仕様が該当します。

イベントハンドラから呼び出される実処理では、change.newの値を受け取ることで、変更後の値を使った処理を実行します。この挙動はipywidget7, 8 ともに共通です。

# 参考:イベントハンドラの呼び出し側(ipywidget7, 8 ともに共通)

# 処理する画像のアップローダ
class ImageUploader:
  # 入力要素・出力要素の定義を行う。実際のレイアウトはset_layout()で設定
  def __init__(self, parent, width, height, mode):
    self.parent = parent
    #画像データ、PIL Imageのインスタンス
    self.src_image = None
    self.mode = mode
    #ウィジェット
    self.src_image_widget = widgets.Image(width=f"{width}", height=f"{height}")
    self.src_upload_button = widgets.FileUpload(accept=".png; .jpg", multiple=False, description="source img")
    self.src_upload_button.observe(self.on_src_upload, names='value') #FileUploadはon_clickを持たないため、valueを監視してイベントハンドラを起動
    self.src_upload_button.layout=Layout(width=f"{width}")

  #イベントハンドラ。ファイルアップロード完了時に元画像を更新する
  def on_src_upload(self, change):
    data = fileUploader_load_png(new_value=change.new) #bytes
    self.src_image = Image.open(io.BytesIO(data)).convert(self.mode)
    self.src_image_widget.value = data

  # ...

そして、IPyWidgets 7.x と 8.x とでは、イベントハンドラに渡されるchangeオブジェクトの仕様が異なります。

ipywidgets 7.x 系の場合、
  change -> {"new"=>..., "old"=>..., } となるobjectが格納
    change.new -> {filename: {"metadata":{}, "content":rawdata} } の構造を持つdictが格納
ipywidgets 8.x 系の場合、
  change -> {"new"=>..., "old"=>..., } となるobjectが格納(変更なし)
    change.new -> ( #tuple
    { #dict
      'name': 'example.txt',
      'type': 'text/plain',
      'size': 36,
      'last_modified': datetime.datetime(2020, 1, 9, 15, 58, 43, 321000, tzinfo=datetime.timezone.utc), 
      'content': <memory at 0x10c1b37c8>
    },
    )
    となる tuple および dict が格納。

ハンドラの実処理メソッドではchange.newの仕様変更をすり合わせて変更後の値を受け取れるようにします。そのためIPyWidgets 8.x 対応では最低限のラッパーをかませることにしました。幸いにも必要なrawデータが含まれるdictのキーは"content"から変更されていないので、実処理は変更せずにすみました。

"""
ipywidgets 7.x 系での処理
"""
# ファイル情報取得の内部処理
def fileUploader_load_png_internal(uploaded_file):
  #rawデータを取得(bytesオブジェクト)
  return uploaded_file["content"]


"""
ipywidgets 8.x 系での処理
"""
# ipywidgets 7.x からの変更点は、tupleからファイル情報を取得

# ラッパー
def fileUploader_load_png(new_value): 
  return fileUploader_load_png_internal(new_value[0])

# ファイル情報取得の内部処理
# ipywidgets 7.x からの大きな変更はない
def fileUploader_load_png_internal(uploaded_file):
  #rawデータを取得(bytesオブジェクト)
  return uploaded_file["content"]

シェルの扱い

jupyterlabではシェルコマンドを実行できますが、実行できるコマンドは実行環境に依存します。そのためLinuxにあるコマンドがWindows(のコンソール)では実行できないことが多いです。具体的には"mkdir -p"コマンド、既存のディレクトリがある場合にエラーを出さない処理がWindowsではできません。そのため、シェルではなくpythonのメソッドで処理を実装しました。

ファイルパスの扱い

ファイルパスに関して、LinuxとWindowsとで大きな違いは、ディレクトリの区切り文字です。これがPythonでファイルパス関連の処理を実行するときに悪影響を及ぼします。
・Pythonでは'\'(バックスラッシュ)がエスケープ文字として扱われる(r"C:\Path\To" のようにr指定でエスケープを回避可)
・Pythonでは、Windowsでも '/' と '\' の両方がフォルダ区切りとして解釈される。混在可。
→とはいえWindowsでもフォルダは'/'でつないだほうが読みやすい
・os.path.join()でディレクトリやフォルダを連結する際、区切り文字はOSに依存する
→Windowsだとバックスラッシュで連結される

上の箇条書きでも想像できると思いますが、LinuxとWindowsの両方に対応できるファイルパス処理の実装はややこしいです。
・ディレクトリの区切りを'/'で統一した場合でも、os.path.join()でパスを連結すると、Windowsではフォルダの連結文字にスラッシュとバックスラッシュが混在する。見た目が悪い
・Windows用にバックスラッシュでフォルダを区切るとLinuxでは動かない。
・一階層ずつos.path.join()するのは記述が煩雑

結局あきらめて、内部処理でファイルパスを生成する部分は os.path.join() のままとしました。

StableDiffusionPipeline側での仕様変更

ノートブック公開後にStableDiffusionPipelineのメソッドpreprocess_image(), preprocess_mask()の引数の仕様が変更されていて、これらを明示的に呼び出すImg2ImgタブとInpaintタブが動かなくなっていました。
メソッドの呼び出し時にbatch_size(実行1回で生成する画像の枚数。枚数ぶんVRAMの消費量が増える)を指定するようにしました。

- def preprocess_image(image):
+ def preprocess_image(image, batch_size):

- def preprocess_mask(mask, scale_factor=8):
+ def preprocess_mask(mask, batch_size, scale_factor=8):

strtobool()の自作

PNG画像に埋め込んだ生成パラメータをエンジンに渡す際にstrtobool()を使っていたのですが、これがPython3.12以降deprecatedとなったので自作しました。

以下の記事を参考にしました。

from_pretrained()のrevision, variantオプション

記事の時点ではwarningの段階ですが、モデルのバージョンを指定するrevisionオプションが非推奨となり、代わりにvariantオプションの使用が必要となります。
revision: ブランチ名を指定。"main"など
variant: モデルの種別を指定。"fp16","ema"など。
    model名.fp16.safetensors というファイル名の"fp16"にあたる名前を指定

ただしvariantを持たないモデルは、revisionでの指定しか受け付けません。そのためvariantオプションの使用は見送りました。

今後

PC買い替えに伴って、Diffusersを実行するノートブックをローカルで実行して画像生成できるようになりました。タイトル絵は動作確認用にノートブックで生成したものです。
一連の作業で見つかった修正点をGitHubのソースに反映させました。
・preprocess_image(), preprocess_mask()の引数の修正
・別に見つかったバグの修正
Colabの環境はPython3.10と IPyWidgets7.x 系なので、上記以外の対応は不要でした。

一方で、新PCでComfyUIも動かせるようになったので、SDManualGUIは開発終了とします。IPyWidgets7,8の両バージョン対応は煩雑なのと、ComfyUIがめちゃくちゃ便利なので…
SDManualGUIに触れた奇特な方(いるか分かりませんが)、短い期間でしたがありがとうございました。

この記事が気に入ったらサポートをしてみませんか?