Stable Diffusionのlatentlabs360を使って360度球体パノラマを生成する方法

この記事では**Stable Diffusion**を使って球体パノラマを生成する方法と注意点について紹介します。

私は普段**diffusers**と**ComfyUI**どれも使っていて、使い方はかなり違うので、今回はそれぞれ説明します。


# はじめに

球体パノラマとは、球体の表面を360度全部が書かれる長方形の画像のことです。

3D分野でもよく使われています。球体の表面に貼って背景を作ったり、ピカピカの物体の映り込みを作るのに使ったりします。

これは完成品の例です。

![latentlabs360_00051_.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/f595e61e-816d-4013-ba14-a491977b968f.jpeg)

そしてこの画像を使ってこのような表面の映り込みを作ることができます。

![fig.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/3ea2db0f-5bc7-e1c3-00f1-473dfb80bc1f.jpeg)

ちょっと余談ですが、この映り込み物体の画像はMATLABによって作られたのです。方法はこの記事に書いてあります。

https://qiita.com/phyblas/items/c749e16a928fd69cb28b

このような画像をStable Diffusionで作るのに「<font color="#937">**latentlabs360**</font>」というよくできたLoRAがあります。

https://civitai.com/models/10753/latentlabs360

ただしそのまま普通のLoRAと同じように使っても意外と上手くいかなくて、更にちょっとした工夫が必要なので、この記事ではこれについて説明していきたいです。

尚、残念ながらこのLoRAはSDXLに使うことができないようでXLなしで使うしかありません。


# diffusersでの使用

まずLoRAの使い方を含め、diffusersの基本的な使い方に関しては以前私が書いた入門の記事でも書いたのでこれを参考に。

https://qiita.com/phyblas/items/00f750b8277f66fb9b13

latentlabs360のトリガーワードは「a 360 equirectangular panorama」なのでこれをいつもプロンプトに入れる必要があります。

チェックポイントモデルは基本的にどれでも使えます。今回はアニメ系のTMND-Mixを使います。

https://civitai.com/models/27259/tmnd-mix

ではひとまずlatentlabs360をこのまま導入してみます。
```python
from diffusers import StableDiffusionPipeline,EulerAncestralDiscreteScheduler
import torch
from translate import Translator

device = 'mps'
cp_model_file = 'stadifmodel/tmndMix_tmndMixSPRAINBOW.safetensors'
pipe = StableDiffusionPipeline.from_single_file(cp_model_file,torch_dtype=torch.float16).to(device)
pipe.scheduler = EulerAncestralDiscreteScheduler.from_config(pipe.scheduler.config)
# ここでlatentLabs360をロードする
lora_file = 'stadifmodel/LatentLabs360.safetensors'
pipe.load_lora_weights(".", weight_name=lora_file)

seed = 20007
generator = torch.Generator().manual_seed(seed)
honyaku = Translator('en','ja').translate
prompt = honyaku('公園の桜並木')
prompt = 'a 360 equirectangular panorama, ' + prompt
img = pipe(
    prompt,
    width=1024,
    height=512,
    num_inference_steps=25,
    generator=generator,
).images[0]
img.save('llsp.jpg')
```

結果はこのようになります。

![llsp.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/6562899c-11ce-7c48-09f0-1f46d0ed7206.jpeg)

これはそれらしく綺麗な画像ができましたが、まだなんか違いますね。右と左の端は繋がっていないからです。このままでは360度パノラマとして使うわけにはいきません。

この問題を簡潔に解決できる方法はdiffusersにはまだ見つかりませんが、色々試したところ<font color="#937">**controlnet inpaint**</font>で何とかできるとわかりました。

controlnet inpaintの使い方について私は以前の記事で紹介しました。

https://qiita.com/phyblas/items/7cacb9297650afd63d34

この方法を使うとなると2度も生成する必要があって複雑になりますが、結果は悪くないです。

