diff --git a/README.md b/README.md index 7b4c679..1ce19a9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PicGen Console -一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.63**。它把 +一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.64**。它把 `/v1/images/generations`、`/v1/images/edits` 与 `/v1/responses`(含 `image_generation` 工具) 包装成统一可观测的代理,前端是一套零依赖的 Web 控制台。 @@ -14,7 +14,9 @@ ![PicGen Console 主程序界面](demo1.png) -## 0.1.63 主要特性 +## 0.1.64 主要特性 + +- **避免重采样后的 LOGO 重复叠加**:已有官方 LOGO 即使被上游缩放并产生少量像素位移,也会在严格颜色阈值下识别并保留,不再另贴第二枚 LOGO。 - **行程图按需嵌入标题字体**:仅打包本次标题和日期实际使用的字形,保留原字体效果,同时显著降低 SVG 体积和客户浏览器解码压力。 @@ -125,10 +127,10 @@ PICGEN_LOG_FORMAT=json \ ### Docker ```bash -docker build -t minorli/picgen:0.1.63 . +docker build -t minorli/picgen:0.1.64 . docker run --rm -p 8000:8000 \ -v picgen-data:/app/data \ - minorli/picgen:0.1.63 + minorli/picgen:0.1.64 ``` 或: @@ -143,10 +145,10 @@ docker compose up -d ./scripts/docker-build-push.sh ``` -默认会构建并推送 `minorli/picgen:0.1.63`。也可以覆盖: +默认会构建并推送 `minorli/picgen:0.1.64`。也可以覆盖: ```bash -IMAGE=minorli/picgen VERSION=0.1.63 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh +IMAGE=minorli/picgen VERSION=0.1.64 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh ``` 镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/health`,以非 root @@ -259,7 +261,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到 ## 图像通道 -PicGen 0.1.63 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: +PicGen 0.1.64 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: | 用户操作 | 默认接口 | 默认模型 | | --- | --- | --- | diff --git a/docker-compose.yml b/docker-compose.yml index 0428773..84220c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: picgen: - image: minorli/picgen:0.1.63 + image: minorli/picgen:0.1.64 build: context: . ports: diff --git a/pyproject.toml b/pyproject.toml index 07b29e9..4563e53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "picgen" -version = "0.1.63" +version = "0.1.64" description = "Enterprise-grade local web console for OpenAI-compatible image generation and editing APIs." readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/docker-build-push.sh b/scripts/docker-build-push.sh index 6ab3b0d..eab2ed1 100755 --- a/scripts/docker-build-push.sh +++ b/scripts/docker-build-push.sh @@ -2,7 +2,7 @@ set -euo pipefail IMAGE="${IMAGE:-minorli/picgen}" -VERSION="${VERSION:-0.1.63}" +VERSION="${VERSION:-0.1.64}" PLATFORM="${PLATFORM:-linux/amd64}" docker buildx build \ diff --git a/static/app.js b/static/app.js index c157e2f..e07ce14 100644 --- a/static/app.js +++ b/static/app.js @@ -2,14 +2,14 @@ import { calculateLogoPlacementScore, calculateOfficialLogoPixelMatch, chooseLogoPlacement, -} from "./logo-placement.mjs?v=0.1.63" +} from "./logo-placement.mjs?v=0.1.64" import { DEFAULT_RESPONSES_MODEL, RESPONSES_MODEL_STORAGE_VERSION, RESPONSES_REASONING_STORAGE_VERSION, migrateStoredResponsesReasoningSettings, migrateStoredResponsesSettings, -} from "./responses-settings.mjs?v=0.1.63" +} from "./responses-settings.mjs?v=0.1.64" const RESPONSES_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max", "ultra"]) const DEFAULT_RESPONSES_REASONING_EFFORT = "xhigh" diff --git a/static/index.html b/static/index.html index c7cbaf7..750488a 100644 --- a/static/index.html +++ b/static/index.html @@ -5,7 +5,7 @@ PicGen Console - +
@@ -1061,7 +1061,7 @@

管理员高级设置

@@ -1371,6 +1371,6 @@

图片预览

