Skip to content

Commit a6a8808

Browse files
committed
Blend scientific hero into background
1 parent bf50295 commit a6a8808

11 files changed

Lines changed: 261 additions & 64 deletions
1.49 MB
Loading
-1.46 MB
Binary file not shown.

‎public/hero/README.md‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@
33
The landing-page artwork combines a protein ribbon, a genomic heatmap, and a
44
coverage/gene track in one transparent 4:3 composition.
55

6-
- Source master: `assets-src/hero/compbio-research-atlas-light-master.png`
6+
- Source: `assets-src/hero/compbio-research-atlas-chroma-source.png`
77
- Generated web assets: `compbio-research-atlas-{light,dark}-{800,1600}.webp`
88
- Regeneration command: `npm run assets:hero`
99

10-
The light master was created with OpenAI image generation from a
11-
user-supplied scientific-layout reference on 2026-07-29, then isolated onto a
12-
true alpha channel. The dark assets are deterministic palette conversions of
13-
that same master, so theme changes do not shift the composition. California
14-
gold is preserved while blue and steel tones become pearl and ice blue.
10+
The source was created with OpenAI image generation from a user-supplied
11+
scientific-layout reference on 2026-07-29. The build script estimates the
12+
chroma field, reconstructs antialiased edge colors, removes residual magenta
13+
spill, and produces one shared alpha geometry. The clean pearl/ice/gold source
14+
becomes the dark asset directly; the light asset is a smooth deterministic
15+
navy/steel/gold palette conversion. Theme changes therefore never shift the
16+
composition or introduce binary color seams around the gold details.
1517

16-
Edit the source master or the conversion script, not the generated WebP files.
18+
The website renders the result as a masked background field so it can bleed
19+
through the right side of the hero without becoming a bounded illustration.
20+
Edit the chroma source or conversion script, not the generated WebP files.
90.8 KB
Loading
13.6 KB
Loading
-32.7 KB
Loading
-13.4 KB
Loading

‎scripts/build-hero-art.mjs‎

Lines changed: 167 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,38 +6,164 @@ import sharp from "sharp";
66
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
77
const sourcePath = path.join(
88
projectRoot,
9-
"assets-src/hero/compbio-research-atlas-light-master.png",
9+
"assets-src/hero/compbio-research-atlas-chroma-source.png",
1010
);
1111
const outputDirectory = path.join(projectRoot, "public/hero");
1212
const outputWidths = [800, 1600];
1313

14-
const isGoldPixel = (red, green, blue) =>
15-
red > 125
16-
&& red > blue * 1.18
17-
&& green > blue * 1.08
18-
&& red > green * 1.015;
14+
const clamp = (value, minimum = 0, maximum = 1) =>
15+
Math.max(minimum, Math.min(maximum, value));
1916

