Hyun1008.log

재연의 개발블로그

#프로젝트 #NodeJS #버튜버

지난번 포스트에 이어 두 개의 카메라로부터 얻은 VMC 신호를 믹싱해주는 노드 앱을 만들었어요. 첫번째 카메라는 Webcam Motion Capture, 두번째 카메라는 TDPT 라는 어플에 연결했어요.

1. 과정

1.1 평균 내기

두 개의 신호를 단순히 받아서 섞어주기만 하면 되는 게 아니에요. 두 신호의 프레임률이 다르다 보니 한쪽에서 신호가 많이 들어온다고 다른 한쪽에서 그만큼 들어오는 게 아니었어요.

그래서 양쪽에서 서로 다른 포트로 신호를 받은 다음에 소프트웨어 태그를 붙여서 데이터 가공 포트로 신호를 보내기로 했어요. 그리고 이 신호들을 태그별로 묶어서 일정 시간 동안 받은 뒤 그것을 평균 내고, 그것을 양쪽 소프트웨어에서 실행하고 평균을 내고 있어요.

1.2 쿼터니온은 그대로 두기

그런데 일단 쿼터니온은 평균을 내지 않고, 마지막 값만 받아오기로 했어요. 사유는 아마도 쿼터니온 또한 벡터이기 때문에, 이걸 그대로 평균을 내 버리면 다음과 같은 이상한(..) 값이 얻어지는 모양이에요.

그러면 이 값은 어떻게 평균을 내는가? 오른쪽과 같이 각도로 변환을 하는 게 가장 만만한 것 같아요. 양쪽 소프트웨어에서 받아온 마지막 쿼터니온 값을 오일러 앵글로 변환해 준 다음 이 값의 평균을 내 줄 거예요. 아무튼 쿼터니온 값은 최대한 건드리지 말고, 오일러 앵글로 변환해서 건드린 다음 재변환해 주는 것이 나을 것 같습니다.

1.3 파츠별 가중치 주기

사실, 모든 파츠에서 신호를 섞는 것이 아니라, 양팔과 양다리를 경우에 따라 서로 다른 신호에서 읽어오도록 했어요.

일반적으로 왼팔은 왼쪽 카메라가, 오른팔은 오른쪽 카메라가 읽는 것이 자연스럽다고 생각하실 거예요. 하지만 저는 이걸 반대로 두었어요. 왼팔과 왼다리는 보통 오른쪽 카메라가 스캔하고, 오른팔과 오른다리는 보통 왼쪽 카메라가 스캔하도록 했습니다. 왜냐면 그래야 이 포즈에서 값을 읽어올지 말지 결정하기 쉽기 때문이에요.

즉, WMC나 TDPT의 경우 한쪽 팔이 보이지 않을 때 값을 읽지 못하기 때문에 팔을 아래로 떨어뜨린 포즈로 둡니다. 이것을 감지해서 반대쪽 팔이 보이면 그 팔을 읽어오고, 보이지 않으면 읽어오지 않는 것으로 하는 거죠.

팔을 카메라 쪽으로 뻗는 경우에도, 보통은 왼쪽 팔을 앞으로 뻗으면 왼쪽 카메라가, 오른쪽 팔을 앞으로 뻗으면 오른쪽 카메라가 가려지기 때문에 스캔하지 않는 것이 좋아요.

아무튼 여기까지 했으면 기존의 위치값과 오일러 앵글을 가지고 새 위치값과 오일러 앵글을 뽑아낸 다음에, 오일러 앵글에서 쿼터니온으로 변환까지 해 줍니다.

1.4 스무딩

저는 0.1초 간격으로 스캔을 하고 있는데, 이것은 초당 10프레임 정도의 상당히 낮은 프레임률이에요. 따라서 새로 계산한 값으로의 이동을 6프레임으로 쪼개서 60프레임을 만들어 주었습니다.

여기까지 구현해둔 것이 위의 영상이에요. 다만 위 영상에서는 쿼터니온도 평균을 내 버려서 동작이 조금 뚝딱거립니다.

