互動程式創作徵文賞 彙整 | Creative Coding TW - 互動程式創作台灣站 https://creativecoding.in/category/collection221110/ 蒐集互動設計案例、教學與業界資源,幫助你一起進入互動程式創作的產業 Mon, 28 Nov 2022 00:21:31 +0000 zh-TW hourly 1 https://wordpress.org/?v=6.2.2 https://creativecoding.in/wp-content/uploads/2022/03/cropped-cct-logo-icon-2-32x32.png 互動程式創作徵文賞 彙整 | Creative Coding TW - 互動程式創作台灣站 https://creativecoding.in/category/collection221110/ 32 32 【徵文賞-動態網頁】優選|使用前端技術製作自己的修圖軟體吧(Python + OpenCV + eel) – 林慶佳 https://creativecoding.in/2022/11/24/collection221110-web-3/ Thu, 24 Nov 2022 14:54:53 +0000 https://creativecoding.in/?p=3308 透過互動程式創作徵文賞,我們期望讓更多人認識並加入 Creative Coding 這個新奇有趣的領域。此作品為動態網頁組佳作,使用 Python, OpenCV, 和 eel 結合前端介面技術的幾個框架製作自己的修圖軟體。

這篇文章 【徵文賞-動態網頁】優選|使用前端技術製作自己的修圖軟體吧(Python + OpenCV + eel) – 林慶佳 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
前言

個人半學期的期末報告專案,因為想做的更好,所以自行研究了 Python 結合前端介面技術的幾個框架。

建議先備知識

  1. 前端開發經驗。
  2. Python 使用經驗。
  3. OpenCV 使用經驗。

最終成果預覽

Python UI 介面選擇

一開始課堂上是教預設的Qt軟體,但我覺得Qt醜醜的,所以忍不住去嘗試其他框架。

Tkinter

跟以前做window form很像,用程式碼掛載與編排UI物件。

from tkinter import *

root = Tk()
#創建label widget
myLabel = Label(root, text="Hello world! ")
# show on screen
myLabel.pack()

root.mainloop()

Qt Designer

在Anaconda預設功能, Anaconda資料夾下找到designer.exe啟動。

排版檔案會取為.ui附檔名,操作邏輯與Tk類似。

範例: 讀取圖片、變暗處理。

#CallMyWindow1.py
import cv2
import numpy as np
from PyQt5.QtGui import *
import sys
from PyQt5.QtWidgets import *
from Mywindow3 import *

class MyMainWindow(QMainWindow, Ui_MainWindow):
	def __init__(self, parent=None):
		super(MyMainWindow, self).__init__(parent)
		self.setupUi(self)
		self.loadBtn.clicked.connect(self.btnLoadfile_clicked)
		self.darkenBtn.clicked.connect(self.btnDarking_clicked)
       #讀取圖片 - 按鈕
	def btnLoadfile_clicked(self):
		global img
		img=cv2.imread("Baboon.bmp")
		imgBGR = cv2.resize(img, (300, 300), interpolation=cv2.INTER_CUBIC)
		imgRGB = cv2.cvtColor(imgBGR, cv2.COLOR_BGR2RGB)
		height, width = imgRGB.shape[:2]
		qimg = QImage(imgRGB, width, height, QImage.Format_RGB888)
		qpix = QPixmap.fromImage(qimg)
		self.img.setPixmap(qpix)
        #圖片變暗 - 按鈕
	def btnDarking_clicked(self):
               #........
		self.resImg.setPixmap(qpix)

if __name__=="__main__":
	app = QApplication(sys.argv)
	myWin = MyMainWindow()
	myWin.show()
	sys.exit(app.exec_())

Eel

讓使用者能用前端技術做UI的Python框架,內部使用Bottle Server框架,但不像flask能直接host在線上。

from turtle import position
import eel
import os
#eel.init('./web') #資料夾
eel.init(f'{os.path.dirname(os.path.realpath(__file__))}/web')
eel.start('main.html',size=(400,200),position=(500,500)) #網頁

#eel.start('main.html',mode ='chrome-app') #網頁 (app模式)

Flask / Django

使用Python編寫的輕量級Web應用框架。

範例: flask基本頁面,但其網頁模板概念跟Django雷同。

from flask import Flask
app= Flask(__name__);

@app.route('/')
def index():
    return "hello worldd123456"

if __name__ =='__main__':
    app.run(debug=True , use_reloader=False port=5000 , host='0.0.0.0')

範例: Django網頁

創個views.py檔案,裡面定義Hello頁面內容

from django.http import HttpResponse

def Hello(request):
    return HttpResponse("Hello world")

在urls.py定義Hello頁面的網址

from django.contrib import admin
from django.urls import path

#from django.conf.urls import url
from django.urls import include, re_path
from . import views

urlpatterns = [
    path('admin/', admin.site.urls),
    #url(r'^$' , views.Hello)
    re_path(r'^$' , views.Hello)  #re_path使用regex
]
框架名稱優點缺點
Tkinter像是用Python程式碼撰寫介面的Qt Designer。邏輯與QtDesigner類似,同樣無法解決程式碼攏長、難做排版等問題。
Flask/Django較完整且可部屬網頁的web應用框架。Python與前端邏輯溝通較複雜,需使用ajax等呼叫方式。
eel旨在於結合前端技術開發桌面應用程式,前後端邏輯串接容易。我試著包在docker環境下部屬但失敗多次,雖然文獻不多但似乎eel不支援網站部屬。

EEL基本使用

