From 52c5b6a484cc531fed2a877c96908cc639f883c7 Mon Sep 17 00:00:00 2001 From: smgr Date: Sun, 30 Nov 2025 22:08:12 -0800 Subject: [PATCH] marginally restucture package, tweak full hue spread 1.4.0 --- monobiome/__main__.py | 19 +++++ monobiome/cli/__init__.py | 32 ++++++++ monobiome/cli/generate.py | 31 ++++++++ monobiome/cli/scheme.py | 0 monobiome/curves.py | 150 +++++++++++++++++++++++++++----------- 5 files changed, 189 insertions(+), 43 deletions(-) create mode 100644 monobiome/__main__.py create mode 100644 monobiome/cli/__init__.py create mode 100644 monobiome/cli/generate.py create mode 100644 monobiome/cli/scheme.py diff --git a/monobiome/__main__.py b/monobiome/__main__.py new file mode 100644 index 0000000..8032f00 --- /dev/null +++ b/monobiome/__main__.py @@ -0,0 +1,19 @@ +from monobiome.cli import create_parser, configure_logging + +def main() -> None: + parser = create_parser() + args = parser.parse_args() + + # skim off log level to handle higher-level option + if hasattr(args, "log_level") and args.log_level is not None: + configure_logging(args.log_level) + + if "func" in args: + args.func(args) + else: + parser.print_help() + + +if __name__ == "__main__": + main() + diff --git a/monobiome/cli/__init__.py b/monobiome/cli/__init__.py new file mode 100644 index 0000000..138891f --- /dev/null +++ b/monobiome/cli/__init__.py @@ -0,0 +1,32 @@ +import argparse +import logging + +from monobiome.cli import generate, scheme + +logger: logging.Logger = logging.getLogger(__name__) + +def configure_logging(log_level: int) -> None: + """ + Configure logger's logging level. + """ + + logger.setLevel(log_level) + +def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Accent modeling CLI", + ) + parser.add_argument( + "--log-level", + type=int, + metavar="int", + choices=[10, 20, 30, 40, 50], + help="Log level: 10=DEBUG, 20=INFO, 30=WARNING, 40=ERROR, 50=CRITICAL", + ) + + subparsers = parser.add_subparsers(help="subcommand help") + + generate.register_parser(subparsers) + scheme.register_parser(subparsers) + + return parser diff --git a/monobiome/cli/generate.py b/monobiome/cli/generate.py new file mode 100644 index 0000000..08df3af --- /dev/null +++ b/monobiome/cli/generate.py @@ -0,0 +1,31 @@ +import argparse + +def generate_scheme(args: argparse.Namespace) -> None: + run_from_json(args.parameters_json, args.parameters_file) + + +def register_parser(subparsers: _SubparserType) -> None: + parser = subparsers.add_parser( + "generate", + help="generate theme variants" + ) + + parser.add_argument( + "-m", + "--contrast-method", + type=str, + help="Raw JSON string with train parameters", + ) + parser.add_argument( + "-c", + "--contrast-level", + type=str, + help="Raw JSON string with train parameters", + ) + parser.add_argument( + "-b", + "-base-lightness", + type=str, + help="Minimum lightness level", + ) + parser.set_defaults(func=generate_scheme) diff --git a/monobiome/cli/scheme.py b/monobiome/cli/scheme.py new file mode 100644 index 0000000..e69de29 diff --git a/monobiome/curves.py b/monobiome/curves.py index 4680d08..efac6f1 100644 --- a/monobiome/curves.py +++ b/monobiome/curves.py @@ -24,13 +24,14 @@ from monobiome.constants import ( @cache -def max_C_Lh(L, h, space='srgb', eps=1e-6, tol=1e-9): - ''' - Binary search for max chroma at fixed lightness and hue +def L_maxC_h(L, h, space='srgb', eps=1e-6, tol=1e-9): + """ + Binary search for max attainable OKLCH chroma at fixed lightness and hue. Parameters: L: lightness percentage - ''' + """ + def C_in_gamut(C): return Color('oklch', [L/100, C, h]).convert(space).in_gamut(tolerance=tol) @@ -48,67 +49,130 @@ def max_C_Lh(L, h, space='srgb', eps=1e-6, tol=1e-9): return Cmax def quad_bezier_rational(P0, P1, P2, w, t): + """ + Compute the point values of a quadratic rational Bezier curve. + + Uses `P0`, `P1`, and `P2` as the three control points of the curve. `w` + controls the weight toward the middle control point ("sharpness" of the + curve"), and `t` is the number of sample points used along the curve. + """ + t = np.asarray(t)[:, None] num = (1-t)**2*P0 + 2*w*(1-t)*t*P1 + t**2*P2 den = (1-t)**2 + 2*w*(1-t)*t + t**2 - return num/den + return num / den def bezier_y_at_x(P0, P1, P2, w, x_query, n=400): + """ + For the provided QBR parameters, provide the curve value at the given + input. + """ + t = np.linspace(0, 1, n) B = quad_bezier_rational(P0, P1, P2, w, t) x_vals, y_vals = B[:, 0], B[:, 1] return np.interp(x_query, x_vals, y_vals) +def Lspace_Cmax_Hmap(h_map: dict[str, float], L_space): + """ + Compute chroma maxima at provided lightness levels across hues. -# compute C max values over each point in L space -h_Lspace_Cmax = { - h_str: [max_C_Lh(_L, _h) for _L in L_space] - for h_str, _h in h_map.items() -} + Parameters: + h_map: map from hue names to hue values + L_space: array-like set of lightness values -# compute *unbounded* chroma curves for all hues -h_L_points_C = {} -h_ctrl_L_C = {} + Returns: + A map with max chroma values for each hue across lightness space -for h_str, _h in monotone_h_map.items(): - h_L_points_C[h_str] = np.array([monotone_C_map[h_str]]*len(L_points)) + { + "red": [ Cmax@L=10, Cmax@L=11, Cmax@L=12, ... ], + "orange": [ Cmax@L=10, Cmax@L=11, Cmax@L=12, ... ], + ... + } + """ + # compute C max values over each point in L space + + h_Lspace_Cmax = { + h_str: [max_C_Lh(_L, _h) for _L in L_space] + for h_str, _h in h_map.items() + } + + return h_Lspace_Cmax + +def (): + """ -for h_str, _h in accent_h_map.items(): - Lspace_Cmax = h_Lspace_Cmax[h_str] + + raw bezier chroma values for each hue across the lightness space + h_L_points_C = { + "red": [ Bezier@L=10, Bezier@L=11, Bezier@L=12, ... ], + ... + } - # get L value of max chroma; will be a bezier control - L_Cmax_idx = np.argmax(Lspace_Cmax) - L_Cmax = L_space[L_Cmax_idx] + three bezier control points for each hue's chroma curve + h_ctrl_L_C = { + "red": np.array([ + [ x1, y1 ], + [ x2, y2 ], + [ x3, y3 ] + ]), + ... + } + """ - # offset control point by any preset x-shift - L_Cmax += h_L_offsets[h_str] + # compute *unbounded* chroma curves for all hues + h_L_points_C = {} + h_ctrl_L_C = {} - # and get max C at the L offset - Cmax = max_C_Lh(L_Cmax, _h) + for h_str, _h in monotone_h_map.items(): + h_L_points_C[h_str] = np.array([monotone_C_map[h_str]]*len(L_points)) + + for h_str, _h in accent_h_map.items(): + Lspace_Cmax = h_Lspace_Cmax[h_str] + + # get L value of max chroma; will be a bezier control + L_Cmax_idx = np.argmax(Lspace_Cmax) + L_Cmax = L_space[L_Cmax_idx] - # set 3 control points; shift by any global linear offest - C_offset = h_C_offsets.get(h_str, 0) - - p_0 = np.array([0, 0]) - p_Cmax = np.array([L_Cmax, Cmax + C_offset]) - p_100 = np.array([100, 0]) - - B_L_points = bezier_y_at_x(p_0, p_Cmax, p_100, h_weights.get(h_str, 1), L_points) - h_L_points_C[h_str] = B_L_points - h_ctrl_L_C[h_str] = np.vstack([p_0, p_Cmax, p_100]) + # offset control point by any preset x-shift + L_Cmax += h_L_offsets[h_str] -# compute full set of final chroma curves; limits every point to in-gamut max -h_LC_color_map = {} -h_L_points_Cstar = {} + # and get max C at the L offset + Cmax = max_C_Lh(L_Cmax, _h) -for h_str, L_points_C in h_L_points_C.items(): - _h = h_map[h_str] + # set 3 control points; shift by any global linear offest + C_offset = h_C_offsets.get(h_str, 0) + + p_0 = np.array([0, 0]) + p_Cmax = np.array([L_Cmax, Cmax + C_offset]) + p_100 = np.array([100, 0]) + + B_L_points = bezier_y_at_x(p_0, p_Cmax, p_100, h_weights.get(h_str, 1), L_points) + h_L_points_C[h_str] = B_L_points + h_ctrl_L_C[h_str] = np.vstack([p_0, p_Cmax, p_100]) - h_L_points_Cstar[h_str] = [ - max(0, min(_C, max_C_Lh(_L, _h))) - for _L, _C in zip(L_points, L_points_C) - ] + +def (): + """ + bezier chroma values, but bounded to attainable gamut colors (bezier fit can produce invalid chroma values) + h_L_points_Cstar = { + "red": [ bounded-bezier@L=10, bounded-bezier@L=11, ... ], + ... + } + """ + + # compute full set of final chroma curves; limits every point to in-gamut max + h_LC_color_map = {} + h_L_points_Cstar = {} + + for h_str, L_points_C in h_L_points_C.items(): + _h = h_map[h_str] + + h_L_points_Cstar[h_str] = [ + max(0, min(_C, max_C_Lh(_L, _h))) + for _L, _C in zip(L_points, L_points_C) + ] # if __name__ == "__main__":