Files
HMDesign/examples/org-chart/create-business-card-assets.py
T

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))