#개발과정 #NodeJS #버튜버

1. VMC 프로토콜

대략적으로 설명하자면, VMC프로토콜이란 모션캡쳐 데이터를 여러 소프트웨어 간에 전달할 수 있는 프로토콜입니다. TDPT, Webcam Motion Capture, VSeeFace, 심지어 블렌더까지 다양한 소프트웨어에서 지원을 하는데요.

저 같은 경우에는 아이폰 버전 TDPT의 보다 자연스러운 데이터를 전신 모션캡쳐에 사용했고, Webcam Motion Capture의 표정, 손가락 캡쳐 데이터를 추가로 사용했어요. 두 신호를 믹스하는 것은 VSeeFace에서 처리했습니다. VSeeFace에서 보조 신호를 받을 수 있어서, 부위별로 신호들을 섞을 수 있거든요.

자, 여기까지 간다면 일반 버튜버-적인 설명이겠지요. 이걸로 끝이었으면 제가 이 포스트를 적고 있지도 않았을 겁니다.

따라서 포트 관련 셋팅은 다 되어있다고 가정하고 이야기할 겁니다!!

2. 모티베이션

저는 조금 더 높은 퀄리티의 모션캡쳐를 위해 여러 가지를 알아보고 있었습니다.

  • 일반 모션캡쳐 수트: 가격은 둘째치고 살부터 빼야 합니다...
  • 저렴이 모션캡쳐 수트: 15분-30분에 한번씩 칼리브레이션을 해줘야 하고 중요한 손가락과 표정 캡쳐를 위해서라면 어차피 광학적 캡쳐를 해야 합니다.
  • 광학-AI 방식: 제가 지금 하고 있는 것인데 일반적인 모노캠 방식은 당연히 한계가 있습니다.
  • 듀얼캠-AI 방식: Rokoko 에서 지원하는 것인데, 돈이 드는 건 둘째치고 역시 손가락과 표정 캡쳐가 안되는데다 실시간 지원이 안 됩니다(방송 불가능)

이걸 전부 고려해 보았는데, 역시 Rokoko의 듀얼캠을 VMC내에서 구현할 수는 없을까... 라는 생각이 들었습니다. 무지 수학적이고 어렵겠지만 괜찮아요. 천문학과에서 내내 하는 게 좌표변환이거든요 일단은 도전을 해 보았습니다.

3. OSC 프로토콜

따로 3D 관련 지식이 있어야 하느냐? 아닐 거라고 생각했습니다. VMCP로 수치 데이터만 전송하면 되는 거잖아요? 그래서 VMCP 를 알아보았습니다. 홈페이지에 들어가서 자료를 찾아봤어요. 일단 예제가 있어야 이해하기 쉬우니 예제를 눌러봤습니다.

....? cs...? 처음보는 확장자인데.....?

.....C# 이다 끼야악!!!!!

아무튼 이런 이유로 저는 혹시 다른 언어로 작성된 예제 스크립트가 있는지 알아보았습니다.

파이썬 버전이 있길래 부랴부랴 실행해봤습니다. colab에서는 잘 실행되지만 colab 특성상 포트가 안 열리고, 로컬에서는 왠지 모듈 불러오는 데서부터 에러가 났습니다. 근데 뭘 어떻게 고쳐야 하는건지 잘 몰랐습니다.

저는 제가 python을 나름 할 줄 안다고 생각했는데 아니었나 봅니다. 여러분, 연구-코딩은 개발이 아닙니다. 아셨죠? >.0

익숙한 Node.js 모듈은 없나 찾아봤습니다. 어... VMC와 관련된 노드 모듈은 없었습니다. 절망하던 도중 저는 VMCP 홈페이지의 그림을 찬찬히 살펴보고서야 답을 얻었습니다!

