Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 控制台。

Expand All @@ -14,7 +14,9 @@

![PicGen Console 主程序界面](demo1.png)

## 0.1.63 主要特性
## 0.1.64 主要特性

- **避免重采样后的 LOGO 重复叠加**:已有官方 LOGO 即使被上游缩放并产生少量像素位移,也会在严格颜色阈值下识别并保留,不再另贴第二枚 LOGO。

- **行程图按需嵌入标题字体**:仅打包本次标题和日期实际使用的字形,保留原字体效果,同时显著降低 SVG 体积和客户浏览器解码压力。

Expand Down Expand Up @@ -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
```

或:
Expand All @@ -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
Expand Down Expand Up @@ -259,7 +261,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到

## 图像通道

PicGen 0.1.63 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定:
PicGen 0.1.64 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定:

| 用户操作 | 默认接口 | 默认模型 |
| --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
picgen:
image: minorli/picgen:0.1.63
image: minorli/picgen:0.1.64
build:
context: .
ports:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion scripts/docker-build-push.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
4 changes: 2 additions & 2 deletions static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PicGen Console</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23108566'/%3E%3Cpath d='M32 13l16 9v20l-16 9-16-9V22l16-9z' fill='%23ecfdf5'/%3E%3Ccircle cx='32' cy='32' r='8' fill='%23108566'/%3E%3C/svg%3E">
<link rel="stylesheet" href="styles.css?v=0.1.63">
<link rel="stylesheet" href="styles.css?v=0.1.64">
</head>
<body class="auth-gate">
<div id="authOverlay" class="auth-page" aria-hidden="false">
Expand Down Expand Up @@ -1061,7 +1061,7 @@ <h2>管理员高级设置</h2>
</div>

<footer class="system-footer">
<span>PicGen Console v0.1.63</span>
<span>PicGen Console v0.1.64</span>
<span>本地代理保存结果到 <strong>data/outputs</strong></span>
</footer>
</div>
Expand Down Expand Up @@ -1371,6 +1371,6 @@ <h2 id="previewModalTitle">图片预览</h2>
<span id="toastMessageText"></span>
</div>

<script src="app.js?v=0.1.63" type="module"></script>
<script src="app.js?v=0.1.64" type="module"></script>
</body>
</html>
47 changes: 41 additions & 6 deletions static/logo-placement.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
Expand Down
48 changes: 48 additions & 0 deletions tests/test_logo_placement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"(() => {"
Expand Down
4 changes: 2 additions & 2 deletions tests/test_static_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading