diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/types/HSBType.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/types/HSBType.java index 571e9948b..c2360f861 100644 --- a/bundles/org.openhab.core/src/main/java/org/openhab/core/library/types/HSBType.java +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/library/types/HSBType.java @@ -26,6 +26,7 @@ import org.openhab.core.types.Command; import org.openhab.core.types.ComplexType; import org.openhab.core.types.PrimitiveType; import org.openhab.core.types.State; +import org.openhab.core.util.ColorUtil; /** * The HSBType is a complex type with constituents for hue, saturation and @@ -51,14 +52,6 @@ public class HSBType extends PercentType implements ComplexType, State, Command public static final HSBType GREEN = new HSBType("120,100,100"); public static final HSBType BLUE = new HSBType("240,100,100"); - // 1931 CIE XYZ to sRGB (D65 reference white) - private static final float XY2RGB[][] = { { 3.2406f, -1.5372f, -0.4986f }, { -0.9689f, 1.8758f, 0.0415f }, - { 0.0557f, -0.2040f, 1.0570f } }; - - // sRGB to 1931 CIE XYZ (D65 reference white) - private static final float RGB2XY[][] = { { 0.4124f, 0.3576f, 0.1805f }, { 0.2126f, 0.7152f, 0.0722f }, - { 0.0193f, 0.1192f, 0.9505f } }; - private static final String UNIT_HSB = "%hsb%"; private static final String UNIT_RGB = "%rgb%"; @@ -162,32 +155,20 @@ public class HSBType extends PercentType implements ComplexType, State, Command } /** - * Returns a HSBType object representing the provided xy color values in CIE XY color model. - * Conversion from CIE XY color model to sRGB using D65 reference white - * Returned color is set to full brightness + * @deprecated Use {@link ColorUtil#xyToHsv(double[])} or {@link ColorUtil#xyToHsv(double[], ColorUtil.Gamut)} + * instead + * + * Returns a HSBType object representing the provided xy color values in CIE XY color model. + * Conversion from CIE XY color model to sRGB using D65 reference white + * Returned color is set to full brightness * * @param x, y color information 0.0 - 1.0 * @return new HSBType object representing the given CIE XY color, full brightness + * */ + @Deprecated public static HSBType fromXY(float x, float y) { - float tmpY = 1.0f; - float tmpX = (tmpY / y) * x; - float tmpZ = (tmpY / y) * (1.0f - x - y); - - float r = tmpX * XY2RGB[0][0] + tmpY * XY2RGB[0][1] + tmpZ * XY2RGB[0][2]; - float g = tmpX * XY2RGB[1][0] + tmpY * XY2RGB[1][1] + tmpZ * XY2RGB[1][2]; - float b = tmpX * XY2RGB[2][0] + tmpY * XY2RGB[2][1] + tmpZ * XY2RGB[2][2]; - - float max = r > g ? r : g; - if (b > max) { - max = b; - } - - r = gammaCompress(r / max); - g = gammaCompress(g / max); - b = gammaCompress(b / max); - - return HSBType.fromRGB((int) (r * 255.0f + 0.5f), (int) (g * 255.0f + 0.5f), (int) (b * 255.0f + 0.5f)); + return ColorUtil.xyToHsv(new double[] { x, y }); } @Override @@ -281,11 +262,8 @@ public class HSBType extends PercentType implements ComplexType, State, Command return false; } HSBType other = (HSBType) obj; - if (!getHue().equals(other.getHue()) || !getSaturation().equals(other.getSaturation()) - || !getBrightness().equals(other.getBrightness())) { - return false; - } - return true; + return getHue().equals(other.getHue()) && getSaturation().equals(other.getSaturation()) + && getBrightness().equals(other.getBrightness()); } public PercentType[] toRGB() { @@ -342,55 +320,17 @@ public class HSBType extends PercentType implements ComplexType, State, Command return new PercentType[] { red, green, blue }; } - // Gamma compression (sRGB) for a single component, in the 0.0 - 1.0 range - private static float gammaCompress(float c) { - if (c < 0.0f) { - c = 0.0f; - } else if (c > 1.0f) { - c = 1.0f; - } - - return c <= 0.0031308f ? 12.92f * c : (1.0f + 0.055f) * (float) Math.pow(c, 1.0f / 2.4f) - 0.055f; - } - - // Gamma decompression (sRGB) for a single component, in the 0.0 - 1.0 range - private static float gammaDecompress(float c) { - if (c < 0.0f) { - c = 0.0f; - } else if (c > 1.0f) { - c = 1.0f; - } - - return c <= 0.04045f ? c / 12.92f : (float) Math.pow((c + 0.055f) / (1.0f + 0.055f), 2.4f); - } - /** * Returns the xyY values representing this object's color in CIE XY color model. * Conversion from sRGB to CIE XY using D65 reference white * xy pair contains color information * Y represents relative luminance * - * @param HSBType color object * @return PercentType[x, y, Y] values in the CIE XY color model */ public PercentType[] toXY() { - // This makes sure we keep color information even if brightness is zero - PercentType sRGB[] = new HSBType(getHue(), getSaturation(), PercentType.HUNDRED).toRGB(); - - float r = gammaDecompress(sRGB[0].floatValue() / 100.0f); - float g = gammaDecompress(sRGB[1].floatValue() / 100.0f); - float b = gammaDecompress(sRGB[2].floatValue() / 100.0f); - - float tmpX = r * RGB2XY[0][0] + g * RGB2XY[0][1] + b * RGB2XY[0][2]; - float tmpY = r * RGB2XY[1][0] + g * RGB2XY[1][1] + b * RGB2XY[1][2]; - float tmpZ = r * RGB2XY[2][0] + g * RGB2XY[2][1] + b * RGB2XY[2][2]; - - float x = tmpX / (tmpX + tmpY + tmpZ); - float y = tmpY / (tmpX + tmpY + tmpZ); - - return new PercentType[] { new PercentType(Float.valueOf(x * 100.0f).toString()), - new PercentType(Float.valueOf(y * 100.0f).toString()), - new PercentType(Float.valueOf(tmpY * getBrightness().floatValue()).toString()) }; + return Arrays.stream(ColorUtil.hsbToXY(this)).mapToObj(d -> new PercentType(new BigDecimal(d * 100.0))) + .toArray(PercentType[]::new); } private int convertPercentToByte(PercentType percent) { diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/util/ColorUtil.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/util/ColorUtil.java new file mode 100644 index 000000000..7c1e7386e --- /dev/null +++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/util/ColorUtil.java @@ -0,0 +1,301 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.util; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.PercentType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link ColorUtil} is responsible for converting HSB to CIE + * + * The class is based work from Erik Baauw for the Homebridge + * project + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public class ColorUtil { + private static final Logger LOGGER = LoggerFactory.getLogger(ColorUtil.class); + public static final Gamut DEFAULT_GAMUT = new Gamut(new double[] { 0.9961, 0.0001 }, new double[] { 0, 0.9961 }, + new double[] { 0, 0.0001 }); + + private ColorUtil() { + // prevent instantiation + } + + /** + * Transform sRGB based {@link HSBType} to + * CIE 1931 `xy` format. + * + * See Hue + * developerportal. + * + * @param hsbType a {@link HSBType} value + * @return double array with the closest matching CIE 1931 colour, x, y between 0.0000 and 1.0000. + */ + public static double[] hsbToXY(HSBType hsbType) { + return hsbToXY(hsbType, DEFAULT_GAMUT); + } + + /** + * Transform sRGB based {@link HSBType} to + * CIE 1931 `xy` format. + * + * See Hue + * developer portal. + * + * @param hsbType a {@link HSBType} value + * @param gamut the gamut supported by the light. + * @return double array with the closest matching CIE 1931 colour, x, y, Y between 0.0000 and 1.0000. + */ + public static double[] hsbToXY(HSBType hsbType, Gamut gamut) { + double r = inverseCompand(hsbType.getRed().doubleValue() / PercentType.HUNDRED.doubleValue()); + double g = inverseCompand(hsbType.getGreen().doubleValue() / PercentType.HUNDRED.doubleValue()); + double b = inverseCompand(hsbType.getBlue().doubleValue() / PercentType.HUNDRED.doubleValue()); + + double X = r * 0.664511 + g * 0.154324 + b * 0.162028; + double Y = r * 0.283881 + g * 0.668433 + b * 0.047685; + double Z = r * 0.000088 + g * 0.072310 + b * 0.986039; + + double sum = X + Y + Z; + Point p = sum == 0.0 ? new Point() : new Point(X / sum, Y / sum); + Point q = gamut.closest(p); + + double[] xyY = new double[] { ((int) (q.x * 10000.0)) / 10000.0, ((int) (q.y * 10000.0)) / 10000.0, + ((int) (Y * 10000.0)) / 10000.0 }; + + LOGGER.trace("HSV: {} - RGB: {} - XYZ: {} {} {} - xyY: {}", hsbType, hsbType.toRGB(), X, Y, Z, xyY); + + return xyY; + } + + /** + * Transform CIE 1931 `xy` format to + * sRGB based {@link HSBType}. + * + * See Hue + * developer portal. + * + * @param xy the CIE 1931 xy colour, x,y between 0.0000 and 1.0000. + * @return the corresponding {@link HSBType}. + */ + public static HSBType xyToHsv(double[] xy) { + return xyToHsv(xy, DEFAULT_GAMUT); + } + + /** + * Transform CIE 1931 `xy` format to + * sRGB based {@link HSBType}. + * + * See Hue + * developer portal. + * + * @param xy the CIE 1931 xy colour, x,y[,Y] between 0.0000 and 1.0000. Y value is optional. + * @param gamut the gamut supported by the light. + * @return the corresponding {@link HSBType}. + * @throws IllegalArgumentException when input array has wrong size or exceeds allowed value range + */ + public static HSBType xyToHsv(double[] xy, Gamut gamut) { + if (xy.length < 2 || xy.length > 3 || !inRange(xy[0]) || !inRange(xy[1]) + || (xy.length == 3 && !inRange(xy[2]))) { + throw new IllegalArgumentException("xy array only allowes two or three values between 0.0 and 1.0."); + } + Point p = gamut.closest(new Point(xy[0], xy[1])); + double x = p.x; + double y = p.y == 0.0 ? 0.000001 : p.y; + double z = 1.0 - x - y; + double Y = (xy.length == 3) ? xy[2] : 1.0; + double X = (Y / y) * x; + double Z = (Y / y) * z; + double r = X * 1.656492 + Y * -0.354851 + Z * -0.255038; + double g = X * -0.707196 + Y * 1.655397 + Z * 0.036152; + double b = X * 0.051713 + Y * -0.121364 + Z * 1.011530; + + // Correction for negative values is missing from Philips' documentation. + double min = Math.min(r, Math.min(g, b)); + if (min < 0.0) { + r -= min; + g -= min; + b -= min; + } + + // rescale + double max = Math.max(r, Math.max(g, b)); + if (max > 1.0) { + r /= max; + g /= max; + b /= max; + } + + r = compand(r); + g = compand(g); + b = compand(b); + + // rescale + max = Math.max(r, Math.max(g, b)); + if (max > 1.0) { + r /= max; + g /= max; + b /= max; + } + + HSBType hsb = HSBType.fromRGB((int) Math.round(255.0 * r), (int) Math.round(255.0 * g), + (int) Math.round(255.0 * b)); + LOGGER.trace("xy: {} - XYZ: {} {} {} - RGB: {} {} {} - HSB: {} ", xy, X, Y, Z, r, g, b, hsb); + + return hsb; + } + + /** + * Gamma correction (inverse sRGB companding) + * + * @param value the value to process + * @return the processed value + */ + private static double inverseCompand(double value) { + return value > 0.04045 ? Math.pow((value + 0.055) / (1.0 + 0.055), 2.4) : value / 12.92; + } + + /** + * Inverse Gamma correction (sRGB companding) + * + * @param value the value to process + * @return the processed value + */ + public static double compand(double value) { + return value <= 0.0031308 ? 12.92 * value : (1.0 + 0.055) * Math.pow(value, (1.0 / 2.4)) - 0.055; + } + + private static class Point { + public final double x; + public final double y; + + /** + * a default point with x/y = 0.0 + */ + public Point() { + this(0.0, 0.0); + } + + /** + * a point with the given values + * + * @param x the x-value (between 0.0 and 1.0) + * @param y the y-value (between 0.0 and 1.0) + */ + public Point(double x, double y) { + this.x = x; + this.y = y; + } + + /** + * distance between this point and another point + * + * @param other the other point + * @return distance as double + */ + public double distance(Point other) { + double dx = this.x - other.x; + double dy = this.y - other.y; + return Math.sqrt(dx * dx + dy * dy); + } + + /** + * return the cross product of this tuple and the other tuple + * + * @param other the other point + * @return the cross product as double + */ + public double crossProduct(Point other) { + return this.x * other.y - this.y * other.x; + } + + /** + * return point closest to this point on a line between a and b + * + * @param a point a + * @param b point b + * @return the point closest to this point on a-b + */ + public Point closest(Point a, Point b) { + Point ap = new Point(this.x - a.x, this.y - a.y); + Point ab = new Point(b.x - a.x, b.y - a.y); + double t = Math.min(1.0, Math.max(0, (ap.x * ab.x + ap.y * ab.y) / (ab.x * ab.x + ab.y * ab.y))); + + return new Point(a.x + t * ab.x, a.y + t * ab.y); + } + } + + public record Gamut(double[] r, double[] g, double[] b) { + /** + * Color gamut + * + * @param r double array with `xy` coordinates for red, x, y between 0.0000 and 1.0000. + * @param g double array with `xy` coordinates for green, x, y between 0.0000 and 1.0000. + * @param b double array with `xy` coordinates for blue, x, y between 0.0000 and 1.0000. + */ + public Gamut { + } + + /** + * return point in color gamut closest to a given point + * + * @param p a color point + * @return the color point closest to {@param p} in this gamut + */ + public Point closest(Point p) { + Point r = new Point(this.r[0], this.r[1]); + Point g = new Point(this.g[0], this.g[1]); + Point b = new Point(this.b[0], this.b[1]); + + Point v1 = new Point(g.x - r.x, g.y - r.y); + Point v2 = new Point(b.x - r.x, b.y - r.y); + Point q = new Point(p.x - r.x, p.y - r.y); + double v = v1.crossProduct(v2); + double s = q.crossProduct(v2) / v; + double t = v1.crossProduct(q) / v; + if (s >= 0.0 && t >= 0.0 && s + t <= 1.0) { + return p; + } + + Point pRG = p.closest(r, g); + Point pGB = p.closest(g, b); + Point pBR = p.closest(b, r); + double dRG = p.distance(pRG); + double dGB = p.distance(pGB); + double dBR = p.distance(pBR); + + double min = dRG; + Point retVal = pRG; + if (dGB < min) { + min = dGB; + retVal = pGB; + } + if (dBR < min) { + retVal = pBR; + } + return retVal; + } + } + + private static boolean inRange(double val) { + return val >= 0.0 && val <= 1.0; + } +} diff --git a/bundles/org.openhab.core/src/test/java/org/openhab/core/library/types/HSBTypeTest.java b/bundles/org.openhab.core/src/test/java/org/openhab/core/library/types/HSBTypeTest.java index 863d3a03f..b90aa505b 100644 --- a/bundles/org.openhab.core/src/test/java/org/openhab/core/library/types/HSBTypeTest.java +++ b/bundles/org.openhab.core/src/test/java/org/openhab/core/library/types/HSBTypeTest.java @@ -129,14 +129,8 @@ public class HSBTypeTest { public void testConversionToXY() { HSBType hsb = new HSBType("220,90,50"); PercentType[] xy = hsb.toXY(); - assertEquals(new PercentType("16.969364"), xy[0]); - assertEquals(new PercentType("12.379659"), xy[1]); - } - - @Test - public void testCreateFromXY() { - HSBType hsb = HSBType.fromXY(5f, 3f); - assertEquals(new HSBType("11,100,100"), hsb); + assertEquals(14.65, xy[0].doubleValue(), 0.01); + assertEquals(11.56, xy[1].doubleValue(), 0.01); } @Test diff --git a/bundles/org.openhab.core/src/test/java/org/openhab/core/util/ColorUtilTest.java b/bundles/org.openhab.core/src/test/java/org/openhab/core/util/ColorUtilTest.java new file mode 100644 index 000000000..a371464a1 --- /dev/null +++ b/bundles/org.openhab.core/src/test/java/org/openhab/core/util/ColorUtilTest.java @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.util; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.lessThan; +import static org.hamcrest.Matchers.lessThanOrEqualTo; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.openhab.core.library.types.HSBType; + +/** + * The {@link ColorUtilTest} is a test class for the color conversion + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public class ColorUtilTest { + private static Stream colors() { + return Stream.of(HSBType.BLACK, HSBType.BLUE, HSBType.GREEN, HSBType.RED, HSBType.WHITE, + HSBType.fromRGB(127, 94, 19)).map(Arguments::of); + } + + private static Stream invalids() { + return Stream.of(new double[] { 0.0 }, new double[] { -1.0, 0.5 }, new double[] { 1.5, 0.5 }, + new double[] { 0.5, -1.0 }, new double[] { 0.5, 1.5 }, new double[] { 0.5, 0.5, -1.0 }, + new double[] { 0.5, 0.5, 1.5 }, new double[] { 0.0, 1.0, 0.0, 1.0 }).map(Arguments::of); + } + + @ParameterizedTest + @MethodSource("colors") + public void inversionTest(HSBType hsb) { + HSBType hsb2 = ColorUtil.xyToHsv(ColorUtil.hsbToXY(hsb)); + + double deltaHue = Math.abs(hsb.getHue().doubleValue() - hsb2.getHue().doubleValue()); + deltaHue = deltaHue > 180.0 ? Math.abs(deltaHue - 360) : deltaHue; // if deltaHue > 180, the "other direction" + // is shorter + double deltaSat = Math.abs(hsb.getSaturation().doubleValue() - hsb2.getSaturation().doubleValue()); + double deltaBri = Math.abs(hsb.getBrightness().doubleValue() - hsb2.getBrightness().doubleValue()); + + assertThat(deltaHue, is(lessThan(5.0))); + assertThat(deltaSat, is(lessThanOrEqualTo(1.0))); + assertThat(deltaBri, is(lessThanOrEqualTo(1.0))); + } + + @ParameterizedTest + @MethodSource("invalids") + public void invalidXyValues(double[] xy) { + assertThrows(IllegalArgumentException.class, () -> ColorUtil.xyToHsv(xy)); + } +} diff --git a/tools/static-code-analysis/checkstyle/suppressions.xml b/tools/static-code-analysis/checkstyle/suppressions.xml index 65f7675d0..f191a031a 100644 --- a/tools/static-code-analysis/checkstyle/suppressions.xml +++ b/tools/static-code-analysis/checkstyle/suppressions.xml @@ -22,6 +22,9 @@ + + +