💙

PowerShellで色を RGB to HSV 変換する。

2022/08/13に公開

この記事は何?

  • RGB 色空間で表現された色を HSV 色空間で表現された色に変換したいです。
    • 言い換えると、いわゆる RGB to HSV 変換したい。
    • ライブラリに依存せず、独自実装で変換したい。
  • PowerShell で片づけましょう。

構成

OS, PowerShell, Pester のバージョンは次の通りです。

PS > (Get-WmiObject -Class Win32_OperatingSystem).Caption
Microsoft Windows 11 Home

PS > [string]$PSVersionTable.PSVersion
5.1.22000.653

PS > [string](Get-InstalledModule -Name Pester).Version
4.10.0

コード

HSVColorクラスを実装し、テストします。

ディレクトリ、ファイル構造

HSVColorクラスの実装があるファイル、テスト用のファイルの2つです。

PS > Get-ChildItem -Recurse | Select-Object -Property Name

Name
----
mycolor.ps1
mycolor.Tests.ps1

実装: mycolor.ps1

class IllegalArgumentException : Exception {
    IllegalArgumentException([String]$Message) : base([String]$Message) {}
}

class InternalErrorException : Exception {
    InternalErrorException([String]$Message) : base([String]$Message) {}
}

# RGB色空間の色
class RGBColor {
    hidden [int32]$Red
    hidden [int32]$Green
    hidden [int32]$Blue
    [int32]GetMaxPrimaryColorDepth() { return [Math]::Pow(2, 8) - 1 }
    hidden ThrowExceptionIfPrimaryColorIsOutOfRange([int32]$PrimaryColor, [string]$ColorName) {
        if (($PrimaryColor -lt 0) -or ($this.GetMaxPrimaryColorDepth() -lt $PrimaryColor)) {
            $Message = "色 {0} が範囲 0-{2} の外です。: {1}" -f $ColorName, $PrimaryColor, $this.GetMaxPrimaryColorDepth()
            throw [IllegalArgumentException]$Message
        }
    }
    RGBColor([int32]$Red, [int32]$Green, [int32]$Blue) {
        $this.ThrowExceptionIfPrimaryColorIsOutOfRange($Red, '赤')
        $this.ThrowExceptionIfPrimaryColorIsOutOfRange($Green, '緑')
        $this.ThrowExceptionIfPrimaryColorIsOutOfRange($Blue, '青')
        $this.Red, $this.Green, $this.Blue = $Red, $Green, $Blue
    }
    static [RGBColor]FromRGB([int32]$Rgb) {
        return [RGBColor]::new(($Rgb % 256), (($Rgb -shr 8) % 256), (($Rgb -shr 16) % 256))
    }
    [int32]GetRed() { return $this.Red }
    [int32]GetGreen() { return $this.Green }
    [int32]GetBlue() { return $this.Blue }
    [int32]GetRGB() {
        return ((($this.Blue -shl 8) + $this.Green) -shl 8) + $this.Red
    }
}

# 角度
class AngleInDegree {
    hidden [float]$AngleInDegree
    AngleInDegree([float]$AngleInDegree) {
        if (($AngleInDegree -lt 0) -or (360 -le $AngleInDegree)) {
            $Message = '角度が範囲 0 以上 360 未満の外です。: {0}' -f $AngleInDegree
            throw [IllegalArgumentException]$Message
        }
        $this.AngleInDegree = $AngleInDegree
    }
    [float]GetAngleInDegree() { return $this.AngleInDegree }
}

# HSV色空間の彩度
class HSVSaturation {
    hidden [float]$Saturation
    HSVSaturation([float]$Saturation) {
        if (($Saturation -lt 0) -or (1 -lt $Saturation)) {
            $Message = '彩度が範囲 0-1 の外です。: {0}' -f $Saturation
            throw [IllegalArgumentException]$Message
        }
        $this.Saturation = $Saturation
    }
    [float]GetSaturation() { return $this.Saturation }
}

