2023-03-14 13:56

[OpenCV] 以最亮顏色的色相來進行灰階處理

OpenCV-Python 教學 https://docs.opencv.org/3.4.16/d6/d00/tutorial_py_root.html

原始圖片

原圖因為燈光散射的關係,整個圖片都出現發紅的現象


預設灰階 cv2.imread('01.jpg', cv2.IMREAD_GRAYSCALE)

有部分的色差變得不明顯,之後要進行辨識變得很困難


色相比例的灰階

透過色相的方式就好很多


HSV 的數值定義

  • H: 色相 0~360度
  • S: 飽和度 0~1
  • V: 明度 (黑)0~1(白)

OpenCV HSV 的數值定義

  • H: 0 ~ 180 => H / 2
  • S: 0 ~ 255 => S * 255
  • V: 0 ~ 255 => V * 255

相依套件:

pip install opencv-python
pip install py-linq     # https://viralogic.github.io/py-enumerable/

程式:

from py_linq import Enumerable
import cv2
import numpy as np


def get_hue(img):
    ''' 圖片色相 '''

    img = cv2.resize(img, (32, 32), interpolation = cv2.INTER_LINEAR)
    img_hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    pixel_hsv = np.reshape(img_hsv,(-1,3))

    result = Enumerable(pixel_hsv)\
        .order_by_descending(lambda p: p[2])\
        .then_by_descending(lambda p: p[1])\
        .first()

    print(f'[result]: {result}')
    return result[0]



def get_gray_rate(hue):
    ''' 根據色相計算灰階比例 '''

    hue_full = np.uint8([[[hue,255,255]]])
    hue_bgr = cv2.cvtColor(hue_full, cv2.COLOR_HSV2BGR)
    (hue_b, hue_g, hue_r) = hue_bgr[0,0]

    hue_total =  0.0 + hue_b + hue_g + hue_r

    return [
        hue_b / hue_total,
        hue_g / hue_total,
        hue_r / hue_total,
    ]



img = cv2.imread('01.jpg')

hue = get_hue(img)
gray_rate = get_gray_rate(hue) # 灰階比例

print(f'hue:{hue}, gray_rate: {gray_rate}')

# 灰階轉換
img_gray = cv2.transform(img, np.array([gray_rate]))

cv2.imshow("img", img)
cv2.imshow("img_gray", img_gray)
cv2.waitKey(0)
cv2.destroyAllWindows()
2023-03-14 13:23

[ThreeJS] 噴水粒子

要完成這個效果有三個問題要解決

  1. 自然的粒子分布
  2. 粒子的拋物線移動
  3. 粒子的擴散效果

我採用簡單的方式達成

  1. 用亂數產生分段的粒子團
  2. 用二次貝茲曲線建立拋物線
  3. 用粒子團的放大產生擴散效果

因為我用簡單的亂數分布粒子,粒子團會有立方體現象,球體分布會更好,但多個區段連起來就不明顯了。

二次貝茲曲線最大的困難在計算出結尾座標的位置,透過轉換到世界空間去定位出平面高度的座標,再轉換回物體空間來計算出結尾座標。

動畫用 AnimationMixer 去控制,只要定出每段粒子團的開始播放時間,粒子團就會接續的移動。

circle.png

程式:

/* 初始化渲染器 */
const renderer = new THREE.WebGLRenderer({ antialias: true });
document.getElementById('Container').appendChild(renderer.domElement);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);

/* 初始化場景 */
const scene = new THREE.Scene();
scene.background = new THREE.Color('#111'); /* 背景顏色 */
scene.add(new THREE.AmbientLight('#FFF', 0.5)); /* 加入環境光 */

/* 地面 */
const floorMesh = new THREE.Mesh(
    new THREE.PlaneGeometry(200, 200),
    new THREE.MeshBasicMaterial({ color: '#DDD', depthWrite: false })
);
floorMesh.rotation.x = THREE.MathUtils.degToRad(-90);
scene.add(floorMesh);


/* 初始化鏡頭 */
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 300);
camera.position.set(0, 10, 22);

/* 初始化軌道控制,鏡頭的移動 */
const orbitControls = new THREE.OrbitControls(camera, renderer.domElement);
orbitControls.update();