- + diff --git a/static/logo-placement.mjs b/static/logo-placement.mjs index 7a488a3..e8e541b 100644 --- a/static/logo-placement.mjs +++ b/static/logo-placement.mjs @@ -2,6 +2,8 @@ const MIN_ABSOLUTE_SCORE_IMPROVEMENT = 2 const MIN_RELATIVE_SCORE_IMPROVEMENT = 0.25 const OFFICIAL_LOGO_MATCH_MIN_ALPHA = 245 const OFFICIAL_LOGO_MATCH_MAX_CHANNEL_DELTA = 24 +// Upstream down/upscaling can shift intact logo edges by a few pixels. +const OFFICIAL_LOGO_MATCH_RADIUS = 3 const SAFETY_PADDING = Object.freeze({ left: 0.12, @@ -31,12 +33,47 @@ function pixelLuminance(data, index) { return 0.2126 * data[index] + 0.7152 * data[index + 1] + 0.0722 * data[index + 2] } +function imageDataDimensions(imageData, pixelCount) { + const width = Math.floor(Number(imageData?.width) || 0) + const height = Math.floor(Number(imageData?.height) || 0) + return width > 0 && height > 0 && width * height === pixelCount + ? { width, height } + : { width: pixelCount, height: 1 } +} + +function hasNearbyLogoPixelMatch(base, logo, logoIndex, x, y, width, height) { + for (let offsetY = -OFFICIAL_LOGO_MATCH_RADIUS; offsetY <= OFFICIAL_LOGO_MATCH_RADIUS; offsetY += 1) { + const candidateY = y + offsetY + if (candidateY < 0 || candidateY >= height) { + continue + } + for (let offsetX = -OFFICIAL_LOGO_MATCH_RADIUS; offsetX <= OFFICIAL_LOGO_MATCH_RADIUS; offsetX += 1) { + const candidateX = x + offsetX + if (candidateX < 0 || candidateX >= width) { + continue + } + const baseIndex = (candidateY * width + candidateX) * 4 + const largestChannelDelta = Math.max( + Math.abs(base[baseIndex] - logo[logoIndex]), + Math.abs(base[baseIndex + 1] - logo[logoIndex + 1]), + Math.abs(base[baseIndex + 2] - logo[logoIndex + 2]), + ) + if (largestChannelDelta <= OFFICIAL_LOGO_MATCH_MAX_CHANNEL_DELTA) { + return true + } + } + } + return false +} + export function calculateOfficialLogoPixelMatch(baseImageData, logoImageData) { const base = baseImageData?.data const logo = logoImageData?.data if (!base || !logo || base.length !== logo.length || base.length % 4 !== 0) { return { score: 0, matchedPixels: 0, comparedPixels: 0 } } + const pixelCount = logo.length / 4 + const { width, height } = imageDataDimensions(logoImageData, pixelCount) let matchedPixels = 0 let comparedPixels = 0 @@ -45,12 +82,10 @@ export function calculateOfficialLogoPixelMatch(baseImageData, logoImageData) { continue } comparedPixels += 1 - const largestChannelDelta = Math.max( - Math.abs(base[index] - logo[index]), - Math.abs(base[index + 1] - logo[index + 1]), - Math.abs(base[index + 2] - logo[index + 2]), - ) - if (largestChannelDelta <= OFFICIAL_LOGO_MATCH_MAX_CHANNEL_DELTA) { + const pixel = index / 4 + const x = pixel % width + const y = Math.floor(pixel / width) + if (hasNearbyLogoPixelMatch(base, logo, index, x, y, width, height)) { matchedPixels += 1 } } diff --git a/tests/test_logo_placement.py b/tests/test_logo_placement.py index 91a234f..84cc994 100644 --- a/tests/test_logo_placement.py +++ b/tests/test_logo_placement.py @@ -64,6 +64,54 @@ def test_official_logo_pixel_match_accepts_lossless_and_small_color_drift() -> N assert result["drift"] == {"score": 1, "matchedPixels": 2, "comparedPixels": 2} +def test_official_logo_pixel_match_accepts_small_resampling_shift() -> None: + result = _run_logo_policy( + "(() => {" + "const width = 7; const height = 3;" + "const logo = new Uint8ClampedArray(width * height * 4);" + "const base = new Uint8ClampedArray(width * height * 4);" + "for (let pixel = 0; pixel < width * height; pixel += 1) {" + "base[pixel * 4] = 230; base[pixel * 4 + 1] = 230; base[pixel * 4 + 2] = 230; base[pixel * 4 + 3] = 255;" + "}" + "for (let y = 0; y < height; y += 1) {" + "for (let x = 2; x <= 4; x += 1) {" + "const logoIndex = (y * width + x) * 4; const baseIndex = (y * width + x + 1) * 4;" + "const red = 20 + x * 31 + y * 7; const green = 45 + x * 13 + y * 29; const blue = 70 + x * 17 + y * 11;" + "logo[logoIndex] = red; logo[logoIndex + 1] = green; logo[logoIndex + 2] = blue; logo[logoIndex + 3] = 255;" + "base[baseIndex] = red; base[baseIndex + 1] = green; base[baseIndex + 2] = blue;" + "}" + "}" + "return calculateOfficialLogoPixelMatch({ data: base, width, height }, { data: logo, width, height });" + "})()" + ) + + assert result == {"score": 1, "matchedPixels": 9, "comparedPixels": 9} + + +def test_official_logo_pixel_match_rejects_matching_colors_outside_local_radius() -> None: + result = _run_logo_policy( + "(() => {" + "const width = 15; const height = 3;" + "const logo = new Uint8ClampedArray(width * height * 4);" + "const base = new Uint8ClampedArray(width * height * 4);" + "for (let pixel = 0; pixel < width * height; pixel += 1) {" + "base[pixel * 4] = 230; base[pixel * 4 + 1] = 230; base[pixel * 4 + 2] = 230; base[pixel * 4 + 3] = 255;" + "}" + "for (let y = 0; y < height; y += 1) {" + "for (let x = 2; x <= 4; x += 1) {" + "const logoIndex = (y * width + x) * 4; const baseIndex = (y * width + x + 8) * 4;" + "const red = 20 + x * 31 + y * 7; const green = 45 + x * 13 + y * 29; const blue = 70 + x * 17 + y * 11;" + "logo[logoIndex] = red; logo[logoIndex + 1] = green; logo[logoIndex + 2] = blue; logo[logoIndex + 3] = 255;" + "base[baseIndex] = red; base[baseIndex + 1] = green; base[baseIndex + 2] = blue;" + "}" + "}" + "return calculateOfficialLogoPixelMatch({ data: base, width, height }, { data: logo, width, height });" + "})()" + ) + + assert result == {"score": 0, "matchedPixels": 0, "comparedPixels": 9} + + def test_official_logo_pixel_match_rejects_unrelated_or_invalid_regions() -> None: result = _run_logo_policy( "(() => {" diff --git a/tests/test_static_assets.py b/tests/test_static_assets.py index 88dae35..81468eb 100644 --- a/tests/test_static_assets.py +++ b/tests/test_static_assets.py @@ -13,7 +13,7 @@ def test_legacy_responses_model_storage_is_migrated_once() -> None: settings_js = (ROOT_DIR / "static" / "responses-settings.mjs").read_text(encoding="utf-8") assert 'const DEPRECATED_RESPONSES_MODELS = new Set(["gpt-5.4"])' in app_js - assert 'from "./responses-settings.mjs?v=0.1.63"' in app_js + assert 'from "./responses-settings.mjs?v=0.1.64"' in app_js assert 'const LEGACY_DEFAULT_RESPONSES_MODEL = "gpt-5.5"' in settings_js assert "const RESPONSES_MODEL_STORAGE_VERSION = 4" in settings_js assert "function migrateStoredResponsesSettings" in settings_js @@ -38,7 +38,7 @@ def test_logo_overlay_uses_uploaded_asset_without_ai_guidance() -> None: assert 'const COMPANY_LOGO_URL = "6renyou.png"' in app_js assert "composeLogoOverlayForCandidates" in app_js assert "createOfficialLogoCanvas" in app_js - assert 'from "./logo-placement.mjs?v=0.1.63"' in app_js + assert 'from "./logo-placement.mjs?v=0.1.64"' in app_js assert "chooseLogoPlacement" in app_js assert "calculateLogoPlacementScore" in app_js assert "calculateOfficialLogoPixelMatch" in app_js diff --git a/uv.lock b/uv.lock index 2f6c562..69e64ea 100644 --- a/uv.lock +++ b/uv.lock @@ -340,7 +340,7 @@ wheels = [ [[package]] name = "picgen" -version = "0.1.63" +version = "0.1.64" source = { editable = "." } dependencies = [ { name = "anyio" },