# HSV色空間の明度
class HSVValue {
    hidden [float]$Value
    HSVValue([float]$Value) {
        if (($Value -lt 0) -or (1 -lt $Value)) {
            $Message = '明度が範囲 0-1 の外です。: {0}' -f $Value
            throw [IllegalArgumentException]$Message
        }
        $this.Value = $Value
    }
    [float]GetValue() { return $this.Value }
}

# HSV色空間の色
class HSVColor {
    hidden [AngleInDegree]$Hue  # 色相
    hidden [HSVSaturation]$Saturation  # 彩度
    hidden [HSVValue]$Value  # 明度
    HSVColor([AngleInDegree]$Hue, [HSVSaturation]$Saturation, [HSVValue]$Value) {
        $this.Hue = $Hue
        $this.Saturation = $Saturation
        $this.Value = $Value
    }
    [AngleInDegree]GetHue() { return $this.Hue }
    [HSVSaturation]GetSaturation() { return $this.Saturation }
    [HSVValue]GetValue() { return $this.Value }
    [HSVValue]GetBrightness() { return $this.GetValue() }
    static [HSVColor]FromRGBColor([RGBColor]$Rgb) {
        # RGB を HSV に変換します。
        # atan() などを使用せず、近似的に計算します。

        # RGB を 0.0 - 1.0 の範囲に変換する。
        [float]$Red = $Rgb.GetRed() / $Rgb.GetMaxPrimaryColorDepth()
        [float]$Green = $Rgb.GetGreen() / $Rgb.GetMaxPrimaryColorDepth()
        [float]$Blue = $Rgb.GetBlue() / $Rgb.GetMaxPrimaryColorDepth()
        [float[]]$PrimaryColors = $Red, $Green, $Blue

        # RGBの最大値、最小値を得る。
        $RgbMax = [System.Linq.Enumerable]::Max($PrimaryColors)
        $RgbMin = [System.Linq.Enumerable]::Min($PrimaryColors)

        # Hue を計算する。
        $HueValue = 0
        if ($RgbMax -eq $Red) {
            if ($RgbMax -ne $RgbMin) {
                $HueValue = 60 * ($Green - $Blue) / ($RgbMax - $RgbMin)
                if ($HueValue -lt 0) {
                    $HueValue += 360
                }
            }
        } elseif ($RgbMax -eq $Green) {
            if ($RgbMax -ne $RgbMin) {
                $HueValue = 60 * ($Blue - $Red) / ($RgbMax - $RgbMin) + 120
            }
        } elseif ($RgbMax -eq $Blue) {
            if ($RgbMax -ne $RgbMin) {
                $HueValue = 60 * ($Red - $Green) / ($RgbMax - $RgbMin) + 240
            }
        } else {
            throw [InternalErrorException]
        }
        $local:Hue = [AngleInDegree]::new($HueValue)

        # 彩度を計算する。
        $SaturationValue = 0
        if ($RgbMax -ne 0) {
            $SaturationValue = ($RgbMax - $RgbMin) / $RgbMax
        }
        $local:Saturation = [HSVSaturation]::new($SaturationValue)

        # 明度を計算する。
        $local:Value = [HSVValue]::new($RgbMax)

        return [HSVColor]::new($local:Hue, $local:Saturation, $local:Value)
    }
}

テスト: mycolor.Tests.ps1

各種の値は HSL and HSV を参照してください。

$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.Tests\.', '.'
. "$here\$sut"

Describe "RGBColor" {
    Context "正常" {
        It "赤を返す。" {
            $c = [RGBColor]::new(10, 20, 30)
            $c.GetRed() | Should -Be 10
        }
        It "緑を返す。" {
            $c = [RGBColor]::new(10, 20, 30)
            $c.GetGreen() | Should -Be 20
        }
        It "青を返す。" {
            $c = [RGBColor]::new(10, 20, 30)
            $c.GetBlue() | Should -Be 30
        }
        It "すべて0で例外を発生しない。" {
            [RGBColor]::new(0, 0, 0)
        }
        It "すべて255で例外を発生しない。" {
            [RGBColor]::new(255, 255, 255)
        }
    }
    Context "異常" {
        It "色の要素が0未満ならば例外を発生する。" {
            try {
                [RGBColor]::new(-1, -1, -1)
                $true | Should -Be $false
            } catch {
            }
        }
        It "色の要素が256以上ならば例外を発生する。" {
            try {
                [RGBColor]::new(256, 256, 256)
                $true | Should -Be $false
            } catch {
            }
        }
    }
}

