Kaelygon

oklab palette generator

Nov 8th, 2025 (edited)
72
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 37.55 KB | None | 0 0
  1. ##CC0 Kaelygon 2025
  2. import math
  3. import numpy as np
  4. from PIL import Image
  5. from dataclasses import dataclass, field
  6. from typing import List, Optional
  7.  
  8. #Floating helpers
  9.  
  10. #3D float operators
  11. def sub_vec3(vec_a,vec_b):
  12.     return [ vec_a[0] - vec_b[0], vec_a[1] - vec_b[1], vec_a[2] - vec_b[2] ]
  13. def add_vec3(vec_a,vec_b):
  14.     return [ vec_a[0] + vec_b[0], vec_a[1] + vec_b[1], vec_a[2] + vec_b[2] ]
  15. def mul_vec3(vec_a,vec_b):
  16.     return [ vec_a[0] * vec_b[0], vec_a[1] * vec_b[1], vec_a[2] * vec_b[2] ]
  17. def div_vec3(vec_a,vec_b):
  18.     vec = [ vec_a[0] / vec_b[0], vec_a[1] / vec_b[1], vec_a[2] / vec_b[2] ]
  19.     return vec
  20.  
  21.  
  22. #Special 3D float operators
  23. def lerp_vec3(vec_a,vec_b,a):
  24.     return add_vec3( mul_vec3(vec_a, sub_vec3([1.0]*3,a) ), mul_vec3(vec_b,a) )
  25. def pow_vec3(vec_a,vec_b):
  26.     return [ vec_a[0] ** vec_b[0], vec_a[1] ** vec_b[1], vec_a[2] ** vec_b[2] ]
  27. def spow_vec3(vec_a,vec_b):
  28.     return [ math_spow(vec_a[0],vec_b[0]),math_spow(vec_a[1],vec_b[1]),math_spow(vec_a[2],vec_b[2]) ]
  29. def sign_vec3(vec):
  30.     return [ 1 if c>=0 else -1 for c in vec ]
  31. def clip_vec3(vec,low,hi):
  32.     return [ min(max(vec[0],low[0]),hi[0]), min(max(vec[1],low[1]),hi[1]), min(max(vec[2],low[2]),hi[2]) ]
  33. def lessThan_vec3(vec_a,vec_b):
  34.     return [ vec_a[0]<vec_b[0], vec_a[1]<vec_b[1], vec_a[2]<vec_b[2] ]
  35. def roundAwayFromZero_vec3(vec):
  36.     return [int(c) if c == int(c) else int(c) + (1 if c > 0 else -1) for c in vec ]
  37. def round_vec3(vec, digits):
  38.    return [round(vec[0],digits),round(vec[1],digits),round(vec[2],digits)]
  39. def valid_vec3(vec):
  40.     for c in vec:
  41.         if not math.isfinite(c):
  42.             return False
  43.     return True
  44.  
  45.  
  46.  
  47. #Vector operators
  48. def dot_vec3(a, b):
  49.     product = 0.0
  50.     for i in range(3):
  51.         product+= a[i] * b[i]
  52.     return product
  53.  
  54. def cross_vec3(a, b):
  55.     return [a[1]*b[2] - a[2]*b[1],
  56.             a[2]*b[0] - a[0]*b[2],
  57.             a[0]*b[1] - a[1]*b[0]]
  58.  
  59. def length_vec3(vec):
  60.     return math.sqrt(vec[0]*vec[0] + vec[1]*vec[1] + vec[2]*vec[2])
  61.  
  62. def norm_vec3(vec,eps=0.0):
  63.     l = length_vec3(vec)
  64.     if l<=eps:
  65.         return [1,0,0]
  66.     return [vec[0]/l, vec[1]/l, vec[2]/l]
  67.  
  68. ### other math tools
  69.  
  70. #If negative base, return  sign(a) * abs(a)**b
  71. MAX_FLOAT = 1.79e308
  72. def math_spow(a:float,b:float):
  73.     if abs(a)>1.0 and abs(b)>1.0 and (abs(a) > MAX_FLOAT ** (1.0 / max(b, 1.0))):
  74.         return math.copysign(MAX_FLOAT, a)
  75.     if a>0:
  76.        return a**b
  77.     if b.is_integer():
  78.         return -((-a)**b)
  79.     return 0.0
  80.  
  81. def math_median(_list):
  82.     if len(_list)==0:
  83.         return None
  84.     _list.sort()
  85.     index=len(_list)//2
  86.     if len(_list)%2==0:
  87.         a = index
  88.         b = max(index-1,0)
  89.         return (_list[a] + _list[b]) / 2.0
  90.     else:
  91.         return _list[index]
  92.  
  93. def oklabGamutVolume(resolution: int=50):
  94.    
  95.     valid_count = 0
  96.     total_count = resolution ** 3
  97.  
  98.     for L in range(0,resolution):
  99.         for a in range(0,resolution):
  100.                 for b in range(0,resolution):
  101.                     if inOklabGamut([
  102.                         float(L)/resolution,
  103.                         float(a)/resolution-0.5,
  104.                         float(b)/resolution-0.5
  105.                     ]):
  106.                         valid_count += 1
  107.    
  108.     return valid_count / total_count
  109.  
  110. ### misc tools
  111. def hexToSrgba(hexstr: str):
  112.     s = hexstr.strip().lstrip('#')
  113.     if len(s) < 6:
  114.         return [0,0,0,0]
  115.     r = int(s[0:2], 16) / 255.0
  116.     g = int(s[2:4], 16) / 255.0
  117.     b = int(s[4:6], 16) / 255.0
  118.     a = 1.0
  119.     if len(s) == 8:
  120.         a = int(s[6:8], 16) / 255.0
  121.     return [r, g, b, a]
  122.  
  123. def printHexList(hex_list, palette_name="", new_line=True):
  124.     out_string= palette_name + " = "
  125.     out_string+="["
  126.     for string in hex_list:
  127.         out_string += "\""+string+"\","
  128.     out_string+="]"
  129.     print(out_string+"\n")
  130.  
  131.  
  132.  
  133. class RorrLCG:
  134.     """
  135.         Python random with seed is inconsistent and we only need randomish numbers
  136.         Simple rotate right LCG for deterministic results
  137.     """  
  138.     LCG_MOD=0xFFFFFFFFFFFFFFFF #2^64-1
  139.     LCG_BITS=64
  140.     LCG_SHIFT=int((LCG_BITS+1)/3)
  141.     LCG_SHIFT_INV=LCG_BITS-LCG_SHIFT
  142.     def __init__(self, in_seed=0):
  143.         self._randInt: int = 0
  144.         self.seed(in_seed)
  145.     #unsigned int
  146.     def ui(self):
  147.         self._randInt=(self._randInt>>self.LCG_SHIFT)|((self._randInt<<self.LCG_SHIFT_INV)&self.LCG_MOD) #RORR
  148.         self._randInt=(self._randInt*3343+11770513)&self.LCG_MOD #LCG
  149.         return self._randInt
  150.  
  151.     #float
  152.     def f(self):
  153.         return self.ui()/self.LCG_MOD
  154.  
  155.     def seed(self, in_seed: int = None):
  156.         if in_seed == 0 or in_seed == None:
  157.             self._start_seed = int( np.random.default_rng().bit_generator.state['state']['state'] ) & self.LCG_MOD
  158.         elif in_seed:
  159.             self._start_seed = in_seed&self.LCG_MOD
  160.         self._randInt=self._start_seed
  161.         self.ui()
  162.         print("Seed: "+str(self._start_seed)+"\n")
  163.  
  164.     def shuffle(self, _list):
  165.         list_size = len(_list)
  166.         buf=None
  167.         for i in range(list_size - 1, 0, -1):
  168.             rand_uint = self.ui()
  169.             j = rand_uint % (i+1)
  170.             buf = _list[i]
  171.             _list[i] = _list[j]
  172.             _list[j] = buf
  173.         return _list
  174.  
  175.     def shuffleDict(self, data):
  176.         items = list(data.items())
  177.         list_size = len(items)
  178.         for i in range(list_size - 1, 0, -1):
  179.             j = self.ui() % (i + 1)
  180.             items[i], items[j] = items[j], items[i]
  181.         return dict(items)
  182.  
  183.     def uniform_f(self, _min, _max):
  184.         rand_f = self.f()
  185.         return rand_f*(_max-_min) + _min
  186.  
  187.     def vec3(self):
  188.         l = 0
  189.         while l==0:
  190.             vec = [self.f()-0.5, self.f()-0.5, self.f()-0.5]
  191.             l = length_vec3(vec)
  192.         return [vec[0]/l, vec[1]/l, vec[2]/l]
  193.  
  194.  
  195. class KaelColor:
  196.     TYPE_STR = ("SRGB", "LINEAR", "OKLAB", "INVALID")
  197.     INVALID_COL = [1.0,0.0,0.5]
  198.  
  199.     def __init__(self, color_space: str, triplet: list[float] = None, alpha: float = 1.0, fixed: bool = False):
  200.         self.id = id(self)
  201.         self.setType(color_space)
  202.         self.col = list(triplet[:3]) if (triplet != None and len(triplet)>=3) else self.INVALID_COL
  203.         self.alpha = float(alpha)
  204.         self.fixed = fixed
  205.  
  206.     ### Color conversion
  207.     def _linearToSrgb(self, linRGB):
  208.         cutoff = lessThan_vec3(linRGB, [0.0031308]*3)
  209.         gammaAdj = spow_vec3(linRGB, [1.0/2.4]*3 )
  210.         higher = sub_vec3( mul_vec3( [1.055]*3 , gammaAdj ), [0.055]*3 )
  211.         lower = mul_vec3( linRGB, [12.92]*3 )
  212.  
  213.         return lerp_vec3(higher, lower, cutoff)
  214.  
  215.     def _srgbToLinear(self,sRGB):
  216.         cutoff = lessThan_vec3(sRGB, [0.04045]*3)
  217.         higher = spow_vec3(div_vec3(add_vec3(sRGB, [0.055]*3), [1.055]*3), [2.4]*3)
  218.         lower = div_vec3( sRGB, [12.92]*3 )
  219.  
  220.         return lerp_vec3(higher, lower, cutoff)
  221.  
  222.     def _linearToOklab(self,col: List[float]):
  223.         r,g,b = col
  224.         l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
  225.         m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
  226.         s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
  227.  
  228.         l_ = math_spow(l, 1.0/3.0)
  229.         m_ = math_spow(m, 1.0/3.0)
  230.         s_ = math_spow(s, 1.0/3.0)
  231.  
  232.         return [
  233.             0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_,
  234.             1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_,
  235.             0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_,
  236.         ]
  237.  
  238.     def _oklabToLinear(self, col: List[float]):
  239.         L,a,b = col
  240.         l_ = L + 0.3963377774 * a + 0.2158037573 * b
  241.         m_ = L - 0.1055613458 * a - 0.0638541728 * b
  242.         s_ = L - 0.0894841775 * a - 1.2914855480 * b
  243.  
  244.         l = l_*l_*l_
  245.         m = m_*m_*m_
  246.         s = s_*s_*s_
  247.  
  248.         return [
  249.             +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
  250.             -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
  251.             -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
  252.         ]
  253.  
  254.     def _srgbToOklab(self, unused_col):
  255.         linRGB = self._srgbToLinear(self.col)
  256.         oklab = self._linearToOklab(linRGB)
  257.         return oklab
  258.  
  259.     def _oklabToSrgb(self, unused_col):
  260.         linRGB = self._oklabToLinear(self.col)
  261.         sRGB = self._linearToSrgb(linRGB)
  262.         return sRGB
  263.  
  264.     _AS_MAP = {
  265.         "OKLAB": { #Convert from OKLAB to...
  266.             "SRGB": _srgbToOklab,
  267.             "LINEAR": _linearToOklab,
  268.             "OKLAB": lambda c, col: col, #return itself unchanged
  269.         },
  270.         "LINEAR": {
  271.             "SRGB": _srgbToLinear,
  272.             "OKLAB": _oklabToLinear,
  273.             "LINEAR": lambda c, col: col,
  274.         },
  275.         "SRGB": {
  276.             "OKLAB": _oklabToSrgb,
  277.             "LINEAR": _linearToSrgb,
  278.             "SRGB": lambda c, col: col,
  279.         },
  280.     }
  281.  
  282.     #get function pointers from _AS_MAP depending on KaelColor type
  283.     def _as(self, target: str):
  284.         if not self._AS_MAP.get(target):
  285.                 return self.INVALID_COL
  286.         func = self._AS_MAP.get(target).get(self.getTypeStr())
  287.         if not func:
  288.                 return self.INVALID_COL
  289.         return func(self, self.col)
  290.  
  291.     def _to(self, target: str):
  292.         new_col = self._as(target)
  293.         if new_col is not None:
  294.                 self.col = new_col
  295.                 self.setType(target)
  296.         return self.col
  297.  
  298.     #### Public KaelColor funcitons
  299.  
  300.     #Returns an object copy instead of this instance
  301.     def copy(self):
  302.         new_col = KaelColor(self.getTypeStr(), self.col, self.alpha)
  303.         return new_col
  304.  
  305.     def getTypeStr(self):
  306.         return self.TYPE_STR[self.type]
  307.  
  308.     def setType(self, new_type_str: str):
  309.         self.type = self.TYPE_STR.index(new_type_str)
  310.         if self.type == None:
  311.             print("Invalid KaelColor type")
  312.             self.type = len(self.TYPE_STR)-1 #Default to last TYPE_STR ("INVALID")
  313.         return self.type
  314.  
  315.     #Return as different color space without mutating object
  316.     def asOklab(self):
  317.         return self._as("OKLAB")
  318.     def asLinear(self):
  319.         return self._as("LINEAR")
  320.     def asSrgb(self):
  321.         return self._as("SRGB")
  322.  
  323.     #Mutates object .col value
  324.     def toOklab(self):
  325.         return self._to("OKLAB")
  326.     def toLinear(self):
  327.         return self._to("LINEAR")
  328.     def toSrgb(self):
  329.         return self._to("SRGB")
  330.  
  331.  
  332.  
  333.     ### Color tools ###
  334.  
  335.     def inOklabGamut(self, eps:float=1e-7):
  336.         r_lin, g_lin, b_lin = self.asLinear()
  337.  
  338.         return (r_lin >= -eps) and (r_lin <= 1+eps) and \
  339.                 (g_lin >= -eps) and (g_lin <= 1+eps) and \
  340.                 (b_lin >= -eps) and (b_lin <= 1+eps)
  341.  
  342.     #Skips unnecessary conversion
  343.     def clipToOklabGamut(self, calc_norm: bool=False, eps:float=1e-7):
  344.         lin = self.asLinear()
  345.  
  346.         in_gamut =(
  347.             (lin[0] >= -eps) and (lin[0] <= 1+eps) and
  348.             (lin[1] >= -eps) and (lin[1] <= 1+eps) and
  349.             (lin[2] >= -eps) and (lin[2] <= 1+eps)
  350.         )
  351.  
  352.         if in_gamut:
  353.             return [0.0,0.0,0.0]
  354.    
  355.         old_ok_pos = self.col
  356.         self.setType("LINEAR")
  357.         self.col = clip_vec3(lin,[0.0]*3,[1.0]*3)
  358.         self.toOklab()
  359.         return sub_vec3(self.col, old_ok_pos) #movement in ok space
  360.  
  361.     #Calc color values
  362.     def calcChroma(self):
  363.         ok = self.asOklab()
  364.         return math.sqrt( ok[1]*ok[1] + ok[2]*ok[2] )
  365.  
  366.     def calcLum(self):
  367.         return self.asOklab()[0]
  368.  
  369.     def isOkSrgbGray(self, threshold: float = 1.0/255.0):
  370.         r,g,b = self.asSrgb()
  371.         if( abs(r-g) < threshold and
  372.             abs(g-b) < threshold
  373.         ):
  374.             return True
  375.         return False
  376.  
  377.     def getSrgbHex(self):
  378.         r,g,b = clip_vec3(self.asSrgb(),[0.0]*3,[1.0]*3)
  379.         r = int(round(r * 255.0))
  380.         g = int(round(g * 255.0))
  381.         b = int(round(b * 255.0))
  382.         return "#{:02x}{:02x}{:02x}".format(r, g, b)
  383.  
  384.  
  385.  
  386. @dataclass
  387. class NeighborData:
  388.     point: KaelColor
  389.     pos_vec:    [float]*3
  390.     dist:   float
  391.  
  392. @dataclass
  393. class NeighborList:
  394.     root: KaelColor
  395.     array: List[NeighborData]
  396.  
  397. class PointGrid:
  398.     """
  399.         Store point cloud as a search PointGrid.grid and a 1D PointGrid.cloud
  400.     """
  401.     INVALID_COLOR = [0.5,0.0,0.0]
  402.  
  403.     def __init__(self, point_radius: float, dimensions: [[float]*3,[float]*3] = None ):
  404.         self.cloud: List[KaelColor] = []
  405.         self.grid = {}
  406.         self.length: int = 0
  407.         self.cell_size: float = point_radius
  408.  
  409.         if dimensions == None:
  410.             self.max_cells = [[-1.0/self.cell_size]*3,[1.0/self.cell_size]*3]
  411.         else:
  412.             self.max_cells = [div_vec3(dimensions[0], [self.cell_size]*3),div_vec3(dimensions[1], [self.cell_size]*3)]
  413.             self.max_cells = [roundAwayFromZero_vec3(self.max_cells[0]),roundAwayFromZero_vec3(self.max_cells[1])]
  414.  
  415.     def key(self, p: KaelColor):
  416.         g_f = div_vec3(p.col,[self.cell_size]*3)
  417.         if valid_vec3(g_f):
  418.             return ( int(g_f[0]),int(g_f[1]),int(g_f[2]) )
  419.         return [None,None,None]
  420.  
  421.     def insert(self, p: KaelColor):
  422.         self.length+=1
  423.         k = self.key(p)
  424.         if k not in self.grid:
  425.             self.grid[k] = []
  426.         self.grid[k].append(p)
  427.         self.cloud.append(p)
  428.  
  429.     def remove(self, p: KaelColor):
  430.         k = self.key(p)
  431.  
  432.         if k in self.grid and p in self.grid[k]:
  433.             self.length -= 1
  434.             self.grid[k].remove(p)
  435.             if not self.grid[k]:
  436.                 del self.grid[k]
  437.  
  438.         if p in self.cloud:
  439.             self.cloud.remove(p)
  440.  
  441.     def setCol(self, p: KaelColor, q_col: [float]*3):
  442.         old_key = self.key(p)
  443.         if old_key in self.grid and p in self.grid[old_key]:
  444.             self.grid[old_key].remove(p)
  445.             if not self.grid[old_key]:
  446.                     del self.grid[old_key]
  447.         else:
  448.             pass
  449.  
  450.         p.col = q_col
  451.  
  452.         new_key = self.key(p)
  453.         if new_key not in self.grid:
  454.             self.grid[new_key] = []
  455.         self.grid[new_key].append(p)
  456.  
  457.     def cloudPosSnapshot(self):
  458.         pts=[]
  459.         for p in self.cloud:
  460.             pts.append(p.col)
  461.         return pts
  462.  
  463.     def findNearest(self, p: KaelColor, point_radius: float, neighbor_margin: float = 0):
  464.         neighbor = None
  465.         closest=float('inf')
  466.  
  467.         kx, ky, kz = self.key(p)
  468.         for dx in (-1, 0, 1):
  469.             for dy in (-1, 0, 1):
  470.                 for dz in (-1, 0, 1):
  471.                     nk = (kx + dx, ky + dy, kz + dz)
  472.                     chunk = self.grid.get(nk, ())
  473.                     for q in chunk:
  474.                         if q is p:
  475.                                 continue
  476.                         pos_vec = sub_vec3(p.col, q.col)
  477.                         l = length_vec3(pos_vec)
  478.                         if l < closest:
  479.                                 closest = l
  480.                                 neighbor = NeighborData(point=q, pos_vec=pos_vec, dist=l)
  481.        
  482.         return neighbor
  483.  
  484.     def findNeighbors(self, p: KaelColor, radius: float, neighbor_margin: float = 0.0):
  485.         neighborhood = NeighborList(root=p, array=[])
  486.         gx, gy, gz = self.key(p)
  487.         cell_span = int(math.ceil((radius + neighbor_margin) / self.cell_size))
  488.         x_r = [max(gx-cell_span, self.max_cells[0][0]), min(gx+cell_span, self.max_cells[1][0])]
  489.         y_r = [max(gy-cell_span, self.max_cells[0][1]), min(gy+cell_span, self.max_cells[1][1])]
  490.         z_r = [max(gz-cell_span, self.max_cells[0][2]), min(gz+cell_span, self.max_cells[1][2])]
  491.         seen = []
  492.         seen.append(p)
  493.  
  494.         for dx in range(x_r[0], x_r[1]+1):
  495.             for dy in range(y_r[0], y_r[1]+1):
  496.                 for dz in range(z_r[0], z_r[1]+1):
  497.                     nk = (dx, dy, dz)
  498.                     for q in self.grid.get(nk, ()):
  499.                             if q in seen:
  500.                                 continue
  501.                             seen.append(q)
  502.        
  503.                             pos_vec = sub_vec3(p.col, q.col)
  504.                             l = length_vec3(pos_vec)
  505.                             if l <= radius + neighbor_margin:
  506.                                 neighbor = NeighborData(point=q, pos_vec=pos_vec, dist=l)
  507.                                 neighborhood.array.append(neighbor)
  508.         return neighborhood
  509.  
  510.  
  511.  
  512. ### ParticleSim and its helper functions
  513.  
  514. class ParticleSim:
  515.  
  516.     @dataclass
  517.     class Particle:
  518.         ref: KaelColor #simulated reference
  519.         id: int #identifier
  520.         fixed: bool #is immovable?
  521.         v: [float]*3 #velocity
  522.         A: float #Drag reference area
  523.         Cd: float #Coefficient of drag
  524.         rho: float #Drag (rho) fluid density
  525.         m: float #Mass
  526.         k: float #sprint constant
  527.         COR: float #Coefficient of restitution
  528.         mu: float #coefficient of friction
  529.         radius: float #point radius
  530.    
  531.     def __init__(self,
  532.         _rand: RorrLCG,
  533.         _point_grid: PointGrid
  534.     ):
  535.         self.sim_dt = 0.1
  536.         self.rand = _rand
  537.         self.point_grid = _point_grid
  538.  
  539.     #Too far: attract, Too close: repel # f=-kx
  540.     def calcSpringForce(self, p0: Particle, neighbor: NeighborList, neighbor_norm: [float]*3, spring_constant: float = 1.0):
  541.         xd = neighbor.dist-p0.radius
  542.         spring_mag = -1.0 * spring_constant * xd #f=-kx
  543.         spring_force = mul_vec3(neighbor_norm,[spring_mag]*3)
  544.  
  545.         return spring_force
  546.  
  547.     def moveToVelocity(self, p0: Particle, move_delta: [float]*3, time_delta: float):
  548.         return div_vec3(move_delta, [time_delta]*3) #v = dx/dt
  549.    
  550.     def velocityToForce(self, p0: Particle, velocity: [float]*3, time_delta: float):
  551.         return mul_vec3([p0.m]*3, div_vec3(velocity, [time_delta]*3) ) #
  552.    
  553.     def forceToVelocity(self, p0: Particle, force: [float]*3, time_delta: float):
  554.         return div_vec3(mul_vec3(force,[time_delta]*3), [p0.m]*3) #dv = f*dt/m
  555.  
  556.     #force from distance moved in time step. f=m*((dx/dt)/dt)
  557.     def moveToForce(self, p0:Particle, move_delta: [float]*3, time_delta: float):
  558.         velocity = self.moveToVelocity(p0, move_delta, time_delta) #v = dx/dt
  559.         return self.velocityToForce(p0, velocity, time_delta)
  560.  
  561.     #returns the force needed to reflect p0 velocity  # f = - 2 * (vec . n) * n
  562.     def calcReflectForce(self, p0: Particle, surface_norm: [float]*3, time_delta: float):
  563.         dot_v = dot_vec3(p0.v, surface_norm) #(vec . n)
  564.         reflected_v = -1.0 * (p0.COR+1.0) * dot_v # - 2 * (vec . n)
  565.         reflected_v = mul_vec3([reflected_v]*3, surface_norm) # - 2 * (vec . n) * n
  566.         p0.v = add_vec3(p0.v, reflected_v)
  567.  
  568.     def calcTimeStep(self, particles: dict, unit_size: float, scale: float = 0.5, prev_dt: float = None, smoothing: float = 0.5, max_dt: float = 0.5):
  569.         max_v = 0.0
  570.         velocity_list=[]
  571.         for particle in particles.values():
  572.             velocity_list.append(length_vec3(particle.v))
  573.         max_v=max(velocity_list)
  574.  
  575.         if max_v == 0.0:
  576.             new_dt = 0.5
  577.         else:
  578.             new_dt = scale * unit_size / max_v
  579.  
  580.         if prev_dt and new_dt>prev_dt: #higher smoothing = bias toward prev_dt if dt increases
  581.             new_dt = new_dt*(1.0-smoothing) + prev_dt*smoothing
  582.         new_dt = min(new_dt,max_dt)
  583.         new_dt+=1e-12 if new_dt==0 else 0
  584.         prev_dt = new_dt
  585.  
  586.         return new_dt, prev_dt
  587.  
  588.     def calcTotalEnergy(self, p_list: list[float]):
  589.         total_energy=0.0
  590.         for particle in self.particles.values():
  591.             kE = 0.5 * particle.m * length_vec3(particle.v)**2
  592.             total_energy = total_energy + kE
  593.         return total_energy
  594.  
  595.     """
  596.     Relations
  597.     Fd=0.5*p*v^2*A*Cd : force drag
  598.     Fs=-kx : sprint force
  599.     F=ma : force
  600.     a=dv/dt : acceleration
  601.     w = v - 2 * (v . n) * n : Reflect
  602.     """
  603.     #Iterative force simulation that pushes points 1 radius apart from each center
  604.     def relaxCloud(self,
  605.         iterations = 64,
  606.         target_dist = None,
  607.         min_energy = 0.0,
  608.         record_frames = "./cloudHistogram.py",
  609.         anneal_steps = 32,
  610.         log_frequency = 25,
  611.         target_kissing = 6
  612.     ):
  613.         if iterations == 0 or len(self.point_grid.cloud) == 0:
  614.             return
  615.  
  616.         if record_frames:
  617.             grid_frames=[]
  618.             grid_frames.append(self.point_grid.cloudPosSnapshot())
  619.  
  620.         if target_dist == None:
  621.             target_dist = self.point_grid.cell_size
  622.  
  623.         self.particles = { p.id: None for p in self.point_grid.cloud }
  624.         for p in self.point_grid.cloud:
  625.             #softball in mud
  626.             point = self.Particle(
  627.                 ref=p,
  628.                 id=p.id,
  629.                 fixed=p.fixed,
  630.                 v=[0.0]*3,
  631.             A=math.pi * (0.1)**2, #m^2
  632.             Cd=0.47, #sphere
  633.             rho=3.0, #kg/l
  634.             m=0.4, #kilograms
  635.             k=0.6,
  636.             COR=0.6,
  637.                 mu=0.2,
  638.                 radius=target_dist
  639.          )
  640.             self.particles[p.id] = point
  641.  
  642.         old_dt = 0.5
  643.         smoothing_dt = 0.5 #Higher = old_dt dominates
  644.         for tick in range(iterations):
  645.             self.particles = self.rand.shuffleDict(self.particles)
  646.    
  647.             self.sim_dt, old_dt = self.calcTimeStep(self.particles, unit_size=target_dist, scale=0.5, prev_dt=old_dt, smoothing=0.5, max_dt = 0.5)
  648.        
  649.             #Calculate velocity synchronously
  650.             for particle in self.particles.values():
  651.                 if particle.fixed:
  652.                     continue
  653.                
  654.                 delta_dist = mul_vec3(particle.v, [self.sim_dt]*3) #dx = v * dt
  655.                 new_col = add_vec3(particle.ref.col, delta_dist)
  656.  
  657.                 if valid_vec3(new_col):
  658.                     self.point_grid.setCol(particle.ref,new_col)
  659.                 else:
  660.                     particle.v=[0.0]*3
  661.  
  662.             kissing_list = []
  663.             #Add forces
  664.             for particle in self.particles.values():
  665.                 if particle.fixed:
  666.                     continue
  667.  
  668.                 all_forces=[]
  669.    
  670.                 #gamut reflect
  671.                 clip_move = particle.ref.clipToOklabGamut() # dx
  672.                 if clip_move !=[0.0]*3 and valid_vec3(clip_move):
  673.                     surface_norm = norm_vec3(clip_move)
  674.                     self.calcReflectForce(particle, surface_norm, self.sim_dt)
  675.    
  676.                 #neighbor forces
  677.                 neighbor_force = [0.0,0.0,0.0]
  678.                 neighborhood = self.point_grid.findNeighbors(particle.ref, particle.radius, 0.0)
  679.                 for neighbor in neighborhood.array:
  680.                     if neighbor.dist > particle.radius:
  681.                         continue
  682.        
  683.                     neighbor_particle = self.particles[neighbor.point.id]
  684.    
  685.                     if neighbor.dist == 0.0: #push out particles inside each other
  686.                         rand_norm = mul_vec3(self.rand.vec3(),[particle.radius]*3)
  687.                         push_move = mul_vec3([ particle.radius*self.sim_dt ]*3,rand_norm)
  688.                         push_force = self.moveToForce(particle, push_move, self.sim_dt)
  689.                         neighbor_force = add_vec3(neighbor_force, push_force)
  690.                         continue
  691.  
  692.                     #spring influence
  693.                     neighbor_norm = norm_vec3(neighbor.pos_vec) #from particle to neighbor normal
  694.                     spring_force = self.calcSpringForce(particle, neighbor, neighbor_norm, particle.k) #no attract forces
  695.                     neighbor_force = add_vec3(neighbor_force, spring_force)
  696.    
  697.                     if not neighbor_particle.fixed:
  698.                         continue
  699.        
  700.                     #bounce if inside fixed
  701.                     surface_norm = norm_vec3(neighbor.pos_vec)
  702.                     self.calcReflectForce(particle, surface_norm, self.sim_dt)
  703.                     continue
  704.    
  705.                 all_forces.append(neighbor_force)
  706.        
  707.                 #drag, opposing
  708.                 #Fd=0.5*p*A*Cd*v^2
  709.                 drag_force = mul_vec3( [-1.0 * 0.5 * particle.rho * particle.A * particle.Cd]*3, mul_vec3(particle.v,particle.v) )
  710.                 all_forces.append(drag_force)
  711.      
  712.                 #internal friction, opposing
  713.                 friction_force = mul_vec3( particle.v, [-1.0*particle.mu]*3 ) #f=cf
  714.                 all_forces.append(friction_force)
  715.      
  716.                 #sum Fdt
  717.                 force_delta=[0.0]*3
  718.                 for force in all_forces:
  719.                     if valid_vec3(force):
  720.                         force_delta = add_vec3(force_delta,force)
  721.      
  722.                 delta_velocity = self.forceToVelocity(particle, force_delta, self.sim_dt)
  723.                 particle.v = add_vec3(particle.v, delta_velocity)
  724.    
  725.                 #get number of touching neighbors, only for particles that aren't at boundary
  726.                 if anneal_steps!=0 and len(neighborhood.array) and clip_move==[0.0]*3:
  727.                     kissing_count=0
  728.                     for neighbor in neighborhood.array:
  729.                         overlap = neighbor.dist-particle.radius
  730.                         if overlap < particle.radius*0.1:
  731.                             kissing_count+=1
  732.                     kissing_list.append(kissing_count)
  733.    
  734.             #update particle.radius adaptively
  735.             if anneal_steps!=0 and tick >= anneal_steps and tick%anneal_steps==0 and len(kissing_list)!=0:
  736.                 median_kissing = math_median(kissing_list)
  737.                 is_stable = max(kissing_list) < len(self.particles)//2
  738.                 if median_kissing != 0 and is_stable :
  739.                     scale_factor = (target_kissing / median_kissing) ** (1/3)
  740.                     for particle in self.particles.values():
  741.                         particle.radius *= max(0.5, min(1.5, scale_factor))
  742.        
  743.             #logging
  744.             if tick%log_frequency==0 or tick==iterations-1:
  745.                 total_energy=self.calcTotalEnergy(self.particles)
  746.                 out_str = "Total force["+str(tick)+"]:"
  747.                 out_str+= " "+str(total_energy)
  748.                 print(out_str)
  749.                 if abs(total_energy) < min_energy and tick>anneal_steps:
  750.                     break
  751.  
  752.             if record_frames:
  753.                 grid_frames.append(self.point_grid.cloudPosSnapshot())
  754.        
  755.         if record_frames:
  756.             with open(record_frames, "w") as f:
  757.                 f.write("oklab_frame_list = ")
  758.                 f.write(str(grid_frames))
  759.    
  760.         p_radius_list=[]
  761.         for p in self.particles.values():
  762.             p_radius_list.append(p.radius)
  763.    
  764.         print("Median particle radius: "+str( round(math_median(p_radius_list),4) ))
  765.         print("relaxCloud Done\n")
  766.         return self.point_grid
  767.  
  768.  
  769.  
  770. ### Palette generator ###
  771. @dataclass
  772. class PalettePreset:
  773.     sample_method: int = 2 #Generate initial cloud by 0 = randomSampler, 1 = randWalkSampler, 2 = rwalk -> random
  774.  
  775.     reserve_transparent: bool = True
  776.     hex_pre_colors: List[[str,bool]] = None # ["#0123abc",...]
  777.     img_pre_colors: str = None #file name to existing color palette
  778.     img_fixed_mask: str = None #file name to fixed mask white=fixed black=movable color
  779.    
  780.     max_colors: int = 64        #Max allowed colors including transparency
  781.     gray_count: int = None  #Grayscale color count, None = Auto
  782.     hue_count:  int = 12        #Split Hues in this many buckets
  783.  
  784.     min_sat: float = 0.0    #min/max ranges are percentages
  785.     max_sat: float = 1.0
  786.     min_lum: float = 0.0
  787.     max_lum: float = 1.2
  788.  
  789.     packing_fac: float = 1.0 #Packing efficiency
  790.     max_attempts: int = 1024 #After this many max_attempts per point, point Sampler will give up
  791.     relax_count: int = 64 #number of relax iteration after point sampling
  792.    
  793.     seed: int = 0 # 0=random run to run
  794.  
  795.  
  796. class PaletteGenerator:
  797.     """
  798.         Generate palette where the colors are perceptually evenly spaced out in OKLab colorspace
  799.     """
  800.     OKLAB_GAMUT_VOLUME = 0.054197416 # print (str(oklabGamutVolume(500))) pre computed
  801.  
  802.     def __init__(self, preset: [PalettePreset] = None, *,
  803.  
  804.         sample_method:      [int]   = None,
  805.         reserve_transparent: [bool]  = None,
  806.         hex_pre_colors:[List[[str,bool]]]=None,
  807.         img_pre_colors:         [str]   = None,
  808.         img_fixed_mask:         [str]   = None,
  809.  
  810.         max_colors:             [int]   = None,
  811.         hue_count:              [int]   = None,
  812.         gray_count:             [int]   = None,
  813.  
  814.         min_sat:                [float] = None,
  815.         max_sat:                [float] = None,
  816.         min_lum:                [float] = None,
  817.         max_lum:                [float] = None,
  818.  
  819.         packing_fac:            [float] = None,
  820.         max_attempts:           [int]   = None,
  821.         relax_count:            [int]   = None,
  822.  
  823.         seed:                   [int]   = None,
  824.  
  825.     ):
  826.         self.point_grid = None
  827.         self.point_radius = None
  828.  
  829.         if preset is None:
  830.             preset = PalettePreset()
  831.    
  832.         self.p = PalettePreset(**{k: getattr(preset, k) for k in preset.__dataclass_fields__})
  833.  
  834.         if self.p.packing_fac <= 0:
  835.             self.p.packing_fac = 1e-12
  836.  
  837.         if self.p.max_colors:
  838.             self.p.max_colors -= self.p.reserve_transparent #max_count includes transparency
  839.         else:
  840.             self.p.max_colors = 64
  841.    
  842.         self.rand = RorrLCG(self.p.seed) if self.p.seed != None else RorrLCG()
  843.  
  844.  
  845.     def getRandOklab(self):
  846.         rand_p = KaelColor("OKLAB")
  847.         while not rand_p.inOklabGamut():
  848.             rand_p.col = [self.rand.f(), self.rand.f()-0.5, self.rand.f()-0.5]
  849.    
  850.         return rand_p
  851.  
  852.     def applyColorLimits(self):
  853.         apply_luminosity = self.p.max_lum!=1.0 or self.p.min_lum!=0.0
  854.         apply_saturation = self.p.max_sat!=1.0 or self.p.min_sat!=0.0
  855.         count=0
  856.         max_chroma = math.sqrt(0.5**2+0.5**2)
  857.         for p in self.point_grid.cloud:
  858.             if apply_luminosity:
  859.                 lum_width = self.p.max_lum - self.p.min_lum
  860.                 p.col[0] = p.col[0]*lum_width + self.p.min_lum
  861.        
  862.             if apply_saturation and not p.isOkSrgbGray():
  863.                 sat_width = self.p.max_sat - self.p.min_sat
  864.                 chroma = p.calcChroma()
  865.    
  866.                 rel_sat = chroma / max_chroma
  867.                 scaled_sat = (rel_sat * sat_width + self.p.min_sat) * max_chroma
  868.  
  869.                 col_vec = [p.col[1], p.col[2]] #2D Vector a,b
  870.                 col_vec = [col_vec[0]/chroma, col_vec[1]/chroma] #Normalize
  871.                 col_vec = [col_vec[0]*scaled_sat, col_vec[1]*scaled_sat] #Scale
  872.                 p.col = [p.col[0], col_vec[0], col_vec[1]]
  873.  
  874.     def addPreColors(self, alpha_threshold:int=int(0), fixed_mask_threshold:int=int(128)):
  875.  
  876.         #Add uint8 colors from image
  877.         if self.p.img_pre_colors != None:
  878.        
  879.             pre_palette = Image.open(self.p.img_pre_colors)
  880.             pre_palette = pre_palette.convert('RGBA')
  881.             rgba_list = list(pre_palette.getdata())
  882.    
  883.             fixed_list = [0]*len(rgba_list)
  884.             if self.p.img_fixed_mask !=None:
  885.                 fixed_mask = Image.open(self.p.img_fixed_mask)
  886.                 fixed_mask = fixed_mask.convert('L')
  887.                 fixed_list = list(fixed_mask.getdata())
  888.    
  889.             index=0
  890.             for col in rgba_list:
  891.                 if col[3] <= alpha_threshold:
  892.                     continue
  893.                 r,g,b,a = col
  894.                 r = float(r)/255.0
  895.                 g = float(g)/255.0
  896.                 b = float(b)/255.0
  897.                 a = float(a)/255.0
  898.                 is_fixed = fixed_list[index] > fixed_mask_threshold
  899.                 new_col = KaelColor( "SRGB", [r,g,b], alpha=a, fixed=is_fixed )
  900.                 new_col.toOklab()
  901.                 self.point_grid.insert(new_col)
  902.                 index+=1
  903.    
  904.         #Add hex colors, format ["#1234abcd",...] or [["abc123",is_fixed]...]
  905.         if self.p.hex_pre_colors != None and len(self.p.hex_pre_colors):
  906.             for hexlet_info in self.p.hex_pre_colors:
  907.                 hexlet,fixed = [hexlet_info[0], False]
  908.                 if len(hexlet)>1:
  909.                     hexlet, fixed = hexlet_info
  910.    
  911.                 srgba=hexToSrgba(hexlet)
  912.                 if srgba[3] < alpha_threshold*255.0:
  913.                     continue
  914.    
  915.                 oklab = srgba[:3]
  916.                 new_col = KaelColor( "SRGB", oklab, alpha=srgba[3], fixed=fixed )
  917.                 new_col.toOklab()
  918.                 self.point_grid.insert(new_col)
  919.  
  920.  
  921. ### Oklab point sampler methods within gamut ###
  922.  
  923.     def zeroSampler(self):
  924.         attempts=0
  925.         while self.point_grid.length < self.p.max_colors:
  926.             new_col = KaelColor("OKLAB",[0.5,0.0,0.0])
  927.             self.point_grid.insert(new_col)
  928.  
  929.     #Simple rejection sampling
  930.     def randomSampler(self, min_dist):
  931.         attempts=0
  932.         while self.point_grid.length < self.p.max_colors:
  933.             attempts+=1
  934.             if attempts > self.p.max_attempts:
  935.                 print("Reached max_attempts to find a new point.")
  936.                 break
  937.             new_col = self.getRandOklab()
  938.             neighbor = self.point_grid.findNearest(new_col, self.point_radius)
  939.             if neighbor and neighbor.dist<min_dist:
  940.                 continue
  941.             self.point_grid.insert(new_col)
  942.         print("randomSampler Loop count "+str(attempts))
  943.  
  944.     #Generate grayscale gradient between black and white
  945.     def generateGrays(self):
  946.         if self.p.gray_count == None:
  947.             self.p.gray_count = int(round(1.0/(self.point_radius)))
  948.         if self.p.gray_count:
  949.             darkest_black = KaelColor( "SRGB", [0.499/255,0.499/255,0.499/255] ).calcLum()
  950.             self.p.gray_count = min(self.p.max_colors - len(self.point_grid.cloud), self.p.gray_count)
  951.             #Use minimum starting luminosity that second darkest black isn't so close to 0
  952.             for i in range(0,self.p.gray_count):
  953.                 denom = max(1, self.p.gray_count-1)
  954.                 lum = float(i)/((denom))
  955.                 scale = (denom-i)/denom
  956.                 lum+= darkest_black*scale #Fade that brightest remains 1.0
  957.                 new_point = [lum,0,0]
  958.                 new_col = KaelColor( "OKLAB", new_point, 1.0, True )
  959.                 self.point_grid.insert(new_col)
  960.  
  961.     #Generate new point exactly 1 radius from another point. Essentially a random walk
  962.     def randWalkSampler(self):
  963.         def rand_cloud_point(random_chance: float=0.5):
  964.             if self.rand.f()>random_chance and len(self.point_grid.cloud):
  965.                 return self.point_grid.cloud[ int(self.rand.f()*len(self.point_grid.cloud)) ].copy()
  966.             return self.getRandOklab()
  967.  
  968.         space_size = math.sqrt(1.0**2 + 1.0**2 + 1.0**2)
  969.         margin = space_size/10000.0
  970.         push_scalar = [self.point_radius]*3
  971.         linRGB_radius = push_scalar[0]
  972.  
  973.         push_fails = 0
  974.         max_push_fails = 10
  975.  
  976.         new_point = rand_cloud_point(0.0)
  977.         skip_rand = False
  978.  
  979.         attempt_counter = 0
  980.  
  981.         while len(self.point_grid.cloud) < self.p.max_colors:
  982.             attempt_counter+=1
  983.             if attempt_counter > self.p.max_attempts:
  984.                 print("Reached max_attempts to find a new point.")
  985.                 break
  986.    
  987.             if not skip_rand:
  988.                 #Move to random direction
  989.                 rand_vec = self.rand.vec3()
  990.                 move_vec = mul_vec3(rand_vec, push_scalar )
  991.                 new_point.col = add_vec3(new_point.col, move_vec)
  992.             skip_rand = False
  993.    
  994.             new_point.clipToOklabGamut()
  995.    
  996.             neighbor = self.point_grid.findNearest(new_point, self.point_radius, margin)
  997.  
  998.             if neighbor:
  999.                 delta = neighbor.dist-self.point_radius
  1000.                 if abs(delta) > margin:
  1001.                     #Too close or far: Set 1 radius apart from nearest point
  1002.                     push_fails+=1
  1003.                     if push_fails > max_push_fails:
  1004.                         push_fails = 0
  1005.                         new_point = rand_cloud_point(0.5)
  1006.                         continue
  1007.    
  1008.                     normal = norm_vec3(neighbor.pos_vec)
  1009.                     new_point.col = add_vec3(neighbor.point.col, mul_vec3(normal, mul_vec3(push_scalar,[1.0]*3) ))
  1010.  
  1011.                     skip_rand = True
  1012.                     continue
  1013.  
  1014.             self.point_grid.insert(new_point)
  1015.             new_point = new_point.copy()
  1016.             push_fails = 0
  1017.    
  1018.         print("randWalkSampler Loop count "+str(attempt_counter)+"\n")
  1019.  
  1020.     def populatePointCloud(self):  
  1021.         unit_volume = self.OKLAB_GAMUT_VOLUME/max(1,self.p.max_colors)
  1022.         self.point_radius = unit_volume**(1.0/3.0) * self.p.packing_fac
  1023.         print("Using point_radius "+str(round(self.point_radius,4)))
  1024.  
  1025.         oklabRange = [[0.0,-0.5,-0.5],[1.0,0.5,0.5]]
  1026.         self.point_grid = PointGrid(self.point_radius, oklabRange )
  1027.  
  1028.         self.addPreColors()
  1029.         self.generateGrays()
  1030.  
  1031.         if self.p.sample_method in [1,2]:
  1032.             self.randWalkSampler()
  1033.         if self.p.sample_method in [0,2]:
  1034.             self.randomSampler(self.point_radius*0.51)
  1035.         if self.p.sample_method in [3]:
  1036.             self.zeroSampler()
  1037.    
  1038.         oksim = ParticleSim(self.rand, self.point_grid)
  1039.         self.point_grid = oksim.relaxCloud(
  1040.             iterations = self.p.relax_count,
  1041.             target_dist = self.point_radius,
  1042.         )
  1043.  
  1044.         self.applyColorLimits()
  1045.  
  1046.         return self.point_grid.cloud
  1047.  
  1048.  
  1049.  
  1050.     ### Palette processing ###
  1051.     def paletteToHex(self):
  1052.         hex_list = []
  1053.         if self.p.reserve_transparent:
  1054.                 hex_list.append("#00000000")
  1055.    
  1056.         for p in self.point_grid.cloud:
  1057.             hex_list.append(p.getSrgbHex())
  1058.        
  1059.         return hex_list
  1060.  
  1061.     def paletteToImg(self, filename: str = "palette.png"):
  1062.         rgba = []
  1063.         if self.p.reserve_transparent:
  1064.             rgba.append((0, 0, 0, 0))
  1065.    
  1066.         for p in self.point_grid.cloud:
  1067.             r,g,b = p.asSrgb()
  1068.             r = min( max( int(round(r * 255.0)), 0 ), 255)
  1069.             g = min( max( int(round(g * 255.0)), 0 ), 255)
  1070.             b = min( max( int(round(b * 255.0)), 0 ), 255)
  1071.             rgba.append((r, g, b, 255))
  1072.    
  1073.         if len(rgba) == 0:
  1074.             return None
  1075.  
  1076.         arr = np.array([rgba], dtype=np.uint8)
  1077.         img = Image.fromarray(arr, mode="RGBA")
  1078.         img.save(filename)
  1079.         return img
  1080.  
  1081.  
  1082.     def separateGrays(self, KaelColor_array = list [KaelColor]):
  1083.         gray_colors = []
  1084.         hued_colors = []
  1085.  
  1086.         for p in KaelColor_array:
  1087.             if p.isOkSrgbGray():
  1088.                 gray_colors.append(p)
  1089.             else:
  1090.                 hued_colors.append(p)
  1091.        
  1092.         return gray_colors, hued_colors
  1093.  
  1094.     def sortByLum(self, KaelColor_array: list[KaelColor]):
  1095.         return sorted(KaelColor_array, key=lambda x: x.col[0])
  1096.  
  1097.     def sortPalette(self):
  1098.         gray_colors, hued_colors = self.separateGrays(self.point_grid.cloud)
  1099.    
  1100.         #Place hues in same buckets
  1101.         hue_buckets = [[] for _ in range(self.p.hue_count)]
  1102.         hue_bucket_width = 2*math.pi * (1.0/self.p.hue_count)
  1103.  
  1104.         for p in hued_colors:
  1105.             col_hue = math.atan2(p.col[2], p.col[1]) + 2* math.pi
  1106.             bucket_index = int(col_hue/hue_bucket_width) % self.p.hue_count
  1107.             hue_buckets[bucket_index].append(p)
  1108.  
  1109.         #Sort hue buckets by luminance
  1110.         sorted_hue_buckets = []
  1111.         for bucket in hue_buckets:
  1112.             sorted_bucket = self.sortByLum(bucket)
  1113.             sorted_hue_buckets.append(sorted_bucket)
  1114.  
  1115.         #combine colors into single array
  1116.         sorted_colors = []
  1117.  
  1118.         sorted_grays = self.sortByLum(gray_colors)
  1119.         for p in sorted_grays:
  1120.             sorted_colors.append(p)
  1121.  
  1122.         for bucket in sorted_hue_buckets:
  1123.             for p in bucket:
  1124.                 sorted_colors.append(p)
  1125.    
  1126.         self.point_grid.cloud = sorted_colors
  1127.  
  1128.     #EOF PaletteGenerator
  1129.  
  1130.  
  1131.  
  1132. ### palette analysis ###
  1133. class PointGridStats:
  1134.  
  1135.  
  1136.     #p0 has type (float3)[L,a,b]
  1137.     #pair_list has type (float,[],[])[dist, p0, p1]
  1138.     @staticmethod
  1139.     def _printPairStats(pair_list, print_count, listName="", calc_error=False):
  1140.        
  1141.         if len(pair_list) < 2:
  1142.             print("Not enough pairs for stats!")
  1143.             return
  1144.        
  1145.         precision = 4
  1146.  
  1147.         print(listName+" Closest pairs")
  1148.         for pair in pair_list[:print_count]:
  1149.             print(str(round(pair[0],precision))+" "+pair[1].getSrgbHex()+" "+pair[2].getSrgbHex())
  1150.  
  1151.         #print only unseen values
  1152.         far_start = max(print_count, len(pair_list)-print_count)
  1153.         if far_start < len(pair_list):
  1154.             print(listName+" Farthest pairs")
  1155.         for pair in pair_list[far_start:]:
  1156.             print(str(round(pair[0],precision))+" "+pair[1].getSrgbHex()+" "+pair[2].getSrgbHex())
  1157.  
  1158.         #Average
  1159.         sumDist = 0.0
  1160.         for pair in pair_list:
  1161.             sumDist+=pair[0]
  1162.         avgDist = max(sumDist / len(pair_list),1e-12)
  1163.  
  1164.         #Median
  1165.         medianDist=0.0
  1166.         medianIndex=len(pair_list)//2
  1167.         if len(pair_list)%2==0:
  1168.             a = medianIndex
  1169.             b = max(medianIndex-1,0)
  1170.             medianDist = (pair_list[a][0] + pair_list[b][0]) / 2.0
  1171.         else:
  1172.             medianDist = pair_list[medianIndex][0]
  1173.  
  1174.         print(listName+" Avg pair distance: "+str(round(avgDist,precision)))
  1175.         print(listName+" Median pair distance: "+str(round(medianDist,precision)))
  1176.         print("")
  1177.        
  1178.         #Compare biggest gap to avg gap
  1179.         if calc_error == True:
  1180.  
  1181.             hued_pairs = [
  1182.                 p for p in pair_list
  1183.                 if not p[1].isOkSrgbGray() and not p[2].isOkSrgbGray()
  1184.             ]
  1185.             hue_pair_count = len(hued_pairs)
  1186.            
  1187.             #Average hued only
  1188.             sumDist = 0.0
  1189.             for pair in hued_pairs:
  1190.                 sumDist+=pair[0]
  1191.             avgHueDist = sumDist / hue_pair_count if hue_pair_count != 0 else 0.0001
  1192.        
  1193.  
  1194.             #All colors gaps
  1195.             all_smallest_gap = pair_list[0][0]
  1196.             all_largest_gap = pair_list[-1][0]
  1197.        
  1198.  
  1199.             #Hued colors gaps
  1200.             hued_smallest_gap = hued_pairs[0][0] if hued_pairs else None
  1201.             hued_largest_gap = hued_pairs[-1][0] if hued_pairs else None
  1202.  
  1203.             allError = abs(1.0 - all_largest_gap/avgDist)
  1204.             print("Biggest_gap  to avg_gap delta "+str(round(100*allError,precision))+" %")
  1205.  
  1206.             allError = abs(1.0 - all_smallest_gap/avgDist)
  1207.             print("Smallest_gap to avg_gap delta "+str(round(100*allError,precision))+" %")
  1208.  
  1209.             if hued_largest_gap:
  1210.                 huedError = abs(1.0 - hued_largest_gap/avgHueDist)
  1211.                 print("Hued biggest_gap  to avg_gap delta "+str(round(100*huedError,precision))+" %")
  1212.             if hued_largest_gap:
  1213.                 huedError = abs(1.0 - hued_smallest_gap/avgHueDist)
  1214.                 print("Hued smallest_gap to avg_gap delta "+str(round(100*huedError,precision))+" %")
  1215.  
  1216.         return
  1217.  
  1218.  
  1219.     # Input [[L,a,b], ...]
  1220.     # Find closest point for every point
  1221.     # Returns list of point pairs [[dist, p0, p1], ...] sorted by distance
  1222.     @staticmethod
  1223.     def _findClosestPairs(point_grid):
  1224.         if len(point_grid.cloud) < 2:
  1225.             return []
  1226.         dist_pair_array = []
  1227.  
  1228.         for p in point_grid.cloud:
  1229.             neighbor = point_grid.findNearest(p, point_grid.cell_size)
  1230.             if neighbor is None:
  1231.                     continue
  1232.             if p == neighbor.point:
  1233.                     continue
  1234.             dist_pair_array.append([neighbor.dist, p, neighbor.point])
  1235.  
  1236.         dist_pair_array.sort(key=lambda x: x[0])
  1237.         return dist_pair_array
  1238.  
  1239.     @staticmethod
  1240.     def printGapStats(point_grid, print_count):
  1241.         full_pair_arr = PointGridStats._findClosestPairs(point_grid)
  1242.         PointGridStats._printPairStats(full_pair_arr,print_count,"Full",1)
  1243.         print("")
  1244.  
  1245.  
  1246.  
  1247. palette_preset_list = {
  1248.     'palTest': PalettePreset(
  1249.         sample_method=2,
  1250.         reserve_transparent=1,
  1251.         hex_pre_colors = None, #[["abcdef",True],["abc123ff",False]],
  1252.         img_pre_colors = None, #"./pre_palette.png",
  1253.         img_fixed_mask = None, #"./pre_palette_mask.png",
  1254.  
  1255.         gray_count  =None,
  1256.         max_colors  =64,
  1257.         hue_count   =12,
  1258.         min_sat     =0.0,
  1259.         max_sat     =1.0,
  1260.         min_lum     =0.0,
  1261.         max_lum     =1.0,
  1262.    
  1263.         packing_fac =1.3,
  1264.         max_attempts=1024*16,
  1265.         relax_count =256,
  1266.         seed=0
  1267.     ),
  1268. }
  1269.  
  1270.  
  1271.  
  1272. def run_generatePalette():
  1273.  
  1274.     palette_name = 'palTest'
  1275.     active_preset = palette_preset_list[palette_name]
  1276.  
  1277.     palette = PaletteGenerator(preset=active_preset)
  1278.     palette.populatePointCloud()
  1279.  
  1280.     palette.sortPalette()
  1281.  
  1282.     palette_file = 'palette.png'
  1283.     palette.paletteToImg(palette_file)
  1284.  
  1285.     PointGridStats.printGapStats(palette.point_grid,4)
  1286.  
  1287.     hex_string = palette.paletteToHex()
  1288.     printHexList(hex_string,palette_name)
  1289.  
  1290.     print("Generated "+str(len(palette.point_grid.cloud) + active_preset.reserve_transparent)+" colors to ./"+palette_file)
  1291.  
  1292. if __name__ == '__main__':
  1293.     run_generatePalette()
Advertisement
Add Comment
Please, Sign In to add comment