```python
from diffusers import StableDiffusionPipeline,ControlNetModel,StableDiffusionControlNetInpaintPipeline,EulerAncestralDiscreteScheduler
import torch
import numpy as np
from PIL import Image
from translate import Translator

# コントロール画像を作るための関数
def img_ctrl_tsukuru(img,img_mask):
    img = np.float32(img)/255
    img[np.array(img_mask.convert('L'))>127] = -1
    return torch.from_numpy(img[None,:].transpose(0,3,1,2))

device = 'mps'
cp_model_file = 'stadifmodel/tmndMix_tmndMixSPRAINBOW.safetensors'
lora_file = 'stadifmodel/LatentLabs360.safetensors'
seed = 20007
generator = torch.Generator().manual_seed(seed)
honyaku = Translator('en','ja').translate
prompt = honyaku('公園の桜並木')
prompt = 'a 360 equirectangular panorama, ' + prompt

# 1回目生成するパイプライン
pipe1 = StableDiffusionPipeline.from_single_file(
    cp_model_file,
    torch_dtype=torch.float16
).to(device)
pipe1.scheduler = EulerAncestralDiscreteScheduler.from_config(pipe1.scheduler.config)
pipe1.load_lora_weights(".", weight_name=lora_file)

# controlnet inpaintを含む再度生成のパイプライン
controlnet = ControlNetModel.from_pretrained(
    'lllyasviel/control_v11p_sd15_inpaint',
    torch_dtype=torch.float16
).to(device)
pipe2 = StableDiffusionControlNetInpaintPipeline.from_single_file(
    cp_model_file,
    controlnet=controlnet,
    torch_dtype=torch.float16,
    scheduler=pipe1.scheduler
).to(device)
pipe2.load_lora_weights(".", weight_name=lora_file)

# まず1回生成
img0 = pipe1(
    prompt,
    width=1024,
    height=512,
    num_inference_steps=25,
    generator=generator,
).images[0]

# 左右半分分けて入れ替える
arimg = np.array(img0)
arimg = np.hstack([arimg[:,512:],arimg[:,:512]])
img0 = Image.fromarray(arimg)
# 中心部を含むマスク画像を作る
arimg_mask = np.zeros([512,1024],dtype=np.uint8)
arimg_mask[:,64:-64] = 255
img_mask = Image.fromarray(arimg_mask)
img_ctrl = img_ctrl_tsukuru(img0,img_mask)

# 中心部だけ再度生成
img = pipe2(
    prompt,
    image=img0,
    mask_image=img_mask,
    control_image=img_ctrl,
    num_inference_steps=25,
    strength=0.5,
    generator=generator
).images[0]
img.save('llsp2.jpg')
```

![llsp2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/07b2d46f-2bf8-eb76-e991-84de1a3876a8.jpeg)

これで綺麗で360度回せる球体パノラマができました。


# ComfyUIでの使用

ComfyUIにはループする画像を作るためのノードを提供するプラグインがあるのでdiffusersみたいな工夫をしなくても簡単に360度球体パノラマを作ることができます。

ただしそのプラグインを使うことでプロンプトに従わなかったり、品質が低下したりするような気がします。だからこれを使う方法とdiffusersと同じようにcontrolnet inpaintを使うという方法をどちらも紹介します。

## ループ画像を作るksamplerとVAEを使う

### プラグインのインストール

ループする画像を作るプラグインは、探してみたら主に2つあります。

<font color="#937">**tiled_ksampler**</font>

https://github.com/FlyingFireCo/tiled_ksampler

<font color="#937">**ComfyUI-seamless-tiling**</font>

https://github.com/spinagon/ComfyUI-seamless-tiling

ノードの組み方は少し違いますが、どちらも試したところ、結果は全く同じです。ただしtiled_ksamplerの方が使いやすいから今回はこれを使う方法を紹介します。

tiled_ksamplerの使い方は、ただ普段の`KSampler`ノードを`Asymmetric Tiled KSampler`ノードに入れ替えて、`VAEDecode`ノードを`Circular VAEDecode`ノードに入れ替えるだけです。


### ノードの組み方

