Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- ##CC0 Kaelygon 2025
- import math
- import numpy as np
- from PIL import Image
- from dataclasses import dataclass, field
- from typing import List, Optional
- #Floating helpers
- #3D float operators
- def sub_vec3(vec_a,vec_b):
- return [ vec_a[0] - vec_b[0], vec_a[1] - vec_b[1], vec_a[2] - vec_b[2] ]
- def add_vec3(vec_a,vec_b):
- return [ vec_a[0] + vec_b[0], vec_a[1] + vec_b[1], vec_a[2] + vec_b[2] ]
- def mul_vec3(vec_a,vec_b):
- return [ vec_a[0] * vec_b[0], vec_a[1] * vec_b[1], vec_a[2] * vec_b[2] ]
- def div_vec3(vec_a,vec_b):
- vec = [ vec_a[0] / vec_b[0], vec_a[1] / vec_b[1], vec_a[2] / vec_b[2] ]
- return vec
- #Special 3D float operators
- def lerp_vec3(vec_a,vec_b,a):
- return add_vec3( mul_vec3(vec_a, sub_vec3([1.0]*3,a) ), mul_vec3(vec_b,a) )
- def pow_vec3(vec_a,vec_b):
- return [ vec_a[0] ** vec_b[0], vec_a[1] ** vec_b[1], vec_a[2] ** vec_b[2] ]
- def spow_vec3(vec_a,vec_b):
- 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]) ]
- def sign_vec3(vec):
- return [ 1 if c>=0 else -1 for c in vec ]
- def clip_vec3(vec,low,hi):
- 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]) ]
- def lessThan_vec3(vec_a,vec_b):
- return [ vec_a[0]<vec_b[0], vec_a[1]<vec_b[1], vec_a[2]<vec_b[2] ]
- def roundAwayFromZero_vec3(vec):
- return [int(c) if c == int(c) else int(c) + (1 if c > 0 else -1) for c in vec ]
- def round_vec3(vec, digits):
- return [round(vec[0],digits),round(vec[1],digits),round(vec[2],digits)]
- def valid_vec3(vec):
- for c in vec:
- if not math.isfinite(c):
- return False
- return True
- #Vector operators
- def dot_vec3(a, b):
- product = 0.0
- for i in range(3):
- product+= a[i] * b[i]
- return product
- def cross_vec3(a, b):
- return [a[1]*b[2] - a[2]*b[1],
- a[2]*b[0] - a[0]*b[2],
- a[0]*b[1] - a[1]*b[0]]
- def length_vec3(vec):
- return math.sqrt(vec[0]*vec[0] + vec[1]*vec[1] + vec[2]*vec[2])
- def norm_vec3(vec,eps=0.0):
- l = length_vec3(vec)
- if l<=eps:
- return [1,0,0]
- return [vec[0]/l, vec[1]/l, vec[2]/l]
- ### other math tools
- #If negative base, return sign(a) * abs(a)**b
- MAX_FLOAT = 1.79e308
- def math_spow(a:float,b:float):
- if abs(a)>1.0 and abs(b)>1.0 and (abs(a) > MAX_FLOAT ** (1.0 / max(b, 1.0))):
- return math.copysign(MAX_FLOAT, a)
- if a>0:
- return a**b
- if b.is_integer():
- return -((-a)**b)
- return 0.0
- def math_median(_list):
- if len(_list)==0:
- return None
- _list.sort()
- index=len(_list)//2
- if len(_list)%2==0:
- a = index
- b = max(index-1,0)
- return (_list[a] + _list[b]) / 2.0
- else:
- return _list[index]
- def oklabGamutVolume(resolution: int=50):
- valid_count = 0
- total_count = resolution ** 3
- for L in range(0,resolution):
- for a in range(0,resolution):
- for b in range(0,resolution):
- if inOklabGamut([
- float(L)/resolution,
- float(a)/resolution-0.5,
- float(b)/resolution-0.5
- ]):
- valid_count += 1
- return valid_count / total_count
- ### misc tools
- def hexToSrgba(hexstr: str):
- s = hexstr.strip().lstrip('#')
- if len(s) < 6:
- return [0,0,0,0]
- r = int(s[0:2], 16) / 255.0
- g = int(s[2:4], 16) / 255.0
- b = int(s[4:6], 16) / 255.0
- a = 1.0
- if len(s) == 8:
- a = int(s[6:8], 16) / 255.0
- return [r, g, b, a]
- def printHexList(hex_list, palette_name="", new_line=True):
- out_string= palette_name + " = "
- out_string+="["
- for string in hex_list:
- out_string += "\""+string+"\","
- out_string+="]"
- print(out_string+"\n")
- class RorrLCG:
- """
- Python random with seed is inconsistent and we only need randomish numbers
- Simple rotate right LCG for deterministic results
- """
- LCG_MOD=0xFFFFFFFFFFFFFFFF #2^64-1
- LCG_BITS=64
- LCG_SHIFT=int((LCG_BITS+1)/3)
- LCG_SHIFT_INV=LCG_BITS-LCG_SHIFT
- def __init__(self, in_seed=0):
- self._randInt: int = 0
- self.seed(in_seed)
- #unsigned int
- def ui(self):
- self._randInt=(self._randInt>>self.LCG_SHIFT)|((self._randInt<<self.LCG_SHIFT_INV)&self.LCG_MOD) #RORR
- self._randInt=(self._randInt*3343+11770513)&self.LCG_MOD #LCG
- return self._randInt
- #float
- def f(self):
- return self.ui()/self.LCG_MOD
- def seed(self, in_seed: int = None):
- if in_seed == 0 or in_seed == None:
- self._start_seed = int( np.random.default_rng().bit_generator.state['state']['state'] ) & self.LCG_MOD
- elif in_seed:
- self._start_seed = in_seed&self.LCG_MOD
- self._randInt=self._start_seed
- self.ui()
- print("Seed: "+str(self._start_seed)+"\n")
- def shuffle(self, _list):
- list_size = len(_list)
- buf=None
- for i in range(list_size - 1, 0, -1):
- rand_uint = self.ui()
- j = rand_uint % (i+1)
- buf = _list[i]
- _list[i] = _list[j]
- _list[j] = buf
- return _list
- def shuffleDict(self, data):
- items = list(data.items())
- list_size = len(items)
- for i in range(list_size - 1, 0, -1):
- j = self.ui() % (i + 1)
- items[i], items[j] = items[j], items[i]
- return dict(items)
- def uniform_f(self, _min, _max):
- rand_f = self.f()
- return rand_f*(_max-_min) + _min
- def vec3(self):
- l = 0
- while l==0:
- vec = [self.f()-0.5, self.f()-0.5, self.f()-0.5]
- l = length_vec3(vec)
- return [vec[0]/l, vec[1]/l, vec[2]/l]
- class KaelColor:
- TYPE_STR = ("SRGB", "LINEAR", "OKLAB", "INVALID")
- INVALID_COL = [1.0,0.0,0.5]
- def __init__(self, color_space: str, triplet: list[float] = None, alpha: float = 1.0, fixed: bool = False):
- self.id = id(self)
- self.setType(color_space)
- self.col = list(triplet[:3]) if (triplet != None and len(triplet)>=3) else self.INVALID_COL
- self.alpha = float(alpha)
- self.fixed = fixed
- ### Color conversion
- def _linearToSrgb(self, linRGB):
- cutoff = lessThan_vec3(linRGB, [0.0031308]*3)
- gammaAdj = spow_vec3(linRGB, [1.0/2.4]*3 )
- higher = sub_vec3( mul_vec3( [1.055]*3 , gammaAdj ), [0.055]*3 )
- lower = mul_vec3( linRGB, [12.92]*3 )
- return lerp_vec3(higher, lower, cutoff)
- def _srgbToLinear(self,sRGB):
- cutoff = lessThan_vec3(sRGB, [0.04045]*3)
- higher = spow_vec3(div_vec3(add_vec3(sRGB, [0.055]*3), [1.055]*3), [2.4]*3)
- lower = div_vec3( sRGB, [12.92]*3 )
- return lerp_vec3(higher, lower, cutoff)
- def _linearToOklab(self,col: List[float]):
- r,g,b = col
- l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
- m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
- s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
- l_ = math_spow(l, 1.0/3.0)
- m_ = math_spow(m, 1.0/3.0)
- s_ = math_spow(s, 1.0/3.0)
- return [
- 0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_,
- 1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_,
- 0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_,
- ]
- def _oklabToLinear(self, col: List[float]):
- L,a,b = col
- l_ = L + 0.3963377774 * a + 0.2158037573 * b
- m_ = L - 0.1055613458 * a - 0.0638541728 * b
- s_ = L - 0.0894841775 * a - 1.2914855480 * b
- l = l_*l_*l_
- m = m_*m_*m_
- s = s_*s_*s_
- return [
- +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
- -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
- -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
- ]
- def _srgbToOklab(self, unused_col):
- linRGB = self._srgbToLinear(self.col)
- oklab = self._linearToOklab(linRGB)
- return oklab
- def _oklabToSrgb(self, unused_col):
- linRGB = self._oklabToLinear(self.col)
- sRGB = self._linearToSrgb(linRGB)
- return sRGB
- _AS_MAP = {
- "OKLAB": { #Convert from OKLAB to...
- "SRGB": _srgbToOklab,
- "LINEAR": _linearToOklab,
- "OKLAB": lambda c, col: col, #return itself unchanged
- },
- "LINEAR": {
- "SRGB": _srgbToLinear,
- "OKLAB": _oklabToLinear,
- "LINEAR": lambda c, col: col,
- },
- "SRGB": {
- "OKLAB": _oklabToSrgb,
- "LINEAR": _linearToSrgb,
- "SRGB": lambda c, col: col,
- },
- }
- #get function pointers from _AS_MAP depending on KaelColor type
- def _as(self, target: str):
- if not self._AS_MAP.get(target):
- return self.INVALID_COL
- func = self._AS_MAP.get(target).get(self.getTypeStr())
- if not func:
- return self.INVALID_COL
- return func(self, self.col)
- def _to(self, target: str):
- new_col = self._as(target)
- if new_col is not None:
- self.col = new_col
- self.setType(target)
- return self.col
- #### Public KaelColor funcitons
- #Returns an object copy instead of this instance
- def copy(self):
- new_col = KaelColor(self.getTypeStr(), self.col, self.alpha)
- return new_col
- def getTypeStr(self):
- return self.TYPE_STR[self.type]
- def setType(self, new_type_str: str):
- self.type = self.TYPE_STR.index(new_type_str)
- if self.type == None:
- print("Invalid KaelColor type")
- self.type = len(self.TYPE_STR)-1 #Default to last TYPE_STR ("INVALID")
- return self.type
- #Return as different color space without mutating object
- def asOklab(self):
- return self._as("OKLAB")
- def asLinear(self):
- return self._as("LINEAR")
- def asSrgb(self):
- return self._as("SRGB")
- #Mutates object .col value
- def toOklab(self):
- return self._to("OKLAB")
- def toLinear(self):
- return self._to("LINEAR")
- def toSrgb(self):
- return self._to("SRGB")
- ### Color tools ###
- def inOklabGamut(self, eps:float=1e-7):
- r_lin, g_lin, b_lin = self.asLinear()
- return (r_lin >= -eps) and (r_lin <= 1+eps) and \
- (g_lin >= -eps) and (g_lin <= 1+eps) and \
- (b_lin >= -eps) and (b_lin <= 1+eps)
- #Skips unnecessary conversion
- def clipToOklabGamut(self, calc_norm: bool=False, eps:float=1e-7):
- lin = self.asLinear()
- in_gamut =(
- (lin[0] >= -eps) and (lin[0] <= 1+eps) and
- (lin[1] >= -eps) and (lin[1] <= 1+eps) and
- (lin[2] >= -eps) and (lin[2] <= 1+eps)
- )
- if in_gamut:
- return [0.0,0.0,0.0]
- old_ok_pos = self.col
- self.setType("LINEAR")
- self.col = clip_vec3(lin,[0.0]*3,[1.0]*3)
- self.toOklab()
- return sub_vec3(self.col, old_ok_pos) #movement in ok space
- #Calc color values
- def calcChroma(self):
- ok = self.asOklab()
- return math.sqrt( ok[1]*ok[1] + ok[2]*ok[2] )
- def calcLum(self):
- return self.asOklab()[0]
- def isOkSrgbGray(self, threshold: float = 1.0/255.0):
- r,g,b = self.asSrgb()
- if( abs(r-g) < threshold and
- abs(g-b) < threshold
- ):
- return True
- return False
- def getSrgbHex(self):
- r,g,b = clip_vec3(self.asSrgb(),[0.0]*3,[1.0]*3)
- r = int(round(r * 255.0))
- g = int(round(g * 255.0))
- b = int(round(b * 255.0))
- return "#{:02x}{:02x}{:02x}".format(r, g, b)
- @dataclass
- class NeighborData:
- point: KaelColor
- pos_vec: [float]*3
- dist: float
- @dataclass
- class NeighborList:
- root: KaelColor
- array: List[NeighborData]
- class PointGrid:
- """
- Store point cloud as a search PointGrid.grid and a 1D PointGrid.cloud
- """
- INVALID_COLOR = [0.5,0.0,0.0]
- def __init__(self, point_radius: float, dimensions: [[float]*3,[float]*3] = None ):
- self.cloud: List[KaelColor] = []
- self.grid = {}
- self.length: int = 0
- self.cell_size: float = point_radius
- if dimensions == None:
- self.max_cells = [[-1.0/self.cell_size]*3,[1.0/self.cell_size]*3]
- else:
- self.max_cells = [div_vec3(dimensions[0], [self.cell_size]*3),div_vec3(dimensions[1], [self.cell_size]*3)]
- self.max_cells = [roundAwayFromZero_vec3(self.max_cells[0]),roundAwayFromZero_vec3(self.max_cells[1])]
- def key(self, p: KaelColor):
- g_f = div_vec3(p.col,[self.cell_size]*3)
- if valid_vec3(g_f):
- return ( int(g_f[0]),int(g_f[1]),int(g_f[2]) )
- return [None,None,None]
- def insert(self, p: KaelColor):
- self.length+=1
- k = self.key(p)
- if k not in self.grid:
- self.grid[k] = []
- self.grid[k].append(p)
- self.cloud.append(p)
- def remove(self, p: KaelColor):
- k = self.key(p)
- if k in self.grid and p in self.grid[k]:
- self.length -= 1
- self.grid[k].remove(p)
- if not self.grid[k]:
- del self.grid[k]
- if p in self.cloud:
- self.cloud.remove(p)
- def setCol(self, p: KaelColor, q_col: [float]*3):
- old_key = self.key(p)
- if old_key in self.grid and p in self.grid[old_key]:
- self.grid[old_key].remove(p)
- if not self.grid[old_key]:
- del self.grid[old_key]
- else:
- pass
- p.col = q_col
- new_key = self.key(p)
- if new_key not in self.grid:
- self.grid[new_key] = []
- self.grid[new_key].append(p)
- def cloudPosSnapshot(self):
- pts=[]
- for p in self.cloud:
- pts.append(p.col)
- return pts
- def findNearest(self, p: KaelColor, point_radius: float, neighbor_margin: float = 0):
- neighbor = None
- closest=float('inf')
- kx, ky, kz = self.key(p)
- for dx in (-1, 0, 1):
- for dy in (-1, 0, 1):
- for dz in (-1, 0, 1):
- nk = (kx + dx, ky + dy, kz + dz)
- chunk = self.grid.get(nk, ())
- for q in chunk:
- if q is p:
- continue
- pos_vec = sub_vec3(p.col, q.col)
- l = length_vec3(pos_vec)
- if l < closest:
- closest = l
- neighbor = NeighborData(point=q, pos_vec=pos_vec, dist=l)
- return neighbor
- def findNeighbors(self, p: KaelColor, radius: float, neighbor_margin: float = 0.0):
- neighborhood = NeighborList(root=p, array=[])
- gx, gy, gz = self.key(p)
- cell_span = int(math.ceil((radius + neighbor_margin) / self.cell_size))
- x_r = [max(gx-cell_span, self.max_cells[0][0]), min(gx+cell_span, self.max_cells[1][0])]
- y_r = [max(gy-cell_span, self.max_cells[0][1]), min(gy+cell_span, self.max_cells[1][1])]
- z_r = [max(gz-cell_span, self.max_cells[0][2]), min(gz+cell_span, self.max_cells[1][2])]
- seen = []
- seen.append(p)
- for dx in range(x_r[0], x_r[1]+1):
- for dy in range(y_r[0], y_r[1]+1):
- for dz in range(z_r[0], z_r[1]+1):
- nk = (dx, dy, dz)
- for q in self.grid.get(nk, ()):
- if q in seen:
- continue
- seen.append(q)
- pos_vec = sub_vec3(p.col, q.col)
- l = length_vec3(pos_vec)
- if l <= radius + neighbor_margin:
- neighbor = NeighborData(point=q, pos_vec=pos_vec, dist=l)
- neighborhood.array.append(neighbor)
- return neighborhood
- ### ParticleSim and its helper functions
- class ParticleSim:
- @dataclass
- class Particle:
- ref: KaelColor #simulated reference
- id: int #identifier
- fixed: bool #is immovable?
- v: [float]*3 #velocity
- A: float #Drag reference area
- Cd: float #Coefficient of drag
- rho: float #Drag (rho) fluid density
- m: float #Mass
- k: float #sprint constant
- COR: float #Coefficient of restitution
- mu: float #coefficient of friction
- radius: float #point radius
- def __init__(self,
- _rand: RorrLCG,
- _point_grid: PointGrid
- ):
- self.sim_dt = 0.1
- self.rand = _rand
- self.point_grid = _point_grid
- #Too far: attract, Too close: repel # f=-kx
- def calcSpringForce(self, p0: Particle, neighbor: NeighborList, neighbor_norm: [float]*3, spring_constant: float = 1.0):
- xd = neighbor.dist-p0.radius
- spring_mag = -1.0 * spring_constant * xd #f=-kx
- spring_force = mul_vec3(neighbor_norm,[spring_mag]*3)
- return spring_force
- def moveToVelocity(self, p0: Particle, move_delta: [float]*3, time_delta: float):
- return div_vec3(move_delta, [time_delta]*3) #v = dx/dt
- def velocityToForce(self, p0: Particle, velocity: [float]*3, time_delta: float):
- return mul_vec3([p0.m]*3, div_vec3(velocity, [time_delta]*3) ) #
- def forceToVelocity(self, p0: Particle, force: [float]*3, time_delta: float):
- return div_vec3(mul_vec3(force,[time_delta]*3), [p0.m]*3) #dv = f*dt/m
- #force from distance moved in time step. f=m*((dx/dt)/dt)
- def moveToForce(self, p0:Particle, move_delta: [float]*3, time_delta: float):
- velocity = self.moveToVelocity(p0, move_delta, time_delta) #v = dx/dt
- return self.velocityToForce(p0, velocity, time_delta)
- #returns the force needed to reflect p0 velocity # f = - 2 * (vec . n) * n
- def calcReflectForce(self, p0: Particle, surface_norm: [float]*3, time_delta: float):
- dot_v = dot_vec3(p0.v, surface_norm) #(vec . n)
- reflected_v = -1.0 * (p0.COR+1.0) * dot_v # - 2 * (vec . n)
- reflected_v = mul_vec3([reflected_v]*3, surface_norm) # - 2 * (vec . n) * n
- p0.v = add_vec3(p0.v, reflected_v)
- 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):
- max_v = 0.0
- velocity_list=[]
- for particle in particles.values():
- velocity_list.append(length_vec3(particle.v))
- max_v=max(velocity_list)
- if max_v == 0.0:
- new_dt = 0.5
- else:
- new_dt = scale * unit_size / max_v
- if prev_dt and new_dt>prev_dt: #higher smoothing = bias toward prev_dt if dt increases
- new_dt = new_dt*(1.0-smoothing) + prev_dt*smoothing
- new_dt = min(new_dt,max_dt)
- new_dt+=1e-12 if new_dt==0 else 0
- prev_dt = new_dt
- return new_dt, prev_dt
- def calcTotalEnergy(self, p_list: list[float]):
- total_energy=0.0
- for particle in self.particles.values():
- kE = 0.5 * particle.m * length_vec3(particle.v)**2
- total_energy = total_energy + kE
- return total_energy
- """
- Relations
- Fd=0.5*p*v^2*A*Cd : force drag
- Fs=-kx : sprint force
- F=ma : force
- a=dv/dt : acceleration
- w = v - 2 * (v . n) * n : Reflect
- """
- #Iterative force simulation that pushes points 1 radius apart from each center
- def relaxCloud(self,
- iterations = 64,
- target_dist = None,
- min_energy = 0.0,
- record_frames = "./cloudHistogram.py",
- anneal_steps = 32,
- log_frequency = 25,
- target_kissing = 6
- ):
- if iterations == 0 or len(self.point_grid.cloud) == 0:
- return
- if record_frames:
- grid_frames=[]
- grid_frames.append(self.point_grid.cloudPosSnapshot())
- if target_dist == None:
- target_dist = self.point_grid.cell_size
- self.particles = { p.id: None for p in self.point_grid.cloud }
- for p in self.point_grid.cloud:
- #softball in mud
- point = self.Particle(
- ref=p,
- id=p.id,
- fixed=p.fixed,
- v=[0.0]*3,
- A=math.pi * (0.1)**2, #m^2
- Cd=0.47, #sphere
- rho=3.0, #kg/l
- m=0.4, #kilograms
- k=0.6,
- COR=0.6,
- mu=0.2,
- radius=target_dist
- )
- self.particles[p.id] = point
- old_dt = 0.5
- smoothing_dt = 0.5 #Higher = old_dt dominates
- for tick in range(iterations):
- self.particles = self.rand.shuffleDict(self.particles)
- 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)
- #Calculate velocity synchronously
- for particle in self.particles.values():
- if particle.fixed:
- continue
- delta_dist = mul_vec3(particle.v, [self.sim_dt]*3) #dx = v * dt
- new_col = add_vec3(particle.ref.col, delta_dist)
- if valid_vec3(new_col):
- self.point_grid.setCol(particle.ref,new_col)
- else:
- particle.v=[0.0]*3
- kissing_list = []
- #Add forces
- for particle in self.particles.values():
- if particle.fixed:
- continue
- all_forces=[]
- #gamut reflect
- clip_move = particle.ref.clipToOklabGamut() # dx
- if clip_move !=[0.0]*3 and valid_vec3(clip_move):
- surface_norm = norm_vec3(clip_move)
- self.calcReflectForce(particle, surface_norm, self.sim_dt)
- #neighbor forces
- neighbor_force = [0.0,0.0,0.0]
- neighborhood = self.point_grid.findNeighbors(particle.ref, particle.radius, 0.0)
- for neighbor in neighborhood.array:
- if neighbor.dist > particle.radius:
- continue
- neighbor_particle = self.particles[neighbor.point.id]
- if neighbor.dist == 0.0: #push out particles inside each other
- rand_norm = mul_vec3(self.rand.vec3(),[particle.radius]*3)
- push_move = mul_vec3([ particle.radius*self.sim_dt ]*3,rand_norm)
- push_force = self.moveToForce(particle, push_move, self.sim_dt)
- neighbor_force = add_vec3(neighbor_force, push_force)
- continue
- #spring influence
- neighbor_norm = norm_vec3(neighbor.pos_vec) #from particle to neighbor normal
- spring_force = self.calcSpringForce(particle, neighbor, neighbor_norm, particle.k) #no attract forces
- neighbor_force = add_vec3(neighbor_force, spring_force)
- if not neighbor_particle.fixed:
- continue
- #bounce if inside fixed
- surface_norm = norm_vec3(neighbor.pos_vec)
- self.calcReflectForce(particle, surface_norm, self.sim_dt)
- continue
- all_forces.append(neighbor_force)
- #drag, opposing
- #Fd=0.5*p*A*Cd*v^2
- drag_force = mul_vec3( [-1.0 * 0.5 * particle.rho * particle.A * particle.Cd]*3, mul_vec3(particle.v,particle.v) )
- all_forces.append(drag_force)
- #internal friction, opposing
- friction_force = mul_vec3( particle.v, [-1.0*particle.mu]*3 ) #f=cf
- all_forces.append(friction_force)
- #sum Fdt
- force_delta=[0.0]*3
- for force in all_forces:
- if valid_vec3(force):
- force_delta = add_vec3(force_delta,force)
- delta_velocity = self.forceToVelocity(particle, force_delta, self.sim_dt)
- particle.v = add_vec3(particle.v, delta_velocity)
- #get number of touching neighbors, only for particles that aren't at boundary
- if anneal_steps!=0 and len(neighborhood.array) and clip_move==[0.0]*3:
- kissing_count=0
- for neighbor in neighborhood.array:
- overlap = neighbor.dist-particle.radius
- if overlap < particle.radius*0.1:
- kissing_count+=1
- kissing_list.append(kissing_count)
- #update particle.radius adaptively
- if anneal_steps!=0 and tick >= anneal_steps and tick%anneal_steps==0 and len(kissing_list)!=0:
- median_kissing = math_median(kissing_list)
- is_stable = max(kissing_list) < len(self.particles)//2
- if median_kissing != 0 and is_stable :
- scale_factor = (target_kissing / median_kissing) ** (1/3)
- for particle in self.particles.values():
- particle.radius *= max(0.5, min(1.5, scale_factor))
- #logging
- if tick%log_frequency==0 or tick==iterations-1:
- total_energy=self.calcTotalEnergy(self.particles)
- out_str = "Total force["+str(tick)+"]:"
- out_str+= " "+str(total_energy)
- print(out_str)
- if abs(total_energy) < min_energy and tick>anneal_steps:
- break
- if record_frames:
- grid_frames.append(self.point_grid.cloudPosSnapshot())
- if record_frames:
- with open(record_frames, "w") as f:
- f.write("oklab_frame_list = ")
- f.write(str(grid_frames))
- p_radius_list=[]
- for p in self.particles.values():
- p_radius_list.append(p.radius)
- print("Median particle radius: "+str( round(math_median(p_radius_list),4) ))
- print("relaxCloud Done\n")
- return self.point_grid
- ### Palette generator ###
- @dataclass
- class PalettePreset:
- sample_method: int = 2 #Generate initial cloud by 0 = randomSampler, 1 = randWalkSampler, 2 = rwalk -> random
- reserve_transparent: bool = True
- hex_pre_colors: List[[str,bool]] = None # ["#0123abc",...]
- img_pre_colors: str = None #file name to existing color palette
- img_fixed_mask: str = None #file name to fixed mask white=fixed black=movable color
- max_colors: int = 64 #Max allowed colors including transparency
- gray_count: int = None #Grayscale color count, None = Auto
- hue_count: int = 12 #Split Hues in this many buckets
- min_sat: float = 0.0 #min/max ranges are percentages
- max_sat: float = 1.0
- min_lum: float = 0.0
- max_lum: float = 1.2
- packing_fac: float = 1.0 #Packing efficiency
- max_attempts: int = 1024 #After this many max_attempts per point, point Sampler will give up
- relax_count: int = 64 #number of relax iteration after point sampling
- seed: int = 0 # 0=random run to run
- class PaletteGenerator:
- """
- Generate palette where the colors are perceptually evenly spaced out in OKLab colorspace
- """
- OKLAB_GAMUT_VOLUME = 0.054197416 # print (str(oklabGamutVolume(500))) pre computed
- def __init__(self, preset: [PalettePreset] = None, *,
- sample_method: [int] = None,
- reserve_transparent: [bool] = None,
- hex_pre_colors:[List[[str,bool]]]=None,
- img_pre_colors: [str] = None,
- img_fixed_mask: [str] = None,
- max_colors: [int] = None,
- hue_count: [int] = None,
- gray_count: [int] = None,
- min_sat: [float] = None,
- max_sat: [float] = None,
- min_lum: [float] = None,
- max_lum: [float] = None,
- packing_fac: [float] = None,
- max_attempts: [int] = None,
- relax_count: [int] = None,
- seed: [int] = None,
- ):
- self.point_grid = None
- self.point_radius = None
- if preset is None:
- preset = PalettePreset()
- self.p = PalettePreset(**{k: getattr(preset, k) for k in preset.__dataclass_fields__})
- if self.p.packing_fac <= 0:
- self.p.packing_fac = 1e-12
- if self.p.max_colors:
- self.p.max_colors -= self.p.reserve_transparent #max_count includes transparency
- else:
- self.p.max_colors = 64
- self.rand = RorrLCG(self.p.seed) if self.p.seed != None else RorrLCG()
- def getRandOklab(self):
- rand_p = KaelColor("OKLAB")
- while not rand_p.inOklabGamut():
- rand_p.col = [self.rand.f(), self.rand.f()-0.5, self.rand.f()-0.5]
- return rand_p
- def applyColorLimits(self):
- apply_luminosity = self.p.max_lum!=1.0 or self.p.min_lum!=0.0
- apply_saturation = self.p.max_sat!=1.0 or self.p.min_sat!=0.0
- count=0
- max_chroma = math.sqrt(0.5**2+0.5**2)
- for p in self.point_grid.cloud:
- if apply_luminosity:
- lum_width = self.p.max_lum - self.p.min_lum
- p.col[0] = p.col[0]*lum_width + self.p.min_lum
- if apply_saturation and not p.isOkSrgbGray():
- sat_width = self.p.max_sat - self.p.min_sat
- chroma = p.calcChroma()
- rel_sat = chroma / max_chroma
- scaled_sat = (rel_sat * sat_width + self.p.min_sat) * max_chroma
- col_vec = [p.col[1], p.col[2]] #2D Vector a,b
- col_vec = [col_vec[0]/chroma, col_vec[1]/chroma] #Normalize
- col_vec = [col_vec[0]*scaled_sat, col_vec[1]*scaled_sat] #Scale
- p.col = [p.col[0], col_vec[0], col_vec[1]]
- def addPreColors(self, alpha_threshold:int=int(0), fixed_mask_threshold:int=int(128)):
- #Add uint8 colors from image
- if self.p.img_pre_colors != None:
- pre_palette = Image.open(self.p.img_pre_colors)
- pre_palette = pre_palette.convert('RGBA')
- rgba_list = list(pre_palette.getdata())
- fixed_list = [0]*len(rgba_list)
- if self.p.img_fixed_mask !=None:
- fixed_mask = Image.open(self.p.img_fixed_mask)
- fixed_mask = fixed_mask.convert('L')
- fixed_list = list(fixed_mask.getdata())
- index=0
- for col in rgba_list:
- if col[3] <= alpha_threshold:
- continue
- r,g,b,a = col
- r = float(r)/255.0
- g = float(g)/255.0
- b = float(b)/255.0
- a = float(a)/255.0
- is_fixed = fixed_list[index] > fixed_mask_threshold
- new_col = KaelColor( "SRGB", [r,g,b], alpha=a, fixed=is_fixed )
- new_col.toOklab()
- self.point_grid.insert(new_col)
- index+=1
- #Add hex colors, format ["#1234abcd",...] or [["abc123",is_fixed]...]
- if self.p.hex_pre_colors != None and len(self.p.hex_pre_colors):
- for hexlet_info in self.p.hex_pre_colors:
- hexlet,fixed = [hexlet_info[0], False]
- if len(hexlet)>1:
- hexlet, fixed = hexlet_info
- srgba=hexToSrgba(hexlet)
- if srgba[3] < alpha_threshold*255.0:
- continue
- oklab = srgba[:3]
- new_col = KaelColor( "SRGB", oklab, alpha=srgba[3], fixed=fixed )
- new_col.toOklab()
- self.point_grid.insert(new_col)
- ### Oklab point sampler methods within gamut ###
- def zeroSampler(self):
- attempts=0
- while self.point_grid.length < self.p.max_colors:
- new_col = KaelColor("OKLAB",[0.5,0.0,0.0])
- self.point_grid.insert(new_col)
- #Simple rejection sampling
- def randomSampler(self, min_dist):
- attempts=0
- while self.point_grid.length < self.p.max_colors:
- attempts+=1
- if attempts > self.p.max_attempts:
- print("Reached max_attempts to find a new point.")
- break
- new_col = self.getRandOklab()
- neighbor = self.point_grid.findNearest(new_col, self.point_radius)
- if neighbor and neighbor.dist<min_dist:
- continue
- self.point_grid.insert(new_col)
- print("randomSampler Loop count "+str(attempts))
- #Generate grayscale gradient between black and white
- def generateGrays(self):
- if self.p.gray_count == None:
- self.p.gray_count = int(round(1.0/(self.point_radius)))
- if self.p.gray_count:
- darkest_black = KaelColor( "SRGB", [0.499/255,0.499/255,0.499/255] ).calcLum()
- self.p.gray_count = min(self.p.max_colors - len(self.point_grid.cloud), self.p.gray_count)
- #Use minimum starting luminosity that second darkest black isn't so close to 0
- for i in range(0,self.p.gray_count):
- denom = max(1, self.p.gray_count-1)
- lum = float(i)/((denom))
- scale = (denom-i)/denom
- lum+= darkest_black*scale #Fade that brightest remains 1.0
- new_point = [lum,0,0]
- new_col = KaelColor( "OKLAB", new_point, 1.0, True )
- self.point_grid.insert(new_col)
- #Generate new point exactly 1 radius from another point. Essentially a random walk
- def randWalkSampler(self):
- def rand_cloud_point(random_chance: float=0.5):
- if self.rand.f()>random_chance and len(self.point_grid.cloud):
- return self.point_grid.cloud[ int(self.rand.f()*len(self.point_grid.cloud)) ].copy()
- return self.getRandOklab()
- space_size = math.sqrt(1.0**2 + 1.0**2 + 1.0**2)
- margin = space_size/10000.0
- push_scalar = [self.point_radius]*3
- linRGB_radius = push_scalar[0]
- push_fails = 0
- max_push_fails = 10
- new_point = rand_cloud_point(0.0)
- skip_rand = False
- attempt_counter = 0
- while len(self.point_grid.cloud) < self.p.max_colors:
- attempt_counter+=1
- if attempt_counter > self.p.max_attempts:
- print("Reached max_attempts to find a new point.")
- break
- if not skip_rand:
- #Move to random direction
- rand_vec = self.rand.vec3()
- move_vec = mul_vec3(rand_vec, push_scalar )
- new_point.col = add_vec3(new_point.col, move_vec)
- skip_rand = False
- new_point.clipToOklabGamut()
- neighbor = self.point_grid.findNearest(new_point, self.point_radius, margin)
- if neighbor:
- delta = neighbor.dist-self.point_radius
- if abs(delta) > margin:
- #Too close or far: Set 1 radius apart from nearest point
- push_fails+=1
- if push_fails > max_push_fails:
- push_fails = 0
- new_point = rand_cloud_point(0.5)
- continue
- normal = norm_vec3(neighbor.pos_vec)
- new_point.col = add_vec3(neighbor.point.col, mul_vec3(normal, mul_vec3(push_scalar,[1.0]*3) ))
- skip_rand = True
- continue
- self.point_grid.insert(new_point)
- new_point = new_point.copy()
- push_fails = 0
- print("randWalkSampler Loop count "+str(attempt_counter)+"\n")
- def populatePointCloud(self):
- unit_volume = self.OKLAB_GAMUT_VOLUME/max(1,self.p.max_colors)
- self.point_radius = unit_volume**(1.0/3.0) * self.p.packing_fac
- print("Using point_radius "+str(round(self.point_radius,4)))
- oklabRange = [[0.0,-0.5,-0.5],[1.0,0.5,0.5]]
- self.point_grid = PointGrid(self.point_radius, oklabRange )
- self.addPreColors()
- self.generateGrays()
- if self.p.sample_method in [1,2]:
- self.randWalkSampler()
- if self.p.sample_method in [0,2]:
- self.randomSampler(self.point_radius*0.51)
- if self.p.sample_method in [3]:
- self.zeroSampler()
- oksim = ParticleSim(self.rand, self.point_grid)
- self.point_grid = oksim.relaxCloud(
- iterations = self.p.relax_count,
- target_dist = self.point_radius,
- )
- self.applyColorLimits()
- return self.point_grid.cloud
- ### Palette processing ###
- def paletteToHex(self):
- hex_list = []
- if self.p.reserve_transparent:
- hex_list.append("#00000000")
- for p in self.point_grid.cloud:
- hex_list.append(p.getSrgbHex())
- return hex_list
- def paletteToImg(self, filename: str = "palette.png"):
- rgba = []
- if self.p.reserve_transparent:
- rgba.append((0, 0, 0, 0))
- for p in self.point_grid.cloud:
- r,g,b = p.asSrgb()
- r = min( max( int(round(r * 255.0)), 0 ), 255)
- g = min( max( int(round(g * 255.0)), 0 ), 255)
- b = min( max( int(round(b * 255.0)), 0 ), 255)
- rgba.append((r, g, b, 255))
- if len(rgba) == 0:
- return None
- arr = np.array([rgba], dtype=np.uint8)
- img = Image.fromarray(arr, mode="RGBA")
- img.save(filename)
- return img
- def separateGrays(self, KaelColor_array = list [KaelColor]):
- gray_colors = []
- hued_colors = []
- for p in KaelColor_array:
- if p.isOkSrgbGray():
- gray_colors.append(p)
- else:
- hued_colors.append(p)
- return gray_colors, hued_colors
- def sortByLum(self, KaelColor_array: list[KaelColor]):
- return sorted(KaelColor_array, key=lambda x: x.col[0])
- def sortPalette(self):
- gray_colors, hued_colors = self.separateGrays(self.point_grid.cloud)
- #Place hues in same buckets
- hue_buckets = [[] for _ in range(self.p.hue_count)]
- hue_bucket_width = 2*math.pi * (1.0/self.p.hue_count)
- for p in hued_colors:
- col_hue = math.atan2(p.col[2], p.col[1]) + 2* math.pi
- bucket_index = int(col_hue/hue_bucket_width) % self.p.hue_count
- hue_buckets[bucket_index].append(p)
- #Sort hue buckets by luminance
- sorted_hue_buckets = []
- for bucket in hue_buckets:
- sorted_bucket = self.sortByLum(bucket)
- sorted_hue_buckets.append(sorted_bucket)
- #combine colors into single array
- sorted_colors = []
- sorted_grays = self.sortByLum(gray_colors)
- for p in sorted_grays:
- sorted_colors.append(p)
- for bucket in sorted_hue_buckets:
- for p in bucket:
- sorted_colors.append(p)
- self.point_grid.cloud = sorted_colors
- #EOF PaletteGenerator
- ### palette analysis ###
- class PointGridStats:
- #p0 has type (float3)[L,a,b]
- #pair_list has type (float,[],[])[dist, p0, p1]
- @staticmethod
- def _printPairStats(pair_list, print_count, listName="", calc_error=False):
- if len(pair_list) < 2:
- print("Not enough pairs for stats!")
- return
- precision = 4
- print(listName+" Closest pairs")
- for pair in pair_list[:print_count]:
- print(str(round(pair[0],precision))+" "+pair[1].getSrgbHex()+" "+pair[2].getSrgbHex())
- #print only unseen values
- far_start = max(print_count, len(pair_list)-print_count)
- if far_start < len(pair_list):
- print(listName+" Farthest pairs")
- for pair in pair_list[far_start:]:
- print(str(round(pair[0],precision))+" "+pair[1].getSrgbHex()+" "+pair[2].getSrgbHex())
- #Average
- sumDist = 0.0
- for pair in pair_list:
- sumDist+=pair[0]
- avgDist = max(sumDist / len(pair_list),1e-12)
- #Median
- medianDist=0.0
- medianIndex=len(pair_list)//2
- if len(pair_list)%2==0:
- a = medianIndex
- b = max(medianIndex-1,0)
- medianDist = (pair_list[a][0] + pair_list[b][0]) / 2.0
- else:
- medianDist = pair_list[medianIndex][0]
- print(listName+" Avg pair distance: "+str(round(avgDist,precision)))
- print(listName+" Median pair distance: "+str(round(medianDist,precision)))
- print("")
- #Compare biggest gap to avg gap
- if calc_error == True:
- hued_pairs = [
- p for p in pair_list
- if not p[1].isOkSrgbGray() and not p[2].isOkSrgbGray()
- ]
- hue_pair_count = len(hued_pairs)
- #Average hued only
- sumDist = 0.0
- for pair in hued_pairs:
- sumDist+=pair[0]
- avgHueDist = sumDist / hue_pair_count if hue_pair_count != 0 else 0.0001
- #All colors gaps
- all_smallest_gap = pair_list[0][0]
- all_largest_gap = pair_list[-1][0]
- #Hued colors gaps
- hued_smallest_gap = hued_pairs[0][0] if hued_pairs else None
- hued_largest_gap = hued_pairs[-1][0] if hued_pairs else None
- allError = abs(1.0 - all_largest_gap/avgDist)
- print("Biggest_gap to avg_gap delta "+str(round(100*allError,precision))+" %")
- allError = abs(1.0 - all_smallest_gap/avgDist)
- print("Smallest_gap to avg_gap delta "+str(round(100*allError,precision))+" %")
- if hued_largest_gap:
- huedError = abs(1.0 - hued_largest_gap/avgHueDist)
- print("Hued biggest_gap to avg_gap delta "+str(round(100*huedError,precision))+" %")
- if hued_largest_gap:
- huedError = abs(1.0 - hued_smallest_gap/avgHueDist)
- print("Hued smallest_gap to avg_gap delta "+str(round(100*huedError,precision))+" %")
- return
- # Input [[L,a,b], ...]
- # Find closest point for every point
- # Returns list of point pairs [[dist, p0, p1], ...] sorted by distance
- @staticmethod
- def _findClosestPairs(point_grid):
- if len(point_grid.cloud) < 2:
- return []
- dist_pair_array = []
- for p in point_grid.cloud:
- neighbor = point_grid.findNearest(p, point_grid.cell_size)
- if neighbor is None:
- continue
- if p == neighbor.point:
- continue
- dist_pair_array.append([neighbor.dist, p, neighbor.point])
- dist_pair_array.sort(key=lambda x: x[0])
- return dist_pair_array
- @staticmethod
- def printGapStats(point_grid, print_count):
- full_pair_arr = PointGridStats._findClosestPairs(point_grid)
- PointGridStats._printPairStats(full_pair_arr,print_count,"Full",1)
- print("")
- palette_preset_list = {
- 'palTest': PalettePreset(
- sample_method=2,
- reserve_transparent=1,
- hex_pre_colors = None, #[["abcdef",True],["abc123ff",False]],
- img_pre_colors = None, #"./pre_palette.png",
- img_fixed_mask = None, #"./pre_palette_mask.png",
- gray_count =None,
- max_colors =64,
- hue_count =12,
- min_sat =0.0,
- max_sat =1.0,
- min_lum =0.0,
- max_lum =1.0,
- packing_fac =1.3,
- max_attempts=1024*16,
- relax_count =256,
- seed=0
- ),
- }
- def run_generatePalette():
- palette_name = 'palTest'
- active_preset = palette_preset_list[palette_name]
- palette = PaletteGenerator(preset=active_preset)
- palette.populatePointCloud()
- palette.sortPalette()
- palette_file = 'palette.png'
- palette.paletteToImg(palette_file)
- PointGridStats.printGapStats(palette.point_grid,4)
- hex_string = palette.paletteToHex()
- printHexList(hex_string,palette_name)
- print("Generated "+str(len(palette.point_grid.cloud) + active_preset.reserve_transparent)+" colors to ./"+palette_file)
- if __name__ == '__main__':
- run_generatePalette()
Advertisement
Add Comment
Please, Sign In to add comment