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
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PicGen Console

一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.61**。它把
一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.62**。它把
`/v1/images/generations`、`/v1/images/edits` 与 `/v1/responses`(含 `image_generation` 工具)
包装成统一可观测的代理,前端是一套零依赖的 Web 控制台。

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

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

## 0.1.61 主要特性
## 0.1.62 主要特性

- **简洁模式可直接接收分享**:主页新增“收到分享”区块,支持刷新并直接打开同事分享的成品;新用户无需先切换到专业模式。

- **局部改图保留蒙版外原图**:服务端按 OpenAI 蒙版语义合成最终图片,透明区域采用编辑结果;PNG 成品的非透明区域逐像素保留输入原图。

- **避免重复叠加官方 LOGO**:发现标准位置已有官方透明 PNG 时直接保留原文件,不重复贴入、不重新编码;新贴入时仍默认锁定左上标准位置,只在安全区复杂度明显改善时移动。

- **完整展示真实输出比例**:结果预览与候选缩略图使用完整包含布局,纵向或横向图片不再被工作区网格裁切。

- **正常页面刷新不再误限流**:业务 API 仍受每分钟总配额保护,5 秒 burst 只约束写请求;生成完成后的统计刷新、工作区重载和作品列表读取不会互相挤占短时写入配额。

Expand Down Expand Up @@ -115,10 +123,10 @@ PICGEN_LOG_FORMAT=json \
### Docker

```bash
docker build -t minorli/picgen:0.1.61 .
docker build -t minorli/picgen:0.1.62 .
docker run --rm -p 8000:8000 \
-v picgen-data:/app/data \
minorli/picgen:0.1.61
minorli/picgen:0.1.62
```

或:
Expand All @@ -133,10 +141,10 @@ docker compose up -d
./scripts/docker-build-push.sh
```

默认会构建并推送 `minorli/picgen:0.1.61`。也可以覆盖:
默认会构建并推送 `minorli/picgen:0.1.62`。也可以覆盖:

```bash
IMAGE=minorli/picgen VERSION=0.1.61 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh
IMAGE=minorli/picgen VERSION=0.1.62 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh
```

镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/health`,以非 root
Expand Down Expand Up @@ -249,7 +257,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到

## 图像通道

