feat(ppclock): 命令层/传输层/CLI/本地模板渲染,90 测试全绿
This commit is contained in:
1 parent
6f5b0c60be
commit
2b01e5f589
60 files changed
+16869
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user