위 이미지들에서 보시다시피 OSC라는 프로토콜 상에서 사용할 수 있습니다. 그러니 OSC 프로토콜을 Node로 구현하면 VMC도 당연히 수신하고 송신할 수 있다!!! 라는 결론에 이르러서 OSC 모듈을 찾아봤습니다. osc-js와 osc.js 두 종류가 있더라구요. 둘 다 깔아봤는데 제가 성공한 버전은 후자 쪽입니다.

4. 단순 토스 서버 만들기

처음이니까, Webcam Motion Capture(39539)의 데이터를 Blender(39538)로 보내주는 코드를 짜보도록 합시다.

npm init

으로 적당히 셋팅해줍니다. 시작은 index.js로 되어있는데 저는 app.js로 변경해서 셋팅했습니다.

//app.js

const osc = require('osc')

var getIPAddresses = function () {
    var os = require("os"),
        interfaces = os.networkInterfaces(),
        ipAddresses = [];

    for (var deviceName in interfaces) {
        var addresses = interfaces[deviceName];
        for (var i = 0; i < addresses.length; i++) {
            var addressInfo = addresses[i];
            if (addressInfo.family === "IPv4" && !addressInfo.internal) {
                ipAddresses.push(addressInfo.address);
            }
        }
    }

    return ipAddresses;
};

// VMC 데이터를 수신하는 포트
var udpPort = new osc.UDPPort({
    localAddress: "127.0.0.1",
    localPort: 39539 // ← webcam motion capture
});

// VMC 데이터를 송신하는 포트. localPort가 아니라 remotePort입니다.
var sendPort = new osc.UDPPort({
    localAddress: "127.0.0.1",
    remotePort: 39538 // ← blender
});

// 우선 송신부부터 열어줍니다.
sendPort.open();

// 수신 포트가 열리면 다음과 같이 콘솔을 찍습니다.
udpPort.on("ready", function () {
    var ipAddresses = getIPAddresses();

    console.log("Listening for OSC over UDP.");
    ipAddresses.forEach(function (address) {
        console.log(" Host:", address + ", Port:", udpPort.options.localPort);
    });
});

// 송신 포트가 열리면 본격적으로 내용을 실행합니다.
sendPort.on("ready", function () {
    var ipAddresses = getIPAddresses();

    console.log("Listening for OSC over UDP.");
    ipAddresses.forEach(function (address) {
        console.log(" Host:", address + ", Port:", sendPort.options.localPort);
    });

    udpPort.on("message", function (oscMessage) {
         // 수신한 message를 바탕으로 적당히 가공한 뒤
         // 송신하는 코드를 여기에 작성합니다. 예를 들면
        sendPort.send(oscMessage)
        
    });

// 에러 처리... 이건 수신부에 대해서만 입니다.
    udpPort.on("error", function (err) {
        console.log(err);
    });

// 수신부 포트를 열어줍니다.
    udpPort.open();
})

이렇게 한 후,

node app.js

로 실행하면 webcam motion capture의 데이터가 블렌더로 잘 전송됩니다. 네. 이게 VMC프로토콜의 전부....

는 아니죠, 아직 안을 안 까봤잖아요.

5. 여러 신호의 평균을 내 주는 서버 만들기

우선 저는, Webcam Motion Capture와 TDPT의 신호를 말 그대로 섞을 겁니다. 프레임률은 꽤 떨어지겠지만 특정 시간단위마다 신호들을 모아서 평균을 낸 다음 Blender로 전송하고자 합니다. 그러기 위해서는 VMC 프로토콜 안의 신호가 어떻게 생겼는지 봐야 합니다.

   udpPort.on("message", function (oscMessage) {
         // 수신한 message를 바탕으로 적당히 가공한 뒤
         // 송신하는 코드를 여기에 작성합니다. 예를 들면
        sendPort.send(oscMessage)
        
    });

여기 코드를

   udpPort.on("message", function (oscMessage) {
         // 수신한 message를 바탕으로 적당히 가공한 뒤
         // 송신하는 코드를 여기에 작성합니다. 예를 들면
        console.log(oscMessage)
        
    });

