117 lines
6.1 KiB
Python
117 lines
6.1 KiB
Python
"""Extract business-management PNG colors into matching vector card templates.
|
|
|
|
Reads the reference only. No bitmap is embedded in the generated SVGs.
|
|
Usage: python create-business-card-assets.py <reference PNG path>
|
|
"""
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
root = Path(__file__).resolve().parents[2]
|
|
reference = Path(sys.argv[1])
|
|
image = Image.open(reference).convert('RGBA')
|
|
pixels = np.asarray(image, dtype=float)
|
|
width, height = image.size
|
|
scale = np.array([width / 146, height / 68])
|
|
start = np.array([124.527, 13.2139]) * scale
|
|
end = np.array([91.4091, 70.3397]) * scale
|
|
vector = end - start
|
|
|
|
# Probe only the flat outer bands, away from rounded corners and inner face.
|
|
probes = ([(x, 2) for x in range(20, width - 20)]
|
|
+ [(3, y) for y in range(20, height - 15)]
|
|
+ [(width - 4, y) for y in range(20, height - 15)]
|
|
+ [(x, height - 3) for x in range(20, width - 20)])
|
|
coordinates = np.array(probes, dtype=float) + 0.5
|
|
times = np.clip((coordinates - start) @ vector / (vector @ vector), 0, 1)
|
|
values = np.array([pixels[y, x] for x, y in probes])
|
|
basis = np.zeros((len(times), 3))
|
|
first = times <= 0.72
|
|
basis[first, 0] = 1 - times[first] / 0.72
|
|
basis[first, 1] = times[first] / 0.72
|
|
basis[~first, 1] = (1 - times[~first]) / 0.28
|
|
basis[~first, 2] = (times[~first] - 0.72) / 0.28
|
|
|
|
# Test both interpolation representations rather than assuming raster alpha.
|
|
fits = []
|
|
for premultiplied in (False, True):
|
|
samples = values.copy()
|
|
if premultiplied:
|
|
samples[:, :3] *= samples[:, 3:] / 255
|
|
stops = np.linalg.lstsq(basis, samples, rcond=None)[0]
|
|
predicted = basis @ stops
|
|
if premultiplied:
|
|
predicted[:, :3] /= np.maximum(predicted[:, 3:], 1) / 255
|
|
stops[:, :3] /= np.maximum(stops[:, 3:], 1) / 255
|
|
error = float(np.abs(predicted - values).mean())
|
|
fits.append((error, premultiplied, np.clip(stops, 0, 255)))
|
|
error, premultiplied, stops = min(fits, key=lambda item: item[0])
|
|
|
|
# Use plateau medians for the clamped endpoints, reducing raster edge noise.
|
|
stops[0] = np.median(values[times == 0], axis=0)
|
|
stops[2] = np.median(values[times == 1], axis=0)
|
|
colors = ['#' + ''.join(f'{int(round(channel)):02X}' for channel in row[:3]) for row in stops]
|
|
opacities = [round(float(row[3] / 255), 2) for row in stops]
|
|
inner_top = pixels[9:35, 20:150].reshape(-1, 4)
|
|
inner_bottom = pixels[height - 13:height - 6, 20:150].reshape(-1, 4)
|
|
inner_colors = ['#' + ''.join(f'{int(round(channel)):02X}' for channel in row[:3])
|
|
for row in [np.median(inner_top, axis=0), np.median(inner_bottom, axis=0)]]
|
|
|
|
# Recover the inner face's gradient direction from its unclamped RGB plane.
|
|
# Colors alone are insufficient: retaining the template's gradient direction
|
|
# would leave a visible tone difference despite matching all endpoint colors.
|
|
inner_start_rgb = np.array([int(inner_colors[0][i:i + 2], 16) for i in (1, 3, 5)])
|
|
inner_end_rgb = np.array([int(inner_colors[1][i:i + 2], 16) for i in (1, 3, 5)])
|
|
delta = inner_start_rgb - inner_end_rgb
|
|
inner_points, inner_times = [], []
|
|
for y in range(12, height - 12, 2):
|
|
for x in range(20, min(220, width // 2 + 16), 2):
|
|
rgb = pixels[y, x, :3]
|
|
t = float((inner_start_rgb - rgb) @ delta / (delta @ delta))
|
|
if 0.06 < t < 0.94 and pixels[y, x, 3] == 255:
|
|
inner_points.append([(x + 0.5) / scale[0], (y + 0.5) / scale[1], 1])
|
|
inner_times.append(t)
|
|
plane = np.linalg.lstsq(np.array(inner_points), np.array(inner_times), rcond=None)[0]
|
|
gradient_vector = plane[:2] / (plane[:2] @ plane[:2])
|
|
inner_start = np.array([78.0741, (-plane[2] - plane[0] * 78.0741) / plane[1]])
|
|
inner_end = inner_start + gradient_vector
|
|
|
|
assets = root / 'packages/org-chart/src/assets/cards'
|
|
for suffix in ('', '-sm'):
|
|
template = (assets / f'경영기획팀{suffix}.svg').read_text(encoding='utf-8')
|
|
# Retain the supplied department geometry and its size-specific gradients.
|
|
for original, replacement in zip(['#DEE6F0', '#A8BBD1', '#577BA5'], colors):
|
|
template = template.replace(f'stop-color="{original}"', f'stop-color="{replacement}"')
|
|
template = template.replace(f'stop-color="{colors[2]}"', f'stop-color="{colors[2]}" stop-opacity="{opacities[2]}"')
|
|
template = template.replace('stop-color="white"', f'stop-color="{inner_colors[0]}"')
|
|
template = template.replace('stop-color="#E0E6F4"', f'stop-color="{inner_colors[1]}"')
|
|
template = template.replace('paint0_linear_', 'business_management_outer_').replace('paint1_linear_', 'business_management_inner_')
|
|
size_scale = np.array([1, 1]) if not suffix else np.array([108 / 146, 52 / 68])
|
|
p1, p2 = inner_start * size_scale, inner_end * size_scale
|
|
template = re.sub(r'(<linearGradient id="business_management_inner_[^"]+")[^>]+>',
|
|
lambda match: f'{match[1]} x1="{p1[0]:.4f}" y1="{p1[1]:.4f}" x2="{p2[0]:.4f}" y2="{p2[1]:.4f}" gradientUnits="userSpaceOnUse">', template)
|
|
template = template.replace('offset="0.97"', 'offset="1"')
|
|
(assets / f'사업관리팀{suffix}.svg').write_text(template, encoding='utf-8')
|
|
|
|
output = root / 'test-results/org-chart-card-design'
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(reference, output / 'business-management-reference.png')
|
|
evidence = {
|
|
'reference': str(reference), 'sha256': hashlib.sha256(reference.read_bytes()).hexdigest(),
|
|
'referenceSize': [width, height], 'outerStops': colors, 'outerOpacities': opacities,
|
|
'innerStops': inner_colors, 'outerStopOffsets': [0, 0.72, 1],
|
|
'innerGradientStart': inner_start.tolist(), 'innerGradientEnd': inner_end.tolist(),
|
|
'innerSampleCount': len(inner_points),
|
|
'sampleCount': len(probes), 'fitMeanAbsoluteRGBAError': error,
|
|
'premultipliedFit': premultiplied,
|
|
'method': 'Outer-band RGBA samples fitted to the supplied department gradient geometry; endpoint plateau medians and inner-face medians. Inner gradient direction recovered from unclamped RGB plane. Reference PNG is preserved; geometry reuses department SVG paths.',
|
|
}
|
|
(output / 'business-management-colors.json').write_text(json.dumps(evidence, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
print(json.dumps(evidence, ensure_ascii=False, indent=2))
|