feat: 沙香科技官网 v2.2 基线(深色科技风/双语/局域网+公网双部署)
This commit is contained in:
25
tools/deploy.sh
Normal file
25
tools/deploy.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# 官网统一部署脚本:bump 静态资源版本号(击穿浏览器缓存)+ 同步双服务器
|
||||
# 用法: bash tools/deploy.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
STAMP=$(date "+%Y%m%d%H%M")
|
||||
|
||||
# bump 资源版本参数:HTML 更新(no-cache)会带新 URL,旧缓存自然失效
|
||||
python3 - "$STAMP" <<'PYEOF'
|
||||
import re, sys
|
||||
stamp = sys.argv[1]
|
||||
path = 'index.html'
|
||||
html = open(path, encoding='utf-8').read()
|
||||
# 正则一次性规范化:无论已有无版本参数,统一替换为当前时间戳
|
||||
html = re.sub(r'assets/css/style\.css(?:\?v=\d+)?\d*', 'assets/css/style.css?v=' + stamp, html)
|
||||
html = re.sub(r'assets/js/main\.js(?:\?v=\d+)?\d*', 'assets/js/main.js?v=' + stamp, html)
|
||||
open(path, 'w', encoding='utf-8').write(html)
|
||||
PYEOF
|
||||
|
||||
echo "[deploy] 版本号 ${STAMP},同步公网 + 局域网"
|
||||
# 注意:assets 不带尾斜杠(带斜杠会把目录拍平,曾导致整站 404)
|
||||
rsync -a --delete index.html assets ubuntu3:/var/www/shashann-homepage/
|
||||
rsync -a --delete index.html assets ubuntu:/home/ubuntu/homepage-site/
|
||||
echo "[deploy] 完成: https://www.shashann.com/ 与 https://192.168.51.66/homepage/"
|
||||
65
tools/gen_contact_avatar.py
Normal file
65
tools/gen_contact_avatar.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
生成 contact 公共邮箱头像:品牌深底 + 青→红渐变 @ 符号
|
||||
视觉规范与官网一致(assets/css/style.css 的 --bg/--cyan/--brand)
|
||||
用法: uv run --with pillow python3 tools/gen_contact_avatar.py
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
SIZE = 512
|
||||
BG = (7, 11, 22) # 官网 --bg-1
|
||||
CYAN = (76, 195, 255) # 官网 --cyan
|
||||
RED = (255, 61, 90) # 官网 --brand
|
||||
OUT = 'assets/brand/contact-avatar.png'
|
||||
|
||||
# ---------- 背景:深底 + 左上青辉光 / 右下红辉光 ----------
|
||||
bg = Image.new('RGB', (SIZE, SIZE), BG)
|
||||
|
||||
def glow_blob(box, blur):
|
||||
blob = Image.new('L', (SIZE, SIZE), 0)
|
||||
ImageDraw.Draw(blob).ellipse(box, fill=80)
|
||||
return blob.filter(ImageFilter.GaussianBlur(blur))
|
||||
|
||||
bg.paste(Image.new('RGB', (SIZE, SIZE), CYAN), (0, 0), glow_blob((-220, -220, 240, 240), 100))
|
||||
bg.paste(Image.new('RGB', (SIZE, SIZE), RED), (0, 0), glow_blob((SIZE - 240, SIZE - 240, SIZE + 220, SIZE + 220), 100))
|
||||
|
||||
# ---------- 对角渐变图(青 → 红),用作 @ 的填充色 ----------
|
||||
c1, c2 = CYAN, RED
|
||||
span = 2 * (SIZE - 1)
|
||||
grad = Image.new('RGB', (SIZE, SIZE))
|
||||
grad.putdata([
|
||||
(
|
||||
round(c1[0] + (c2[0] - c1[0]) * (x + y) / span),
|
||||
round(c1[1] + (c2[1] - c1[1]) * (x + y) / span),
|
||||
round(c1[2] + (c2[2] - c1[2]) * (x + y) / span),
|
||||
)
|
||||
for y in range(SIZE) for x in range(SIZE)
|
||||
])
|
||||
|
||||
# ---------- 渲染 @ 文字 mask ----------
|
||||
# macOS 西文字体按优先级找,@ 字形各字体差异不大
|
||||
FONT_CANDIDATES = [
|
||||
'/System/Library/Fonts/SFNS.ttf',
|
||||
'/System/Library/Fonts/SFNSText.ttf',
|
||||
'/System/Library/Fonts/Helvetica.ttc',
|
||||
]
|
||||
font_path = next((p for p in FONT_CANDIDATES if __import__('os').path.exists(p)), None)
|
||||
if not font_path:
|
||||
raise SystemExit('找不到可用系统字体: ' + ', '.join(FONT_CANDIDATES))
|
||||
|
||||
font = ImageFont.truetype(font_path, 400)
|
||||
mask = Image.new('L', (SIZE, SIZE), 0)
|
||||
ImageDraw.Draw(mask).text((SIZE // 2, SIZE // 2), '@', font=font, fill=255, anchor='mm')
|
||||
|
||||
# 自检:@ 必须真的渲染出来了,否则静默输出空底图
|
||||
ink = sum(1 for v in mask.getdata() if v > 0)
|
||||
ratio = ink / (SIZE * SIZE)
|
||||
if not 0.05 < ratio < 0.45:
|
||||
raise SystemExit(f'@ 渲染异常,墨迹占比 {ratio:.1%}(预期 5%~45%),检查字体 {font_path}')
|
||||
|
||||
bg.paste(grad, (0, 0), mask)
|
||||
|
||||
import os
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
bg.save(OUT, 'PNG', optimize=True)
|
||||
print(f'完成: {OUT} {SIZE}x{SIZE} @{ratio:.0%} 字体={font_path}')
|
||||
39
tools/make_favicon.py
Normal file
39
tools/make_favicon.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从反白 logo 提取左侧图形生成 favicon(180x180 PNG):
|
||||
深色圆角底(与站内卡片底色一致)+ 图形居中占 72%
|
||||
标签栏明暗模式均以深色块呈现,反白图形恒清晰。
|
||||
用法: uv run --with pillow python3 tools/make_favicon.py
|
||||
"""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
SRC = 'assets/img/logo-light.png'
|
||||
OUT = 'assets/img/favicon.png'
|
||||
SIZE = 180
|
||||
PAD_GRAPHIC = 0.72 # 图形占画布比例
|
||||
|
||||
# logo 左侧图形实测内容段(x 投影分析):x[47:224] y[44:260],取 bbox 略收边
|
||||
CROP = (47, 44, 224, 260)
|
||||
BG = (7, 11, 22) # 站内 --bg-1
|
||||
RADIUS = 38
|
||||
|
||||
graphic = Image.open(SRC).convert('RGBA').crop(CROP)
|
||||
|
||||
canvas = Image.new('RGBA', (SIZE, SIZE), (0, 0, 0, 0))
|
||||
# 圆角深色底
|
||||
mask = Image.new('L', (SIZE, SIZE), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle((0, 0, SIZE, SIZE), RADIUS, fill=255)
|
||||
canvas.paste(Image.new('RGBA', (SIZE, SIZE), BG + (255,)), (0, 0), mask)
|
||||
|
||||
# 图形等比缩放到 72% 高度内居中
|
||||
target = round(SIZE * PAD_GRAPHIC)
|
||||
scale = min(target / graphic.width, target / graphic.height)
|
||||
graphic = graphic.resize((round(graphic.width * scale), round(graphic.height * scale)), Image.LANCZOS)
|
||||
canvas.alpha_composite(graphic, ((SIZE - graphic.width) // 2, (SIZE - graphic.height) // 2))
|
||||
|
||||
# 自检:中心应有不透明图形像素,四角透明
|
||||
px = canvas.load()
|
||||
assert px[SIZE // 2, SIZE // 2][3] > 0, '中心无图形'
|
||||
assert px[2, 2][3] == 0 and px[SIZE - 3, SIZE - 3][3] == 0, '圆角未生效'
|
||||
canvas.save(OUT, 'PNG', optimize=True)
|
||||
print(f'完成: {OUT} {SIZE}x{SIZE},图形 {graphic.size}')
|
||||
71
tools/make_logo_light.py
Normal file
71
tools/make_logo_light.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
生成深色站反白版 logo(logo-light.png):
|
||||
1. flood-fill 镂空深蓝底(容差 84 = v1 验证成功参数,能吃掉 #000020 底色)
|
||||
2. 不透明内容亮度反转 L' = 1 - L(黑字→白字,红/青/绿提亮,色相保留)
|
||||
深色页黑字看不清、白底块又突兀,反白是深色站标准解法。
|
||||
用法: uv run --with pillow python3 tools/make_logo_light.py
|
||||
"""
|
||||
import colorsys
|
||||
from collections import deque
|
||||
|
||||
from PIL import Image
|
||||
|
||||
SRC = 'assets/img/logo.png'
|
||||
OUT = 'assets/img/logo-light.png'
|
||||
TOL = 84 # 曼哈顿距离容差(v1 实测:清除 83%、红色主体完整保留)
|
||||
|
||||
im = Image.open(SRC).convert('RGBA')
|
||||
w, h = im.size
|
||||
px = im.load()
|
||||
|
||||
# --- flood-fill 清底 ---
|
||||
corners = [px[0, 0], px[w-1, 0], px[0, h-1], px[w-1, h-1]]
|
||||
bg = tuple(sum(c[i] for c in corners) // 4 for i in range(3))
|
||||
|
||||
def is_bg(p):
|
||||
if p[3] < 10:
|
||||
return True
|
||||
return abs(p[0]-bg[0]) + abs(p[1]-bg[1]) + abs(p[2]-bg[2]) <= TOL
|
||||
|
||||
seen = [[False]*w for _ in range(h)]
|
||||
q = deque()
|
||||
for x in range(w):
|
||||
q.extend(((x, 0), (x, h-1)))
|
||||
for y in range(h):
|
||||
q.extend(((0, y), (w-1, y)))
|
||||
cleared = 0
|
||||
while q:
|
||||
x, y = q.popleft()
|
||||
if x < 0 or x >= w or y < 0 or y >= h or seen[y][x]:
|
||||
continue
|
||||
seen[y][x] = True
|
||||
p = px[x, y]
|
||||
if not is_bg(p):
|
||||
continue
|
||||
px[x, y] = (0, 0, 0, 0)
|
||||
cleared += 1
|
||||
q.extend(((x+1, y), (x-1, y), (x, y+1), (x, y-1)))
|
||||
ratio = cleared / (w * h)
|
||||
assert 0.10 < ratio < 0.92, f'镂空异常: 清除 {ratio:.0%}'
|
||||
|
||||
# --- 暗部提亮、亮部保留(logo 含 ~20% 浅色元素,全量反转会把它们变黑) ---
|
||||
# L' = max(L, 1-L):暗像素翻到亮侧(黑→白),亮像素不动,色相全保留
|
||||
opaque = [px[x, y] for y in range(h) for x in range(w) if px[x, y][3] > 128]
|
||||
assert opaque, '镂空后内容为空'
|
||||
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b, a = px[x, y]
|
||||
if a == 0:
|
||||
continue
|
||||
hh, ll, ss = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
|
||||
r2, g2, b2 = colorsys.hls_to_rgb(hh, max(ll, 1 - ll), ss)
|
||||
px[x, y] = (round(r2 * 255), round(g2 * 255), round(b2 * 255), a)
|
||||
|
||||
im.save(OUT, 'PNG', optimize=True)
|
||||
|
||||
# 验证:反白后所有不透明像素亮度下限
|
||||
final = [px[x, y] for y in range(h) for x in range(w) if px[x, y][3] > 128]
|
||||
dark = sum(1 for p in final if 0.299*p[0]+0.587*p[1]+0.114*p[2] < 90) / len(final)
|
||||
print(f'完成: {OUT} 镂空 {ratio:.0%},暗部像素剩 {dark:.1%}(应为 0)')
|
||||
119
tools/process_images.py
Normal file
119
tools/process_images.py
Normal file
@@ -0,0 +1,119 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从 PPT 提取的原始素材生成网站用图:
|
||||
1. logo 与 7 张黑底产品图:边缘 flood-fill 镂空(背景变透明,适配深色网页)
|
||||
2. 水培架图背景复杂(39% 均匀度),不镂空,走统一描边容器展示
|
||||
3. 截图/场景/照片转 WebP 压缩;二维码保持 PNG 无损
|
||||
用法: uv run --with pillow python3 tools/process_images.py <pptx解压目录>/ppt/media <输出目录>
|
||||
"""
|
||||
import sys
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
src = Path(sys.argv[1])
|
||||
out = Path(sys.argv[2])
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
RENAME = {
|
||||
'image2.png': 'logo.png', # 原样无损输出(见 PASS_THROUGH)
|
||||
'image1.jpeg': 'hero-bg.webp', # v2 起不再做 hero 背景,仅作 og:image
|
||||
'image3.png': 'about-main.webp', # v2 起弃用(PPT 拼贴图有白缝)
|
||||
'image4.png': 'about-base.webp',
|
||||
'image5.png': 'collar-sg03-mini.webp',
|
||||
'image13.png': 'collar-sg03.webp',
|
||||
'image10.png': 'ear-tag-st01.webp',
|
||||
'image11.png': 'base-station-bg01.webp',
|
||||
'image12.png': 'grazing-scene-1.webp',
|
||||
'image18.png': 'grazing-scene-2.webp',
|
||||
'image19.png': 'hydroponics-hs01.webp',
|
||||
'image20.png': 'planting-scene.webp',
|
||||
'image31.png': 'terminal-bb03.webp',
|
||||
'image32.png': 'uwb-tag-sl01.webp',
|
||||
'image33.png': 'bt-tag-st02.webp',
|
||||
'image29.png': 'asset-scene-1.webp',
|
||||
'image30.jpeg': 'asset-scene-2.webp',
|
||||
'image34.png': 'platform-grazing-1.webp',
|
||||
'image35.png': 'platform-grazing-app.webp',
|
||||
'image36.png': 'platform-planting-1.webp',
|
||||
'image37.png': 'platform-planting-2.webp',
|
||||
'image38.png': 'platform-asset-1.webp',
|
||||
'image39.png': 'platform-asset-2.webp',
|
||||
'image40.png': 'custom-liangchang-1.webp',
|
||||
'image41.png': 'custom-liangchang-2.webp',
|
||||
'image42.png': 'custom-simulation.webp',
|
||||
'image43.png': 'custom-agent.webp',
|
||||
'image44.png': 'qrcode.png',
|
||||
}
|
||||
|
||||
# 这些图做边缘 flood-fill 镂空(检测过:边缘色 96-100% 均匀的纯黑底)
|
||||
# 注意:logo 不镂空——其底色 #000020 与四角检测色差超容差,v2 曾清成斑驳残留导致看不清
|
||||
CUTOUT = {
|
||||
'image5.png', 'image13.png', 'image10.png', 'image11.png',
|
||||
'image31.png', 'image32.png', 'image33.png',
|
||||
}
|
||||
CUTOUT_TOL = 30 # 曼哈顿距离阈值,宁小勿大:黑底纯度高,产品暗部 (#20 系) 距离约 96 不会被误清
|
||||
|
||||
def cutout_bg(path: Path, tol: int) -> Image.Image:
|
||||
"""从画布四边缘 flood-fill 清除均匀底色,只清与边缘连通的背景,不动被包围的内容"""
|
||||
im = Image.open(path).convert('RGBA')
|
||||
w, h = im.size
|
||||
px = im.load()
|
||||
corners = [px[0, 0], px[w-1, 0], px[0, h-1], px[w-1, h-1]]
|
||||
bg = tuple(sum(c[i] for c in corners) // 4 for i in range(3))
|
||||
|
||||
def is_bg(p):
|
||||
if p[3] < 10:
|
||||
return True # 原本就是透明的像素直接视为背景
|
||||
return abs(p[0]-bg[0]) + abs(p[1]-bg[1]) + abs(p[2]-bg[2]) <= tol
|
||||
|
||||
seen = [[False]*w for _ in range(h)]
|
||||
q = deque()
|
||||
for x in range(w):
|
||||
q.extend(((x, 0), (x, h-1)))
|
||||
for y in range(h):
|
||||
q.extend(((0, y), (w-1, y)))
|
||||
cleared = 0
|
||||
while q:
|
||||
x, y = q.popleft()
|
||||
if x < 0 or x >= w or y < 0 or y >= h or seen[y][x]:
|
||||
continue
|
||||
seen[y][x] = True
|
||||
p = px[x, y]
|
||||
if not is_bg(p):
|
||||
continue
|
||||
px[x, y] = (0, 0, 0, 0)
|
||||
cleared += 1
|
||||
q.extend(((x+1, y), (x-1, y), (x, y+1), (x, y-1)))
|
||||
ratio = cleared / (w * h)
|
||||
# 镂空比例健全性检查:太低=没清干净,太高=误伤内容
|
||||
if not 0.10 < ratio < 0.92:
|
||||
raise SystemExit(f'镂空异常 {path.name}: 清除 {ratio:.0%},需人工检查')
|
||||
return im
|
||||
|
||||
# 原样无损拷贝:logo 色块文字经不起有损压缩,二维码必须无损
|
||||
PASS_THROUGH = {'image2.png', 'image44.png'}
|
||||
|
||||
for src_name, dst_name in RENAME.items():
|
||||
src_path = src / src_name
|
||||
if not src_path.exists():
|
||||
print(f' 跳过缺失: {src_name}')
|
||||
continue
|
||||
dst_path = out / dst_name
|
||||
if src_name in PASS_THROUGH:
|
||||
Image.open(src_path).save(dst_path)
|
||||
elif src_name in CUTOUT:
|
||||
im = cutout_bg(src_path, CUTOUT_TOL)
|
||||
# 镂空图必须带 alpha,webp 无损存 alpha(有损会弄脏半透明边缘)
|
||||
im.save(dst_path, 'WEBP', quality=100, lossless=True, method=6)
|
||||
else:
|
||||
im = Image.open(src_path).convert('RGB')
|
||||
if im.width > 1600:
|
||||
im = im.resize((1600, round(im.height * 1600 / im.width)), Image.LANCZOS)
|
||||
im.save(dst_path, 'WEBP', quality=82, method=6)
|
||||
kb = dst_path.stat().st_size // 1024
|
||||
tag = ' [镂空]' if src_name in CUTOUT else ''
|
||||
print(f' {src_name:14s} -> {dst_name:28s} {kb}KB{tag}')
|
||||
|
||||
print(f'完成,共 {len(RENAME)} 张 -> {out}')
|
||||
50
tools/process_new_assets.py
Normal file
50
tools/process_new_assets.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
处理用户补充的新素材(assets/img 内):
|
||||
- 7 张透明底产品图 p-*:无损 WebP 保留 alpha(已是镂空,无需再处理)
|
||||
- p-hs01 背景复杂不透明:有损压缩(页面走 framed 描边容器)
|
||||
- 12 张照片 JPG:有损 WebP(宽度 >1600 的缩到 1600)
|
||||
- qr-wechat.png 二维码:不处理,HTML 直接引用原 PNG
|
||||
原图不删除(用户提供的源文件)。用法: uv run --with pillow python3 tools/process_new_assets.py
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
IMG = Path('assets/img')
|
||||
|
||||
# 已检测为真透明的产品图(透明像素 37%~88%)
|
||||
ALPHA_PRODUCTS = [
|
||||
'p-bb03.png', 'p-bg01.png', 'p-sg03.png', 'p-sg03mini.png',
|
||||
'p-sl01.png', 'p-st01.png', 'p-st02.png',
|
||||
]
|
||||
# 有损压缩的照片/截图
|
||||
PHOTOS = [
|
||||
'p-hs01.png', # 背景复杂不透明,同照片处理
|
||||
'ai-agent.jpg', 'app-livestock.jpg', 'building.jpg', 'cctv.jpg',
|
||||
'digital-twin.jpg', 'factory-line.jpg', 'factory.jpg', 'goat.jpg',
|
||||
'hero-grazing.jpg', 'hydro-class.jpg', 'liangchang.jpg',
|
||||
'machine-install.jpg', 'platform-asset.jpg', 'platform-hydro.jpg',
|
||||
'platform-pasture.jpg',
|
||||
]
|
||||
|
||||
for name in ALPHA_PRODUCTS:
|
||||
src = IMG / name
|
||||
dst = IMG / (name[:-4] + '.webp')
|
||||
im = Image.open(src).convert('RGBA')
|
||||
# 自检:透明像素占比必须显著,否则说明误判为透明图
|
||||
trans = sum(1 for p in im.getdata() if p[3] < 250) / (im.width * im.height)
|
||||
assert trans > 0.2, f'{name} 透明像素仅 {trans:.0%},不符合透明底预期'
|
||||
im.save(dst, 'WEBP', lossless=True, method=6)
|
||||
print(f'{name:18s} -> {dst.name:22s} 透明{trans:.0%} {dst.stat().st_size // 1024}KB')
|
||||
|
||||
for name in PHOTOS:
|
||||
src = IMG / name
|
||||
dst = IMG / (name[:-4] + '.webp')
|
||||
im = Image.open(src).convert('RGB')
|
||||
if im.width > 1600:
|
||||
im = im.resize((1600, round(im.height * 1600 / im.width)), Image.LANCZOS)
|
||||
im.save(dst, 'WEBP', quality=82, method=6)
|
||||
print(f'{name:18s} -> {dst.name:22s} {im.width}x{im.height} {dst.stat().st_size // 1024}KB')
|
||||
|
||||
print('完成')
|
||||
Reference in New Issue
Block a user