feat(ppclock): 命令层/传输层/CLI/本地模板渲染,90 测试全绿

This commit is contained in:
agent committed 2026-07-24 10:57:25 +00:00
1 parent 6f5b0c60be
commit 2b01e5f589
60 files changed
+16869

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
"""图像管线:调整 → 抖动量化 → 1bpp 平面打包。
算法与参数证据:analysis/web/timedjs.js(见 docs/protocol.md §5)。
所有实现与 Web 端 JS 语义对齐:同样的灰度系数、误差核、BCD 无 —— 像素级等价为目标。
"""
from __future__ import annotations
from PIL import Image
GRAY = (0.299, 0.587, 0.114)
BAYER4 = (
(15, 135, 45, 165),
(195, 75, 225, 105),
(60, 180, 30, 150),
(240, 120, 210, 90),
)
# 误差扩散核:(dx, dy, 权重分子);除数见 _KERNELS 键名
_KERNELS = {
"floydsteinberg": ([(1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)], 16),
"atkinson": (
[(1, 0, 1), (2, 0, 1), (-1, 1, 1), (0, 1, 1), (1, 1, 1), (0, 2, 1)],
8,
),
"stucki": (
[
(1, 0, 8), (2, 0, 4),
(-2, 1, 2), (-1, 1, 4), (0, 1, 8), (1, 1, 4), (2, 1, 2),
(-2, 2, 1), (-1, 2, 2), (0, 2, 4), (1, 2, 2), (2, 2, 1),
],
42,
),
"jarvis": (
[
(1, 0, 7), (2, 0, 5),
(-2, 1, 3), (-1, 1, 5), (0, 1, 7), (1, 1, 5), (2, 1, 3),
(-2, 2, 1), (-1, 2, 3), (0, 2, 5), (1, 2, 3), (2, 2, 1),
],
48,
),
}
ALGORITHMS = ["none", "floydsteinberg", "atkinson", "bayer", "stucki", "jarvis"]
def _clamp(v: float) -> int:
return 0 if v < 0 else (255 if v > 255 else round(v))
def _gray_of(r: float, g: float, b: float) -> float:
return GRAY[0] * r + GRAY[1] * g + GRAY[2] * b
def adjust_pixels(pixels, brightness=0, contrast=0, saturation=100):
"""JS applyImageAdjustments 语义(timedjs.js:1-41)。
brightness: ±100 → v±255*b/100;contrast: ±100 → v*f+(1-f)*128, f=(c+100)/100;
saturation: 0–100+ → gray+(v-gray)*s/100。"""
out = []
bf = 255 * brightness / 100
cf = (contrast + 100) / 100
ca = (1 - cf) * 128
sf = saturation / 100
for r, g, b in pixels:
if brightness:
r, g, b = r + bf, g + bf, b + bf
if contrast:
r, g, b = r * cf + ca, g * cf + ca, b * cf + ca
if saturation != 100:
gray = _gray_of(r, g, b)
r, g, b = (gray + (v - gray) * sf for v in (r, g, b))
out.append((_clamp(r), _clamp(g), _clamp(b)))
return out
def _is_red(r, g, b, threshold):
return r > g * 1.5 and r > b * 1.5 and r > threshold
def dither_pixels(pixels, width, height, algorithm, threshold=125,
diffusion=1.0, tricolor=False):
"""量化到黑/白(+红)。返回与输入等长的 (r,g,b) 列表。
与 JS 一致:红判定优先且红像素不扩散误差;误差按通道扩散;写入即截断取整。"""
if algorithm not in ALGORITHMS:
raise ValueError(f"未知抖动算法 {algorithm!r},可选:{ALGORITHMS}")
buf = [[float(r), float(g), float(b)] for r, g, b in pixels]
out = [(0, 0, 0)] * (width * height)
def quantize(i, x, y):
r, g, b = buf[i]
if tricolor and _is_red(r, g, b, threshold):
out[i] = (255, 0, 0)
return None # 红像素无误差扩散
gray = _gray_of(r, g, b)
if algorithm == "bayer":
limit = BAYER4[y % 4][x % 4]
else:
limit = threshold
new = 0 if gray < limit else 255
out[i] = (new, new, new)
return (r - new) * diffusion, (g - new) * diffusion, (b - new) * diffusion
if algorithm in ("none", "bayer"):
for y in range(height):
for x in range(width):
quantize(y * width + x, x, y)
return out
kernel, div = _KERNELS[algorithm]
for y in range(height):
for x in range(width):
i = y * width + x
errs = quantize(i, x, y)
if errs is None:
continue
er, eg, eb = errs
for dx, dy, w in kernel:
nx, ny = x + dx, y + dy
if 0 <= nx < width and ny < height:
ni = ny * width + nx
# JS:写入 Uint8ClampedArray 即截断取整(round)
buf[ni][0] = _clamp(buf[ni][0] + er * w / div)
buf[ni][1] = _clamp(buf[ni][1] + eg * w / div)
buf[ni][2] = _clamp(buf[ni][2] + eb * w / div)
return out
def pack_plane(pixels, plane):
"""(r,g,b) 列表 → 1bpp MSB-first 字节流。
bw: R,G,B 全>0 → 1;red: R>0 且 G=0 且 B=0 → 1(timedjs.js:564-584)。"""
if len(pixels) % 8:
raise ValueError("像素数必须是 8 的倍数")
out = bytearray()
acc = 0
nbits = 0
for r, g, b in pixels:
if plane == "bw":
bit = 1 if (r > 0 and g > 0 and b > 0) else 0
elif plane == "red":
bit = 1 if (r > 0 and g == 0 and b == 0) else 0
else:
raise ValueError("plane 必须是 bw 或 red")
acc = (acc << 1) | bit
nbits += 1
if nbits == 8:
out.append(acc)
acc = 0
nbits = 0
return bytes(out)
def process_image(img: Image.Image, *, size=(400, 300), algorithm="atkinson",
tricolor=True, threshold=125, diffusion=1.0,
brightness=0, contrast=0, saturation=100, rotate=0):
"""完整管线:缩放/旋转 → 调整 → 抖动 → 打包。返回 (bw_bytes, red_bytes|None)。"""
img = img.convert("RGB").resize(size, Image.LANCZOS)
if rotate:
img = img.rotate(-rotate, expand=False) # 与 JS 顺时针旋转语义一致
w, h = size
px = list(img.getdata())
px = adjust_pixels(px, brightness, contrast, saturation)
px = dither_pixels(px, w, h, algorithm, threshold, diffusion, tricolor)
bw = pack_plane(px, "bw")
red = pack_plane(px, "red") if tricolor else None
return bw, red