StreamDiffusion:用 OSC 控制提示詞,並以 NDI 傳送即時影像

Ted Liou 2025.06.13 StreamDiffusion 最後更新 2026.03.17

快速摘要

StreamDiffusion 要接進互動系統,先把通訊層搭起來。本文把責任拆成兩條線:用 OSC 收提示詞等控制訊息,用 NDI 收送影像。這層骨架一旦穩住,後面接 TouchDesigner 或 Unity 都會輕鬆很多。

前一篇把 StreamDiffusion 裝到能跑起來之後,還有一個很現實的問題:它目前仍然像一支範例程式。我們每改一次 Prompt,都要停下來、改碼、重跑;每生成一張圖,又只是存成檔案。這樣當然可以驗證模型,但還談不上互動系統。

要把它接進外部軟體,最先該補的是通訊層。這一層如果先穩住,後面不管是 TouchDesigner、Unity,還是你自己的前端介面,都只是在接同一套入口和出口。

本文把責任拆成兩條線來做:OSC 負責接收控制訊息,像是 Prompt;NDI 負責送出生成影像,也可以反過來接收外部影像來做 Img2Img。拆成這樣之後,系統角色就很清楚了。

為什麼是 OSC 加 NDI

這裡先講結論。控制訊息和影像資料最好不要混在同一條路上。

OSC 適合做參數控制。它很輕,傳一段 Prompt、模式切換或數值參數都很自然。TouchDesigner、Unity 或其他互動系統如果只需要發控制訊息過來,這條線就夠了。

NDI 適合做影像進出。它的角色是把每一幀畫面穩定送出去,或者把外部畫面送進來。只要把這一層接好,StreamDiffusion 就能從「範例程式」轉成「影像服務」。

安裝通訊層會用到的套件

本文沿用前一篇的 Python 3.10 環境。這裡刻意不升版,原因很單純:ndi-python 目前支援的 Windows Python 版本仍到 3.10,StreamDiffusion 本身也要求 3.10 起跳。環境不換,後面事情會單純很多。

請在專案根目錄輸入:

1uv add opencv-python python-osc ndi-python==5.1.1.1

這三個套件的分工如下:

  • python-osc:接收外部傳來的 OSC 訊息。
  • ndi-python:收送 NDI 影像串流。
  • opencv-python:做影像格式轉換,讓輸出的陣列格式能被 NDI 正常送出。

先把 Txt2Img 改成可被外部控制

第一步先不要急著做雙向影像交換,先把最小骨架搭起來:外部能更新 Prompt,Python 端能持續生成,並把結果即時送出去。

這段程式有幾個重點:

  • 用一個全域的 Prompt 狀態儲存當前文字。
  • 開一個 OSC Server,接收 /prompt 訊息。
  • 用 NDI 持續送出目前生成的畫面。

請把 main.py 改成下面這段:

 1from threading import Lock, Thread
 2
 3import cv2
 4import NDIlib as ndi
 5import numpy as np
 6from pythonosc.dispatcher import Dispatcher
 7from pythonosc.osc_server import ThreadingOSCUDPServer
 8
 9from utils.wrapper import StreamDiffusionWrapper
10
11OSC_HOST = "127.0.0.1"
12OSC_PORT = 7000
13NDI_OUTPUT_NAME = "streamdiffusion-output"
14
15prompt = "cat, detailed, fantasy, 8k"
16prompt_lock = Lock()
17
18
19def set_prompt(_, value):
20    global prompt
21    with prompt_lock:
22        prompt = value
23
24
25def get_prompt():
26    with prompt_lock:
27        return prompt
28
29
30def main():
31    stream = StreamDiffusionWrapper(
32        model_id_or_path="stabilityai/sd-turbo",
33        t_index_list=[0, 24, 32],
34        width=512,
35        height=512,
36        cfg_type="none",
37        acceleration="xformers",
38        mode="txt2img",
39        output_type="np"
40    )
41    stream.prepare(prompt=get_prompt())
42
43    dispatcher = Dispatcher()
44    dispatcher.map("/prompt", set_prompt)
45    osc_server = ThreadingOSCUDPServer((OSC_HOST, OSC_PORT), dispatcher)
46    osc_thread = Thread(target=osc_server.serve_forever)
47    osc_thread.start()
48
49    ndi.initialize()
50    ndi_send_cfg = ndi.SendCreate()
51    ndi_send_cfg.ndi_name = NDI_OUTPUT_NAME
52    ndi_send = ndi.send_create(ndi_send_cfg)
53    ndi_frame = ndi.VideoFrameV2()
54    ndi_frame.FourCC = ndi.FOURCC_VIDEO_TYPE_RGBA
55
56    for _ in range(stream.batch_size - 1):
57        stream(prompt=get_prompt())
58
59    try:
60        while True:
61            current_prompt = get_prompt()
62            output = stream(prompt=current_prompt)
63            output = (output * 255).astype(np.uint8)
64            output = cv2.cvtColor(output, cv2.COLOR_RGB2RGBA)
65
66            ndi_frame.data = output
67            ndi.send_send_video_v2(ndi_send, ndi_frame)
68
69    except KeyboardInterrupt:
70        print("中斷執行")
71
72    finally:
73        ndi.send_destroy(ndi_send)
74        ndi.destroy()
75        osc_server.shutdown()
76        osc_thread.join()
77        print("已停止")
78
79
80if __name__ == "__main__":
81    main()

