diff --git a/pyproject.toml b/pyproject.toml index bf320cbc..755e357a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,12 +25,10 @@ requires-python = ">=3.8" dependencies = [ "requests", "pandas", - "jsonpath_rw", "pysolr", "navis", "numpy>=1.26.4", "seaborn>0.13", - "colormath", "tqdm", ] diff --git a/requirements.txt b/requirements.txt index 4df25e16..44a49b69 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,11 +9,9 @@ python-catmaid nbformat>=4.2.0 ipywidgets vispy -colormath tqdm sphinx-autodoc-typehints numpy>=1.26.4 -jsonpath_rw bottleneck<=1.3.6 matplotlib>3.9 seaborn>0.13 diff --git a/src/vfb_connect/color_conversion.py b/src/vfb_connect/color_conversion.py new file mode 100644 index 00000000..3afa3174 --- /dev/null +++ b/src/vfb_connect/color_conversion.py @@ -0,0 +1,60 @@ +"""Minimal CIE Lab -> sRGB conversion. + +Vendored replacement for the abandoned ``colormath`` dependency (see issue +#287). Reproduces colormath's ``convert_color(LabColor(l, a, b), sRGBColor)`` +pipeline exactly: Lab (D50, 2 degree observer) -> XYZ (D50) -> Bradford +chromatic adaptation to D65 -> linear sRGB -> gamma encoding, with the result +clamped to [0, 1]. +""" + +import numpy as np + +_WHITE_D50 = np.array([0.96422, 1.00000, 0.82521]) +_WHITE_D65 = np.array([0.95047, 1.00000, 1.08883]) +_BRADFORD = np.array([[0.8951, 0.2664, -0.1614], + [-0.7502, 1.7135, 0.0367], + [0.0389, -0.0685, 1.0296]]) +_XYZ_TO_SRGB = np.array([[3.24071, -1.53726, -0.498571], + [-0.969258, 1.87599, 0.0415557], + [0.0556352, -0.203996, 1.05707]]) + + +def _adaptation_matrix(): + source = _BRADFORD @ _WHITE_D50 + destination = _BRADFORD @ _WHITE_D65 + scale = np.diag(destination / source) + return np.linalg.inv(_BRADFORD) @ scale @ _BRADFORD + + +_ADAPT = _adaptation_matrix() + +# CIE constants (actual values, as used by colormath). +_EPSILON = 216.0 / 24389.0 +_KAPPA = 24389.0 / 27.0 + + +def lab_to_srgb_clamped(l, a, b): + """Convert a CIE Lab (D50) colour to clamped sRGB floats. + + :param l: Lightness, 0-100. + :param a: Green-red axis. + :param b: Blue-yellow axis. + :return: ``numpy.ndarray`` of (r, g, b) floats clamped to [0, 1]. + """ + fy = (l + 16.0) / 116.0 + fx = fy + a / 500.0 + fz = fy - b / 200.0 + + def _f_inv(t): + t3 = t ** 3 + return t3 if t3 > _EPSILON else (116.0 * t - 16.0) / _KAPPA + + xyz = np.array([_f_inv(fx), _f_inv(fy), _f_inv(fz)]) * _WHITE_D50 + xyz = _ADAPT @ xyz + rgb_linear = _XYZ_TO_SRGB @ xyz + + def _gamma(c): + return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1.0 / 2.4)) - 0.055 + + rgb = np.array([_gamma(c) for c in rgb_linear]) + return np.clip(rgb, 0.0, 1.0) diff --git a/src/vfb_connect/cross_server_tools.py b/src/vfb_connect/cross_server_tools.py index 5091548e..08a5fa2f 100644 --- a/src/vfb_connect/cross_server_tools.py +++ b/src/vfb_connect/cross_server_tools.py @@ -15,8 +15,7 @@ SIMILAR_NEURONS_COLUMNS, CONNECTED_NEURONS_BY_TYPE_COLUMNS) import pandas as pd import numpy as np -from colormath.color_objects import LabColor, sRGBColor -from colormath.color_conversions import convert_color +from .color_conversion import lab_to_srgb_clamped from scipy.spatial import KDTree VFB_DBS_2_SYMBOLS = {"JRC_OpticLobe":"neuprint_JRC_OpticLobe_v1_0_1", "FAFB":"catmaid_fafb", "L1EM":"catmaid_l1em", "MANC":"neuprint_JRC_Manc_1_2_1", @@ -1876,11 +1875,10 @@ def generate_lab_colors(self, num_colors, min_distance=100, verbose=False): # Convert Lab to RGB for lab in selected_lab_colors: - lab_color = LabColor(lab[0], lab[1], lab[2]) - rgb_color = convert_color(lab_color, sRGBColor) - rgb_tuple = (int(round(rgb_color.clamped_rgb_r * 255)), - int(round(rgb_color.clamped_rgb_g * 255)), - int(round(rgb_color.clamped_rgb_b * 255))) + rgb = lab_to_srgb_clamped(lab[0], lab[1], lab[2]) + rgb_tuple = (int(round(rgb[0] * 255)), + int(round(rgb[1] * 255)), + int(round(rgb[2] * 255))) rgb_colors.append(rgb_tuple) if verbose: diff --git a/src/vfb_connect/neo/query_wrapper.py b/src/vfb_connect/neo/query_wrapper.py index 5a4f32dc..9d02ed2b 100644 --- a/src/vfb_connect/neo/query_wrapper.py +++ b/src/vfb_connect/neo/query_wrapper.py @@ -16,7 +16,6 @@ -# from jsonpath_rw import parse as parse_jpath from vfb_connect.neo.neo4j_tools import chunks, Neo4jConnect, dict_cursor, escape_string # Connect to the VFB SOLR server diff --git a/src/vfb_connect/test/color_conversion_test.py b/src/vfb_connect/test/color_conversion_test.py new file mode 100644 index 00000000..be23f549 --- /dev/null +++ b/src/vfb_connect/test/color_conversion_test.py @@ -0,0 +1,45 @@ +"""Offline tests for the vendored Lab -> sRGB conversion (issue #287). + +Reference values were generated with colormath 3.0.0's +``convert_color(LabColor(l, a, b), sRGBColor)`` (clamped channels), which this +module replaces. +""" + +import unittest + +import numpy as np + +from vfb_connect.color_conversion import lab_to_srgb_clamped + + +class LabToSrgbTest(unittest.TestCase): + + # ((L, a, b), (r, g, b)) pairs from colormath 3.0.0 + REFERENCE = [ + ((0, 0, 0), (0.000000, 0.000000, 0.000000)), + ((100, 0, 0), (1.000000, 0.999994, 0.999935)), + ((50, 0, 0), (0.466344, 0.466324, 0.466295)), + ((53.24, 80.09, 67.2), (0.982320, 0.000000, 0.025792)), + ((87.74, -86.18, 83.18), (0.000000, 1.000000, 0.000000)), + ((32.3, 79.19, -107.86), (0.355268, 0.000000, 1.000000)), + ((50, -30, 40), (0.313814, 0.516759, 0.172645)), + ((75, 20, -50), (0.711677, 0.684838, 1.000000)), + ((20, 60, 10), (0.467718, 0.000000, 0.147970)), + ] + + def test_matches_colormath_reference(self): + for lab, expected in self.REFERENCE: + got = lab_to_srgb_clamped(*lab) + np.testing.assert_allclose(got, expected, atol=5e-6, + err_msg=f"Lab {lab}") + + def test_output_clamped(self): + for l in np.linspace(0, 100, 5): + for a in np.linspace(-110, 110, 5): + for b in np.linspace(-110, 110, 5): + rgb = lab_to_srgb_clamped(l, a, b) + self.assertTrue(np.all(rgb >= 0.0) and np.all(rgb <= 1.0)) + + +if __name__ == "__main__": + unittest.main()