tiled_ksamplerをlatentlabs360のLoRAと、私が以前の記事で紹介した[自動翻訳`DeepTranslatorCLIPTextEncodeNode`ノード](https://qiita.com/phyblas/items/0c69ac370daa0aa01d15)と[jpgで保存できる`SaveImagePlus`ノード](https://qiita.com/phyblas/items/2f686cee2e0b82af274c)と一緒に組み立てたらこんな感じのワークフローになります。（勿論、デフォルトの翻訳無しの`CLIPTextEncode`とpng保存の`SaveImage`を使っても構いません）

![latentlabs360_comfyui.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/7f52bc53-e37c-75e2-483d-8dce1b5bad69.jpeg)

今回使ったチェックポイントモデルはリアル系のAbsoluteRealityです。

https://civitai.com/models/81458/absolutereality

結果の画像はこれです。この画像をドラッグしてワークフローをロードすることができます。（ただし[Save Image Plus for ComfyUI](https://github.com/Goktug/comfyui-saveimage-plus)又は[Extended Save Image for ComfyUI](https://github.com/palant/extended-saveimage-comfyui)プラグインをインストールしている必要があります）

![latentlabs360_00003_.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/1f698fef-fb20-33e1-524d-973349fcf5d7.jpeg)

### ワークフロー

API形式のワークフローもここに載せておきます。これを保存してComfyUIにロードできます。
```json
{
  "1": {
    "inputs": {
      "ckpt_name": "absolutereality_v181.safetensors"
    },
    "class_type": "CheckpointLoaderSimple"
  },
  "2": {
    "inputs": {
      "width": 1024,
      "height": 512,
      "batch_size": 1
    },
    "class_type": "EmptyLatentImage"
  },
  "3": {
    "inputs": {
      "lora_name": "LatentLabs360.safetensors",
      "strength_model": 1,
      "strength_clip": 1,
      "model": ["1", 0],
      "clip": ["1", 1]
    },
    "class_type": "LoraLoader"
  },
  "4": {
    "inputs": {
      "from_translate": "japanese",
      "to_translate": "english",
      "add_proxies": false,
      "service": "GoogleTranslator [free]",
      "text": "a 360 equirectangular panorama, ボロボロな倉庫",
      "Show proxy": "proxy_hide",
      "Show authorization": "authorization_hide",
      "clip": ["3", 1]
    },
    "class_type": "DeepTranslatorCLIPTextEncodeNode"
  },
  "5": {
    "inputs": {
      "from_translate": "japanese",
      "to_translate": "english",
      "add_proxies": false,
      "service": "GoogleTranslator [free]",
      "text": "低品質",
      "Show proxy": "proxy_hide",
      "Show authorization": "authorization_hide",
      "clip": ["3", 1]
    },
    "class_type": "DeepTranslatorCLIPTextEncodeNode"
  },
  "6": {
    "inputs": {
      "seed": 231544903223482,
      "tileX": 1,
      "tileY": 0,
      "steps": 20,
      "cfg": 7,
      "sampler_name": "dpmpp_2m",
      "scheduler": "karras",
      "denoise": 1,
      "model": ["3", 0],
      "positive": ["4", 0],
      "negative": ["5", 0],
      "latent_image": ["2", 0]
    },
    "class_type": "Asymmetric Tiled KSampler"
  },
  "7": {
    "inputs": {
      "samples": ["6", 0],
      "vae": ["1", 2]
    },
    "class_type": "Circular VAEDecode"
  },
  "8": {
    "inputs": {
      "filename_prefix": "latentlabs360",
      "file_type": "JPEG",
      "remove_metadata": false,
      "images": ["7", 0]
    },
    "class_type": "SaveImagePlus"
  }
}
```

## controlnet inpaintと一緒に使う方法

### プラグインのインストール
この方法はかなり沢山のノードを使う必要があるのでかなり複雑になります。

追加でインストールする必要があるプラグインは以下です。
- `Image Blank`ノード：[was-node-suite-comfyui](https://github.com/WASasquatch/was-node-suite-comfyui)
- `ImageConcanate`ノード：[ComfyUI-KJNodes](https://github.com/kijai/ComfyUI-KJNodes)
- `InpaintPreprocessor`ノード：[comfyui_controlnet_aux](https://github.com/Fannovel16/comfyui_controlnet_aux)

### ノードの組み方
組んでみたらこんな感じです。

![latentlabs360_comfyui2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/6f633f50-7c0f-c858-8a82-751535b8468a.png)

このワークフローでできた画像。

![latentlabs360_00163_.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/71a8d066-44ae-b903-7171-32506ceb1283.jpeg)

以上のワークフローは細かすぎて詳細まで表示されないので、この画像をComfyUIにドラッグして読み込んでいいでしょう。


### ワークフロー

ちょっと長いですが、API形式のjsonワークフローもここに載せておきます。

```json
{
  "1": {
    "inputs": {
      "ckpt_name": "absolutereality_v181.safetensors"
    },
    "class_type": "CheckpointLoaderSimple"
  },
  "2": {
    "inputs": {
      "width": 1024,
      "height": 512,
      "batch_size": 1
    },
    "class_type": "EmptyLatentImage"
  },
  "3": {
    "inputs": {
      "lora_name": "LatentLabs360.safetensors",
      "strength_model": 1,
      "strength_clip": 1,
      "model": ["1", 0],
      "clip": ["1", 1]
    },
    "class_type": "LoraLoader"
  },
  "4": {
    "inputs": {
      "from_translate": "japanese",
      "to_translate": "english",
      "add_proxies": false,
      "service": "GoogleTranslator [free]",
      "text": "a 360 equirectangular panorama, ボロボロな倉庫",
      "Show proxy": "proxy_hide",
      "Show authorization": "authorization_hide",
      "clip": ["3", 1]
    },
    "class_type": "DeepTranslatorCLIPTextEncodeNode"
  },
  "5": {
    "inputs": {
      "from_translate": "japanese",
      "to_translate": "english",
      "add_proxies": false,
      "service": "GoogleTranslator [free]",
      "text": "低品質",
      "Show proxy": "proxy_hide",
      "Show authorization": "authorization_hide",
      "clip": ["3", 1]
    },
    "class_type": "DeepTranslatorCLIPTextEncodeNode"
  },
  "6": {
    "inputs": {
      "seed": 877456268177392,
      "steps": 20,
      "cfg": 7,
      "sampler_name": "dpmpp_2m",
      "scheduler": "karras",
      "denoise": 1,
      "model": ["3", 0],
      "positive": ["4", 0],
      "negative": ["5", 0],
      "latent_image": ["2", 0]
    },
    "class_type": "KSampler"
  },
  "7": {
    "inputs": {
      "samples": ["6", 0],
      "vae": ["1", 2]
    },
    "class_type": "VAEDecode"
  },
  "8": {
    "inputs": {
      "width": 512,
      "height": 512,
      "x": 512,
      "y": 0,
      "image": ["7", 0]
    },
    "class_type": "ImageCrop"
  },
  "9": {
    "inputs": {
      "width": 512,
      "height": 512,
      "x": 0,
      "y": 0,
      "image": ["7", 0]
    },
    "class_type": "ImageCrop"
  },
  "10": {
    "inputs": {
      "direction": "right",
      "match_image_size": false,
      "image1": ["8", 0],
      "image2": ["9", 0]
    },
    "class_type": "ImageConcanate"
  },
  "11": {
    "inputs": {
      "width": 448,
      "height": 512,
      "red": 0,
      "green": 0,
      "blue": 0
    },
    "class_type": "Image Blank"
  },
  "12": {
    "inputs": {
      "width": 128,
      "height": 512,
      "red": 255,
      "green": 255,
      "blue": 255
    },
    "class_type": "Image Blank"
  },
  "13": {
    "inputs": {
      "width": 448,
      "height": 512,
      "red": 0,
      "green": 0,
      "blue": 0
    },
    "class_type": "Image Blank"
  },
  "14": {
    "inputs": {
      "inputcount": 3,
      "direction": "right",
      "match_image_size": false,
      "Update inputs": null,
      "image_1": ["11", 0],
      "image_2": ["12", 0],
      "image_3": ["13", 0]
    },
    "class_type": "ImageConcatMulti"
  },
  "15": {
    "inputs": {
      "channel": "red",
      "image": ["14", 0]
    },
    "class_type": "ImageToMask"
  },
  "16": {
    "inputs": {
      "grow_mask_by": 40,
      "pixels": ["10", 0],
      "vae": ["1", 2],
      "mask": ["15", 0]
    },
    "class_type": "VAEEncodeForInpaint"
  },
  "17": {
    "inputs": {
      "image": ["10", 0],
      "mask": ["15", 0]
    },
    "class_type": "InpaintPreprocessor"
  },
  "18": {
    "inputs": {
      "control_net_name": "control_v11p_sd15_inpaint_fp16.safetensors"
    },
    "class_type": "ControlNetLoader"
  },
  "19": {
    "inputs": {
      "strength": 1,
      "start_percent": 0,
      "end_percent": 1,
      "positive": ["4", 0],
      "negative": ["5", 0],
      "control_net": ["18", 0],
      "image": ["17", 0]
    },
    "class_type": "ControlNetApplyAdvanced"
  },
  "20": {
    "inputs": {
      "seed": 258289190362142,
      "steps": 20,
      "cfg": 7,
      "sampler_name": "dpmpp_2m",
      "scheduler": "karras",
      "denoise": 1,
      "model": ["3", 0],
      "positive": ["19", 0],
      "negative": ["19", 1],
      "latent_image": ["16", 0]
    },
    "class_type": "KSampler"
  },
  "21": {
    "inputs": {
      "samples": ["20", 0],
      "vae": ["1", 2]
    },
    "class_type": "VAEDecode"
  },
  "22": {
    "inputs": {
      "filename_prefix": "latentlabs360",
      "file_type": "JPEG",
      "remove_metadata": false,
      "images": ["21", 0]
    },
    "class_type": "SaveImagePlus"
  }
}
```