20-
const clearSafeMargin = async (input) => {
17+
const smoothstep = (start, end, value) => {
18+
const amount = clamp((value - start) / (end - start));
19+
return amount * amount * (3 - 2 * amount);
20+
};
21+
22+
const getBackgroundModel = (data, info) => {
23+
const rows = Array.from({ length: info.height }, () => [0, 0, 0, 0]);
24+
const columns = Array.from({ length: info.width }, () => [0, 0, 0, 0]);
25+
const global = [0, 0, 0, 0];
26+
27+
for (let y = 0; y < info.height; y += 1) {
28+
for (let x = 0; x < info.width; x += 1) {
29+
const index = (y * info.width + x) * info.channels;
30+
const red = data[index];
31+
const green = data[index + 1];
32+
const blue = data[index + 2];
33+
const screenStrength = (red + blue) / 2 - green;
34+
35+
if (screenStrength <= 120 || Math.abs(red - blue) >= 90) continue;
36+
37+
for (let channel = 0; channel < 3; channel += 1) {
38+
rows[y][channel] += data[index + channel];
39+
columns[x][channel] += data[index + channel];
40+
global[channel] += data[index + channel];
41+
}
42+
43+
rows[y][3] += 1;
44+
columns[x][3] += 1;
45+
global[3] += 1;
46+
}
47+
}
48+
49+
const globalMean = global
50+
.slice(0, 3)
51+
.map((value) => value / global[3]);
52+
53+
const fillMeans = (values) =>
54+
values.map((entry) =>
55+
entry[3]
56+
? entry.slice(0, 3).map((value) => value / entry[3])
57+
: globalMean);
58+
59+
const smooth = (values, radius) =>
60+
values.map((_, index) => {
61+
const result = [0, 0, 0];
62+
let samples = 0;
63+
64+
for (
65+
let sample = Math.max(0, index - radius);
66+
sample <= Math.min(values.length - 1, index + radius);
67+
sample += 1
68+
) {
69+
for (let channel = 0; channel < 3; channel += 1) {
70+
result[channel] += values[sample][channel];
71+
}
72+
samples += 1;
73+
}
74+
75+
return result.map((value) => value / samples);
76+
});
77+
78+
return {
79+
columns: smooth(fillMeans(columns), 14),
80+
global: globalMean,
81+
rows: smooth(fillMeans(rows), 10),
82+
};
83+
};
84+
85+
const keyChromaSource = async (input) => {
2186
const { data, info } = await sharp(input)
22-
.ensureAlpha()
2387
.raw()
2488
.toBuffer({ resolveWithObject: true });
89+
const background = getBackgroundModel(data, info);
90+
const output = Buffer.alloc(info.width * info.height * 4);
91+
const safeMargin = Math.round(Math.min(info.width, info.height) * 0.055);
2592

2693
for (let y = 0; y < info.height; y += 1) {
2794
for (let x = 0; x < info.width; x += 1) {
28-
if (x >= 100 && x < info.width - 100 && y >= 100 && y < info.height - 120) {
95+
const sourceIndex = (y * info.width + x) * info.channels;
96+
const outputIndex = (y * info.width + x) * 4;
97+
98+
if (
99+
x < safeMargin
100+
|| x >= info.width - safeMargin
101+
|| y < safeMargin
102+
|| y >= info.height - safeMargin
103+
) {
104+
output[outputIndex] = 0;
105+
output[outputIndex + 1] = 0;
106+
output[outputIndex + 2] = 0;
107+
output[outputIndex + 3] = 0;
108+
continue;
109+
}
110+
111+
const backdrop = [0, 1, 2].map((channel) =>
112+
clamp(
113+
background.rows[y][channel]
114+
+ background.columns[x][channel]
115+
- background.global[channel],
116+
0,
117+
255,
118+
));
119+
const red = data[sourceIndex];
120+
const green = data[sourceIndex + 1];
121+
const blue = data[sourceIndex + 2];
122+
const screenStrength = (red + blue) / 2 - green;
123+
const backgroundStrength =
124+
(backdrop[0] + backdrop[2]) / 2 - backdrop[1];
125+
const colorDistance = Math.hypot(
126+
red - backdrop[0],
127+
green - backdrop[1],
128+
blue - backdrop[2],
129+
);
130+
131+
let alpha = 1 - clamp(screenStrength / Math.max(1, backgroundStrength));
132+
133+
if (colorDistance < 4) alpha = 0;
134+
alpha = clamp((alpha - 0.018) / 0.982);
135+
136+
if (alpha < 0.035) {
137+
output[outputIndex] = 0;
138+
output[outputIndex + 1] = 0;
139+
output[outputIndex + 2] = 0;
140+
output[outputIndex + 3] = 0;
29141
continue;
30142
}
31143

32-
const index = (y * info.width + x) * 4;
33-
data[index] = 0;
34-
data[index + 1] = 0;
35-
data[index + 2] = 0;
36-
data[index + 3] = 0;
144+
const recovered = [0, 1, 2].map((channel) =>
145+
clamp(
146+
(data[sourceIndex + channel] - (1 - alpha) * backdrop[channel])
147+
/ alpha,
148+
0,
149+
255,
150+
));
151+
const magentaSpill = Math.max(
152+
0,
153+
Math.min(recovered[0], recovered[2]) - recovered[1] - 4,
154+
);
155+
156+
recovered[0] -= magentaSpill;
157+
recovered[2] -= magentaSpill;
158+
159+
for (let channel = 0; channel < 3; channel += 1) {
160+
output[outputIndex + channel] = Math.round(recovered[channel]);
161+
}
162+
output[outputIndex + 3] = Math.round(alpha * 255);
37163
}
38164
}
39165

40-
return sharp(data, {
166+
return sharp(output, {
41167
raw: {
42168
width: info.width,
43169
height: info.height,
@@ -48,9 +174,8 @@ const clearSafeMargin = async (input) => {
48174
.toBuffer();
49175
};
50176

51-
const buildDarkVariant = async (input) => {
177+
const buildLightVariant = async (input) => {
52178
const { data, info } = await sharp(input)
53-
.ensureAlpha()
54179
.raw()
55180
.toBuffer({ resolveWithObject: true });
56181

@@ -62,26 +187,28 @@ const buildDarkVariant = async (input) => {
62187

63188
if (alpha === 0) continue;
64189

65-
if (isGoldPixel(red, green, blue)) {
66-
data[index] = Math.min(255, Math.round(red * 1.02));
67-
data[index + 1] = Math.min(255, Math.round(green * 1.03));
68-
data[index + 2] = Math.min(255, Math.round(blue * 0.96));
69-
continue;
70-
}
71-
72-
const luminance = Math.max(
73-
0,
74-
Math.min(1, (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255),
190+
const maximum = Math.max(red, green, blue);
191+
const minimum = Math.min(red, green, blue);
192+
const saturation = maximum ? (maximum - minimum) / maximum : 0;
193+
const goldWeight =
194+
smoothstep(0.1, 0.45, (red - blue) / 255)
195+
* smoothstep(0.08, 0.35, saturation)
196+
* smoothstep(80, 170, red);
197+
const luminance = clamp(
198+
(0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255,
75199
);
76-
const pearl = [247, 243, 232];
77-
const iceBlue = [139, 164, 194];
200+
const tone = smoothstep(0.32, 0.94, luminance);
201+
const navy = [12, 52, 101];
202+
const steel = [200, 214, 229];
203+
const neutral = navy.map((channel, channelIndex) =>
204+
channel * (1 - tone) + steel[channelIndex] * tone);
78205

79-
data[index] = Math.round(pearl[0] * (1 - luminance) + iceBlue[0] * luminance);
206+
data[index] = Math.round(neutral[0] * (1 - goldWeight) + red * goldWeight);
80207
data[index + 1] = Math.round(
81-
pearl[1] * (1 - luminance) + iceBlue[1] * luminance,
208+
neutral[1] * (1 - goldWeight) + green * goldWeight,
82209
);
83210
data[index + 2] = Math.round(
84-
pearl[2] * (1 - luminance) + iceBlue[2] * luminance,
211+
neutral[2] * (1 - goldWeight) + blue * goldWeight,
85212
);
86213
}
87214

@@ -107,7 +234,6 @@ const writeVariant = async (variant, input, width) => {
107234
.resize(width, height, {
108235
fit: "fill",
109236
kernel: sharp.kernel.lanczos3,
110-
withoutEnlargement: true,
111237
})
112238
.webp({
113239
quality: 90,
@@ -120,25 +246,24 @@ const writeVariant = async (variant, input, width) => {
120246
console.log(`Wrote ${path.relative(projectRoot, outputPath)}`);
121247
};
122248

123-
const sourceMaster = await sharp(sourcePath).png().toBuffer();
124-
const metadata = await sharp(sourceMaster).metadata();
249+
const metadata = await sharp(sourcePath).metadata();
125250

126251
if (
127-
metadata.width !== 1600
128-
|| metadata.height !== 1200
129-
|| metadata.hasAlpha !== true
252+
!metadata.width
253+
|| !metadata.height
254+
|| Math.abs(metadata.width / metadata.height - 4 / 3) > 0.001
130255
) {
131256
throw new Error(
132-
"Hero master must be a 1600x1200 PNG with a transparent alpha channel.",
257+
"Hero chroma source must be a 4:3 PNG.",
133258
);
134259
}
135260

136261
await mkdir(outputDirectory, { recursive: true });
137262

138-
const source = await clearSafeMargin(sourceMaster);
263+
const darkSource = await keyChromaSource(sourcePath);
139264
const variants = {
140-
light: source,
141-
dark: await buildDarkVariant(source),
265+
light: await buildLightVariant(darkSource),
266+
dark: darkSource,
142267
};
143268

144269
for (const [variant, input] of Object.entries(variants)) {

‎src/components/HeroScienceGraphic.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const heroImageProps = {
1212
decoding: "async" as const,
1313
draggable: false,
1414
loading: "eager" as const,
15-
sizes: "(min-width: 1280px) 640px, (min-width: 1024px) 52vw, 92vw",
15+
sizes: "(min-width: 1024px) 72vw, (min-width: 640px) 110vw, 135vw",
1616
};
1717

1818
interface HeroScienceGraphicProps {

0 commit comments

Comments
 (0)