/* 動畫刷新器 */
const mixers = [];

function newMixer(target) {
    const mixer = new THREE.AnimationMixer(target);
    mixers.push(mixer);
    return mixer;
}

/* 動畫計時器 */
const clock = new THREE.Clock();

/* 渲染週期 */
function renderCycle() {
    const delta = clock.getDelta();
    mixers.forEach(x => x.update(delta));

    renderer.render(scene, camera);
    requestAnimationFrame(renderCycle);
}
renderCycle();



/*---------------------------------------------------------------*/

/* 水管物件,水滴粒子群組會附加在這裡 */
const pipeMesh = new THREE.Mesh(
    new THREE.CylinderGeometry(0.5, 0.5, 9, 16),
    new THREE.MeshBasicMaterial( {color: '#eea236'} )
);
pipeMesh.rotation.x = THREE.MathUtils.degToRad(0);
pipeMesh.position.set(0, 4, 0);
scene.add(pipeMesh);


const dripGroup = new THREE.Group();
dripGroup.position.set(0, 4, 0);
pipeMesh.add(dripGroup);


/* 水滴材質 */
const dripMaterial = new THREE.PointsMaterial({
    map: new THREE.TextureLoader().load('circle.png'),
    color: '#0aa',
    size: 1,
    opacity: 0.7,
    depthWrite: false,
    transparent: true,
});


/* 建立水滴,用亂數建立粒子點 */
for (let i = 0; i < 60; i++) {
    const vertices = [];

    for (let j = 0; j < 40; j++) {
        const x = Math.random() - 0.5;
        const y = Math.random() - 0.5;
        const z = Math.random() - 0.5;
        vertices.push(x, y, z);
    }

    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));

    const particles = new THREE.Points(geometry, dripMaterial);
    dripGroup.add(particles);

    newMixer(particles); /* 水滴動畫 */
}


/*---------------------------------------------------------------*/

const cycle = 2; /* 循環週期,會影響水流速度 */
const scale = 8; /* 擴散大小 */
const length = 14; /* 水流長度 */


/* 二次貝茲曲線,用來取得拋物線的座標點 */
const curve = new THREE.QuadraticBezierCurve3();
curve.v1.set(0, length, 0); /* 曲線控制點座標 */


/* 計算結尾座標 */
const rootPos = dripGroup.getWorldPosition(new THREE.Vector3());
const quaternion = dripGroup.getWorldQuaternion(new THREE.Quaternion());
const toPos = curve.v1.clone();
toPos.applyQuaternion(quaternion); /* 轉換到世界空間 */

/* 當水流是向上時,增加平面位置 */
if (toPos.y > (length / 3)) {
    toPos.x *= 1.8;
    toPos.z *= 1.8;
}
toPos.y = Math.min(-rootPos.y, toPos.y * 1.5); /* 將結尾拉回平面高度 */
toPos.applyQuaternion(quaternion.conjugate()); /* 轉換回物體空間 */
curve.v2.copy(toPos); /* 曲線結尾點座標 */


/* 建立拋物線及擴散動畫 */
const points = curve.getPoints(10); /* 曲线分段 */

const curveTime = [];
const curvePos = [];
points.forEach((v, i) => {
    curveTime.push(cycle * i / points.length);
    curvePos.push(v.x, v.y, v.z);
});
const posTrack = new THREE.VectorKeyframeTrack('.position', curveTime, curvePos);
const scaleTrack = new THREE.VectorKeyframeTrack('.scale', [0, cycle], [0, 0, 0, scale, scale, scale]);
const clip = new THREE.AnimationClip('scale', cycle, [posTrack, scaleTrack]);

mixers.forEach((mixer, i) => {
    const action = mixer.clipAction(clip);
    action.time = cycle * i / mixers.length;
    action.play();
});
2023-03-10 14:33

TypeScript 強轉型的地雷

用 Visual Studio 撰寫 TypeScript 一整個就很開心開心,只要好好的進行型態宣告,就可享受到跟強型別一樣的 IntelliSense 提示,實在太開心了,然後就大意了,忘記了其實骨子裡還是 JavaScript 這件事。

在取回後端的 json 資料的時候,貪圖方便就直接進行強轉型,哈哈!這裡存在不確定性,我就踩到地雷了!當然如果後端是可靠的,直接強轉型是不會有問題的。