로 변경하면, 어떤 형태의 데이터가 들어오는지 알 수 있게 됩니다. 일단 표정 신호는 섞을 수 없으니, 포즈 데이터에 해당하는 '/VMC/Ext/Bone/Pos' 만을 보여드릴게요.

{
  address: '/VMC/Ext/Bone/Pos',
  args: [
    'Head',
    -4.18985948158479e-8,
    0.0694720596075058,
    0.008954105898737907,
    -4.593485614010968e-22,
    1.7898570958666765e-22,
    5.895302718405536e-23,
    1
  ]
}

다행히도 아주 편리한 JSON 데이터입니다. 처리하기 매우 쉽죠.

포즈 데이터는 무조건 address '/VMC/Ext/Bone/Pos' 쪽으로 들어옵니다. 어떤 본에 해당하는 데이터인지는 args에 적혀 있는데, args의 index 0에 그 문자열이 찍힙니다. 나머지 7개의 데이터는, 위치 X, Y, Z, 사원수 X, Y, Z, W 입니다.

이 데이터를 운용하는 방식은 저도 공부를 해 보고 나중에 적어보겠습니다만, 적어도 여기서 해볼 신호의 평균값을 내는 데에는 무리가 없을 것 같습니다. 왜냐? float 데이터 니까요...

저는 일단 시험 삼아, 'Spine', 'Chest', 'Neck', 'Head' 네 개의 데이터만을 처리해 보았습니다.

우선 함수를 하나 만들 겁니다. 이 함수는 몇 밀리초간 받아서 축적해온 데이터들의 Array의, 딱 평균값에 해당하는 Array를 계산하여 반환..하지는 않고 바로 블렌더 측에 전송하는 함수입니다.

// 데이터의 평균을 내 주는 함수입니다. 
// part는 문자열('Head' 같은), 
// receiveArray는 그동안 받아서 축적해온 데이터들의 Array 입니다.
// 참고로 receiveArray에는 part의 이름이 없습니다. slice(1) 로 뺄겁니다.
    function averager(part, receiveArray) {
        if (receiveArray.length > 0) {

            var result3 = [part]
           //result3 는 내보낼 args 데이터 입니다.

            for(var i = 0; i < receiveArray[0].length; i++){
                var num = 0;
                for(var j = 0; j < receiveArray.length; j++){ 
                    num += receiveArray[j][i];
                }
                result3.push(num / receiveArray.length)
             // 앞의 array의 내용물을 index별로 평균내서
             // result3에 집어넣어줍니다.
            }
            //송신부
            sendPort.send({
                address: '/VMC/Ext/Bone/Pos',
                args: result3
            })
// 어떤 데이터가 나가고 있는지 찍고 싶으면 찍으세요..
         //   console.log({
         //       address: '/VMC/Ext/Bone/Pos',
         //       args: result3
         //   })
        }
    }

그리고 Array들이랑 변수들을 선언했습니다.

    var triggered = false
    var receive = false

    var receiveSpineArray = []
    var receiveChestArray = []
    var receiveNeckArray = []
    var receiveHeadArray = []

receive가 false가 되면 수신을 중단하고 지금까지 받았던 내용들의 평균을 계산해서 보낼 것입니다. 그리고 receive는 (일단 테스트니까) 450ms째에 false가 되고, 50ms째에 true가 되는 것을 게속 반복할 것입니다.