Describe "AngleInDegree" {
    Context "正常" {
        It "0度を返す。" {
            $a = [AngleInDegree]::new(0)
            $a.GetAngleInDegree() | Should -Be 0
        }
        It "359度を返す。" {
            $a = [AngleInDegree]::new(359)
            $a.GetAngleInDegree() | Should -Be 359
        }
    }
    Context "異常" {
        It "0未満ならば例外を発生する。" {
            try {
                [AngleInDegree]::new(-0.1)
                $true | Should -Be $false
            } catch {
            }
        }
        It "360度以上ならば例外を発生する。" {
            try {
                [AngleInDegree]::new(360)
                $true | Should -Be $false
            } catch {
            }
        }
    }
}

Describe "HSVSaturation" {
    Context "正常" {
        It "0を返す。" {
            $s = [HSVSaturation]::new(0)
            $s.GetSaturation() | Should -Be 0
        }
        It "1を返す。" {
            $s = [HSVSaturation]::new(1)
            $s.GetSaturation() | Should -Be 1
        }
    }
    Context "異常" {
        It "0未満ならば例外を発生する。" {
            try {
                [HSVSaturation]::new(-0.1)
                $true | Should -Be $false
            } catch {
            }
        }
        It "1を超えるならば例外を発生する。" {
            try {
                [HSVSaturation]::new(1.1)
                $true | Should -Be $false
            } catch {
            }
        }
    }
}

Describe "HSVValue" {
    Context "正常" {
        It "0を返す。" {
            $s = [HSVValue]::new(0)
            $s.GetValue() | Should -Be 0
        }
        It "1を返す。" {
            $s = [HSVValue]::new(1)
            $s.GetValue() | Should -Be 1
        }
    }
    Context "異常" {
        It "0未満ならば例外を発生する。" {
            try {
                [HSVValue]::new(-0.1)
                $true | Should -Be $false
            } catch {
            }
        }
        It "1を超えるならば例外を発生する。" {
            try {
                [HSVValue]::new(1.1)
                $true | Should -Be $false
            } catch {
            }
        }
    }
}

