Skip to content

Commit

Permalink
add hsv_to_rgba and roundtrip test for #2339
Browse files Browse the repository at this point in the history
  • Loading branch information
mikedh committed Jan 18, 2025
1 parent a94c287 commit 2a63259
Show file tree
Hide file tree
Showing 4 changed files with 151 additions and 20 deletions.
30 changes: 30 additions & 0 deletions tests/test_color.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,36 @@ def test_concatenate(self):
r = a + b
assert any(g.np.ptp(r.visual.face_colors, axis=0) > 1)

def test_random_color(self):
from trimesh.visual.color import random_color

c = random_color()
assert c.shape == (4,)
assert c.dtype == g.np.uint8

c = random_color(count=10)
assert c.shape == (10, 4)
assert c.dtype == g.np.uint8

def test_hsv_rgba(self):
# our HSV -> RGBA function
# the non-vectorized stdlib HSV -> RGB function
from colorsys import hsv_to_rgb

from trimesh.visual.color import hsv_to_rgba

# create some random HSV values in the 0.0 - 1.0 range
hsv = g.random((100, 3))

# run our conversion
ours = hsv_to_rgba(hsv, dtype=g.np.float64)

# check the result from the standard library
truth = g.np.array([hsv_to_rgb(*v) for v in hsv])

# they should match
assert g.np.allclose(ours[:, :3], truth, atol=0.0001)

def test_concatenate_empty_mesh(self):
box = g.get_mesh("box.STL")

Expand Down
34 changes: 31 additions & 3 deletions tests/test_gltf.py
Original file line number Diff line number Diff line change
Expand Up @@ -866,11 +866,39 @@ def test_primitive_geometry_meta(self):
def test_points(self):
# test a simple pointcloud export-import cycle
points = g.np.arange(30).reshape((-1, 3))
export = g.trimesh.Scene(g.trimesh.PointCloud(points)).export(file_type="glb")

# get a pointcloud object
cloud = g.trimesh.PointCloud(points)

# export as gltf
export = g.trimesh.Scene(cloud).export(file_type="glb")
validate_glb(export)
reloaded = g.trimesh.load(g.trimesh.util.wrap_as_stream(export), file_type="glb")
reloaded = next(
iter(
g.trimesh.load_scene(
g.trimesh.util.wrap_as_stream(export), file_type="glb"
).geometry.values()
)
)
# make sure points survived export and reload
assert g.np.allclose(next(iter(reloaded.geometry.values())).vertices, points)
assert g.np.allclose(reloaded.vertices, points)

# now try adding color
colors = g.trimesh.visual.color.random_color(count=len(points))
cloud.colors = colors
export = g.trimesh.Scene(cloud).export(file_type="glb")
validate_glb(export)
reloaded = next(
iter(
g.trimesh.load_scene(
g.trimesh.util.wrap_as_stream(export), file_type="glb"
).geometry.values()
)
)

# make sure points with color survived export and reload
assert g.np.allclose(reloaded.vertices, points)
assert g.np.allclose(reloaded.colors, colors)

def test_bulk(self):
# Try exporting every loadable model to GLTF and checking
Expand Down
3 changes: 2 additions & 1 deletion trimesh/typed.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from numpy import float64, floating, int64, integer, unsignedinteger

# requires numpy>=1.20
from numpy.typing import ArrayLike, NDArray
from numpy.typing import ArrayLike, DTypeLike, NDArray