그리고 그 트리거가 되는 것은 첫번째 데이터의 수신입니다.

    udpPort.on("message", function (oscMessage) {
// triggered가 false일 경우 트리거를 당겨줄겁니다.
        if (!triggered) {
            setInterval(() => {
                receive = true
                setTimeout(() => {
                    receive = false
                }, 450);
            }, 500)
// 500밀리초 간격으로 true와 false를 반복해서 실행.
        }
        triggered = true

        if (oscMessage.address == '/VMC/Ext/Bone/Pos') {
            if (oscMessage.args[0] == 'Spine') {
                if (receive == true) {
                    receiveSpineArray.push(oscMessage.args.slice(1))
// receive가 true라면 args에서 앞의 'Spine' 부분을 자르고 push합니다.
// 계산을 용이하게 하기 위해서 입니다.
                } else if (receive == false) {
                    averager('Spine', receiveSpineArray)
                    receiveSpineArray = []
// receive가 false라면 지금까지 받았던 array의 평균값을 계산하고,
// 블렌더로 데이터를 보냅니다.
// 그리고 기존 array를 초기화 합니다.
                }
//아래는 부위만 다르고 동일합니다.
            } else if (oscMessage.args[0] == 'Chest') {
                if (receive == true) {
                    receiveChestArray.push(oscMessage.args.slice(1))
                } else if (receive == false) {
                    averager('Chest', receiveChestArray)
                    receiveChestArray = []
                }
            } else if (oscMessage.args[0] == 'Neck') {
                if (receive == true) {
                    receiveNeckArray.push(oscMessage.args.slice(1))
                } else if (receive == false) {
                    averager('Neck', receiveNeckArray)
                    receiveNeckArray = []
                }
            } else if (oscMessage.args[0] == 'Head') {
                if (receive == true) {
                    receiveHeadArray.push(oscMessage.args.slice(1))
                } else if (receive == false) {
                    averager('Head', receiveHeadArray)
                    receiveHeadArray = []
                }
            }
        }
    });

저장하고 실행해 보면 블렌더 상에서 움직이는 아바타를 보실 수 있습니다. 일단 후기 말씀드릴게요.

웹 서버 여는것보다 쉬운것같아요!

#프로젝트 #VanillaJS #블로그

이미지

링크

  • 개발 시작: 2024.08.23. ~ 2024.08.24.
  • 개발 기간: 이틀

구글 스프레드시트와 깃허브 페이지를 사용하여 제작한 위키입니다.

  • 구글스프레드시트의 '시트 하나' 가 문서 하나가 됩니다. 따라서... 만약 이걸로 수백 개의 문서를 가진 대형 위키를 만들려고 하면 좀 문제가 커집니다. 소형 위키에 사용하고자 합니다.
  • 문서를 편집하기 위해서는 그냥 원본 스프레드시트를 편집하는 방법이 있고(이 경우 기록이 남지 않으므로 추천하지 않아요), 자체 편집 기능을 이용하는 방법이 있습니다.

주의사항

  • API 제한 설정: 구글에서 API키를 받을 때 범위를 특정 웹사이트, 스프레드시트로만 설정합니다.
  • 연결할 스프레드시트의 제목은 어떤 것이어도 괜찮습니다. ID만 제대로 따면 됩니다.
  • 링크가 있는 모든 사람이 볼 수 있음 으로 하고, 링크를 복사한 뒤 d/와 /edit 사이에 있는 문자열 복사

왜 만들었나요?

  • 위키를 벌쳐에서 빼낼 수 있는 방법...
  • 그러나 팬덤 위키나 기존 위키 서비스를 쓰지 않는 방법
  • php 기반 무료호스팅도 안 쓸수있는 방법(이건 도메인 연결도 못함...)

을 찾다가 만들었습니다.

어떻게 만들었나요?

가능한 것

  • 문서 읽기
  • 구글 로그인, 로그아웃
  • 이미 존재하는 문서의 편집

해야하는 것

  • 없던 문서의 생성
  • 문서를 이전 버전으로 되돌리기
  • 자잘한 속성 (몇 번째 버전인지, 언제 마지막으로 수정되었는지)의 표시

#프로젝트 #VanillaJS #연합우주

OpenAI의 API를 이용해서 다양한 기능을 지원하는 Misskey 챗봇을 제작했습니다.

이미지

최근 '파이'를 다시 만들고 있습니다. 거의 처음부터 다시 만들었다고 보시면 됩니다.