Describe "HSVColor" {
    Context "RGB to HSV" {
        Context "正常" {
            It "色相0度を返す。" {
                $RgbPct = 100, 0, 0
                $Rgb = $RgbPct | ForEach-Object { ($_ / 100) * 255 }
                $Red, $Green, $Blue = $Rgb
                $Color1 = [RGBColor]::new($Red, $Green, $Blue)
                $Color2 = [HSVColor]::FromRGBColor($Color1)
                $Hue = $Color2.GetHue()
                $Diff = [Math]::Abs($Hue.GetAngleInDegree() - 0)
                $Diff | Should -BeLessThan 0.1
            }
            It "色相60度を返す。" {
                $RgbPct = 75, 75, 0
                $Rgb = $RgbPct | ForEach-Object { ($_ / 100) * 255 }
                $Red, $Green, $Blue = $Rgb
                $Color1 = [RGBColor]::new($Red, $Green, $Blue)
                $Color2 = [HSVColor]::FromRGBColor($Color1)
                $Hue = $Color2.GetHue()
                $Diff = [Math]::Abs($Hue.GetAngleInDegree() - 60)
                $Diff | Should -BeLessThan 0.1

            }
            It "色相120度を返す。" {
                $RgbPct = 0, 50, 0
                $Rgb = $RgbPct | ForEach-Object { ($_ / 100) * 255 }
                $Red, $Green, $Blue = $Rgb
                $Color1 = [RGBColor]::new($Red, $Green, $Blue)
                $Color2 = [HSVColor]::FromRGBColor($Color1)
                $Hue = $Color2.GetHue()
                $Diff = [Math]::Abs($Hue.GetAngleInDegree() - 120)
                $Diff | Should -BeLessThan 0.1
            }
            It "色相180度を返す。" {
                $RgbPct = 50, 100, 100
                $Rgb = $RgbPct | ForEach-Object { ($_ / 100) * 255 }
                $Red, $Green, $Blue = $Rgb
                $Color1 = [RGBColor]::new($Red, $Green, $Blue)
                $Color2 = [HSVColor]::FromRGBColor($Color1)
                $Hue = $Color2.GetHue()
                $Diff = [Math]::Abs($Hue.GetAngleInDegree() - 180)
                $Diff | Should -BeLessThan 0.1
            }
            It "色相240度を返す。" {
                $RgbPct = 50, 50, 100
                $Rgb = $RgbPct | ForEach-Object { ($_ / 100) * 255 }
                $Red, $Green, $Blue = $Rgb
                $Color1 = [RGBColor]::new($Red, $Green, $Blue)
                $Color2 = [HSVColor]::FromRGBColor($Color1)
                $Hue = $Color2.GetHue()
                $Diff = [Math]::Abs($Hue.GetAngleInDegree() - 240)
                $Diff | Should -BeLessThan 0.1
            }
            It "色相300度を返す。" {
                $RgbPct = 75, 25, 75
                $Rgb = $RgbPct | ForEach-Object { ($_ / 100) * 255 }
                $Red, $Green, $Blue = $Rgb
                $Color1 = [RGBColor]::new($Red, $Green, $Blue)
                $Color2 = [HSVColor]::FromRGBColor($Color1)
                $Hue = $Color2.GetHue()
                $Diff = [Math]::Abs($Hue.GetAngleInDegree() - 300)
                $Diff | Should -BeLessThan 0.1
            }
            It "彩度を返す。" {
                $RgbPct = 75, 75, 0
                $Rgb = $RgbPct | ForEach-Object { ($_ / 100) * 255 }
                $Red, $Green, $Blue = $Rgb
                $Color1 = [RGBColor]::new($Red, $Green, $Blue)
                $Color2 = [HSVColor]::FromRGBColor($Color1)
                $Saturation = $Color2.GetSaturation()
                $Diff = [Math]::Abs($Saturation.GetSaturation() - 1)
                $Diff | Should -BeLessThan 0.01
            }
            It "明度を返す。" {
                $RgbPct = 75, 75, 0
                $Rgb = $RgbPct | ForEach-Object { ($_ / 100) * 255 }
                $Red, $Green, $Blue = $Rgb
                $Color1 = [RGBColor]::new($Red, $Green, $Blue)
                $Color2 = [HSVColor]::FromRGBColor($Color1)
                $Value = $Color2.GetValue()
                $Diff = [Math]::Abs($Value.GetValue() - 0.75)
                $Diff | Should -BeLessThan 0.01
            }
            It "RGB(100%,100%,100%)は期待通りのHSVを返す。" {
                $RgbPct = 100, 100, 100
                $Rgb = $RgbPct | ForEach-Object { ($_ / 100) * 255 }
                $Red, $Green, $Blue = $Rgb
                $Color1 = [RGBColor]::new($Red, $Green, $Blue)
                $Color2 = [HSVColor]::FromRGBColor($Color1)

                # 白の色相は0を返す仕様とする。
                $Hue = $Color2.GetHue()
                $AngleInDegree = $Hue.GetAngleInDegree()
                $Diff = [Math]::Abs($AngleInDegree - 0)
                $Diff | Should -BeLessThan 0.1

                $Saturation = $Color2.GetSaturation()
                $Diff = [Math]::Abs($Saturation.GetSaturation() - 0)
                $Diff | Should -BeLessThan 0.01

                $Value = $Color2.GetValue()
                $Diff = [Math]::Abs($Value.GetValue() - 1)
                $Diff | Should -BeLessThan 0.01
            }
            It "RGB(0%,0%,0%)は期待通りのHSVを返す。" {
                $RgbPct = 0, 0, 0
                $Rgb = $RgbPct | ForEach-Object { ($_ / 100) * 255 }
                $Red, $Green, $Blue = $Rgb
                $Color1 = [RGBColor]::new($Red, $Green, $Blue)
                $Color2 = [HSVColor]::FromRGBColor($Color1)

                # 黒の色相は0を返す仕様とする。
                $Hue = $Color2.GetHue()
                $AngleInDegree = $Hue.GetAngleInDegree()
                $Diff = [Math]::Abs($AngleInDegree - 0)
                $Diff | Should -BeLessThan 0.1

                $Saturation = $Color2.GetSaturation()
                $Diff = [Math]::Abs($Saturation.GetSaturation() - 0)
                $Diff | Should -BeLessThan 0.01

                $Value = $Color2.GetValue()
                $Diff = [Math]::Abs($Value.GetValue() - 0)
                $Diff | Should -BeLessThan 0.01
            }
            It "RGB(62.8%,64.3%,14.2%)は期待通りのHSVを返す。" {
                $RgbPct = 62.8, 64.3, 14.2
                $Rgb = $RgbPct | ForEach-Object { ($_ / 100) * 255 }
                $Red, $Green, $Blue = $Rgb
                $Color1 = [RGBColor]::new($Red, $Green, $Blue)
                $Color2 = [HSVColor]::FromRGBColor($Color1)

                $Hue = $Color2.GetHue()
                $AngleInDegree = $Hue.GetAngleInDegree()
                $Diff = [Math]::Abs($AngleInDegree - 61.8)
                $Diff | Should -BeLessThan 0.1

                $Saturation = $Color2.GetSaturation()
                $Diff = [Math]::Abs($Saturation.GetSaturation() - 0.779)
                $Diff | Should -BeLessThan 0.01

                $Value = $Color2.GetValue()
                $Diff = [Math]::Abs($Value.GetValue() - 0.643)
                $Diff | Should -BeLessThan 0.01
            }
        }
    }
}