這裡我特別把輸出的 NDI 名稱設成 streamdiffusion-output。原因很實際,後面不管是 TouchDesigner 還是 Unity,看到這個名字就知道要接哪一路,不用再回頭猜。

這一版程式在做什麼

StreamDiffusionWrapper 還是同一套,但它現在不再只跑一次,而是放進持續生成的主循環裡。每次迴圈都先讀目前的 Prompt,再根據這個狀態生成新畫面。

OSC Server 是另一條執行緒。它的工作很單純,就是在收到 /prompt 訊息時,把字串寫回目前的 Prompt 狀態。這裡用 Lock,目的是避免之後外部更新 Prompt 的時候,主循環剛好也在讀,結果讓狀態變得很難追。

NDI 這一層則只做一件事:把生成後的 numpy.ndarray 轉成 RGBA,再穩定送出。到這一步為止,StreamDiffusion 已經可以像一個影像節點那樣被別的系統接收,不再只是把結果存成 PNG

實際測試:Prompt 可以在外部改動

這個階段最簡單的驗證方式,就是用 TouchDesigner 先送一個 Prompt 過來,看畫面有沒有跟著變。

TouchDesigner 介面以按鈕切換 Prompt,並用 NDI 接收 StreamDiffusion 的即時輸出

如果畫面能從貓切到馬,代表外部控制真的進到生成迴圈了。這是後面所有整合工作的起點。

再補上 Img2Img 的影像輸入

有了外部 Prompt 控制之後,下一步才是讓外部畫面流進來。這一版會多一條 NDI 輸入,讓外部軟體可以把攝影機、即時渲染或任何 TOP/Texture 畫面送進 StreamDiffusion。

和前一段相比,真正多出來的事情只有兩件:

  • 啟動時先找到指定的 NDI 來源。
  • 在主循環裡先收影像,再把影像和 Prompt 一起丟進 StreamDiffusion。

請把 main.py 改成下面這段:

  1from threading import Lock, Thread
  2
  3from PIL import Image
  4import cv2
  5import NDIlib as ndi
  6import numpy as np
  7from pythonosc.dispatcher import Dispatcher
  8from pythonosc.osc_server import ThreadingOSCUDPServer
  9
 10from utils.wrapper import StreamDiffusionWrapper
 11
 12OSC_HOST = "127.0.0.1"
 13OSC_PORT = 7000
 14NDI_INPUT_NAME = "td-input"
 15NDI_OUTPUT_NAME = "streamdiffusion-output"
 16
 17prompt = "cat, detailed, fantasy, 8k"
 18prompt_lock = Lock()
 19
 20
 21def set_prompt(_, value):
 22    global prompt
 23    with prompt_lock:
 24        prompt = value
 25
 26
 27def get_prompt():
 28    with prompt_lock:
 29        return prompt
 30
 31
 32def wait_for_ndi_source(target_name: str):
 33    finder = ndi.find_create_v2()
 34    try:
 35        while True:
 36            ndi.find_wait_for_sources(finder, 1000)
 37            sources = ndi.find_get_current_sources(finder)
 38            for source in sources:
 39                if source.ndi_name == target_name:
 40                    return source
 41    finally:
 42        ndi.find_destroy(finder)
 43
 44
 45def main():
 46    stream = StreamDiffusionWrapper(
 47        model_id_or_path="stabilityai/sd-turbo",
 48        t_index_list=[24, 32],
 49        width=512,
 50        height=512,
 51        acceleration="xformers",
 52        mode="img2img",
 53        output_type="np"
 54    )
 55    stream.prepare(prompt=get_prompt())
 56
 57    dispatcher = Dispatcher()
 58    dispatcher.map("/prompt", set_prompt)
 59    osc_server = ThreadingOSCUDPServer((OSC_HOST, OSC_PORT), dispatcher)
 60    osc_thread = Thread(target=osc_server.serve_forever)
 61    osc_thread.start()
 62
 63    ndi.initialize()
 64
 65    source = wait_for_ndi_source(NDI_INPUT_NAME)
 66    ndi_recv_cfg = ndi.RecvCreateV3()
 67    ndi_recv_cfg.color_format = ndi.RECV_COLOR_FORMAT_BGRX_BGRA
 68    ndi_recv = ndi.recv_create_v3(ndi_recv_cfg)
 69    ndi.recv_connect(ndi_recv, source)
 70
 71    ndi_send_cfg = ndi.SendCreate()
 72    ndi_send_cfg.ndi_name = NDI_OUTPUT_NAME
 73    ndi_send = ndi.send_create(ndi_send_cfg)
 74    ndi_frame = ndi.VideoFrameV2()
 75    ndi_frame.FourCC = ndi.FOURCC_VIDEO_TYPE_RGBA
 76
 77    for _ in range(stream.batch_size - 1):
 78        stream(prompt=get_prompt())
 79
 80    try:
 81        while True:
 82            frame_type, video_frame, _, _ = ndi.recv_capture_v2(ndi_recv, 1000)
 83            if frame_type != ndi.FRAME_TYPE_VIDEO:
 84                continue
 85
 86            input_image = np.copy(video_frame.data)
 87            ndi.recv_free_video_v2(ndi_recv, video_frame)
 88
 89            input_image = Image.fromarray(input_image)
 90            input_image = stream.preprocess_image(input_image)
 91
 92            current_prompt = get_prompt()
 93            output = stream(prompt=current_prompt, image=input_image)
 94            output = (output * 255).astype(np.uint8)
 95            output = cv2.cvtColor(output, cv2.COLOR_RGB2RGBA)
 96
 97            ndi_frame.data = output
 98            ndi.send_send_video_v2(ndi_send, ndi_frame)
 99