(推薦影片: https://youtu.be/FNPW2ZFksCQ)

python 呼叫js方法

python 方法上加@eel.expose 讓js端可以呼叫。

import eel
import os

@eel.expose
def print_js():
    print("Hi hi")


#eel.init('./web') #資料夾
eel.init(f'{os.path.dirname(os.path.realpath(__file__))}/web')
eel.start('main.html',mode ='chrome-app') #網頁 (app模式)

func.js檔案:

function hello(){
    eel.print_js()()
}

Async方法使用

import random
import eel
import os

@eel.expose
def getnum():
    return random.randint(0,100)

#eel.init('./web') #資料夾
eel.init(f'{os.path.dirname(os.path.realpath(__file__))}/web')
eel.start('main.html',mode ='chrome-app') #網頁 (app模式)


eel.sleep(5)
#time.sleep(5) 要sleep就用eel
async function doA(){
    var a=await eel.getnum()()
    alert(a)

}

按下按鈕後,js端等待python處理結果後出現alert。

<!DOCTYPE html>
<html>

<head>
    <title></title>
    <script src="/eel.js"></script>
    <link rel="stylesheet" href="style.css">
    <script src="func.js"></script>

</head>

<body>
    <h1>Hell world</h1>
    <button onclick="doA()">hello</button>
</body>

</html>

JS-EEL圖檔傳輸

跨語言開發首先遇到的問題便是如何傳遞圖片資訊? 由於處理後的圖片需轉換成前端html可顯示的based64編碼,其跟openCV imread讀取進來的圖片格式之間的關係又是如何? 為此我必須先了解從HTML的input 標籤輸入的圖片至python端之間的格式與轉換。我整理出如下圖這個流程:

(1.) 由input標籤開啟的圖片為大型二進位檔物件(Binary Large Object ,blob),代表了一個相當於檔案(原始資料)的不可變物件。

(2.) 需實作js的FileReader物件,將blob轉碼成based64字串。

(3.) 透過eel框架傳送字串至Python端。

(4.) 使用based64處理套件將字串解析成byte陣列。

(5.) 透過numpy將byte陣列轉成uint8陣列。

(6.) 透過cv2套件將uint陣列解碼成圖片,此時得到的檔案形同於imread進來的圖片。

(7.) 使用圖片資料進行影像處理操作。

(8.) 將處理完的圖片回傳至前端作顯示時,逆著遵循前述的轉碼步驟,先將圖片轉成byte陣列,再以based64編碼成字串透過eel框架溝通回前端,由js接收並設定img標籤的src。

JS讀取圖片

使用jQuery偵測input標籤Change事件,搭配fileReader轉成base64字串。

$("#img").on("change", function () {
  var file = this.files[0];
  const fr = new FileReader();
  fr.onload = function (e) {
    preview.src = e.target.result;
    currentFile = e.target.result;
    console.log(e.target.result);
  };

  if (file) {
    fr.readAsDataURL(file);
  } else {
    console.log("沒有檔案");
  }
  console.log(file);
});

Python接收圖片

將base64字串透過轉碼,轉成opencv可使用的格式。

def data_uri_to_cv2_img(uri):
     encoded_data = uri.split(',')[1]
    img = base64.b64decode(encoded_data)
    npimg = np.frombuffer(img, np.uint8)
    image = cv2.imdecode(npimg,cv2.IMREAD_COLOR)
    return image

搭配OpenCV

範例: 將圖片轉灰階

eel.expose(SetImg);
function SetImg(newimg) {
  preview.src = "data:image/png;base64," + newimg
}

function Gray() {
  currentFile = preview.src;
  eel.toGray(currentFile);
}
import base64
import random
from cv2 import imread
import eel
import os
import cv2
import numpy as np

def data_uri_to_cv2_img(uri):
     encoded_data = uri.split(',')[1]
    img = base64.b64decode(encoded_data)
    npimg = np.frombuffer(img, np.uint8)
    image = cv2.imdecode(npimg,cv2.IMREAD_COLOR)
    return image

@eel.expose
def toGray(url):
    img = data_uri_to_cv2_img(url)
    img=cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
    #TODO:.....回傳

eel.init(f'{os.path.dirname(os.path.realpath(__file__))}/web')
eel.start('main.html',mode ='chrome-app') #網頁 (app模式)

Python傳回圖片

def img_to_base64(img):
    b64_str = cv2.imencode('.png', img)[1].tobytes()
    blob = base64.b64encode(b64_str)
    blob = blob.decode('utf-8')
    return blob

直方圖

直方圖常常用在檢視圖片顏色趨勢、集中位置等等。 (一開始使用Chart.js插件)

js 端

// 直方圖
eel.expose(UpdateGarphData);
function UpdateGarphData(graphID , b,g,r) {
  var labs= [Array.from({ length: 255 }).map((currentElement, i) => i)]
  const ctx = document.getElementById(graphID).getContext("2d");

  const chart = new Chart(ctx, {
    type: "bar",
    data: {
      labels:labs[0],
      datasets: [
        {
          label: "b",
          data: b,
          backgroundColor: "blue",
        },
        {
          label: "g",
          data: g,
          backgroundColor: "green",
        },
        {
          label: "r",
          data: r,
          backgroundColor: "red",
        },
      ],
    },

  });
}
@eel.expose
def histGraph(url):
    img = data_uri_to_cv2_img(url)

    # 單色
    if img.ndim !=3:
        b=cv2.calcHist( [img], [0], None, [256], [0,256] )
        b= b.astype("float").flatten().tolist()

    # 彩色
    else:
        b=cv2.calcHist( [img], [0], None, [256], [0,256] )
        g=cv2.calcHist( [img], [1], None, [256], [0,256] )
        r=cv2.calcHist( [img], [2], None, [256], [0,256] )
        b= b.astype("float").flatten().tolist()
        g= g.astype("float").flatten().tolist()
        r= r.astype("float").flatten().tolist()

    eel.UpdateGarphData("histogram",b,g,r)()

注意np array無法直接傳輸,所以要轉成一般array。

瀏覽器無法像Python的numpy與的plot套件快速做統計與圖表,加上本身也不支援多線程(Multi-threading),因此當圖片解析度大,需要統計的數變多,前端直方圖生成便會延遲,除了採用異步執行避免程序卡死外,我還試過多個輕量圖表套件如Chart.js、dygraphs、EChart.js,尋找對資料最友善的解決方案,雖然目前前端直方圖生成仍有延遲存在,是日後需想辦法解決的問題(目前已是在Python生成直方圖數據,再傳輸至前端表現,理論上只需處理255*3個顏色資料,我認為會卡的點在於繪製與自動尋找最小至最大值區間)。

等化功能

等化是將像素顏色拉得平均一點。

@eel.expose
def equalizeHist(url):
    img = data_uri_to_cv2_img(url)
    g=img.copy()
    #單色
    if g.ndim!=3:
        g=cv2.equalizeHist( img );
    #彩色
    else:
        for i in range(0,3):
            g[:,:,i]=cv2.equalizeHist(img[:,:,i])

    histGraphByImg(g)
    blob = img_to_base64(g)
    eel.SetImg(blob)()

操作Hot Key

註冊document事件,並藉由array紀錄各步驟的base64字串。

const maxSaves=10;
const previousSaves = [];
const nextSaves = [];
//上一步
function BackToPreviouse() {
  if (previousSaves.length > 0) {
    base_str=previousSaves.pop();
    preview.src = base_str;
    SaveNextStep(base_str)
  }
}
//下一步
function BackToNext(){
  if (nextSaves.length > 0) {
    base_str=nextSaves.pop();
    SaveStep(base_str);
    preview.src = base_str;
  }
}
//紀錄步驟
function SaveStep(bs64){
  if(previousSaves.length<maxSaves){
    previousSaves.push(bs64);
  }
  else{
    //移除第一個
    previousSaves.shift();
    previousSaves.push(bs64);
  }
}
//紀錄返回上一步步驟
function SaveNextStep(bs64){
  if(nextSaves.length<maxSaves){
    nextSaves.push(bs64);
  }
  else{
    //移除第一個
    nextSaves.shift();
    nextSaves.push(bs64);
  }
}

// ***************** 註冊 hot key *********************


function RegiseterHotKey(e) {
  //上一步
  if (e.ctrlKey && e.key === 'z') {
    BackToPreviouse();
  }
  //下一步
  if (e.ctrlKey && e.key === 'x') {
    BackToNext();
  }
}
document.addEventListener('keyup', RegiseterHotKey, false);

// ***************** --- *********************
範例:上下一步熱鍵。

JS寫出圖片

源圖與輸出圖。
// Save as
async  function SaveAs() {
  const opts = {
    types: [
      {
        description: "Images",
        accept: {
          "image/*": [".png", ".gif", ".jpeg", ".jpg"],
        },
      },
    ],
    excludeAcceptAllOption: true,
    multiple: false,
  };

  //base64 轉 blob
  const url= decodeURIComponent(preview.src).split(",")[1];
  const imgBlob = Uint8Array.from(atob(url), c => c.charCodeAt(0));
  console.log(imgBlob);

  const newHandel = await window.showSaveFilePicker(opts);
   const writableStream = await newHandel.createWritable();

   // write our file
   await writableStream.write(imgBlob);

   // close the file and write the contents to disk.
   await writableStream.close();
}

拖拉圖片

//preview 畫布拖拉
$(function () {
  $("#preview").draggable();
});

//畫布滑鼠滾輪縮放
function zoom(event) {
  event.preventDefault();

  scale += event.deltaY * -0.02;

  // Restrict scale
  scale = Math.min(Math.max(0.125, scale), 500);
  $("#preview_slider").val(scale).change();

}

let scale = 1;
const el = document.querySelector("#preview");
el.onwheel = zoom;

el.addEventListener("wheel", zoom);

JS事件

在修改圖片時觸發事件,如此便能最後續處理,例如圖片縮圖僅需在圖片有更動時才須更新。

//修改圖片事件:
$(document).on("mainImageChanged",function(e,eventInfo){
  console.log(eventInfo);
  //.....
})

//修改圖片
function SetImg(newimg) {
  preview.src = "data:image/png;base64," + newimg;

	//觸發事件
  $(document).trigger("mainImageChanged",preview)
}

顏色通道

圖片RGB拆成可以分別開關的通道,在P圖的時候常會從通道中選一張二值化效果最好的通道做遮罩,不過目前本軟體的通道只能顯示、不能編輯(;´༎ຶД༎ຶ`)  (時間不夠..)。

@eel.expose
def useChannel(url, isR, isG, isB):
    isR = bool(isR)
    isG = bool(isG)
    isB = bool(isB)
    img = data_uri_to_cv2_img(url)

    g = np.zeros(img.shape)

    if isR:
        g[:, :, 2] = img[:, :, 2]
    if isB:
        g[:, :, 0] = img[:, :, 0]
    if isG:
        g[:, :, 1] = img[:, :, 1]

    blob = img_to_base64(g)
    eel.SetImg(blob)()

在地化

加上為了降低開發環境複雜度,本軟體前端使用較原生的JQuery輔助,沒有用如React.js等完整的web生態系統,能使用的工具較為受限,最後使用行之有名的i18n在地化解決方案應付小需求翻譯。

$.i18n({
  //不要打預設語系就會自動抓瀏覽器語言
  //locale: "zh-TW",
  //locale: "en",
});

$.i18n()
  .load({
    en: "/i18n/en.json",
    'zh-TW': "/i18n/zh-TW.json",
  })
  .done(function () {
    $('body').i18n();
  });


function SetLanguage(lang){
  $.i18n().locale = lang;
  $('body').i18n();
}
在地化效果。

簡易圖層

將兩張圖疊在一起
<!-- 混和圖片 -->
        <div class="opt_btns_container">
            <div class="opt_bar">加入圖片</div>
            <!-- 欲加入的圖片 -->
            <div class="side_small_preview_container" id="overlay">
                <img class="channel_preview_img" id="newLayerImage"
                    src="https://react.semantic-ui.com/images/wireframe/image.png">
                <script>
                    createSelect("overlay", "overlayMethod", "混合模式", [
                        { val: "normal", text: "正常" },
                        { val: "brigher", text: "變亮" },
                        { val: "darken", text: "變暗" },
                    ]) 
                </script>
                <!-- 加入按鈕 -->
                <label for="layerImgInput">
                    <img class="toggleImg" id="ct_b" isToggled="true" onclick="LoadLayerImage(this)"
                        src="./img/eye.png"></img>
                </label>
                <input type="file" id="layerImgInput" name="layerImgInput" hidden>
                <button class="opt_apply_btn" onclick="OverlayerImage()">套用</button>
            </div>
        </div>

HTML操作按鈕

//疊圖層
function OverlayerImage(){
  b64_str = realImage;
  layer_b64_str =newLayerImage.src ;
  opt=overlayMethod.value;
  SaveStep(b64_str);
  eel.overlayImage(b64_str,layer_b64_str,opt)

}
def overlayImage(url,layerUrl,opt):
    img = data_uri_to_cv2_img(url)
    img_overlay = data_uri_to_cv2_img(layerUrl)
    
    h, w = img_overlay.shape[:2]    
    hh, ww = img.shape[:2]    

    yoff = np.clip( round((hh-h)/2),0,hh)
    xoff =np.clip( round((ww-w)/2),0,ww)
    
    yMax= np.clip(yoff+h , 0 , hh)
    xMax= np.clip(xoff+w , 0 , ww)
    print(yoff,xoff , "|",yMax,xMax)

    xMin= min(ww,w)
    yMin= min(hh,h)

    result = img.copy()
    result[yoff:yMax, xoff:xMax] = img_overlay[:yMin,:xMin] #避免取出邊界
    
    blob = img_to_base64(result)
    eel.SetImg(blob, false, false)()

小優化 — 副檔名溝通

原本以png回傳,但圖片大小因此暴增10倍

# 圖片轉base64
def img_to_base64(img):
    b64_str = cv2.imencode(".png", img)[1].tobytes()
    blob = base64.b64encode(b64_str)
    blob = blob.decode('utf-8')
    print("process Time --- %s seconds ---" % (time.time() - start_time))
    return blob
jpg編碼檔案大小: 120KB
png檔案變成 1MB

解決辦法:互相溝通檔案型態

if (file) {
    fr.readAsDataURL(file);
    console.log(file);
    currentFileExtension =file.type.split("/")[1];
    //更新 footer KB數
    $("#fileSize_text").html(Math.round(file.size / 1024) + "KB");
    //設定副檔名
    eel.setFileExtension(currentFileExtension)
  } else {
    console.log("沒有檔案");
    alert("不支援檔案類型");
  }
@eel.expose
def setFileExtension(ext):
    global current_file_extension
    current_file_extension="."+ext

【補充】

Base64 轉法造成卡頓?

原本以為卡頓問題是反覆的base64轉碼造成的。

import time
image =""

# base64轉np image
def data_uri_to_cv2_img(uri, forceUpdate=false):
    global image
    start_time = time.time()
    #print(image)
    if image!="" and not forceUpdate:
        return image

    uri_spt = uri.split(',')
    if(len(uri_spt)>1):
        encoded_data=uri_spt[1]
    else:
        encoded_data=uri_spt[0]
  
    img = base64.b64decode(encoded_data)
    npimg = np.frombuffer(img, np.uint8)
    image = cv2.imdecode(npimg, cv2.IMREAD_COLOR)
    print("base 64 decode Time --- %s seconds ---" % (time.time() - start_time))
    return image

做了點log發現轉碼所需時間不長,主要問題是Chart.js插件效能問題。(javascript – ChartJS is slow – Stack Overflow)

base 64 decode Time --- 0.010005474090576172 seconds ---
base 64 decode Time --- 0.007999897003173828 seconds ---
base 64 decode Time --- 0.008002042770385742 seconds ---
base 64 decode Time --- 0.007974386215209961 seconds ---
base 64 decode Time --- 0.007002353668212891 seconds ---
base 64 decode Time --- 0.0069997310638427734 seconds ---
base 64 decode Time --- 0.00800013542175293 seconds ---
base 64 decode Time --- 0.007972240447998047 seconds ---
base 64 decode Time --- 0.007972240447998047 seconds ---

ERROR : WebSocket connection to ‘ws://localhost:8000/eel?page=main.html’ failed: Could not decode a text frame as UTF-8

https://github.com/python-eel/Eel/issues/299

在js呼叫另一個eel方法時,上一個還沒結束產生的ERROR。

打包輸出exe檔案

Eel is a little Python library for making simple Electron-like offline HTML/JS GUI apps, with… (curatedpython.com)

*EelPoriect拼錯字

cd src
python -m eel app.py web

---
python -m eel ./src/app.py ./src/web

這篇文章 【徵文賞-動態網頁】優選|使用前端技術製作自己的修圖軟體吧(Python + OpenCV + eel) – 林慶佳 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
【徵文賞-互動藝術】佳作|P5.js學習筆記:時間之隙Time gap – 羅瑋婷 https://creativecoding.in/2022/11/24/collection221110-art-1/ Thu, 24 Nov 2022 14:00:06 +0000 https://creativecoding.in/?p=3301 透過互動程式創作徵文賞,我們期望讓更多人認識並加入 Creative Coding 這個新奇有趣的領域。此作品為互動藝術組佳作,嘗試使用 shader 疊加不同效果,實驗的思路分成兩個方向,一是純粹改變外觀形狀,二是讓其他動態線條追上水流的變化。

這篇文章 【徵文賞-互動藝術】佳作|P5.js學習筆記:時間之隙Time gap – 羅瑋婷 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
裂隙彼方的幻影

之前曾經用shader成功做出夢想中的水流效果,因此之後就開始研究是否能把不同的效果疊加在上面,呈現更多不一樣的變化。實驗的思路分成兩個方向,一是純粹改變外觀形狀,二是讓其他動態線條追上水流的變化。目前雖然只暫時成功了關於形狀的部分,但這也算是一種進度,因此決定作為筆記先記錄下來。

使用的效果有兩種:

  • fbm:水流背景
  • shockwave:形狀變化的定義

fbm的部分,沿用了之前曾做過的雲水,所以重點在控制變形的 shockwave 。濾鏡的參考來源是andras這位作者,原本的效果是有點類似墨漬在紙上暈染開來的感覺,經過加工後把它改成類似在虛空中撕開時間縫隙的視覺感。

將原本的濾鏡拆解後,主要的功能函數基本上可以分成4個部分,依參數的使用層級排序為:

  • rectToPolar
  • effect
  • line
  • sw

首先 rectToPolar 這個函數負責的是「形狀呈現的基礎位置」。

  • p:畫面座標。
  • ms:時間。
  • a + r:配合atan + PI,回傳有固定範圍的擴張圓。
	vec2 rectToPolar(vec2 p, vec2 ms) {
		p -= ms / 1.35;
		const float PI = 3.1415926534;
		float r = length(p);
		float a = ((atan(p.y+0.2, p.x) / PI) * 0.85 + 0.5) * ms.x;
		return vec2(a, r);	
	}

effect接收rectToPolar回傳的參數後,再藉由fbm產生的參數重定義顯示座標。

  • p:rectToPolar處理過的範圍。
  • o:原點,這裡傳入的是float(0.0)。
  • e:透過abs()和sqrt(),緩和變動的範圍。如果想要比較尖銳的形狀,可以省略這段處理。
float effect(vec2 p, float o) {
		p *= 1.5;
		float f1 = fbm(p * vec2(13.0, 1.0) + 100.0 + vec2(0.0, o) );
    
		float e = fbm(p * vec2(15.0, 1.0) + vec2(f1 * 0.85, o));
    e = abs(e) * sqrt(p.y / 5.0);
    
    return e * 0.75;
	}

之後line負責的是「以圓為基礎擴張出去的線條曲化度」。

  • v:畫面座標值。
  • from, to:開始與結束的數值。to的值受effect函數影響。
  • d:搭配傳入的參數(v, from, to),定義最後起點到終點的最大值,讓形狀擴張到一定的距離後自動停下來。
  • smoothstep:WebGL的函數之一,不想啃官方文件的話其實在The Book of Shaders可以找到中文說明。用來將線條平滑曲化,傳入的三個值類似(start, end, value)這樣的定義。
 float line(float v, float from, float to, float f)
	{
		float d = max(from - v, v - to);
		return 1.0 - smoothstep(0.0, f, d);
	}

最後sw把前三個搭配在一起,產生出要在畫面上變動的值:

  1. 首先用rectToPolar產生橢圓的範圍座標p。
  2. 用line產生x軸的偏移曲線。
  3. 將p傳入effect,產生從0到結束位移點兩種不一樣的濾鏡範圍。
  4. 最後混合time的變化,透過line產生y軸的偏移曲線。
  5. 回傳平滑後的形狀。
float sw(vec2 p, vec2 ms) {
		
		p = rectToPolar(p, ms);
		p.x = mod(p.x + 2.95, ms.x);
		
		// Create the seem mask at that offset
		const float b = 0.5;
		const float d = 0.04;
		float seem = line(p.x, -1.0, d, b) + line(p.x, ms.x - d, ms.x + 1.0, b);
		seem = min(seem, 1.0);
		
		float s1 = effect(p, 0.0);
		
		// Create another noise to fade to, but the seem has the be at a different position
		p.x = mod(p.x + 3.6, ms.x);
		float s2 = effect(p, -1020.0);
		
		// Blend them together
		float s = s1;
		s = mix(s1, s2, seem);
		    
    float perc = min( max(abs(sin(u_time * 0.1)), u_time * 0.1), 1.0);
    
    float f1 = perc * 0.25;
    float f2 = perc * 1.;
    
    float m = line(p.y, 0.1, f1 + s * f2 * sin(1.5), 0.2);
		
		return smoothstep(0.31, 0.6, m);
	}

完成以上系列行為,最後要將參數輸出到畫布上。使用fbm的部分略過不看,下面就只列出在main裡引用shockwave相關函數的部分:

  • c:用sw()回傳的函數s加工後的參數。先除後加是為了產生形狀再把顏色疊上去。
  • col:把fbm產生的函數f和c混合,最後再稍微加一點隨機變化。
    vec2 p = gl_FragCoord.xy / u_resolution.xy;
    float m = u_resolution.x / u_resolution.y;
		vec2 ms = vec2(m, 1.5);
     
    float c = .90;
    
    float s = sw(p, ms);
    c /= s;
    c += s;
    float t = random(p * 2.0);
  	
		vec3 pic = vec3(f);
		
   	vec3 col = mix(color, pic, c); 
    
    // Some grain
    col -= (2.0 - s) * t * 0.04;
    
		gl_FragColor = vec4((f*f*f + .26 * f*f + .15 * f) * col,1.0);

成品大概是這種感覺。雖然結果和最初的想像有落差,但意外的看起來還可以⋯⋯?其實一開始是想找有沒有方法能做出把墨滴進水裡的擴散感,後來因為單純相加的效果不好,重複嘗試後才轉向這種呈現方式,也算是另類的收穫。

最後,順便記錄一下自我流派式的學習心得吧。雖然不明白其他人是如何學習的,但我算是「累積式」。在學習路途上,我其實挺常間歇性的陷入某種循環:有想要的效果→搜尋資料→嘗試→做不出來→自我厭惡→loop。從旁觀角度看雖然有些智障,身處其中時還真是笑不出來的狀況。

說到底,擁有藝術和程式兩面性的生成藝術和普通的繪畫創作本就不同,但對我來說要把兩者徹底分開一直都有困難。和直接拿著筆不同,最容易挫折的不是畫不出好東西,而是知道自己畫不出來卻不明白該用什麼方式改進,當自我厭惡積累到某種程度時可能會就此放棄。

所以我現在變成了順其自然的累積式。簡單來說就是看多過做,嘗試時發現看不懂就繼續加長搜尋資料的週期,過段時間回來就會像突然打通任督二脈一樣成功做出來。當然這方式缺點是學習時間超~級長,但每個人的學習狀態各有不同,或許在他人眼中算不上好,如果能讓自己繼續堅持學習,我想那肯定就會是對自己來說的「好方法」了。

這篇文章 【徵文賞-互動藝術】佳作|P5.js學習筆記:時間之隙Time gap – 羅瑋婷 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
【徵文賞-互動藝術】佳作|Eyeo Festival 2022 心得 – 王浩平 https://creativecoding.in/2022/11/24/collection221110-art-2/ Thu, 24 Nov 2022 13:52:36 +0000 https://creativecoding.in/?p=3283 透過互動程式創作徵文賞,我們期望讓更多人認識並加入 Creative Coding 這個新奇有趣的領域。此作品為互動藝術組佳作,Eyeo Festival 是一場集結藝術家、設計師和開發者的研討會,每年固定在美國 Minneapolis 舉辦,透過演講、工作坊和自由交流的形式進行。

這篇文章 【徵文賞-互動藝術】佳作|Eyeo Festival 2022 心得 – 王浩平 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
“Buildings, Cities, and Minneapolis”

Eyeo Festival 是一場集結藝術家、設計師和開發者的研討會,每年固定在美國 Minneapolis 舉辦,第一次是 2011 年,除了 2020、2021 因為疫情取消之外,每年都有舉辦。內容相當廣泛,包含 Creative Coding、資料視覺化、裝置藝術、體驗設計、人工智慧、VR/AR 等等,基本上只要跟藝術或創作沾得上邊都包含在內。Eyeo Festival 透過演講、工作坊和自由交流的形式進行,因此實際上不如「研討會」這麼嚴肅,接下來用 conference 代替。

我是透過 OpenProcessing 得知這個活動,本來因為地點、時間跟錢,沒有考慮要參加。直到活動前的一個多月,OpenProcessing 在平台上發起了創作比賽,只要在 5/5-21 把作品提交至比賽的 curation -「Buildings, Cities, and Minneapolis」,並且獲得最多的愛心就可以獲得 Eyeo Festival 門票一張。

一開始其實沒特別積極要參加,覺得要能在眾多作品中勝出應該相當困難,但也覺得這個主題蠻有趣的。所以一邊關注其他人提交的作品,一邊想如果是自己做的話,作品的內容和呈現會是什麼樣子?過了幾天發現作品量不多,愛心數似乎也不是遙不可及,也對這個主題有一點創作想法了。於是就花了幾天零碎時間,完成了大概的雛形。最原先想法是做出台灣鐵皮屋頂俯視角度印象,過程中為了讓線條有點隨機、不整齊,意外做出一點素描的風格。本來還想把不規則的馬路排列做出來,但發現難度遠超過目前能力所及,硬著頭皮還是把半成品 “city on the rooftop” 提交出去了。

接下來的幾天我用盡全力在 Twitter、IG、Discord 和 FB 社團推廣我的作品,求大家如果喜歡的話幫按個愛心,終於在截止當天超越了最多愛心的作品。看了一下按了愛心的人有:還沒開始分享就先按的、開始推廣後打開自己 OpenProcessing 帳號支持的、沒在寫程式但特別去註冊投票的朋友們(還好沒被認定是灌票),到現在還是覺得很奇妙,好像臉皮夠厚、願意踏出主動分享創作的那一步,就會得到來自全宇宙的大力幫助。

city on the rooftop

Are you caaatisgood?

比賽截止兩天後,OpenProcessing 在 Twitter 上宣布了結果,我拿到 Eyeo 門票了!有趣的是,當時 OpenProcessing 創辦人 Sinan 也在倫敦,我們就約了隔天在 Camden Market 見面喝個咖啡。記得當時他晚了一點點到,我一度懷疑一切會不會是太過美好的一場夢?或是如姜太公釣魚般的一場騙局?怎麼會贏了網路上的創作比賽,隔幾天就約連名字都還不知道的網友出來喝咖啡?(當時是用 OpenProcessing Twitter 溝通,所以還不知道他是誰)不一會兒從我身後傳來的一句 Are you caaatisgood?(我網路上的暱稱),那些想法才頓時煙消雲散。

雖然時間不長,但我們聊了 Creative Coding、生成式藝術 NFT 平台、OpenProcessing 未來的經營方向、倫敦和他待了一陣子的紐約之間差異、一些 side project 等等。拿了票,約好到時候 Minneapolis 再見。

Code + Care Summit

時間快轉到 Eyeo Festival 前一天,是一場被官方稱為 un-conference 的一日活動「Code + Care Summit」,對我而言最大的特色是,討論的內容和方式全部由參加者來決定。當天除了幾間會議室、交流空間和一些茶點,就只有一面寫著空白時程的白版,依照會議室的數量,同時間最多有四個不同主題,參加者可以按自己興趣選擇,也可以拿起白板筆寫上想討論的主題,就自己成為討論的主導人,有二十分鐘或一個小時不同長度的時間安排。雖然官方的主題是 Code + Care,但最終的討論內容可說是五花八門,有 Accessibility in Web3、Caring for open source、Mentorship、教 p5.js 的老師為了教學方便做的 p5 party 工具、如何創造更友善(避免歧視、霸凌)的線上沈浸式體驗、Data Storytelling、把音樂作為教育內容等等。

每場討論都相當開放,沒有一個明確的結論。大家的背景和專業不同,也讓整個討論非常多元,有許多議題或想法是自己從來沒了解過的。其中一個相當印象深刻的討論是「我們怎麼紀念因為 COVID-19 失去的生命」,所有人分成五六個小組,發想出各種「紀念」的方式,實體、虛擬、大方向的概念等等。更有人提到這件事其實還是進行式,應該要納入紀念的形式的考量之中。

當然也有一些討論沒有如預期中的「順利」,有些時候隨著主題的延伸和聯想,最後引發共鳴的反而離本來主題有點遠,也有的大概自我介紹完就差不多沒時間了。但無論如何都不影響大家提出自己想法和意見的熱情。

以前從沒參加過這樣密集的開放式討論,一整天下來覺得自己的好奇心有被提升了一些,也體會到要把「自己」縮小或擺在一旁,才更容易了解每個不同背景的人的獨特想法。另一方面是理解到關懷(caring)的重要性,它似乎是在產品、軟體、體驗和週遭環境,容易被忽視或欠缺資源的一部分。如果在生活或專業上可以多一點關懷,我們會有更高機會打造出能包容各種不同背景的人和需要的環境。

Eyeo Festival

來到晚上的 Eyeo Festival 開幕之夜,只見大家人手一杯飲料交談、聊天,現場甚至有爵士配樂,整體是種輕鬆以對卻又迫不及待要揭開序幕的氛圍。現在回想起來,可以理解為什麼主辦方時不時會提到這是節慶(festival)不是 conference。自己對當晚的主題演講比較沒有共鳴,倒是主辦方的 Dave 開場時給大家的心態矯正覺得很不錯。他提到的其中幾點:

  • 每個人都感覺自己有冒牌者症候群,不必覺得害羞或沒有能力分享、發問和交流。
  • 投入活動越多,獲得的就越多。

這類型的活動能否辦得成功,有一部分會取決於與會者的表現。雖然多數人都是來交流和吸收新知的,在開始前提醒大家要帶著開放的心態、凝聚參與者們的共識,讓對的人之外還有對的心態。這部分的細節處理對活動整體大大加分。

當晚還有多位參加者的五分鐘分享,其中有位 DeepMind 工程師 Michael 分享學習射箭的心得,他有一陣子在分數上一直無法進步,後來他發現當他停止在意分數時,準度反而明顯提升。他接著說到生活中很多其他的「計分工具」,例如社群媒體。這些計分工具能做到相對客觀的成效衡量,但太在意分數也有可能造成反效果,限制了自己的能力。下次卡關時,不仿試試忘掉「計分工具」,也許能得到更好的結果。

接下來是連三天滿滿的議程和活動,白天時段是 4~5 場雙軌並行的演講,中午休息時間有參加者自由報名的閃電秀,晚上則是形式較輕鬆的交流活動。以往參加的 conference 幾乎都是網頁相關技術領域為主,不管內容再怎麼廣,基本上都還在自己的守備範圍內。因此以設計、藝術、創作者等領域為主的 Eyeo Festival 對我來說是很新鮮的,沒接觸過的創作類型或手法不說,有些甚至連想都沒想過。即便如此,在參加前仍很肯定的知道自己多少都能從不同的人和創作中獲得啟發,而實際上確實是如此。聽著每位藝術家對你分享自己投入、開創已久的宇宙,是很難不被他們的熱情所感染的。

我特別對 Design I/O 的 Emily Gobeille 和 Theodore Watson 開發 The Pack 這款策略遊戲的分享感到印象深刻,遊戲包含很多程式和運算邏輯的元素,他們將不同的指令和邏輯個性化,設計成不同的角色,玩家可以自由組合,做出能重複執行的指令和演算法。Daniel Brown 分享他的 Neuromancer and the Sprawl 作品也非常讓人驚艷,這系列受到 cyberpunk 小說家 William Gibson 的啟發,後來也被 William 邀請為他的小說製作封面。這位曾經意外重傷導致下半身麻痺,卻執意回歸創作且做得有聲有色的藝術家,對創作的熱情與執念實在讓人佩服。

活動中也遇到很多有趣的人,有幾位甚至是聊了許久才知道原來是早就在 Twitter 或 IG 上 follow 已久的神級人物,像是生成式藝術家 Chris RiedSimon。最特別的是一位活動志工 Blair,就讀人因工程與設計博士的他對 OpenProcessing 平台上使用者複製(fork)別人的作品(sketch)加以修改創作很有興趣,他針對這個再創作行為寫了一篇報告,還用了送我來 Eyeo Festival 的作品當封面,他也打算投入更多時間深入研究。

首次嘗試 pen plotter 繪圖機

Eyeo Festival 結束前一天,一時興起想把 “city on the rooftop” 用繪圖機畫出來做成明信片寄給自己,很幸運的是真的有人有繪圖機並且願意帶來借給我,當天趕在附近的用品店關門前找到適合的卡紙跟黑筆,隔天一早把作品調整成適合輸出的大小和格式,利用中午的空擋開始試印。雖然最後因為時間的關係只輸出一張,還因為比例沒抓好有點落漆。自己的數位作品透過實體介質繪製出來,可以拿在手上看,感覺非常不一樣,似乎也多了點紀念價值。

密西西比河畔的最後一晚

很喜歡 Eyeo Festival 的場地安排,開幕夜在一個表演場地 Aria,白天的活動在 Walker Art Center 的會議廳,其中一晚的自由交流和工作坊舉辦在另一個表演場地 Machine Shop,最後一晚則是在密西西比河上 Nicollet Island 的表演空間。望著流速強勁卻又看似平靜的河與夜景,回想當初看到 OpenProcessing 比賽徵件直到此刻站密西西比河畔回想著這一切,試著讓自己相信這不是夢一場。

一位有許多以鳥類為主題的研究和創作的藝術家 Kitundu,在閉幕夜分享過往的創作和為氣候變遷組織 C40 製作獎盃 Ancient Air 的過程。有句非常打動我的一句話「Take your imagination seriously」,認真看待你的想像力。每個人的都有獨特的想像力和創造力,這完全無關你的背景,如果能夠去探索、理解、善用,甚至回應它,一定會有意想不到的收穫。也許是對本業產生新想法、找到新的興趣或開展出新的一條路也說不定。在 Eyeo Festival 遇到非藝術或技術背景的人,有位是在五角大廈做一些國際情勢分析,有人是在學校教書,也有在其他公家機關工作的人,但他們都以「尋找靈感」為由來到這裡。探索想像力和找尋靈感,真的不是藝術家才可以(或應該)做的事。希望自己可以一直記得並實踐這句話。

離開前,我把繪圖機畫出來的唯一一張 “city on the roof top” 送給 Sinan,特別謝謝他做了 OpenProcessing 平台讓我有這個機會出現在這裡,祝他接下來從紐約搬去阿姆斯特丹一切順利。

關於我

本業是一位前端工程師,2021 年出海到倫敦生活,在更早之前因為吳哲宇而接觸到生成式藝術(感謝哲宇!),2021 年底開始比較認真的創作,也慢慢接觸到 Web3 領域,希望能在 Creative Coding 和 Web3 世界裡留下一些足跡。感謝你讀到這邊,任何想法或回饋歡迎到 TwitterIG 上告訴我。

相關連結

這篇文章 【徵文賞-互動藝術】佳作|Eyeo Festival 2022 心得 – 王浩平 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
【徵文賞-沉浸式內容】佳作|VR漫畫剖析 —— 360º場景融合漫畫框,開啟敘事新角度 – 張佳穎 https://creativecoding.in/2022/11/24/collection221110-immersive-2/ Thu, 24 Nov 2022 13:31:54 +0000 https://creativecoding.in/?p=3266 透過互動程式創作徵文賞,我們期望讓更多人認識並加入 Creative Coding 這個新奇有趣的領域。此作品為沉浸式內容佳作,在現今社會,漫畫隨著科技的演進與人們的閱聽習慣逐步發展。本文將討論漫畫在平面與VR裝置之流變,以及針對VR漫畫現存問題與在我們創作歷程中嘗試出的解方,並提出VR漫畫未來發展可能。

這篇文章 【徵文賞-沉浸式內容】佳作|VR漫畫剖析 —— 360º場景融合漫畫框,開啟敘事新角度 – 張佳穎 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
《Out of the Cave》VR 漫畫創作者經驗總結

作者:李煜函、張佳穎

VR漫畫《Out of the Cave》

在現今社會,漫畫隨著科技的演進與人們的閱聽習慣逐步發展。本文將討論漫畫在平面與VR裝置之流變,以及針對 VR 漫畫現存問題與在我們創作歷程中嘗試出的解方,並提出 VR 漫畫未來發展可能。

隨著手機的普及,人們逐漸轉變閱讀習慣,捲動瀏覽的條漫現今已是十分主流的漫畫形式,而因應現代人對聲光刺激的喜愛,漫畫也慢慢出現加上配樂、配音以及部分動態等不同形式。韓國企業旗下漫畫平台 Webtoon 更是在 2020 推出漫畫結合 3D 模型,利用滑動旋轉場景的方式,讓觀眾能主動與漫畫互動,創造更高層次的參與度與懸疑驚嚇效果。漫畫演變至此,已然超越純粹的平面空間,有了更多的可能性,而漫畫的本質也逐漸超越了紙面上框格之間的圖像與文字,成為了一種特定風格的敘事手法。

Webtoon 新形式滑動旋轉條漫

早在2017年, Webtoon 恐怖漫畫作者 HORANG 就已嘗試將條漫轉化為 VR ,聯合數位創作者推出專門觀看 VR 漫畫的平台 Spheretoon 。不過大部分平台上的漫畫都只是將部分分鏡的角色背景分割,前後放置製造立體空間感,並在重點場景使用 360 全景來加深沈浸力度,但大部分依舊維持條漫的單格出現模式,淪為純粹「在立體空間看平面漫畫」,沒有足夠的吸引力與手法創新,來說服觀者使用 VR 觀看漫畫。

Webtoon 新形式滑動旋轉條漫

這樣的體驗問題在於無法成功發揮 VR 的最大優勢——高強度的沈浸體驗,以及 360 度可探索、敘事的空間。那麼 VR 漫畫該如何才能發揮此特性,跳脫純粹展示框格內容的表現形式,是我們希望可以解決的問題。在開始設計解方之前,我們也觀察到 VR 有其敘事缺陷,而那正是平面裝置所擁有的長處——分鏡。自由切換分鏡使得敘事變得靈活、順暢,也能重點呈現資訊與情感,而視角的選擇也是展現作品「觀點」的重要手法,但頻繁在VR場景中切換視角,會導致體驗上的斷層與困惑,甚至會有生理上的不適感。

而漫畫所擁有的分鏡能力,恰能補足 VR 敘事觀點難以自由流動的問題。因此同時凸顯 VR 與漫畫的優勢,使其不只是用 VR 裝置看漫畫,而是一個為 VR 體驗而誕生的的漫畫。

不過在實務上,如何在漫畫與 VR 中取得平衡?何時該以漫畫框分鏡呈現內容?何時又該將資訊還給場景?在創作歷程中,我們總結出幾點手法:

漫畫框與VR場景敘事上發揮不同效果

VR 漫畫具有強烈的空間感,但缺點是無法靈活的進行鏡位變換,此時,在立體場景中加入漫畫框補敘,就會是好的時機點。在兩者分工時須特別注意漫畫框不要重複講述場景中發生的事,可以把連續動作、環境特色等留給 VR 360º 場景去處理,發揮 VR 身歷其境的特點;而故事細節、特殊角度、角色情緒或想法則留給漫畫框,做更精緻或更自由的設計。

角色可以在立體空間中自由活動,而漫畫框則作為輔助提供觀者更多故事細節。

善用空間營造與聲音設計,豐富視覺感受

在創作上,可以讓單一場景發揮最大價值,創造層次豐富的感官體驗。除了近景的細節探索外,較遠的中景也是可以讓角色活動、加入一些故事或小彩蛋的地方,而遠景則可以營造整體氛圍。除了視覺,也可利用不同位置的聲音效果,與簡單的循環動畫加強體驗感受。

在場景建構上可利用了高低落差大的自然場景,例如山谷、階梯與山洞岩壁,最大限度地利用空間。

視覺引導帶領觀者體驗場景,動線環繞觀者發生

有了豐富的場景,觀者的視覺引導便是帶領他們體驗場景的重要角色,巧妙的動線可以呼應場景的設計,更讓角色與漫畫不會永遠停留在同一區塊。

與一般VR動畫、電影不同,VR漫畫會有更大量文字訊息需要閱讀,因此必須儘可能避免鏡頭在空間中移動,以降低視覺跟體感訊號不同所造成的暈眩,以免造成體驗上過度疲累。

在場景建構上可利用了高低落差大的自然場景,例如山谷、階梯與山洞岩壁,最大限度地利用空間。

簡單總結我們在製作VR漫畫中得到的心得,我們認為在設計VR漫畫時,必須更全域性的去思考各種情境與面向,無論是降低視覺疲乏可能性、減少導致體感不適的因素以及因應故事所選擇的敘事手法等。而在VR漫畫的發展上,未來也可以朝加強漫畫的互動性或綜合更多感官體驗的方向去發展。

這篇文章 【徵文賞-沉浸式內容】佳作|VR漫畫剖析 —— 360º場景融合漫畫框,開啟敘事新角度 – 張佳穎 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
【徵文賞-沉浸式內容】佳作|基於電腦圖學之及時渲染超音波檢測VR遊戲環境 – 林慶佳 https://creativecoding.in/2022/11/24/collection221011-immersive-1/ Thu, 24 Nov 2022 08:33:01 +0000 https://creativecoding.in/?p=3235 透過互動程式創作徵文賞,我們期望讓更多人認識並加入 Creative Coding 這個新奇有趣的領域。此文章為沈浸式內容組佳作,將介紹如何使用電腦圖學知識與工具,開發出如下的及時渲染超音波特效。

這篇文章 【徵文賞-沉浸式內容】佳作|基於電腦圖學之及時渲染超音波檢測VR遊戲環境 – 林慶佳 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
前言

鑒於APIL曾推出過類似的專案,藉由科技教育的方式讓使用者能用更少的成本進行訓練。本文將介紹如何使用電腦圖學知識與工具,開發出如下的及時渲染超音波特效。

APIL製作的模擬遊戲。

內容目錄

超音波原理
透視圖
邊緣高光
強度圖
Radical Blur
上層皮膚模擬
扇型裁切
內臟透視
Stencil masking
雙Pass覆寫深度
用戶操作
額外閱讀
參考文獻

本文章成品效果展示

建議先備知識

  1. Unity 基本操作概念。
  2. Shader lab 或 GLSL 、 HLSL 等渲染語言知識。
  3. 基本線性代數、三角函數知識。

超音波原理

由感測器發出聲波,返回的是該聲波垂直向下的頗面回聲(echo)圖,echo越強的像素越白。 因為器官表面不平,會產生音波漫射(diffuse),所以大部分是灰的。

  • 液體和空氣幾乎沒有回聲,所以是黑的。
  • 組織到另一個組織時,若材質差異大,會產生邊緣高光(specular)反射。
  • 漫射的光會造成雜訊、顆粒感(speckle)。
  • 聲波每經過一層會反射的物質,其強度減弱,產生陰影。
  • 離感測器最近的組織回聲最強,隨著滲透越深,強度衰弱(attenuation)。

透視圖

先準備正常內臟模型,寫個簡單的透明shader並用alpha控制透明度來代表透光程度,讓攝影機最終呈現如圖:

事前準備

可使用任意模型,例如本範例使用Unity Asset store上的Human Organ System PBR模型素材。

https://assetstore.unity.com/packages/3d/characters/humanoids/humans/human-organ-system-pbr-175755

使用Shader渲染模型切面

使用透明混合(blend)技術,可看到方塊內還包含著另一個圓與方塊。

圖源:[1]
Tags { 
            "RenderType" = "Transparent" 
            "Queue" = "Transparent"
            "IgnoreProjector" = "True" }
        LOD 100

        Pass
        {
            Blend SrcAlpha OneMinusSrcAlpha
            ZWrite Off
				}
				...
				fixed4 frag(v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                col.a = _Alpha;
                return _Alpha;
            }

混合Shader code

邊緣高光

藉由sample 該uv的顏色與上面一點的uv的顏色,相減後取得顏色變化差距。 (只取上面的顏色變化是因為我們假設聲波來源是上面)

先將頗面圖模糊後取得的線條比較清楚
v2f vert (appdata v)
            {
                v2f o;
                //假設音波來源是上中央
                float2 sourcePosition = float2(0.5, 1);
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                //上緣uv位置
                o.uvabove=v.uv.xy + (sourcePosition - v.uv.xy) * _MainTex_TexelSize.y *_EdgeSize;

                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                fixed4 above_col=tex2D(_MainTex, i.uvabove);
                return col * abs(above_col-col)*10 ;
            }
            ENDCG

強度圖

將顏色由貼圖上方往下加,最後反轉後會得到如速度線的效果。

struct v2f {
    float4 pos : SV_POSITION;
    float2 uv : TEXCOORD0;
    float2 toSource : TEXCOORD1;
};

v2f vert(appdata_base v) {
    v2f output;
    // ...
    float2 sourcePosition = float2(0.5, 1);
    output.uv = v.texcoord.xy;
    output.toSource = sourcePosition - v.texcoord.xy;

    return output;
}

half4 frag(v2f input) : SV_Target {
    half4 output = half4(0, 0, 0, 1);
    float2 normalizedToSource = input.toSource / length(input.toSource);
    float2 texelToSource = float2(normalizedToSource * _MainTex_TexelSize.y);

    // _TexelSize.w is automatically assigned by Unity to the texture’s height
    for (int i = 0; i < _MainTex_TexelSize.w; i++) {
        output += tex2D(_MainTex, input.uv + texelToSource * i);
    }

    return output;
}

Radical Blur

原理:根據(x,y)找到對應的( r , θ) 座標,取鄰近像素座標 ( r , Θ) =(r , θ ± l / 2 ) 平均值輸出。

struct v2f
        {
            float4 pos : SV_POSITION;
            float2 uv : TEXCOORD0;
        };

        sampler2D _MainTex;
        float4 _MainTex_TexelSize;
        float4 _MainTex_ST;
        float _BlurSize;
        float2 _BlurCenter;
        float _Iteration
        v2f vert(appdata_img v)
        {
            v2f o;
            o.pos = UnityObjectToClipPos(v.vertex);
            o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
            return o;
        }

        float4 frag(v2f i) : SV_TARGET
        {
            float2 blurVector = (_BlurCenter.xy - i.uv.xy) * _BlurSize;
            float r = length((_BlurCenter.xy - i.uv.xy));
            float angle=acos((i.uv.x - _BlurCenter.x )/r);
            angle *=sign(i.uv.y - _BlurCenter.y);

            float4 acumulateColor = float4(0, 0, 0, 0);

            for (int it = -_Iteration; it <= _Iteration; it++)
            {
                float phi = angle + radians(it);
                float2 _arcuv = float2(_BlurCenter.x + r * cos(phi), _BlurCenter.y + r * sin(phi));

                acumulateColor += tex2D(_MainTex, _arcuv);
            }

            return acumulateColor / _Iteration;
        }

這邊使用acos取角度,其性質如下

上層皮膚模擬

超音波圖之所以上面通常都是白白一絲一絲的,因為那是人的表層皮膚、肌肉,容易產生echo,所以偏白。 文獻[1]教了我們基本的超音波效果模擬,為了讓圖更逼真,所以再來多做一些吧。

可以看到超音波上面的皮膚組織偏白。圖源:來自網路

使用tanh取得上層偏白的遮罩,再調整一下參數可得到以下曲線:

float whiteIntensity = 1 - tanh((1 - input.uv.y) * 9);
遮罩效果

搭配雜訊貼圖可得到以下效果:

再調整一下曲線讓他不要太白:

扇型裁切

我們需要的是右邊這種形狀的,需再加點角度範圍判斷。

參考文獻[3],畫出扇型遮罩。
使用兩個圓弧剪裁出扇型。
half4 frag(v2f i) : SV_Target
            {
                //Re-map this now rather than later
                float2 pos = - (i.uv * 2.0 - 1.0);

                //Calculate the angle of our current pixel around the circle
                float theta = degrees(atan2(pos.x, pos.y)) + 180.0;

                //Get circle and sector masks
                float circle = length(pos) <= 1.0 && length(pos) >=_innerDistance;
                float sector = (theta <= _EndAngle) && (theta >= _StartAngle);

                //Return the desired colour masked by the circle and sector
                return _Color * (circle * sector);
            }

結果:

內臟透視

為了讓使用者能知道內臟的位置,本專案提供透視選項,能讓指定內臟顯示於最上層。 這邊嘗試了(1)Stencil masking 與(2)雙Pass覆寫深度 兩種作法。

Stencil masking

利用Stencil 遮罩將特定部位的像素裁剪出來,並指定疊加顯示於畫面上。

來源文獻[4]

在人體等想被穿透的物體shader上加

Stencil
{
    Ref 1 // ReferenceValue = 1
    Comp NotEqual // Only render pixels whose reference value differs from the value in the buffer.
}

能穿透的物件shader加上

Stencil
{
    Ref 1 // ReferenceValue = 1
    Comp Always // Comparison Function - Make the stencil test always pass.
    Pass Replace // Write the reference value into the buffer.
}

缺點是只有PC能執行,由於Occulus是安卓系統,礙於該平台不支援,導致只會渲染在一顆眼睛上。

雙Pass覆寫深度

原理:

第一個pass無視深度條件寫入背面,同時也會寫下深度,讓深度能在它之上的只有自己的正面,因此讓第二個pass正常渲染即可。

Cull Front
ZTest Always
Shader "Unlit/OrganOutline"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" { }
        _Color ("outline color", Color) = (1, 0, 0, 1)
        _OutlineWidth ("Outline width", Range(0.0, 1.0)) = .005
    }
    SubShader
    {
        Tags { "RenderType" = "Opaque" }
        LOD 100

        pass
        {
            Cull Front
            ZTest Always
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
            float4 _Color;
            float _OutlineWidth = 0.5;

            struct v2f
            {
                float4 pos : SV_POSITION;
            };

            v2f vert(appdata_base v)
            {
                v2f o;
                float3 norm = normalize(v.normal);
                v.vertex.xyz += v.normal * _OutlineWidth;
                o.pos = UnityObjectToClipPos(v.vertex);

                return o;
            }
            fixed4 frag(v2f i) : COLOR
            {
                return _Color;
            }
            ENDCG

        }

        Pass
        {
            Cull Back
            //...... render your texture
        }
    }
}

用戶操作

為了提升VR體驗,所以如影片中的感測器會自動貼合人體表面,防止穿模。

public LayerMask skinMeshLayer;

    [SerializeField]
    private Transform sensorPoint;

    [SerializeField]
    private float rayDistance = 10;

    private float stickLength;

    private void Start() {
        stickLength = (sensorPoint.position - transform.position).magnitude;
    }

    void Update()
    {
        Vector3 alignDir = (sensorPoint.position - transform.position).normalized;
        Vector3 _rayStart = transform.position -  alignDir*rayDistance ; //往後退保留ray空間

        RaycastHit hit;

        if (Physics.Raycast(_rayStart, alignDir, out hit, rayDistance * 2, skinMeshLayer))
        {
            Vector3 _stickPoint = hit.point;
            Vector3 _movePoint = _stickPoint - alignDir * stickLength;
            transform.position = _movePoint;
        }
        else {
            transform.localPosition = Vector3.zero;
        }

    }

總結

製作步驟整理如下:

額外閱讀

[5] 核磁共振是3D立體空間,而超聲波是建立在聲波網格(acoustic grid),渲染比較困難。

[6] 快速剖面演算法。 (因為超音波是取截面)

[7] 更專業的演算法。


參考文獻

  1. Ultrasound simulation with shaders – Avangarde-Software
  2. Barnouin, C., Zara, F., & Jaillet, F. (2020, February). A real-time ultrasound rendering with model-based tissue deformation for needle insertion. In 15th International Conference on Computer Graphics Theory and Applications, GRAPP 2020. [pdf]
  3. How to draw circular sector in RunTime? – Unity Answers
  4. unity – How can I create a “see behind walls” effect? – Game Development Stack Exchange
  5. Chapter 40. Applying Real-Time Shading to 3D Ultrasound Visualization | NVIDIA Developer
  6. https://www.scitepress.org/papers/2017/60972/60972.pdf
  7. GPU Ultrasound Simulation and Volume Reconstruction (tum.de)

這篇文章 【徵文賞-沉浸式內容】佳作|基於電腦圖學之及時渲染超音波檢測VR遊戲環境 – 林慶佳 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
【徵文賞-互動網頁】佳作|互動式網頁! 製作縮圖編輯器 – 林慶佳 https://creativecoding.in/2022/11/24/collection221110-web-1/ Thu, 24 Nov 2022 06:50:30 +0000 https://creativecoding.in/?p=3194 透過互動程式創作徵文賞,我們期望讓更多人認識並加入 Creative Coding 這個新奇有趣的領域。此文章為互動網頁組佳作,此編輯器系統基於React框架製作,但大部分是由Js實現,能夠輕鬆移植到不同平台。

這篇文章 【徵文賞-互動網頁】佳作|互動式網頁! 製作縮圖編輯器 – 林慶佳 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
前言

最近在重製自己的網頁,為了有更好的展示空間,所以也順便做了文章的縮圖編輯器。本系統基於 React 框架,但大部分是由 Js 實現,輕鬆移植到不同平台。


使用componentDidMount

React 生命週期中, componentDidMount 是在 render() 結束後呼叫,可用於建立該元件的基本參數,在此用來呼叫抓取 DOM 物件 (因為在 render 之前該 DOM 物件並不存在)。

const openEditor = () => {
  var canvas = document.getElementById("avatarCanvas"),
    context = canvas.getContext("2d");
}

class AvatarEditor extends React.Component {
  state = {};
  constructor(props) {
    super(props);
    this.state = this.props;
  }
  componentDidMount() {
    openEditor();
  }
  render() {
    return (
      <div>
        <canvas
          id="avatarCanvas"
          width="500"
          height="500"
          className="avatarEditor-canvas"
        ></canvas>
      </div>
    );
  }
}

設定 React Component 預設值

在上面我們創了一個繼承自 React.Component 的類別 AvatarEditor 。

設定該元件的預設值可使用類別 .defaultProps :

AvatarEditor.defaultProps = {
  img: "https://react.semantic-ui.com/images/wireframe/image.png",
  scale: 100,
  width: 100,
  height: 100,
  top: 0,
  left: 0,
  //preview大小
  previewSize: 300,
  onsubmit: (e) => {
    console.log("submit");
  },
  oncancel:(e)=>{
    //取消
  }
};

畫四個角的控制器

黑色方塊代表控制點
import React from "react";
import "../Css/AvatarEditor.css";

const openEditor = () => {
  var canvas = document.getElementById("avatarCanvas"),
    context = canvas.getContext("2d"),
    width = canvas.width,
    height = canvas.height;

	//因為rect是左上為中心開始畫
  var controllerSize = 25;
  var rectSize = 500 * (1 - controllerSize / width);

  //四個角落
  var points = [
    { x: 0, y: 0 },
    { x: 1, y: 0 },
    { x: 1, y: 1 },
    { x: 0, y: 1 },
  ];

  renderPoints();
  function update() {}

  function renderPoints() {
    context.clearRect(0, 0, width, height);
    for (var i = 0; i < points.length; i++) {
      var p = points[i];
      context.fillStyle = "#464646";
      context.beginPath();
      context.rect(p.x * rectSize, p.y * rectSize, 25, 25);
      context.fill();
    }
  }

  console.log(canvas);
};

class AvatarEditor extends React.Component {
  state = {};
  constructor(props) {
    super(props);
    this.state = this.props;
  }
  componentDidMount() {
    openEditor();
  }
  render() {
    return (
      <div>
        <canvas
          id="avatarCanvas"
          width="500"
          height="500"
          className="avatarEditor-canvas"
        ></canvas>
      </div>
    );
  }
}

export default AvatarEditor;

拖拉控制

透過控制對角線的x與y的位置,一同移動其餘的三個控制點。

var controllerSize = 25;
  var controllerSizeRatio = controllerSize / width;
  var rectSize = 500 * (1 - controllerSizeRatio);

  //四個角落
  var points = [
    { x: 0, y: 1 },
    { x: 0, y: 0 },
    { x: 1, y: 0 },
    { x: 1, y: 1 },
  ];

var isDraggingController = false;

  //右下控制器
  var sizeController = document.getElementById("sizeController");
  sizeController.onmousedown = function (e) {
    e.preventDefault();
    isDraggingController = true;
  };

document.body.addEventListener("mousemove", function (event) {
    //移動size controller位置
    if (isDraggingController) {
      var mousePos = getMousePos(canvas, event);
      var moveRate = 1 - mousePos.y / rectSize;
      sizeController.style.right = moveRate * 100 + "%";
      sizeController.style.bottom = moveRate * 100 + "%";

      //修改其他點位置:
      points[0].y = 1 - controllerSizeRatio - moveRate;
      points[2].x = 1 - controllerSizeRatio - moveRate;
    }
  });

....
render() {
    return (
      <div className="avatarEditor-root">
        <canvas
          id="avatarCanvas"
          width="500"
          height="500"
          className="avatarEditor-canvas"
        ></canvas>
        <button
          id="sizeController"
          className="avatarEditor-controller"
          onClick={(e) => {
            e.preventDefault();
          }}
        >
          123
        </button>
      </div>
    );
  }

拖拉位置

透過改變與父物件相對位置的 top , left 值實現移動的效果。

//位置控制器
  var positionController = document.getElementById("positionController");
  positionController.onmousedown = function (e) {
    e.preventDefault();
    is_gragging_reposisiton_controller = true;
    previous_mouse_pos=getMousePos(canvas,e);
  };
  //位置控制器--原本mouse位置
  var previous_mouse_pos = { x: 0, y: 0 };

document.body.addEventListener("mousemove", function (event) {
    //改變size
    if (is_gragging_resize_controller) {
      var mousePos = getMousePos(canvas, event);
      var moveRate = 1 - mousePos.y / rectSize;
      sizeController.style.right = moveRate * 100 + "%";
      sizeController.style.bottom = moveRate * 100 + "%";

      //修改其他點位置:
      points[0].y = 1 - controllerSizeRatio - moveRate;
      points[2].x = 1 - controllerSizeRatio - moveRate;
      points[3].x = 1 - controllerSizeRatio - moveRate;
      points[3].y = 1 - controllerSizeRatio - moveRate;

      //修改選擇範圍
      positionController.style.height = (points[0].y - points[1].y) * 100 + "%";
      positionController.style.width = (points[2].x - points[0].x) * 100 + "%";

      positionController.style.top = points[1].y * 100 + "%";
      positionController.style.left = points[0].x * 100 + "%";
    }

    //移動選取位置
    else if (is_gragging_reposisiton_controller) {
      var mousePos = getMousePos(canvas, event);
      //offset轉成%數
      var offsetX = (mousePos.x - previous_mouse_pos.x) / rectMaxSize;
      var offsetY = (mousePos.y - previous_mouse_pos.y) / rectMaxSize;

      //移動每個points
      for (var i = 0; i < points.length; i++) {
        var p = points[i];
        p.x += offsetX;
        p.y += offsetY;
        console.log(p);
        points[i] = p;
      }
      //移動resize按鈕
      sizeController.style.right =
        (1 - points[3].x - controllerSizeRatio) * 100 + "%";
      sizeController.style.bottom =
        (1 - points[3].y - controllerSizeRatio) * 100 + "%";

      //移動inner範圍
      positionController.style.top = points[1].y * 100 + "%";
      positionController.style.left = points[0].x * 100 + "%";

      previous_mouse_pos = mousePos;
    }
  });

同步操作縮圖位置

同樣在操作預覽圖片的 top , left 值達到效果。

{/* 預覽 */}
<div
	className="avatarEditor-preview-container"
  id="preview"
  style={{ height: this.state.previewSize, width: this.state.previewSize }}
 >
  <img src={this.state.img} id="avatarEditor-preview-img"></img>
</div>

function updatePreviewImg() {
    var previewWidthRatio = 1 / (points[3].x - points[1].x);
    previewImg.style.width = previewWidthRatio * 100 + "%";
    previewImg.style.height = previewWidthRatio * 100 + "%";
    previewImg.style.left = -points[0].x * previewWidthRatio * 100 + "%";
    previewImg.style.top = -points[1].y * previewWidthRatio * 100 + "%";

    previewResult = previewImg;
 }

如何使用?

先宣告全域變數previewResult 乘載編輯結果。

var previewResult;

並透過按鈕呼叫本元件的 callback 方法。

 handleSubmit(e) {
    //用點的位子計算縮放比例:
    var scale =
      (1 / (this.state.points[3].x - this.state.points[1].x)) * 100 + "%";

    var data = {
      //e: e,
      scale: scale,
      src: this.state.img,
      width: e.width,
      height: e.height,
      top: e.style.top,
      left: e.style.left,
    };
    this.state.onsubmit(data);
  }

還記得我們在設定元件初始值的時候宣告了 onsubmit 方法當作傳入參數。在執行建構子時將該方法參數連接本程式的方法。

注意等號左邊的是傳入的 callback 方法參數( property ),右邊的是本程式碼的方法。

 constructor(props) {
    super(props);
    this.state = this.props;
    this.handleSubmit = this.handleSubmit.bind(this);
    this.handleURLChange = this.handleURLChange.bind(this);
  }

至此,本元件腳本結束。

外部呼叫

呼叫開啟編輯器:

將 useState() 方法當作參數傳入,使在元件裡面和下確認後能更新資料給外部元件。

const [thumbnail, setThumbnail] = useState({    src: "https://react.semantic-ui.com/images/wireframe/image.png",  });
....

<div style={{ display: openAvatarEditor ? "block" : "none" }}>
                  <AvatarEditor
                    img={thumbnail.src}
                    scale={thumbnail.scale}
                    width={thumbnail.scale}
                    height={thumbnail.scale}
                    top={thumbnail.top}
                    left={thumbnail.left}
                    onsubmit={(e) => {
                      setThumbnail(e);
                      setOpenAvatarEditor(false);
                    }}
                    oncancel={(e) => {
                      setOpenAvatarEditor(false);
                    }}
                  ></AvatarEditor>
                </div>

透過更新圖片大小、上下錨點呈現效果。

💡 注意圖片大小是紀錄縮放百分比例而不是固定的px數值,如此在不同大小的物件下才能正常顯示。 (例如:編輯頁面的預覽圖片大小是300X300px,但在小屋清單上是200X200px,若紀錄的是圖片的px數值,則會發生跑版)

<img
                      src={thumbnail.src}
                      className="createArticle__image"
                      style={{
                        width: thumbnail.scale,
                        height: thumbnail.scale,
                        top: thumbnail.top,
                        left: thumbnail.left,
                      }}
                    ></img>
成功使用!

這篇文章 【徵文賞-互動網頁】佳作|互動式網頁! 製作縮圖編輯器 – 林慶佳 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
【徵文賞-延展實境】佳作|史上最累Debug!深蹲才能過關(Unity + OpenCV 跨軟體傳輸實作) – 林慶佳 https://creativecoding.in/2022/11/24/collection221110-xr-1/ Thu, 24 Nov 2022 03:31:26 +0000 https://creativecoding.in/?p=3141 透過互動程式創作徵文賞,我們期望讓更多人認識並加入 Creative Coding 這個新奇有趣的領域。此作品為延展實境組佳作,以 Unity 結合 OpenCV 跨軟體實作,偵測深蹲姿勢到達一定水準後,讓遊戲中的物件跳起來。

這篇文章 【徵文賞-延展實境】佳作|史上最累Debug!深蹲才能過關(Unity + OpenCV 跨軟體傳輸實作) – 林慶佳 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>
透過互動程式創作徵文賞,我們期望讓更多人認識並加入 Creative Coding 這個新奇有趣的領域。此作品為延展實境組佳作,以 Unity 結合 OpenCV 跨軟體實作,偵測深蹲姿勢到達一定水準後,讓遊戲中的物件跳起來。

實作成果:

偵測臉並回傳位置,使Unity中的物件可以跟隨他。

使用C++指標傳輸

原文:Unity and OpenCV – Part three: Passing detection data to Unity – Thomas Mountainborn

偵測臉並回傳位置,使Unity中的物件可以跟隨他。

建立與Unity溝通的結構

C++

struct Circle
{
	//建構子
	Circle(int x, int y, int radius) : X(x), Y(y), Radius(radius) {}
	int X, Y, Radius;
};

編譯器在產生dll檔案時會打亂method名稱,為讓方法保持原名稱,則外顯”C”

Normally, the C++ compiler will mangle the method names when packaging them into a .dll. Therefore, we instruct it to use the classic “C” style of signatures, which leaves the method names just as you wrote them.

extern "C" int __declspec(dllexport) __stdcall  Init(int& outCameraWidth, int& outCameraHeight)
extern "C" void __declspec(dllexport) __stdcall Detect(Circle* outFaces, int maxOutFacesCount, int& outDetectedFacesCount)

Circle* outFaces 表an array of Circles。

int& outDetectedFacesCount 表該變數是傳址(ref)。

C#

與C++溝通的格式,變數欄位、宣告順序必需與c++相同

// Define the structure to be sequential and with the correct byte size (3 ints = 4 bytes * 3 = 12 bytes)
[StructLayout(LayoutKind.Sequential, Size = 12)]
public struct CvCircle
{
    public int X, Y, Radius;
}

unsafe:讓你在C#能使用指標。

fixed:使編譯器讓該變數記憶體位置不被garbage collector處理掉。

在fixed區塊中,openCV會直接將變數寫入CvCircle結構陣列中,而省去copy的成本。

void Update()
    {     
				//接收陣列大小
        int detectedFaceCount = 0;
        unsafe
        {
            //pass fixed pointer
            fixed (CvCircle* outFaces = _faces)
            {
                OpenCVInterop.Detect(outFaces, _maxFaceDetectCount, ref detectedFaceCount);
            }
        }
}

fixed中只接受:

The legal initializers for a fixed statement are:

  • The address operator & applied to a variable reference.
  • An array
  • A string
  • A fixed-size buffer.

分段解析

C++

// Declare structure to be used to pass data from C++ to Mono. (用來與Unity溝通的結構)
struct Circle
{
	//建構子
	Circle(int x, int y, int radius) : X(x), Y(y), Radius(radius) {}
	int X, Y, Radius;
};

CascadeClassifier 是Opencv中做人臉檢測的時候的一個級聯分類器。 並且既可以使用Haar,也可以使用LBP特徵。()

C#

// Define the structure to be sequential and with the correct byte size 
//(3 ints = 4 bytes * 3 = 12 bytes)
[StructLayout(LayoutKind.Sequential, Size = 12)]
public struct CvCircle
{
    public int X, Y, Radius;
}

StructLayout :C#中StructLayout的特性 - IT閱讀 (itread01.com)

char型資料,對齊值為1,對於short型為2,對於int,float,double型別,其對齊值為4,單位位元組。

初始化鏡頭大小

C++

extern "C" int __declspec(dllexport) __stdcall  Init(int& outCameraWidth, 
																										 int& outCameraHeight)
{
	// Load LBP face cascade.
	if (!_faceCascade.load("lbpcascade_frontalface.xml"))
		return -1;

	// 打開鏡頭
	_capture.open(0);
	if (!_capture.isOpened())
		return -2;
	
	//取得視訊大小
	outCameraWidth = _capture.get(CAP_PROP_FRAME_WIDTH);
	outCameraHeight = _capture.get(CAP_PROP_FRAME_HEIGHT);

	return 0;
}

C#

在Opencv資料夾下找到lbpcascade_frontalface.xml,並放到Unity專案root資料夾下。

				int camWidth = 0, camHeight = 0;
        int result = OpenCVInterop.Init(ref camWidth, ref camHeight);
        if (result < 0)
        {
            if (result == -1)
            {
                Debug.LogWarningFormat("[{0}] Failed to find cascades definition.", GetType());
            }
            else if (result == -2)
            {
                Debug.LogWarningFormat("[{0}] Failed to open camera stream.", GetType());
            }

            return;
        }

        CameraResolution = new Vector2(camWidth, camHeight);

鏡頭大小的變數使用傳址呼叫,讓c++開啟鏡頭後順便設定好大小。使C#和c++使用相同的變數。

傳遞參數scale

C++

extern "C" void __declspec(dllexport) __stdcall SetScale(int scale)
{
	_scale = scale;
}

C#

private const int DetectionDownScale = 1;
void Start()
    {
        ...
        OpenCVInterop.SetScale(DetectionDownScale);
        _ready = true;
    }

辨識人臉

C++

extern "C" void __declspec(dllexport) __stdcall Detect(Circle* outFaces, 
																											int maxOutFacesCount,
																											int& outDetectedFacesCount)
{
	Mat frame;
	_capture >> frame;
	if (frame.empty())
		return;

	std::vector<Rect> faces;
	// Convert the frame to grayscale for cascade detection.
	Mat grayscaleFrame;
	cvtColor(frame, grayscaleFrame, COLOR_BGR2GRAY);
	Mat resizedGray;
	// Scale down for better performance.
	resize(grayscaleFrame, resizedGray, Size(frame.cols / _scale, frame.rows / _scale));
	equalizeHist(resizedGray, resizedGray);

	// Detect faces.
	_faceCascade.detectMultiScale(resizedGray, faces);

	// Draw faces.
	for (size_t i = 0; i < faces.size(); i++)
	{
		Point center(_scale * (faces[i].x + faces[i].width / 2), _scale * (faces[i].y + faces[i].height / 2));
		ellipse(frame, center, Size(_scale * faces[i].width / 2, _scale * faces[i].height / 2), 0, 0, 360, Scalar(0, 0, 255), 4, 8, 0);

		// Send to application.
		outFaces[i] = Circle(faces[i].x, faces[i].y, faces[i].width / 2);
		//返回數量用的
		outDetectedFacesCount++;

		if (outDetectedFacesCount == maxOutFacesCount)
			break;
	}

	// Display debug output.
	imshow(_windowName, frame);
}

步驟:

灰階→縮小解析度→直方圖均衡化→偵測

【補充】

直方圖均衡化(equalizeHist):

將拉伸數值分佈範圍從0-255。假設影像過曝(如藍色曲線),則直方圖均衡化能將其值範圍拉伸0-255區間內,使黑白更分明。 https://youtu.be/jWShMEhMZI4

人臉偵測 (detectMultiScale):

https://blog.csdn.net/leaf_zizi/article/details/107637433

CascadeClassifier.detectMultiScale(輸入圖片, 輸出向量, scaleFactor=1.1 , minNeighbor=3);

輸入圖片: 只接受灰階

scaleFactor:每次圖像縮小的比例,

minNeighbor:每個候選矩形有多少個”鄰居”,我的理解是:一個滑動窗口中的圖元需要符合幾個條件才能判斷為真。

大概意思是Haar cascade的工作原理是一種”滑動視窗”的方法,通過在圖像中不斷的”滑動檢測視窗”來匹配人臉。

因為圖像的圖元有大有小,圖像中的人臉因為遠近不同也會有大有小,所以需要通過scaleFactor參數設置一個縮小的比例,對圖像進行逐步縮小來檢測,這個參數設置的越大,計算速度越快,但可能會錯過了某個大小的人臉。

其實可以根據圖像的圖元值來設置此參數,圖元大縮小的速度就可以快一點,通常在1~1.5之間。

那麼,經過多次的反覆運算,實際會檢測出很多很多個人臉,這一點可以通過把minNeighbors 設為0來驗證。

所以呢,minNeighbors參數的作用就來了,只有其”鄰居”大於等於這個值的結果才認為是正確結果。

返回Rect ,其包含<x,y,w,h>

使用UDP

基本的傳輸協定,因為不會檢查是否收到或重傳等資訊,因此封包小、速度較快,缺點是長度上限548 Bytes,不適合串流影片,在此只用來傳輸事件資訊。 好處是就算Unity那端的port沒有開啟,UPD因不會檢查是否收到,所以不會報錯。

成果:

偵測手掌開闔。 參考:https://www.raywenderlich.com/5475-introduction-to-using-opencv-with-unity

尋找輪廓的原理先略過,主要流程是轉灰階=>高思模糊=>Canny邊緣偵測=>向外擴張=>findContours(尋找輪廓)

Python 傳送資料

import numpy as np
import cv2
import socket

UDP_IP = "127.0.0.1"
UDP_PORT = 5065

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

while true:
    sock.sendto( ("data!").encode(), (UDP_IP, UDP_PORT) )
    print("data sent")
    
capture.release()
cv2.destroyAllWindows()

C# UPD 建立連線

// 1. Declare Variables
    Thread receiveThread;  //在背景持續接受UDP訊息
    UdpClient client;   // parse the pre-defined address for data
    int port;   //port number

// 2. Initialize variables
    void Start()
    {
        port = 5065;
        InitUDP();
    }

    // 3. InitUDP
    private void InitUDP()
    {
        print("UDP Initialized");
        receiveThread = new Thread(new ThreadStart(ReceiveData)); //開個新的帶有參數的thread,傳入方法當參數
        receiveThread.IsBackground = true;
        receiveThread.Start();
    }

C# 定義接受方法

// 4. Receive Data
    private void ReceiveData()
    {
        client = new UdpClient(port); //指定port
        while (true)
        {
            try
            {
                IPEndPoint anyIP = new IPEndPoint(IPAddress.Parse("0.0.0.0"), port); //任何ip
                byte[] data = client.Receive(ref anyIP); //資料

                string text = Encoding.UTF8.GetString(data); //binary => utf8 text
                print(">> " + text);
								//....
            }
            catch (Exception e)
            {
                print(e.ToString());
            }
        }
    }

使用TCP

需經過三項交握確認連線。
若server(這裡是unity)的port沒有開,會收到傳送失敗的exception。

Python 建立連線

import numpy as np
import cv2
import socket

TCP_IP = "127.0.0.1"
TCP_PORT = 5066

#sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # SOCK_DGRAM 長度限制 548bytes,但不需要預先connect
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # TCP

#TCP連線
address=(TCP_IP ,TCP_PORT )
sock.connect(address)

print('sock init')
sock.send('Hi'.encode('utf-8'));

sock.close() #才會把累積的資料傳送

C# 建立連線

public class ImageReceiver : MonoBehaviour
{
    //TCP Port 開啟
    Thread receiveThread;
    TcpClient client;
    TcpListener listener;
    int port;
    private void Start()
    {
        InitTcp();
    }
    void InitTcp()
    {
        port = 5066;
        print("TCP Initialized");
        IPEndPoint anyIP = new IPEndPoint(IPAddress.Parse("127.0.0.1"), port);
        listener = new TcpListener(anyIP);
        listener.Start();
				//開個新的帶有參數的thread,傳入方法當參數
        receiveThread = new Thread(new ThreadStart(ReceiveData));
        receiveThread.IsBackground = true;
        receiveThread.Start();
    }
    private void OnDestroy()
    {
        receiveThread.Abort();
    }
}

定義接收方法

private void ReceiveData()
    {
        print("received somthing...");
        try
        {
            while (true)
            {
                client = listener.AcceptTcpClient();
                NetworkStream stream = new NetworkStream(client.Client);
                StreamReader sr = new StreamReader(stream);
                print(sr.ReadToEnd());
            }
        }
        catch (Exception e)
        {
            print(e);
        }
    }

注意1,由於一開始需要經過三項交握,所以”TCP Initialized”之後會log一次”received something…”,該訊息為用來回應tcp連線的。

注意2,在python端sock.close()之前收到的訊息會一直存在sr,直到close之後才一次print出,所以傳輸每frame都會經過:建立連線=>打包資料=>傳送=>close() 的循環。

傳輸畫面

Python 端

注意資料只能傳byte,string等型態,所以這邊使用json格式。Python有另一個類次的pickel插件,但該格式只能python使用,不方便給unity。

while cap.isOpened():          
    ret, img = cap.read()
    img_data={'image':cv2.imencode('.jpg',img)[1].ravel().tolist()}
    data=json.dumps(img_data);
    
    #準備連線
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # TCP
    sock.connect(address)
    
    #傳送資料
    sock.sendall(bytes(data,encoding='utf-8'))
    #print('sock sent')
    
    cv2.imshow("Image",img)
    cv2.waitKey(10)
    sock.close()

C# 端

宣告texture:

public class ImageReceiver : MonoBehaviour
{
    //TCP Port 開啟
    Thread receiveThread;
    TcpClient client;
    TcpListener listener;
    int port;
    private void Start()
    {
        InitTcp();
    }
    void InitTcp()
    {
        port = 5066;
        print("TCP Initialized");
        IPEndPoint anyIP = new IPEndPoint(IPAddress.Parse("127.0.0.1"), port);
        listener = new TcpListener(anyIP);
        listener.Start();
				//開個新的帶有參數的thread,傳入方法當參數
        receiveThread = new Thread(new ThreadStart(ReceiveData));
        receiveThread.IsBackground = true;
        receiveThread.Start();
    }
    private void OnDestroy()
    {
        receiveThread.Abort();
    }
}

由於Unity不支援多線程,無法在接收方法中直接設定texture,所以在fixedUpdate中設定。

private void FixedUpdate()
    {
        tex.LoadImage(imageDatas);
        img.texture = tex;
    }

成功~

Python傳輸畫面至Unity

操控物件也同理,在json資料中夾帶著操作的訊號就好。 但由於是跟著畫面資料一起串流,會有滿嚴重的延遲。 若資料改用UDP、畫面使用TCP傳輸應該會比較好!

這篇文章 【徵文賞-延展實境】佳作|史上最累Debug!深蹲才能過關(Unity + OpenCV 跨軟體傳輸實作) – 林慶佳 最早出現於 Creative Coding TW - 互動程式創作台灣站

]]>