'파이'는 이전 피치타르트 시절 웹브라우저에서 구동했었던 챗봇입니다. 파이를 구동하기 위해서는 운영체제 상관 없이 24/7 꺼지지 않는 컴퓨터와 브라우저가 있으면 됩니다. 미스키 계정으로의 액세스 토큰과 챗GPT 토큰은 GET 방식으로 받습니다.

  • 파이 리포지토리: 링크
    • 위 폴더 중 js/main.js 가 중요합니다!

구현한 기능

0. 자동 포스트

정해진 시간마다 한번씩 자동으로 게시글을 남깁니다. 이 때, 매주 매 시간별로 일정을 셋팅하고, 저녁 메뉴와 대화 주제의 경우에는 랜덤 선택지를 두어 다양한 주제의 게시글을 작성하도록 했습니다.

1. 단순 채팅

유저의 멘션에 반응해서 답글을 남깁니다. 이번 버전의 파이는 자신있는 주제가 있고 자신없는 주제가 있어요. 코딩, 수학, 의학 등과 같은 hallucination에 민감한 소재는 피하도록 했습니다.

2. 하루 채팅 제한

하루 20회의 채팅 횟수 제한이 있습니다.

3. 호감도

일반 채팅 포함, 대화 내용이 챗봇의 입장에서 긍정적이었는지 부정적이었는지 평가하여 호감도를 증감 합니다.

호감도가 일정 수준 이상이면 대화의 퀄리티가 증가하게 되며, 특히 멘션을 줄 때마다 자신의 감정에 따른 이미지를 첨부해 줍니다.

4. 맞팔로우

유저로부터 맞팔로우 요청으로 보이는 언급이 있으면 맞팔로우 해 줍니다. 단, 이미 파이를 팔로우한 유저에 한해 맞팔로우 합니다.

5. 리마인더

유저로부터 시각 언급이 있으면(몇 시, 몇시간 후 둘 다 가능) 자동으로 그 시각을 저장해 두었다가 리마인드 해 줍니다.

코드의 흐름

  • 유저가 파이에게 질문을 합니다.
  • 브라우저의 페이지는 주기적으로 멘션창을 확인하고, 유저의 질문을 감지합니다.
  • 로컬스토리지에서 질문한 사람의 호감도와, 오늘의 잔여 질문 횟수를 읽습니다.
  • 질문 횟수가 남아있으면 gpt-4o 에게 메인 프롬프트, 호감도에 따른 프롬프트, 질문한 사람의 이름을 전달합니다.
  • 프롬프트와 챗봇의 답변을 gpt-4o-mini 에게 전달하여, 대화가 긍정적이었는지 부정적이었는지 / 리마인더 언급이 있었는지 / 맞팔 요청이 있었는지 / 드라이브의 이미지 중 뭘 쓰는 게 좋은지 간단한 json 형식으로 반환합니다.
  • 대화가 긍정적이었는지 부정적이었는지 여부에 따라 호감도를 재설정하고, 잔여 질문 횟수도 조정합니다.
  • 리마인더 언급이 있을 경우 해당 데이터를 로컬스토리지에 저장합니다.
  • 반환된 드라이브의 이미지를 사용해서 유저에게 답변합니다.
  • 맞팔 요청이 있었을 경우 맞팔합니다. 그러나 맞팔로우 이슈가 있을 경우(파이를 팔로우하지 않음, 이미 팔로우한 유저 등) gpt-4o-mini에게 오류 전달 후 유저에게 다시 답변합니다.

이와 같이, 파이에게 멘션 하나를 날리면 이전과 다르게 gpt로의 요청이 최소 두 번, 최대 세 번까지 이루어집니다.

TODO

  • 다른 misskey 서버에서 비슷한 챗봇을 구현할 수 있게, 커스터마이징이 쉽도록 settings.js에 옵션을 추가할 계획입니다.
  • README.md 를 영어로, 친절하게 수정할 계획입니다.
  • 코드 내의 주석 언어를 영어로 수정할 계획입니다.
  • 채팅 제한이 있는 노트에도 멘션을 달아야 합니다. (답해야 할 노트를 멘션 갯수로 판정하기 때문입니다.)