我想都已經進行型別宣告了,難到不能自動轉型嗎?哈哈!在網路上找了很久都沒找到,找到的方式都需要二次宣定去做轉型。

簡單的展示一下這個問題,建立一個 any 的物件 a,然後強轉型成 DataModel 的物件 b,這時候 a 跟 b 其實還是同一個 Instance

interface DataModel {
    id: number;
    status: string;
}

let a: any = { id: '1', status: 3 };

let b: DataModel = a as DataModel;

在存取 property 時都還是 a 的資料型態,這樣在調用該型態的方法時就會出錯。

為了型態的正確必須進行轉換:

let b: DataModel = <DataModel>{
    id: Number(a.id),
    status: String(a.status),
};
2023-03-08 16:45

[Angular] 簡單製作多國語系

我想要用一種簡單而且直覺的方式製作多國語系,想像的用法如下:

user-list.component.html

<h2>{{lang['T_編輯使用者']}}</h2>

<div>
    <p *ngIf="lang.id == 'en'">
    Complicated English description...
    </p>
    <p *ngIf="lang.id == 'tw'">
    複雜的中文描述...
    </p>
</div>

<button type="submit">{{lang['B_儲存']}}</button>

<select [(ngModel)]="lang.id" (change)="lang.change()">
    <option *ngFor="let x of lang.options | keyvalue" [value]="x.key">{{x.value}}</option>
</select>

我希望能使用中文變數,有熟悉的文字可以讓程式看起來親切一點,對於複雜的文字區段也保留彈性,簡單的切換方式,以及狀態保留,讓 browser 紀錄選擇的語系。

user-list.component.ts

import { Component, OnInit } from '@angular/core';
import { AppLang } from 'app/app.lang';

@Component({
    selector: 'app-user-list',
    templateUrl: './user-list.component.html'
})
export class UserListComponent implements OnInit {

    /** 狀態選項 */
    statusItems = {
        'y': this.lang.E_啟用,
        'n': this.lang.E_停用,
    };

    constructor(
        public lang: AppLang
    ) { }

    ngOnInit() {
        let msg = String(this.lang.M_請填寫_0_的刻度值).format(3);
    }
}

已經想好使用情境了,接著來就是完成實作了!

app.lang.ts

import { Injectable } from '@angular/core';

/** 多國語言 */
@Injectable()
export class AppLang {

    constructor() {
        let _: any = {}; /* 語系字典 */
        let self: any = this;
        let names: string[] = Object.keys(this).filter(n => !'id,options,change'.includes(n));

        /* 重組語系字典 */
        Object.keys(this.options).forEach(id => {
            _[id] = {};
            names.forEach(name => _[id][name] = self[name][id]);
        });

        /* 複寫 change */
        this.change = function () {
            if (!_[this.id]) { this.id = 'tw'; }

            Object.assign(this, _[this.id]);
            localStorage['langId'] = this.id;
        }

        this.change ();
    }


    id: string = localStorage['langId'];

    options: any = {
        tw: '中文',
        en: 'English',
    };

    /** 變更語言 */
    change() { }



    /*=[ Title ]=============================================*/

    T_編輯使用者: any = {
        tw: '編輯使用者',
        en: 'Edit User',
    };
    //...

    /*=[ Field ]=============================================*/

    F_名稱: any = {
        tw: '名稱',
        en: 'Name',
    };
    //...

    /*=[ Button ]=============================================*/

    B_儲存: any = {
        tw: '儲存',
        en: 'Save',
    };
    //...


    /*=[ Enum ]=============================================*/

    E_啟用: any = {
        tw: '啟用',
        en: 'Enable',
    };
    E_停用: any = {
        tw: '停用',
        en: 'Disable',
    };
    //...


    /*=[ Message ]=============================================*/

    M_請填寫_0_的刻度值: any = {
        tw: '請填寫 {0} 的刻度值',
        en: 'Please fill in the scale value of {0}.',
    };
    //...

}

app.module.ts 配置

//...
import { AppLang } from 'app/app.lang';