100    except KeyboardInterrupt:
101        print("中斷執行")
102
103    finally:
104        ndi.recv_destroy(ndi_recv)
105        ndi.send_destroy(ndi_send)
106        ndi.destroy()
107        osc_server.shutdown()
108        osc_thread.join()
109        print("已停止")
110
111
112if __name__ == "__main__":
113    main()

這裡我把輸入來源名稱寫成 td-input,是因為下一篇我們會用 TouchDesigner 當第一個前端。之後若要換成 Unity 或其他工具,只要改這個名稱,不用整段程式跟著改。

這一版多了什麼

上一版是「外部改 Prompt,程式自己生畫面」。這一版則是「外部同時丟 Prompt 和影像進來」。也就是說,StreamDiffusion 會開始真的扮演一個服務節點,不再只是自己關起門來產圖。

wait_for_ndi_source() 的作用,是在啟動時等待指定名稱的來源出現。這比直接硬抓第一個 NDI Source 安全很多,因為實際做系統時,電腦上常常不只一條 NDI 串流。先把來源名稱講清楚,後面才不會在錯的畫面上追 bug。

實際測試:外部畫面已經能進來

這個階段一樣可以先用 TouchDesigner 測。只要把一條即時畫面送成 td-input,再把 streamdiffusion-output 接回來,就能看到外部畫面真的進了 Img2Img 流程。

TouchDesigner 把一條即時畫面送進 StreamDiffusion,再把生成結果即時接回來顯示

走到這裡,Prompt 控制和影像進出都已經打通了。之後再接 TouchDesigner、Unity,重點就不再是 Python 端會不會通訊,而是各自前端的互動設計要怎麼安排。

FAQ

為什麼控制訊息和影像要分成 OSC 與 NDI 兩條線?

因為這兩種資料本來就不是同一種東西。OSC 很適合傳 Prompt、參數、開關狀態;NDI 則是拿來穩定送畫面。分開之後,每一層出問題時比較好找,系統也比較容易擴充。

為什麼這裡還是維持 Python 3.10?

一方面是 StreamDiffusion 官方要求 Python 3.10 起跳,另一方面是 ndi-python 目前支援的 Windows Python 版本到 3.10。通訊層一旦加入之後,環境就不只是模型能不能跑,還要顧到視訊傳輸套件能不能裝。

總結

把 StreamDiffusion 變成互動系統的一部分,真正的第一步是先把通訊層搭起來。本文先用 OSC 接 Prompt,再用 NDI 收送畫面,讓 StreamDiffusion 從單次執行的範例,變成可以被外部系統穩定驅動的節點。這層一旦站穩,TouchDesigner 和 Unity 的整合就不再需要碰模型核心,只要專心處理各自的互動前端。

本文提到的通訊元件與文件,請參考:

作者

Ted Liou

現職 Unity C# 工程師,主要分享 Unity、C# 與 Vibe Coding 相關技術教學。

上一篇 Windows 11 怎麼改 DNS?3 步驟處理「此網域已經遭到封鎖」 下一篇 Unity + Arduino:用 RFID 開發實體道具辨識的互動功能