テスト結果

こんな感じになりました。

PS > .\mycolor.Tests.ps1

Describing RGBColor

  Context 正常
    [+] 赤を返す。 1ms
    [+] 緑を返す。 1ms
    [+] 青を返す。 3ms
    [+] すべて0で例外を発生しない。 0ms
    [+] すべて255で例外を発生しない。 0ms

  Context 異常
    [+] 色の要素が0未満ならば例外を発生する。 2ms
    [+] 色の要素が256以上ならば例外を発生する。 1ms

Describing AngleInDegree

  Context 正常
    [+] 0度を返す。 1ms
    [+] 359度を返す。 1ms

  Context 異常
    [+] 0未満ならば例外を発生する。 0ms
    [+] 360度以上ならば例外を発生する。 2ms

Describing HSVSaturation

  Context 正常
    [+] 0を返す。 1ms
    [+] 1を返す。 10ms

  Context 異常
    [+] 0未満ならば例外を発生する。 1ms
    [+] 1を超えるならば例外を発生する。 0ms

Describing HSVValue

  Context 正常
    [+] 0を返す。 1ms
    [+] 1を返す。 1ms

  Context 異常
    [+] 0未満ならば例外を発生する。 0ms
    [+] 1を超えるならば例外を発生する。 0ms

Describing HSVColor

  Context RGB to HSV

    Context 正常
      [+] 色相0度を返す。 1ms
      [+] 色相60度を返す。 1ms
      [+] 色相120度を返す。 1ms
      [+] 色相180度を返す。 0ms
      [+] 色相240度を返す。 2ms
      [+] 色相300度を返す。 1ms
      [+] 彩度を返す。 1ms
      [+] 明度を返す。 1ms
      [+] RGB(100%,100%,100%)は期待通りのHSVを返す。 3ms
      [+] RGB(0%,0%,0%)は期待通りのHSVを返す。 2ms
      [+] RGB(62.8%,64.3%,14.2%)は期待通りのHSVを返す。 2ms
PS >

簡単ですね!

まとめ

  • RGB を独自のHSV変換で、色相(Hue)、彩度(Saturation)、明度(Value) に変換できました。
  • もうちょっといろいろ遊べそうですね!

現場からは以上です。

参考資料

Discussion