@NgModule({
    declarations: [
        //...
    ],
    imports: [
        //...
    ],
    providers: [
        //...
        AppLang
    ],
    bootstrap: [AppComponent]
})
export class AppModule { }
2023-03-08 14:21

[Angular] 將 select, radio 改成一般等於 (==)

Angular 預設的 select, radio 比對是用全等於(===),這對用 string 的選項來說沒甚麼問題,但如果是數字選項就會出現型態不一致的問題,就必須型態轉換,這有點煩人,所以想要改成一般等於(==),在 Angular 的原始碼找到可以複寫判斷句的地方。

app.module.ts

import { RadioControlValueAccessor, SelectControlValueAccessor } from '@angular/forms';

/* 將 SelectControlValueAccessor 的全等於改成一般等於 */
SelectControlValueAccessor.prototype.compareWith = function (a, b) {
    return a == b;
};

/* 將 RadioControlValueAccessor 的全等於改成一般等於 */
RadioControlValueAccessor.prototype.writeValue = function (value: any) {
    let self: any = this;
    self._state = value == this.value;
    self._renderer.setProperty(self._elementRef.nativeElement, 'checked', self._state);
};
2023-03-07 16:55

[ThreeJS] 單次動畫

/* 單次動畫 */
function animateOnce(mixer, prop, dur, values) {
    /* 停止 & 清除 先前的動畫 */
    mixer.stopAllAction();
    mixer.uncacheRoot(mixer.getRoot());

    /* 增加動畫片段 */
    const track = new THREE.KeyframeTrack(prop, [0, dur], values);
    const clip = new THREE.AnimationClip('move', dur, [track]);
    const action = mixer.clipAction(clip);
    action.clampWhenFinished = true;
    action.setLoop(THREE.LoopOnce);

    /* Promise 來處理完成事件,這樣就可以用 await */
    return new Promise((resolve) => {
        const finished = function () {
            mixer.removeEventListener('finished', finished);
            resolve();
        };
        mixer.addEventListener('finished', finished);

        action.play(); /* 播放動畫 */
    });
}

想要能像 SVG 一樣簡單的控制模型的移動,ThreeJS 並沒有直接提供我想要的模式,試了很久終於成功了。

mixer 中的 action 是不斷添加的,這會混亂播放動畫,所以在播放新的動畫前必須[停止]且[清除]先前的動畫。

為了可以簡單串接不同模型的動畫,所以就想用 await 來達成,所以加上 Promise 的包裝。

完整程式

/* 初始化渲染器 */
const renderer = new THREE.WebGLRenderer({ antialias: true });
document.getElementById('Container').appendChild(renderer.domElement);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);

/* 初始化場景 */
const scene = new THREE.Scene();
scene.background = new THREE.Color('#000'); /* 背景顏色 */
scene.add(new THREE.AmbientLight('#FFF', 0.5)); /* 加入環境光 */
scene.add(new THREE.AxesHelper(50)); /* 3D 軸標示 */

/* 初始化鏡頭 */
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 300);
camera.position.set(0, 4, 12);

/* 初始化軌道控制,鏡頭的移動 */
const orbitControls = new THREE.OrbitControls(camera, renderer.domElement);
orbitControls.update();


/* 動畫刷新器 */
const mixers = [];

function newMixer(target) {
    const mixer = new THREE.AnimationMixer(target);
    mixers.push(mixer);
    return mixer;
}

/* 動畫計時器 */
const clock = new THREE.Clock();

/* 渲染週期 */
function renderCycle() {
    const delta = clock.getDelta();
    mixers.forEach(x => x.update(delta));

    renderer.render(scene, camera);
    requestAnimationFrame(renderCycle);
}
renderCycle();



/*---------------------------------------------------------------*/

/* 單次動畫 */
function animateOnce(mixer, prop, dur, values) {
    /* 停止 & 清除 先前的動畫 */
    mixer.stopAllAction();
    mixer.uncacheRoot(mixer.getRoot());

    /* 增加動畫片段 */
    const track = new THREE.KeyframeTrack(prop, [0, dur], values);
    const clip = new THREE.AnimationClip('move', dur, [track]);
    const action = mixer.clipAction(clip);
    action.clampWhenFinished = true;
    action.setLoop(THREE.LoopOnce);

    /* Promise 來處理完成事件,這樣就可以用 await */
    return new Promise((resolve) => {
        const finished = function () {
            mixer.removeEventListener('finished', finished);
            resolve();
        };
        mixer.addEventListener('finished', finished);

        action.play(); /* 播放動畫 */
    });
}