if version_info >= (3, 9):
# use PEP585 hints on newer python
Expand Down Expand Up @@ -63,6 +63,7 @@
"ArrayLike",
"BinaryIO",
"Callable",
"DTypeLike",
"Dict",
"Hashable",
"Integer",
Expand Down
104 changes: 88 additions & 16 deletions trimesh/visual/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,14 @@
and setting or altering a value should automatically change the mode.
"""

import colorsys
import copy

import numpy as np

from .. import caching, util
from ..constants import tol
from ..grouping import unique_rows
from ..typed import ArrayLike, NDArray
from ..typed import ArrayLike, DTypeLike, Integer, NDArray, Optional
from .base import Visuals


Expand Down Expand Up @@ -648,26 +647,97 @@ def hex_to_rgba(color):
return rgba


def random_color(dtype=np.uint8):
def hsv_to_rgba(hsv: ArrayLike, dtype: DTypeLike = np.uint8) -> NDArray:
"""
Convert an (n, 3) array of 0.0-1.0 HSV colors into an
array of RGBA colors.
A vectorized implementation that matches `colorsys.hsv_to_rgb`.
Parameters
-----------
hsv
Should be `(n, 3)` array of 0.0-1.0 values.
Returns
------------
rgba
An (n, 4) array of RGBA colors.
"""

hsv = np.array(hsv, dtype=np.float64)
if len(hsv.shape) != 2 or hsv.shape[1] != 3:
raise ValueError("(n, 3) values of HSV are required")

# expand into flat arrays for each of
# hue, saturation, and value
H, S, V = hsv.T

# chroma
C = S * V
# check which case we fall into
Hi = H * 6.0
X = C * (1.0 - np.abs((Hi % 2.0) - 1.0))
# use a lookup table for an integer to match the
# cases specified on the wikipedia article
# These are indexes of C = 0 , X = 1, 0 = 2
LUT = np.array(
[[0, 1, 2], [1, 0, 2], [2, 0, 1], [2, 1, 0], [1, 2, 0], [0, 2, 1]], dtype=np.int64
)

# stack values we need so we can access them with the lookup table
stacked = np.column_stack((C, X, np.zeros_like(X)))
# get the indexes per-row
indexes = LUT[Hi.astype(np.int64)]
# multiply them by the column count so we can use them on a flat array
indexes_flat = (np.arange(len(indexes)) * 3).reshape((-1, 1)) + indexes

# get the inermediate point along the bottom three faces of the RGB cube
RGBi = stacked.ravel()[indexes_flat]

# stack it into the final RGBA array
RGBA = np.column_stack((RGBi + (V - C).reshape((-1, 1)), np.ones(len(H))))

# now check the return type and do what's necessary
dtype = np.dtype(dtype)
if dtype.kind == "f":
return RGBA.astype(dtype)
elif dtype.kind in "iu":
return (RGBA * np.iinfo(dtype).max).round().astype(dtype)

raise ValueError(f"dtype `{dtype}` not supported")


def random_color(dtype: DTypeLike = np.uint8, count: Optional[Integer] = None):
"""
Return a random RGB color using datatype specified.
Parameters
----------
dtype: numpy dtype of result
dtype
Color type of result.
count
If passed return (count, 4) colors instead of
a single (4,) color.
Returns
----------
color: (4,) dtype, random color that looks OK
color : (4,) or (count, 4)
Random color or colors that look "OK"
"""
hue = np.random.random() + 0.61803
hue %= 1.0
color = np.array(colorsys.hsv_to_rgb(hue, 0.99, 0.99))
if np.dtype(dtype).kind in "iu":
max_value = (2 ** (np.dtype(dtype).itemsize * 8)) - 1
color *= max_value
color = np.append(color, max_value).astype(dtype)
return color
# generate a random hue
hue = (np.random.random(count or 1) + 0.61803) % 1.0

# saturation and "value" as constant
sv = np.ones_like(hue) * 0.99
# convert our random hue to RGBA
colors = hsv_to_rgba(np.column_stack((hue, sv, sv)))

# unspecified count is a single color
if count is None:
return colors[0]
return colors


def vertex_to_face_color(vertex_colors, faces):
Expand Down Expand Up @@ -799,7 +869,9 @@ def linear_color_map(values, color_range=None):
return colors


def interpolate(values, color_map=None, dtype=np.uint8):
def interpolate(
values: ArrayLike, color_map: Optional[str] = None, dtype: DTypeLike = np.uint8
):
"""
Given a 1D list of values, return interpolated colors
for the range.
Expand Down Expand Up @@ -844,7 +916,7 @@ def interpolate(values, color_map=None, dtype=np.uint8):
return rgba


def uv_to_color(uv, image):
def uv_to_color(uv, image) -> NDArray[np.uint8]:
"""
Get the color in a texture image.
Expand Down Expand Up @@ -884,7 +956,7 @@ def uv_to_color(uv, image):
return colors


def uv_to_interpolated_color(uv, image):
def uv_to_interpolated_color(uv, image) -> NDArray[np.uint8]:
"""
Get the color from texture image using bilinear sampling.
Expand Down

0 comments on commit 2a63259

Please sign in to comment.