PicGen 0.1.61 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定:
PicGen 0.1.62 把四类图像操作统一提交给 `/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.61
image: minorli/picgen:0.1.62
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.61"
version = "0.1.62"
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.61}"
VERSION="${VERSION:-0.1.62}"
PLATFORM="${PLATFORM:-linux/amd64}"

docker buildx build \
Expand Down
50 changes: 50 additions & 0 deletions src/picgen/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
)
from .storage import (
MAX_EXACT_IMAGE_PIXELS,
composite_masked_edit_image,
detect_image_mime,
extension_for_mime,
resolve_storage_path,
Expand Down Expand Up @@ -1100,6 +1101,45 @@ def _prepare_mask(part: FilePayload, max_image_bytes: int) -> dict[str, Any]:
return file_info


def _masked_edit_image_transform(
source_part: dict[str, Any] | None,
mask_part: dict[str, Any] | None,
) -> Callable[[bytes, str, int], tuple[bytes, str, dict[str, object]]] | None:
if not source_part or not mask_part:
return None
source_bytes = bytes(source_part.get("data") or b"")
mask_bytes = bytes(mask_part.get("data") or b"")
if not source_bytes or not mask_bytes:
return None

def transform(
generated_bytes: bytes,
generated_mime: str,
candidate_index: int,
) -> tuple[bytes, str, dict[str, object]]:
try:
return composite_masked_edit_image(
source_image_bytes=source_bytes,
mask_image_bytes=mask_bytes,
generated_image_bytes=generated_bytes,
generated_image_mime=generated_mime,
)
except (OSError, ValueError) as exc:
log_event(
logger,
logging.WARNING,
"mask_composite_failed",
candidate_index=candidate_index,
error=type(exc).__name__,
)
return generated_bytes, generated_mime, {
"mask_composited": False,
"mask_composite_error": type(exc).__name__,
}

return transform


def _itinerary_style_reference_part(settings: Settings) -> dict[str, Any] | None:
candidates = (
settings.static_dir / "itinerary-style-reference.png",
Expand Down Expand Up @@ -4855,6 +4895,7 @@ async def _run_edit_upstream() -> dict[str, Any]:
"sample_count": parsed.sample_count,
**({"user": user.public_dict()} if user else {}),
},
image_transform=_masked_edit_image_transform(image_parts[0], mask_part),
)


Expand Down Expand Up @@ -4960,6 +5001,7 @@ async def handle_responses_image(
for key in ("quality", "background", "output_format", "output_compression", "moderation"):
if key in image_options:
tool[key] = image_options[key]
mask_info: dict[str, Any] | None = None
if parsed.mask is not None:
mask_info = _validate_image_size(parsed.mask, settings.max_image_bytes)
mask_b64 = base64.b64encode(mask_info["data"]).decode("ascii")
Expand Down Expand Up @@ -5058,6 +5100,10 @@ async def _run_responses_upstream() -> dict[str, Any]:
"sample_count": parsed.sample_count,
**({"user": user.public_dict()} if user else {}),
},
image_transform=_masked_edit_image_transform(
image_parts[0] if image_parts else None,
mask_info,
),
)


Expand All @@ -5068,6 +5114,7 @@ async def _finalize_image_response(
client: UpstreamClient,
save_context: dict[str, Any],
extra: dict[str, Any],
image_transform: Callable[[bytes, str, int], tuple[bytes, str, dict[str, object]]] | None = None,
) -> dict[str, Any]:
fetch_remote_sync = _make_remote_fetcher(client)
saved = await anyio.to_thread.run_sync(
Expand All @@ -5078,6 +5125,7 @@ async def _finalize_image_response(
user_agent=settings.upstream_user_agent,
save_context=save_context,
fetch_remote=fetch_remote_sync,
image_transform=image_transform,
)
)
# An empty upstream response (no candidates) must be a clear error, not a blank
Expand Down Expand Up @@ -5196,6 +5244,7 @@ async def _finalize_with_size_retry(
save_context: dict[str, Any],
extra: dict[str, Any],
sample_count: int,
image_transform: Callable[[bytes, str, int], tuple[bytes, str, dict[str, object]]] | None = None,
) -> dict[str, Any]:
"""Retry the (paid) upstream call when it "lazily" returns a smaller size.

Expand Down Expand Up @@ -5225,6 +5274,7 @@ async def _finalize_with_size_retry(
client=client,
save_context=save_context,
extra=extra,
image_transform=image_transform,
)
except APIError as exc:
if best is not None:
Expand Down
62 changes: 62 additions & 0 deletions src/picgen/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,68 @@ def resize_image_to_exact_size(
)


def composite_masked_edit_image(
*,
source_image_bytes: bytes,
mask_image_bytes: bytes,
generated_image_bytes: bytes,
generated_image_mime: str,
) -> tuple[bytes, str, dict[str, object]]:
"""Keep source pixels wherever the OpenAI-style mask is opaque."""

normalized_mime = generated_image_mime.lower().split(";", 1)[0].strip()
output_format = _MIME_TO_PIL_FORMAT.get(normalized_mime)
if output_format is None:
raise ValueError(f"unsupported image mime for masked edit: {generated_image_mime}")

with (
Image.open(BytesIO(source_image_bytes)) as source_file,
Image.open(BytesIO(mask_image_bytes)) as mask_file,
Image.open(BytesIO(generated_image_bytes)) as generated_file,
):
source = ImageOps.exif_transpose(source_file).convert("RGBA")
mask = ImageOps.exif_transpose(mask_file)
generated = ImageOps.exif_transpose(generated_file).convert("RGBA")
if source.width * source.height > MAX_EXACT_IMAGE_PIXELS:
raise ValueError(f"source image exceeds {MAX_EXACT_IMAGE_PIXELS} pixels")
if mask.size != source.size:
raise ValueError("mask dimensions must match source image dimensions")
if "A" not in mask.getbands():
raise ValueError("mask image must contain an alpha channel")
if generated.size != source.size:
generated = ImageOps.fit(
generated,
source.size,
method=Image.Resampling.LANCZOS,
centering=(0.5, 0.5),
)

edit_alpha = ImageOps.invert(mask.getchannel("A"))
composed = Image.composite(generated, source, edit_alpha)
if output_format == "JPEG":
composed = composed.convert("RGB")

output = BytesIO()
if output_format == "PNG":
composed.save(output, format=output_format, compress_level=6)
elif output_format == "JPEG":
composed.save(output, format=output_format, quality=95, optimize=True)
elif output_format == "WEBP":
composed.save(output, format=output_format, quality=95)
else: # pragma: no cover - guarded by _MIME_TO_PIL_FORMAT
composed.save(output, format=output_format)

return (
output.getvalue(),
normalized_mime,
{
"mask_composited": True,
"mask_preserve_mode": "inverse_alpha",
"mask_source_size": f"{source.width}x{source.height}",
},
)


def storage_url_for_path(data_dir: Path, file_path: Path) -> str:
# Relative URL (no leading slash) so it resolves correctly both when PicGen is
# served at the site root and when it is mounted under a sub-path (e.g. /picgen/).
Expand Down
19 changes: 19 additions & 0 deletions src/picgen/upstream/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import logging
import re
from collections.abc import Callable
from datetime import UTC, datetime
from http import HTTPStatus
from pathlib import Path
Expand All @@ -23,6 +24,8 @@

logger = get_logger("picgen.upstream.payload")

ImageTransform = Callable[[bytes, str, int], tuple[bytes, str, dict[str, object]]]

_IMAGE_OPTION_KEYS = ("quality", "background", "output_format", "output_compression", "moderation")
_RAW_IMAGE_KEYS = frozenset({"b64_json", "result", "partial_image_b64", "image_b64"})
_RAW_IMAGE_URL_KEYS = frozenset({"url", "image_url"})
Expand Down Expand Up @@ -283,6 +286,7 @@ def _image_item_payload(
user_agent: str,
save_context: dict[str, Any],
fetch_remote: Any | None = None,
image_transform: ImageTransform | None = None,
index: int = 0,
) -> dict[str, Any]:
base64_image = item.get("b64_json")
Expand Down Expand Up @@ -325,6 +329,17 @@ def _image_item_payload(
image_mime,
save_context,
)
transform_metadata: dict[str, object] = {}
if image_transform is not None:
image_bytes, image_mime, transform_metadata = image_transform(
image_bytes,
image_mime,
index,
)
image_data_url = (
f"data:{image_mime};base64,"
f"{base64.b64encode(image_bytes).decode('ascii')}"
)
saved_payload = save_output_image(
data_dir=data_dir,
outputs_dir=outputs_dir,
Expand All @@ -340,9 +355,11 @@ def _image_item_payload(
"upstream_image_url": image_url,
"saved_image_mime": image_mime,
**normalization_metadata,
**transform_metadata,
},
)
saved_payload.update(normalization_metadata)
saved_payload.update(transform_metadata)

# When the image is persisted, the browser loads it from the saved file URL
# (same-origin), so we drop the multi-megabyte inline data URL to keep
Expand Down Expand Up @@ -370,6 +387,7 @@ def prepare_image_payload(
user_agent: str,
save_context: dict[str, Any],
fetch_remote: Any | None = None,
image_transform: ImageTransform | None = None,
) -> dict[str, Any]:
"""Build the response payload sent back to the browser and persist the image.

Expand Down Expand Up @@ -397,6 +415,7 @@ def prepare_image_payload(
user_agent=user_agent,
save_context=save_context,
fetch_remote=fetch_remote,
image_transform=image_transform,
index=index,
)
except Exception as exc:
Expand Down
Loading
Loading