const cubeA = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshBasicMaterial( {color: '#0F0'} )
);
const mixerA = newMixer(cubeA);
scene.add(cubeA);


const cubeB = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshBasicMaterial( {color: '#00F'} )
);
const mixerB = newMixer(cubeB);
scene.add(cubeB);


async function run() {
    await animateOnce(mixerA, '.position[x]', 3, [0, 3]);
    await animateOnce(mixerB, '.position[y]', 3, [0, 2]);
    await animateOnce(mixerA, '.position[x]', 3, [3, 0]);
    await animateOnce(mixerB, '.position[y]', 3, [2, 0]);
}

run();
2023-03-07 16:06

[ThreeJS] 用 SVG 貼圖顯示中文

HTML

<div id="Container"></div>

<div id="LabelTpl" style="display:none;">
    <svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 30 30">
        <text x="15" y="15" fill="#fff" text-anchor="middle"></text>
    </svg>
</div>

<script src="../Scripts/three.145/three.min.js"></script>
<script src="../Scripts/three.145/controls/OrbitControls.js"></script>
<script src="../Scripts/three.145/loaders/GLTFLoader.js"></script>
<script src="main.js"></script>

main.js

/* 初始化渲染器 */
const renderer = new THREE.WebGLRenderer({ antialias: true });
document.getElementById('Container').appendChild(renderer.domElement);

renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);


/* 初始化場景 */
const scene = new THREE.Scene();
scene.background = new THREE.Color('#000'); /* 背景顏色 */
scene.add(new THREE.AmbientLight('#FFF', 0.5)); /* 加入環境光 */
scene.add(new THREE.AxesHelper(50)); /* 3D 軸標示 */

/* 初始化鏡頭 */
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 300);
camera.position.set(0, 4, 12);


/* 初始化軌道控制,鏡頭的移動 */
const orbitControls = new THREE.OrbitControls(camera, renderer.domElement);
orbitControls.update();


/* 渲染週期 */
function renderCycle() {
    renderer.render(scene, camera);
    requestAnimationFrame(renderCycle);
}
renderCycle();



/*---------------------------------------------------------------*/

function svgBase64(svg) {
    svg = svg.replace(/\s+</g, '<').replace(/>\s+/g, '>');
    return 'data:image/svg+xml,' + encodeURIComponent(svg);
}


const textureLoader = new THREE.TextureLoader();
const $labelTpl = document.querySelector('#LabelTpl');
const $labelText = document.querySelector('#LabelTpl text');


/* 環線 */
function addCircle(label,  z) {
    $labelText.innerHTML = label;
    let imageData = svgBase64($labelTpl.innerHTML);

    const particles = new THREE.Points(
        new THREE.EdgesGeometry(new THREE.CircleGeometry(10, 6)),
        new THREE.PointsMaterial({
            map: textureLoader.load(imageData),
            color: '#FFF',
            size: 4,
            depthWrite: false,
            transparent: true,
        })
    );
    particles.position.z = z;
    scene.add(particles);


    const circle = new THREE.LineSegments(
        new THREE.EdgesGeometry(new THREE.CircleGeometry(10, 60)),
        new THREE.LineBasicMaterial({ color: '#FFF' })
    );
    circle.position.z = z;
    scene.add(circle);
}

addCircle('冬至', 4);
addCircle('夏至', -4);
2023-03-07 13:01

[ThreeJS] 解決陰影造成的條紋

 會出現條紋現象是因為雙面材質,只要設置為單面材質就可以解決。

/* 載入模型 */
new THREE.GLTFLoader().load('Model.glb', function (gltf) {
    const model = gltf.scene;

    model.traverse(obj => {
        if (!obj.isMesh) { return; }

        obj.frustumCulled = false;
        obj.castShadow = true;
        obj.receiveShadow = true;

        /* 解決陰影造成的條紋 */
        obj.material.side = THREE.FrontSide;
        obj.material.shadowSide = THREE.FrontSide;
    });

    scene.add(model);
});