feat: enforce karuoai skill single source
This commit is contained in:
107
01_卡资(金)/金仓_存储备份/Codex账号备份恢复/SKILL.md
Normal file
107
01_卡资(金)/金仓_存储备份/Codex账号备份恢复/SKILL.md
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: Codex账号备份恢复
|
||||
description: 备份当前 Codex 的配置、skills、对话索引和本地状态,并在切换账号后恢复。触发词:Codex备份、Codex恢复、账号切换恢复、Codex迁移、Codex材料恢复、Codex记录恢复。
|
||||
member: 金仓
|
||||
group: 金
|
||||
version: "1.0"
|
||||
updated: "2026-06-07"
|
||||
---
|
||||
|
||||
# Codex账号备份恢复
|
||||
|
||||
把当前机器里的 Codex 关键材料导出成一份可恢复快照,方便切换其他 Codex 账号后继续沿用旧的本地配置、skills、记录索引和状态文件。
|
||||
|
||||
## 适用范围
|
||||
|
||||
- 同一台 Mac 上切换不同 Codex 账号
|
||||
- 重装 Codex 后恢复本地工作环境
|
||||
- 迁移 `~/.codex` 中的高价值材料
|
||||
- 保留一份脱离账号的本地恢复包
|
||||
|
||||
## 默认备份内容
|
||||
|
||||
- `~/.codex/config.toml`
|
||||
- `~/.codex/auth.json` 与 `provider_secrets.toml`(仅显式要求时恢复)
|
||||
- `~/.codex/AGENTS.md`
|
||||
- `~/.codex/skills/`
|
||||
- `~/.codex/memories/`
|
||||
- `~/.codex/sessions/`、`archived_sessions/`、`shell_snapshots/`
|
||||
- `~/.codex/session_index.jsonl`
|
||||
- `~/.codex/*.sqlite*`
|
||||
- `~/.codex/.codex-global-state.json*`
|
||||
- `~/.codex/process_manager/chat_processes.json`
|
||||
|
||||
## 不默认备份
|
||||
|
||||
- `cache/`
|
||||
- `tmp/`
|
||||
- `vendor_imports/`
|
||||
- 插件缓存和浏览器临时缓存
|
||||
|
||||
这些内容可重新生成,保留它们通常只会让包变大。
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 创建备份
|
||||
|
||||
```bash
|
||||
python3 "01_卡资(金)/金仓_存储备份/CodexAccountBackupRestore/scripts/codex_backup_restore.py" backup
|
||||
```
|
||||
|
||||
输出目录默认在:
|
||||
|
||||
`~/Documents/CodexBackups/最新时间戳/`
|
||||
|
||||
### 2. 恢复到当前账号
|
||||
|
||||
```bash
|
||||
python3 "01_卡资(金)/金仓_存储备份/CodexAccountBackupRestore/scripts/codex_backup_restore.py" restore \
|
||||
--snapshot "~/Documents/CodexBackups/20260607-xxxxxx"
|
||||
```
|
||||
|
||||
默认行为:
|
||||
|
||||
- 先给现有 `~/.codex` 做时间戳备份
|
||||
- 恢复配置、skills、状态库、索引
|
||||
- **不恢复密钥文件**
|
||||
|
||||
### 3. 连密钥一起恢复
|
||||
|
||||
```bash
|
||||
python3 "01_卡资(金)/金仓_存储备份/CodexAccountBackupRestore/scripts/codex_backup_restore.py" restore \
|
||||
--snapshot "~/Documents/CodexBackups/20260607-xxxxxx" \
|
||||
--include-secrets
|
||||
```
|
||||
|
||||
仅在你明确要沿用旧账号或旧网关密钥时使用。
|
||||
|
||||
### 4. 安装到 Codex skills
|
||||
|
||||
```bash
|
||||
bash "01_卡资(金)/金仓_存储备份/CodexAccountBackupRestore/scripts/install_to_codex_skills.sh"
|
||||
```
|
||||
|
||||
这样以后即使切换账号,`~/.codex/skills/codex-account-backup/` 里也会有一份同内容 skill。
|
||||
|
||||
## 交付物
|
||||
|
||||
每次备份会生成:
|
||||
|
||||
- `manifest.json`:备份清单
|
||||
- `restore-notes.md`:恢复说明
|
||||
- `sidebar-index.md`:项目目录与聊天会话对照表(恢复后侧边栏验收用)
|
||||
- `payload/`:实际文件
|
||||
|
||||
## 恢复铁律
|
||||
|
||||
- 先退出 Codex App 再恢复
|
||||
- 恢复后重新打开 Codex
|
||||
- 若只想恢复工作流和记录,**不要**带 `--include-secrets`
|
||||
- 若恢复后异常,可用脚本生成的 `~/.codex.before-restore-*` 目录回滚
|
||||
|
||||
## 相关说明
|
||||
|
||||
更多细节见:
|
||||
|
||||
- `references/迁移说明.md`
|
||||
- `scripts/codex_backup_restore.py --help`
|
||||
27
01_卡资(金)/金仓_存储备份/Codex账号备份恢复/references/迁移说明.md
Normal file
27
01_卡资(金)/金仓_存储备份/Codex账号备份恢复/references/迁移说明.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Codex 迁移说明
|
||||
|
||||
## 目标
|
||||
|
||||
把账号相关和账号无关的本地材料拆开:
|
||||
|
||||
- 账号无关:`skills`、`AGENTS.md`、大部分状态索引、记忆和本地说明
|
||||
- 账号相关:`auth.json`、`provider_secrets.toml`
|
||||
|
||||
这样切换账号时,可以先恢复工作流,再决定要不要恢复旧密钥。
|
||||
|
||||
## 推荐顺序
|
||||
|
||||
1. 退出 Codex App
|
||||
2. 跑 `backup`
|
||||
3. 切换账号或重装
|
||||
4. 跑 `restore`
|
||||
5. 打开 Codex 验证 skills、配置和历史材料
|
||||
6. 若需要旧网关配置,再用 `--include-secrets`
|
||||
|
||||
## 验收点
|
||||
|
||||
- `~/.codex/skills/codex-account-backup/` 存在
|
||||
- `~/.codex/config.toml` 已恢复
|
||||
- `~/.codex/session_index.jsonl` 已恢复
|
||||
- `~/.codex/*.sqlite*` 已恢复
|
||||
- `~/Documents/CodexBackups/` 下有快照目录
|
||||
329
01_卡资(金)/金仓_存储备份/Codex账号备份恢复/scripts/codex_backup_restore.py
Executable file
329
01_卡资(金)/金仓_存储备份/Codex账号备份恢复/scripts/codex_backup_restore.py
Executable file
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HOME = Path.home()
|
||||
CODEX_ROOT = HOME / ".codex"
|
||||
BACKUP_HOME = HOME / "Documents" / "CodexBackups"
|
||||
|
||||
INCLUDE_ITEMS = [
|
||||
"AGENTS.md",
|
||||
"config.toml",
|
||||
"auth.json",
|
||||
"provider_secrets.toml",
|
||||
"session_index.jsonl",
|
||||
".codex-global-state.json",
|
||||
".codex-global-state.json.bak",
|
||||
"skills",
|
||||
"memories",
|
||||
"sessions",
|
||||
"archived_sessions",
|
||||
"shell_snapshots",
|
||||
"process_manager/chat_processes.json",
|
||||
"logs_2.sqlite",
|
||||
"logs_2.sqlite-shm",
|
||||
"logs_2.sqlite-wal",
|
||||
"state_5.sqlite",
|
||||
"state_5.sqlite-shm",
|
||||
"state_5.sqlite-wal",
|
||||
"goals_1.sqlite",
|
||||
"goals_1.sqlite-shm",
|
||||
"goals_1.sqlite-wal",
|
||||
"memories_1.sqlite",
|
||||
"memories_1.sqlite-shm",
|
||||
"memories_1.sqlite-wal",
|
||||
]
|
||||
|
||||
SECRET_ITEMS = {
|
||||
"auth.json",
|
||||
"provider_secrets.toml",
|
||||
}
|
||||
|
||||
|
||||
def sha256_for_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def iter_files(root: Path):
|
||||
if root.is_file():
|
||||
yield root
|
||||
return
|
||||
for path in sorted(root.rglob("*")):
|
||||
if path.is_file():
|
||||
yield path
|
||||
|
||||
|
||||
def copy_item(src: Path, dest: Path) -> dict:
|
||||
if src.is_dir():
|
||||
shutil.copytree(src, dest, dirs_exist_ok=True)
|
||||
else:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
|
||||
files = []
|
||||
for file_path in iter_files(dest):
|
||||
rel_path = file_path.relative_to(dest.parent)
|
||||
stat = file_path.stat()
|
||||
files.append(
|
||||
{
|
||||
"path": rel_path.as_posix(),
|
||||
"size": stat.st_size,
|
||||
"sha256": sha256_for_file(file_path),
|
||||
}
|
||||
)
|
||||
return {"source": str(src), "target": str(dest), "files": files}
|
||||
|
||||
|
||||
def build_restore_notes(snapshot_dir: Path, include_secrets: bool) -> str:
|
||||
note = [
|
||||
"# Codex Restore Notes",
|
||||
"",
|
||||
f"- Snapshot: `{snapshot_dir}`",
|
||||
f"- Generated: `{dt.datetime.now().isoformat(timespec='seconds')}`",
|
||||
f"- Includes secrets: `{str(include_secrets).lower()}`",
|
||||
"",
|
||||
"## Restore",
|
||||
"",
|
||||
"1. Quit Codex App.",
|
||||
"2. Run the restore command with this snapshot.",
|
||||
"3. Re-open Codex and verify config, skills, and indexes.",
|
||||
"",
|
||||
"## Command",
|
||||
"",
|
||||
"```bash",
|
||||
f"python3 \"{snapshot_dir / 'payload' / 'codex-account-backup' / 'scripts' / 'codex_backup_restore.py'}\" restore --snapshot \"{snapshot_dir}\"",
|
||||
"```",
|
||||
"",
|
||||
"Add `--include-secrets` only if you want to restore old API keys and provider secrets.",
|
||||
]
|
||||
return "\n".join(note) + "\n"
|
||||
|
||||
|
||||
def ensure_installed_into_payload(payload_dir: Path) -> None:
|
||||
source_root = Path("/Users/karuo/Documents/个人/卡若AI/01_卡资(金)/金仓_存储备份/CodexAccountBackupRestore")
|
||||
skill_target = payload_dir / "codex-account-backup"
|
||||
shutil.copytree(source_root, skill_target, dirs_exist_ok=True)
|
||||
|
||||
|
||||
def _latest_thread_names() -> dict[str, str]:
|
||||
index_path = CODEX_ROOT / "session_index.jsonl"
|
||||
if not index_path.exists():
|
||||
return {}
|
||||
names: dict[str, str] = {}
|
||||
for line in index_path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
row = json.loads(line)
|
||||
thread_id = row.get("id")
|
||||
if thread_id:
|
||||
names[thread_id] = row.get("thread_name", thread_id)
|
||||
return names
|
||||
|
||||
|
||||
def _thread_workspace_map() -> dict[str, str]:
|
||||
mapping: dict[str, str] = {}
|
||||
for folder in ("sessions", "archived_sessions"):
|
||||
root = CODEX_ROOT / folder
|
||||
if not root.exists():
|
||||
continue
|
||||
for rollout in root.rglob("rollout-*.jsonl"):
|
||||
try:
|
||||
first = rollout.read_text(encoding="utf-8").splitlines()[0]
|
||||
row = json.loads(first)
|
||||
except (IndexError, json.JSONDecodeError, OSError):
|
||||
continue
|
||||
payload = row.get("payload") or {}
|
||||
thread_id = payload.get("id")
|
||||
cwd = payload.get("cwd")
|
||||
if thread_id and cwd:
|
||||
mapping[thread_id] = cwd
|
||||
return mapping
|
||||
|
||||
|
||||
def build_sidebar_index(snapshot_dir: Path) -> str:
|
||||
global_state_path = CODEX_ROOT / ".codex-global-state.json"
|
||||
gs = json.loads(global_state_path.read_text(encoding="utf-8")) if global_state_path.exists() else {}
|
||||
labels = gs.get("electron-workspace-root-labels") or {}
|
||||
project_order = gs.get("project-order") or gs.get("electron-saved-workspace-roots") or []
|
||||
pinned_ids = set(gs.get("pinned-thread-ids") or [])
|
||||
thread_names = _latest_thread_names()
|
||||
thread_cwd = _thread_workspace_map()
|
||||
|
||||
by_workspace: dict[str, list[tuple[str, str]]] = {}
|
||||
for thread_id, name in thread_names.items():
|
||||
cwd = thread_cwd.get(thread_id, gs.get("thread-workspace-root-hints", {}).get(thread_id, "(未知目录)"))
|
||||
by_workspace.setdefault(cwd, []).append((thread_id, name))
|
||||
|
||||
lines = [
|
||||
"# Codex 侧边栏目录索引",
|
||||
"",
|
||||
f"- 生成时间:`{dt.datetime.now().isoformat(timespec='seconds')}`",
|
||||
f"- 会话数:`{len(thread_names)}`",
|
||||
f"- 项目目录数:`{len(project_order)}`",
|
||||
"",
|
||||
"## 恢复后应看到的项目目录",
|
||||
"",
|
||||
]
|
||||
for root in project_order:
|
||||
label = labels.get(root, Path(root).name)
|
||||
lines.append(f"- **{label}** → `{root}`")
|
||||
|
||||
if pinned_ids:
|
||||
lines.extend(["", "## 置顶会话", ""])
|
||||
for thread_id in pinned_ids:
|
||||
lines.append(f"- {thread_names.get(thread_id, thread_id)} (`{thread_id[:8]}…`)")
|
||||
|
||||
lines.extend(["", "## 按项目归类的聊天", ""])
|
||||
ordered_roots = list(project_order) + [r for r in by_workspace if r not in project_order]
|
||||
for root in ordered_roots:
|
||||
threads = by_workspace.get(root)
|
||||
if not threads:
|
||||
continue
|
||||
label = labels.get(root, Path(root).name if root != "(未知目录)" else "未归类")
|
||||
lines.append(f"### {label}")
|
||||
lines.append(f"`{root}`")
|
||||
lines.append("")
|
||||
for thread_id, name in sorted(threads, key=lambda x: x[1]):
|
||||
pin = " 📌" if thread_id in pinned_ids else ""
|
||||
rollout = thread_cwd.get(thread_id, "")
|
||||
rollout_name = Path(rollout).name if rollout else "-"
|
||||
lines.append(f"- {name}{pin} · `{thread_id[:8]}…` · `{rollout_name}`")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def create_backup(args: argparse.Namespace) -> int:
|
||||
timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
snapshot_dir = Path(args.output).expanduser() if args.output else BACKUP_HOME / timestamp
|
||||
payload_dir = snapshot_dir / "payload"
|
||||
payload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest = {
|
||||
"created_at": dt.datetime.now().isoformat(timespec="seconds"),
|
||||
"codex_root": str(CODEX_ROOT),
|
||||
"snapshot_dir": str(snapshot_dir),
|
||||
"include_secrets": bool(args.include_secrets),
|
||||
"items": [],
|
||||
"missing": [],
|
||||
}
|
||||
|
||||
for item in INCLUDE_ITEMS:
|
||||
src = CODEX_ROOT / item
|
||||
if not src.exists():
|
||||
manifest["missing"].append(item)
|
||||
continue
|
||||
if item in SECRET_ITEMS and not args.include_secrets:
|
||||
manifest["items"].append(
|
||||
{
|
||||
"source": str(src),
|
||||
"target": None,
|
||||
"files": [],
|
||||
"skipped": "secret",
|
||||
}
|
||||
)
|
||||
continue
|
||||
dest = payload_dir / ".codex" / item
|
||||
manifest["items"].append(copy_item(src, dest))
|
||||
|
||||
ensure_installed_into_payload(payload_dir)
|
||||
|
||||
(snapshot_dir / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(snapshot_dir / "restore-notes.md").write_text(
|
||||
build_restore_notes(snapshot_dir, bool(args.include_secrets)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(snapshot_dir / "sidebar-index.md").write_text(
|
||||
build_sidebar_index(snapshot_dir),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(snapshot_dir)
|
||||
return 0
|
||||
|
||||
|
||||
def create_restore_backup(target_root: Path) -> Path:
|
||||
timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
backup_root = target_root.parent / f"{target_root.name}.before-restore-{timestamp}"
|
||||
if target_root.exists():
|
||||
shutil.copytree(target_root, backup_root, dirs_exist_ok=True)
|
||||
return backup_root
|
||||
|
||||
|
||||
def restore_backup(args: argparse.Namespace) -> int:
|
||||
snapshot_dir = Path(args.snapshot).expanduser().resolve()
|
||||
payload_root = snapshot_dir / "payload" / ".codex"
|
||||
if not payload_root.exists():
|
||||
raise SystemExit(f"Snapshot payload not found: {payload_root}")
|
||||
|
||||
backup_root = create_restore_backup(CODEX_ROOT)
|
||||
CODEX_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for item in INCLUDE_ITEMS:
|
||||
src = payload_root / item
|
||||
if not src.exists():
|
||||
continue
|
||||
if item in SECRET_ITEMS and not args.include_secrets:
|
||||
continue
|
||||
dest = CODEX_ROOT / item
|
||||
if dest.is_dir():
|
||||
shutil.rmtree(dest)
|
||||
elif dest.exists():
|
||||
dest.unlink()
|
||||
if src.is_dir():
|
||||
shutil.copytree(src, dest, dirs_exist_ok=True)
|
||||
else:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
|
||||
skill_src = snapshot_dir / "payload" / "codex-account-backup"
|
||||
if skill_src.exists():
|
||||
skill_dest = CODEX_ROOT / "skills" / "codex-account-backup"
|
||||
skill_dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if skill_dest.exists():
|
||||
shutil.rmtree(skill_dest)
|
||||
shutil.copytree(skill_src, skill_dest, dirs_exist_ok=True)
|
||||
|
||||
print(json.dumps({"restored_to": str(CODEX_ROOT), "previous_backup": str(backup_root)}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Backup and restore Codex local materials.")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
backup = subparsers.add_parser("backup", help="Create a backup snapshot.")
|
||||
backup.add_argument("--output", help="Custom snapshot directory.")
|
||||
backup.add_argument("--include-secrets", action="store_true", help="Include auth.json and provider_secrets.toml.")
|
||||
backup.set_defaults(func=create_backup)
|
||||
|
||||
restore = subparsers.add_parser("restore", help="Restore from a snapshot.")
|
||||
restore.add_argument("--snapshot", required=True, help="Snapshot directory to restore from.")
|
||||
restore.add_argument("--include-secrets", action="store_true", help="Restore secret files too.")
|
||||
restore.set_defaults(func=restore_backup)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
10
01_卡资(金)/金仓_存储备份/Codex账号备份恢复/scripts/install_to_codex_skills.sh
Executable file
10
01_卡资(金)/金仓_存储备份/Codex账号备份恢复/scripts/install_to_codex_skills.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SOURCE_ROOT="/Users/karuo/Documents/个人/卡若AI/01_卡资(金)/金仓_存储备份/CodexAccountBackupRestore"
|
||||
TARGET_ROOT="${HOME}/.codex/skills/codex-account-backup"
|
||||
|
||||
mkdir -p "${TARGET_ROOT}"
|
||||
rsync -a --delete "${SOURCE_ROOT}/" "${TARGET_ROOT}/"
|
||||
|
||||
echo "Installed skill to ${TARGET_ROOT}"
|
||||
56
04_卡火(火)/火炬_全栈消息/Codex开通向导/references/Codex重置代币与自动监控.md
Normal file
56
04_卡火(火)/火炬_全栈消息/Codex开通向导/references/Codex重置代币与自动监控.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Codex 重置代币与自动监控
|
||||
|
||||
> ChatGPT 订阅(`codex login`)专用 · 不走 API Key · 仅消耗 OpenAI 已发放的「保存的重置代币」
|
||||
|
||||
## 当前账号速查
|
||||
|
||||
```bash
|
||||
python3 ~/.codex/scripts/codex_reset.py status
|
||||
# 或
|
||||
node ~/.codex/skills/codex-card-flow/scripts/codex_account_cli.mjs reset-status
|
||||
```
|
||||
|
||||
## 手动重置(官方,与 App 内按钮相同)
|
||||
|
||||
- Codex 对话框:`/reset`
|
||||
- 终端:`/Applications/Codex.app/Contents/Resources/codex /reset`
|
||||
- 脚本(有代币时):`python3 ~/.codex/scripts/codex_reset.py consume -y`
|
||||
|
||||
## 自动监控(限额到 / 5h≥85% 时自动烧代币)
|
||||
|
||||
```bash
|
||||
bash ~/.codex/scripts/codex_auto_reset_ctl.sh on # 开启(LaunchAgent,每 2 分钟)
|
||||
bash ~/.codex/scripts/codex_auto_reset_ctl.sh off # 关闭
|
||||
bash ~/.codex/scripts/codex_auto_reset_ctl.sh status
|
||||
bash ~/.codex/scripts/codex_auto_reset_ctl.sh once # 立即检查一次
|
||||
```
|
||||
|
||||
Codex 内斜杠命令说明:`~/.codex/prompts/auto-reset.md`(输入 `/auto-reset on|off|status`)
|
||||
|
||||
## 配置在哪改
|
||||
|
||||
| 文件 | 作用 |
|
||||
|:---|:---|
|
||||
| `~/.codex/codex-auto-reset.json` | 总开关、阈值 85%、检查间隔 120s |
|
||||
| `~/.codex/codex-auto-reset-state.json` | 最近一次检查 / 是否触发重置 |
|
||||
| `~/.codex/logs/auto-reset.log` | 监控日志 |
|
||||
|
||||
`primary_threshold_percent`:5 小时窗用到多少 % 时尝试消耗代币(默认 85)。
|
||||
`trigger_on_limit_reached`:已满额(100%)时是否也自动烧代币(默认 true)。
|
||||
**若 `available_count=0`,脚本只记录日志,不会 bypass,只能等自然恢复。**
|
||||
|
||||
## API(与 Codex Desktop 相同 OAuth)
|
||||
|
||||
- `GET /backend-api/wham/usage` — 用量 + `rate_limit_reset_credits.available_count`
|
||||
- `GET /backend-api/wham/rate-limit-reset-credits` — 代币明细
|
||||
- `POST /backend-api/wham/rate-limit-reset-credits/consume` — 消耗 1 枚
|
||||
|
||||
Auth:`~/.codex/auth.json`(需 `ChatGPT-Account-Id` 头)。
|
||||
|
||||
## 用量登记
|
||||
|
||||
```bash
|
||||
node ~/.codex/skills/codex-card-flow/scripts/codex_account_cli.mjs usage
|
||||
```
|
||||
|
||||
登记册:`/Users/karuo/Documents/卡若Ai的文件夹/账号/keria/Codex用量登记册.md`
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const scripts = __dirname;
|
||||
const codexHome = path.join(process.env.HOME || "", ".codex");
|
||||
const resetPy = path.join(codexHome, "scripts", "codex_reset.py");
|
||||
const autoResetCtl = path.join(codexHome, "scripts", "codex_auto_reset_ctl.sh");
|
||||
|
||||
function argVal(name, def) {
|
||||
const i = process.argv.indexOf(name);
|
||||
@@ -41,6 +44,10 @@ function runNode(script, args = []) {
|
||||
execSync(cmd, { stdio: "inherit", shell: "/bin/bash" });
|
||||
}
|
||||
|
||||
function runShell(cmd) {
|
||||
execSync(cmd, { stdio: "inherit", shell: "/bin/bash" });
|
||||
}
|
||||
|
||||
function cmdList() {
|
||||
ensureAccountsFile();
|
||||
const rows = listAccounts();
|
||||
@@ -169,6 +176,19 @@ function cmdUsage() {
|
||||
runNode("codex_usage_snapshot.mjs", []);
|
||||
}
|
||||
|
||||
function cmdResetStatus() {
|
||||
runShell(`python3 "${resetPy}" status`);
|
||||
}
|
||||
|
||||
function cmdResetNow() {
|
||||
runShell(`python3 "${resetPy}" consume -y`);
|
||||
}
|
||||
|
||||
function cmdAutoReset(mode) {
|
||||
const sub = mode || "status";
|
||||
runShell(`bash "${autoResetCtl}" ${sub}`);
|
||||
}
|
||||
|
||||
function cmdRegister() {
|
||||
runNode("register_login_record.mjs", ["--from-codex-auth"]);
|
||||
const acc = getAccount();
|
||||
@@ -228,7 +248,10 @@ function help() {
|
||||
keria-poll 轮询 keria 私密账号池验证码
|
||||
sms-new 切换新手机号并轮询短信码
|
||||
sms-poll 复用当前 session 再轮询短信码
|
||||
usage Codex 用量快照(5小时/1周窗口)
|
||||
usage Codex 用量快照(5小时/1周窗口 + 重置代币)
|
||||
reset-status 查看重置代币与当前限额
|
||||
reset-now 手动消耗 1 枚重置代币(需 available_count>0)
|
||||
auto-reset [on|off|status|once] 自动监控并在到限时烧代币
|
||||
register 从 ~/.codex/auth.json 登记到登记册
|
||||
init 从 example 生成 accounts.local.json
|
||||
|
||||
@@ -277,6 +300,15 @@ async function main() {
|
||||
case "usage":
|
||||
cmdUsage();
|
||||
break;
|
||||
case "reset-status":
|
||||
cmdResetStatus();
|
||||
break;
|
||||
case "reset-now":
|
||||
cmdResetNow();
|
||||
break;
|
||||
case "auto-reset":
|
||||
cmdAutoReset(process.argv[3]);
|
||||
break;
|
||||
case "register":
|
||||
cmdRegister();
|
||||
break;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -31,7 +32,9 @@ function decodeAuth() {
|
||||
if (!fs.existsSync(p)) throw new Error("NO_CODEX_AUTH");
|
||||
const auth = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
const token = auth?.tokens?.access_token;
|
||||
const accountId = auth?.tokens?.account_id || auth?.account_id || "";
|
||||
if (!token) throw new Error("NO_ACCESS_TOKEN");
|
||||
if (!accountId) throw new Error("NO_ACCOUNT_ID");
|
||||
const id = auth.tokens?.id_token;
|
||||
let email = "";
|
||||
if (id) {
|
||||
@@ -40,16 +43,39 @@ function decodeAuth() {
|
||||
);
|
||||
email = payload.email || "";
|
||||
}
|
||||
return { token, email, last_refresh: auth.last_refresh || "" };
|
||||
return { token, accountId, email, last_refresh: auth.last_refresh || "" };
|
||||
}
|
||||
|
||||
async function fetchUsage(token) {
|
||||
const r = await fetch("https://chatgpt.com/backend-api/codex/usage", {
|
||||
async function fetchUsageViaPython() {
|
||||
const resetPy = path.join(process.env.HOME || "", ".codex/scripts/codex_reset.py");
|
||||
const raw = execSync(`python3 "${resetPy}" status --json`, {
|
||||
encoding: "utf8",
|
||||
timeout: 60000,
|
||||
});
|
||||
const status = JSON.parse(raw);
|
||||
return {
|
||||
email: status.email,
|
||||
plan_type: status.plan_type,
|
||||
rate_limit: {
|
||||
allowed: status.allowed,
|
||||
limit_reached: status.limit_reached,
|
||||
primary_window: status.primary_window,
|
||||
secondary_window: status.secondary_window,
|
||||
},
|
||||
rate_limit_reset_credits: status.reset_credits,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchUsage(token, accountId) {
|
||||
const r = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"ChatGPT-Account-Id": accountId,
|
||||
Accept: "application/json",
|
||||
Referer: "https://chatgpt.com/codex",
|
||||
Origin: "https://chatgpt.com",
|
||||
"OpenAI-Beta": "codex-1",
|
||||
originator: "Codex Desktop",
|
||||
"User-Agent": "CodexUsageSnapshot/1.0",
|
||||
},
|
||||
cache: "no-store",
|
||||
@@ -89,20 +115,20 @@ function rebuildMd(rows) {
|
||||
const lines = [
|
||||
"# Codex 用量登记册",
|
||||
"",
|
||||
`> 真源 \`${USAGE_JSONL}\` · API \`GET /backend-api/codex/usage\``,
|
||||
`> 真源 \`${USAGE_JSONL}\` · API \`GET /backend-api/wham/usage\``,
|
||||
`> 更新:${new Date().toISOString().slice(0, 16).replace("T", " ")}`,
|
||||
"",
|
||||
"Codex 设置菜单「剩余用量」可切换查看各窗口;本登记册按 API 窗口键对齐。",
|
||||
"",
|
||||
"| # | 快照时间 | 邮箱 | 套餐 | 限额已达 | 窗口 | 剩余% | 重置时间(CST) |",
|
||||
"|:---|:---|:---|:---|:---|:---|:---|:---|",
|
||||
"| # | 快照时间 | 邮箱 | 套餐 | 限额已达 | 重置代币 | 窗口 | 剩余% | 重置时间(CST) |",
|
||||
"|:---|:---|:---|:---|:---|:---|:---|:---|:---|",
|
||||
];
|
||||
let n = 0;
|
||||
rows.forEach((row) => {
|
||||
row.windows.forEach((w) => {
|
||||
n += 1;
|
||||
lines.push(
|
||||
`| ${n} | ${row.snapshot_at_cst} | ${row.email} | ${row.plan_type} | ${row.limit_reached ? "是" : "否"} | ${w.ui_label} | ${w.remaining_percent}% | ${w.reset_at_cst} |`
|
||||
`| ${n} | ${row.snapshot_at_cst} | ${row.email} | ${row.plan_type} | ${row.limit_reached ? "是" : "否"} | ${row.reset_credits_available ?? "—"} | ${w.ui_label} | ${w.remaining_percent}% | ${w.reset_at_cst} |`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -115,6 +141,7 @@ function rebuildMd(rows) {
|
||||
lines.push(`- 套餐:${last.plan_type}`);
|
||||
lines.push(`- allowed:${last.allowed}`);
|
||||
lines.push(`- limit_reached:${last.limit_reached}`);
|
||||
lines.push(`- 重置代币可用:${last.reset_credits_available ?? "—"}`);
|
||||
for (const w of last.windows) {
|
||||
lines.push(
|
||||
`- **${w.ui_label}**:剩余 **${w.remaining_percent}%**(已用 ${w.used_percent}%)· 重置 **${w.reset_at_cst}**`
|
||||
@@ -135,7 +162,7 @@ function loadRows() {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { token, email, last_refresh } = decodeAuth();
|
||||
const { token, accountId, email, last_refresh } = decodeAuth();
|
||||
const snapshotAt = new Date();
|
||||
const entry = {
|
||||
id: `usage_${Date.now()}`,
|
||||
@@ -149,20 +176,29 @@ async function main() {
|
||||
allowed: null,
|
||||
limit_reached: null,
|
||||
windows: [],
|
||||
reset_credits_available: null,
|
||||
auth_last_refresh: last_refresh,
|
||||
source: "codex_usage_api",
|
||||
note: "",
|
||||
};
|
||||
|
||||
try {
|
||||
const data = await fetchUsage(token);
|
||||
let data;
|
||||
try {
|
||||
data = await fetchUsage(token, accountId);
|
||||
} catch (e1) {
|
||||
data = await fetchUsageViaPython();
|
||||
entry.note = `Node fetch 失败(${e1.message});已用 Python wham/usage 回退`;
|
||||
}
|
||||
const rl = data.rate_limit || {};
|
||||
entry.email = data.email || email;
|
||||
entry.plan_type = data.plan_type || "";
|
||||
entry.allowed = rl.allowed;
|
||||
entry.limit_reached = rl.limit_reached;
|
||||
entry.windows = normalizeWindows(rl);
|
||||
entry.note = "API 实时拉取";
|
||||
entry.reset_credits_available =
|
||||
data.rate_limit_reset_credits?.available_count ?? null;
|
||||
if (!entry.note) entry.note = "API 实时拉取";
|
||||
} catch (e) {
|
||||
entry.source = "codex_usage_fallback";
|
||||
entry.note = `API 失败(${e.message});使用截图+早前 API 样本`;
|
||||
|
||||
201
04_卡火(火)/火炬_全栈消息/卡若宠物制作/LICENSE.txt
Normal file
201
04_卡火(火)/火炬_全栈消息/卡若宠物制作/LICENSE.txt
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf of
|
||||
any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don\'t include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
923
04_卡火(火)/火炬_全栈消息/卡若宠物制作/SKILL.md
Normal file
923
04_卡火(火)/火炬_全栈消息/卡若宠物制作/SKILL.md
Normal file
@@ -0,0 +1,923 @@
|
||||
---
|
||||
name: 卡若宠物制作
|
||||
description: Create, repair, validate, visually QA, and package 卡若AI v2 animated pets from character art, generated images, company or prospect brand cues, or visual references. Use for any new 卡若AI宠物, custom mascot, non-pixel pet style, brand-inspired pet, existing-pet repair, or 8x11 spritesheet workflow requiring all 9 standard animation rows, 16 look directions, deterministic assembly, QA artifacts, and spriteVersionNumber 2 packaging.
|
||||
---
|
||||
|
||||
# Hatch Pet
|
||||
|
||||
## Overview
|
||||
|
||||
Create a 卡若AI v2 animated pet from a concept, brand cue, company/prospect name, one or more reference images, or any combination of those inputs. Every newly hatched pet is an 8x11 atlas with the 9 standard animation rows plus 16 clockwise look directions and is packaged with `spriteVersionNumber: 2`. The intermediate 8x9 atlas exists only to assemble and review rows 0-8; never package it as a new pet.
|
||||
|
||||
User-facing inputs are optional. If the user omits a pet name, infer one from the concept, brand, company, or reference filenames; if that is not possible, choose a short friendly name. If the user omits a description, infer one from the concept or references. If the user omits reference images, generate the base pet from text first, then use that base as the canonical reference for every animation row.
|
||||
|
||||
## Existing Inputs And Upgrades
|
||||
|
||||
Treat character art, generated images, standard or v2 atlases, contact sheets, and built-in pet art as first-class grounding inputs.
|
||||
|
||||
- Preserve user-provided art as a generation reference; do not assume it already has final cell geometry.
|
||||
- For an existing valid 8x9 atlas, use it as the rows `0-8` intermediate after deterministic and visual validation, then generate rows `9-10` and package the result as v2.
|
||||
- For an existing 8x11 atlas, preserve approved standard rows. If a look cell fails, correct the complete containing 8-frame row before deterministic reassembly. Never package a newly generated one-off repair cell beside cells from another generation.
|
||||
- For a built-in pet, extract and use its atlas or neutral/idle cell as the canonical identity reference.
|
||||
- Include every image that defines head shape, face, palette, markings, material, flame/ears/hair, props, or look mechanics in look-direction generation.
|
||||
- When a renderer or source provides a dedicated neutral/front frame, pass it through `--neutral-cell`; otherwise use the approved idle/default frame. The 16 directional cells never treat `000` as neutral.
|
||||
|
||||
## Generation Delegation
|
||||
|
||||
Use `$imagegen` for all normal visual generation.
|
||||
|
||||
Before generating base art, row strips, or repair rows, load and follow the installed image generation skill:
|
||||
|
||||
```text
|
||||
${CODEX_HOME:-$HOME/.codex}/skills/.system/imagegen/SKILL.md
|
||||
```
|
||||
|
||||
Do not call the Image API, image CLI, or any other image-generation path directly. Let `$imagegen` choose its own built-in-first path and fallback rules. If `$imagegen` says a fallback requires confirmation, ask the user before continuing.
|
||||
|
||||
When invoking `$imagegen`, pass the generated pet prompt as the authoritative visual spec. Pet prompts should stay concise, state-specific, sprite-production oriented, and grounded in the listed input images. Keep longer policy and QA rules in this skill and the deterministic review scripts rather than expanding them into every image prompt. Do not wrap prompts in the generic `$imagegen` shared prompt schema.
|
||||
|
||||
Use this skill's scripts for deterministic image work only: preparing layout guides and prompts, mirroring approved `running-left`, extracting frames, validating rows, composing the final atlas, and creating contact-sheet plus motion-preview QA media. Parent-owned shell/`jq` steps handle manifest updates, packaging, and cleanup.
|
||||
|
||||
## Runtime Dependencies
|
||||
|
||||
Before running any bundled script, call `load_workspace_dependencies`. Set `PYTHON` to the exact Python executable path returned by that tool and use `"$PYTHON"` for every command below. The bundled runtime includes Pillow, which these scripts require. Do not use a bare system `python`; if workspace dependencies are unavailable, stop and report that the bundled runtime is required.
|
||||
|
||||
## Storage Controls
|
||||
|
||||
The built-in `$imagegen` path stores generated PNG bytes in the rollout that invokes it, even when it also writes a file under `${CODEX_HOME:-$HOME/.codex}/generated_images`. Deleting files later reduces filesystem use, but it does not shrink an already-written rollout. Keep image generation isolated and bounded:
|
||||
|
||||
- Use one lightweight generation worker per visual job. Do not batch multiple base/row jobs into the same worker.
|
||||
- Workers must return only `selected_source=...` and `qa_note=...`; they must not include Markdown image previews, base64, or extra visual attachments in their final response.
|
||||
- The parent must not open every generated PNG visually. Use worker QA for each job and inspect only the final contact sheet.
|
||||
- After copying the selected generated output into `decoded/`, remove the selected original from `${CODEX_HOME:-$HOME/.codex}/generated_images` when it lives there, then remove its now-empty generation directory if possible.
|
||||
- For storage-sensitive full runs, ask the user whether to use the `$imagegen` CLI fallback when available. That path requires local API credentials and explicit user confirmation, but it can avoid built-in image payloads being embedded in rollout events.
|
||||
|
||||
## Brand Discovery
|
||||
|
||||
If the user provides a brand, company, product, or prospect name rather than a concrete avatar description or reference image, run a lightweight discovery subagent before preparing the pet run. The discovery worker must use web search and prefer official sources such as the brand site, product pages, docs, about pages, press pages, or brand pages. Use reputable secondary sources only when official pages are too thin. Keep the search narrow: enough to extract visual and personality cues, not a market-research brief.
|
||||
|
||||
Skip discovery when the user already provides a concrete mascot/avatar description or reference images, unless the user explicitly asks for brand research.
|
||||
|
||||
Discovery worker responsibilities:
|
||||
|
||||
- search the web for 2-4 relevant sources, preferring official pages
|
||||
- write an adaptive markdown brief rather than a rigid field dump
|
||||
- cover identity/category, audience/use context, visual system, personality/tone, product/domain motifs, mascot translation cues, avoidances, and evidence/confidence
|
||||
- mark mascot guidance that is inferred from sources as inference
|
||||
- avoid copying logos, readable marks, UI screenshots, slogans, or text
|
||||
- end with a compact `Generation handoff` section containing only `brand_name`, `brand_brief`, `avatar_seed`, `avoid`, and `brand_sources`
|
||||
- do not generate images, prepare run folders, or edit unrelated files
|
||||
|
||||
Use this discovery worker prompt:
|
||||
|
||||
```text
|
||||
Research a brand for hatch-pet mascot creation.
|
||||
|
||||
Brand/product/prospect: <brand name>
|
||||
User context: <short user request>
|
||||
Output file: <absolute path to brand-discovery.md>
|
||||
|
||||
Use web search. Prefer official brand, product, docs, about, press, or brand pages. Use reputable secondary sources only if official sources are too thin. Write an adaptive markdown brief to the output file. Headings may flex by brand, but the brief must cover:
|
||||
- identity/category: canonical name, product type, what it does
|
||||
- audience/use context: who it serves and where it appears
|
||||
- visual system: palette, shapes, line quality, materials, typography feel, iconography, patterns
|
||||
- personality/tone: emotional traits, energy, formality, playfulness
|
||||
- product/domain motifs: objects, workflows, verbs, metaphors, environments
|
||||
- mascot translation cues: candidate forms, signature traits, props, what must read at pet size
|
||||
- avoidances: logos/text, trademark-sensitive elements, misleading cues, competitor confusion, poor mascot fits
|
||||
- evidence/confidence: source URLs plus notes where evidence is weak or inferred
|
||||
|
||||
Do not copy logos, readable marks, UI screenshots, slogans, or text. Clearly label mascot guidance that is inferred rather than directly sourced.
|
||||
|
||||
End the brief with a `Generation handoff` section containing exactly:
|
||||
- brand_name=<canonical brand/product name>
|
||||
- brand_brief=<one sentence, max 45 words, covering palette/tone/domain motifs/personality>
|
||||
- avatar_seed=<short mascot-safe visual idea, no logo copying>
|
||||
- avoid=<short comma-separated list>
|
||||
- brand_sources=<comma-separated source URLs>
|
||||
|
||||
Return exactly:
|
||||
brand_discovery_file=<absolute output file path>
|
||||
brand_name=<canonical brand/product name>
|
||||
brand_brief=<same compact sentence from Generation handoff>
|
||||
avatar_seed=<same short seed from Generation handoff>
|
||||
avoid=<same short avoid list from Generation handoff>
|
||||
brand_sources=<same comma-separated URLs from Generation handoff>
|
||||
```
|
||||
|
||||
The parent should save the markdown brief before preparing the run, then pass it to `prepare_pet_run.py` as `--brand-discovery-file` together with `--brand-name`, `--brand-brief`, repeated `--brand-source`, and a concise `--pet-notes` value based on `avatar_seed` when the user did not provide a better avatar description. Keep the full brief for review; only the compact handoff fields should shape prompts. If web search is unavailable and the user gave only a bare brand name, ask for brand cues before generating.
|
||||
|
||||
## Generation Contract
|
||||
|
||||
### Visual Job Graph
|
||||
|
||||
Expect up to 13 visual jobs: 1 base pet, 9 standard row strips, 1 required four-cardinal anchor strip, and 2 required coherent look-direction row strips. The standard states are `idle`, `running-right`, `running-left`, `waving`, `jumping`, `failed`, `waiting`, `running`, and `review`. The only deterministic visual derivation is `running-left`, which may be produced by mirroring `running-right` only after `running-right` has been generated, visually inspected, and explicitly approved as safe to mirror. If mirroring is not appropriate, generate `running-left` as a normal grounded `$imagegen` row.
|
||||
|
||||
### Look Direction Sequence
|
||||
|
||||
After validating rows 0–8, write qa/look-mechanics.md, then generate and approve one four-pose cardinal strip in this fixed order: 000 up, 090 screen-right, 180 down, and 270 screen-left. Generate row 9 as one coherent eight-pose family from those approved cardinal pose families, interpolating the intermediate directions as even 22.5-degree steps. Deterministically register its eight ordered pose groups, then run final-cell edge, semantic, and continuity QA immediately. Only after row 9 passes, generate row 10 as one coherent eight-pose family, using the approved cardinals for direction meaning and completed row 9 for identity, scale, registration, and boundary continuity. Run the same QA immediately after row 10. Row 9 contains 000, 022.5, 045, 067.5, 090, 112.5, 135, and 157.5; row 10 contains 180, 202.5, 225, 247.5, 270, 292.5, 315, and 337.5. 000 means up, not neutral/front. Never ask $imagegen to generate or repair a complete 8×11 atlas.
|
||||
|
||||
### Visual Provenance And Grounding
|
||||
|
||||
After selecting a visual output, the parent agent copies that exact image into the job's `decoded/` path, runs its required incremental checks, and only then marks the job complete in `imagegen-jobs.json`. Do not write helper scripts that populate row outputs. The deterministic Python scripts may only process already-generated visual outputs.
|
||||
|
||||
Only the base job may be prompt-only. Every row-strip job generated through `$imagegen` must use the input images listed in `imagegen-jobs.json`, including the canonical base reference created after the selected base output is copied. Treat any row generation without attached grounding images as invalid.
|
||||
|
||||
## Pet-Safe Styles
|
||||
|
||||
Default style is `auto`: infer the pet's style from the user's prompt and references, then preserve that style across every row. If the user names a style, honor it. Supported style presets include `pixel`, `plush`, `clay`, `sticker`, `flat-vector`, `3d-toy`, `painterly`, `brand-inspired`, and `auto`.
|
||||
|
||||
Any style is acceptable when it remains pet-safe:
|
||||
|
||||
- compact whole-body silhouette readable inside a `192x208` cell
|
||||
- consistent face, proportions, material, palette, and props across all rows
|
||||
- clean removable chroma-key background
|
||||
- details large enough to read at pet size
|
||||
- no text, labels, UI, or readable logos unless the user explicitly provides approved reference art and asks for them
|
||||
|
||||
Non-pixel styles are first-class. Plush, clay, sticker, vector, 3D toy, painterly mascot, ink, and brand-inspired looks should be accepted when they satisfy the atlas and readability constraints.
|
||||
|
||||
## Transparency And Effects
|
||||
|
||||
Pet rows are processed into transparent `192x208` cells, so every generated pixel must either belong to the pet sprite or be cleanly removable chroma-key background. Prefer pose, expression, and silhouette changes over decorative effects.
|
||||
|
||||
The deterministic raster pipeline owns the transparency and chroma-cleanup invariants. Its final edge-local spill-suppression step selects every translucent silhouette-boundary pixel plus opaque boundary pixels whose chroma points toward the known key, then extends clean interior RGB outward through that band in linear light. It preserves alpha exactly, clears hidden RGB under fully transparent pixels, and reports the algorithm and parameters used. The cleanup report plus atlas validator are authoritative for chroma contamination. Once the final report has `ok: true` and atlas validation passes, do not regenerate imagery or add another chroma-cleanup pass.
|
||||
|
||||
Fully transparent pixels are allowed outside the sprite silhouette, in unused cells, and in intentional negative-space openings that are part of the pet's design, such as loops or holes in a ribbon body. Reject any generated or repaired cell with accidental 100%-transparent holes inside a filled body, including horizontal bands, seam rows, scanline-like gaps, sliced-tile boundaries, or "see-through" interior stripes. Inspect suspect cells on a high-contrast background or alpha mask before accepting them; ordinary atlas validation is not enough when the hole is inside the silhouette.
|
||||
|
||||
Allowed effects must satisfy all of these conditions:
|
||||
|
||||
- The effect is state-relevant and helps explain the animation.
|
||||
- The effect is physically attached to, touching, or overlapping the pet silhouette, not floating nearby.
|
||||
- The effect is inside the same frame slot as the pet and does not create a separate sprite component.
|
||||
- The effect is opaque, hard-edged enough for clean extraction, and uses non-chroma-key colors.
|
||||
- The effect is small enough to remain readable at `192x208` without clutter.
|
||||
|
||||
Avoid these by default because they usually break transparent-background cleanup or component extraction:
|
||||
|
||||
- wave marks, motion arcs, speed lines, action streaks, afterimages, blur, or smears
|
||||
- detached stars, loose sparkles, floating punctuation, floating icons, falling tear drops, separated smoke clouds, or loose dust
|
||||
- cast shadows, contact shadows, drop shadows, oval floor shadows, floor patches, landing marks, impact bursts, glow, halo, aura, or soft transparent effects
|
||||
- text, labels, frame numbers, visible grids, guide marks, speech bubbles, thought bubbles, UI panels, code snippets, checkerboard transparency, white backgrounds, black backgrounds, or scenery
|
||||
- chroma-key-adjacent colors in the pet, prop, effects, highlights, or shadows
|
||||
- stray pixels, disconnected outline bits, speckle/noise, cropped body parts, overlapping poses, or any pose that crosses into a neighboring frame slot
|
||||
|
||||
State-specific guidance:
|
||||
|
||||
- `idle`: keep this calm and low-distraction. Use only subtle breathing, a tiny blink, a slight head or body bob, a very small material sway, or another quiet persona-preserving motion. The loop must still contain visible micro-variation; do not accept six effectively identical copies. Do not show waving, walking, running, jumping, talking, working, reviewing, emotional reactions, large gestures, item interactions, or new props.
|
||||
- `waving`: show the wave through paw, hand, wing, or limb pose only. Do not draw wave marks, motion arcs, lines, sparkles, symbols, or floating effects around the gesture.
|
||||
- `jumping`: show vertical motion through body position only. Do not draw shadows, dust, landing marks, impact bursts, bounce pads, or floor cues.
|
||||
- `failed`: tears, attached smoke puffs, or attached stars are allowed if they obey the allowed-effects rules; do not use red X marks, floating symbols, detached smoke, detached stars, or separate tear droplets.
|
||||
- `waiting`: show that Codex needs approval, help, or user input through an expectant asking pose. Keep it distinct from ordinary idle and review.
|
||||
- `running`: show active task work, processing, thinking, scanning, typing, or focused effort. Do not show literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, directional travel, speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.
|
||||
- `review`: show focus through lean, blink, eyes, head tilt, or paw/hand position. Do not add magnifying glasses, papers, code, UI, punctuation, symbols, or other new props unless they already exist in the base pet identity.
|
||||
- `running-right` and `running-left`: show directional drag movement through body, limb, and prop movement only. `running-right` must face and travel right; `running-left` must face and travel left. Their cadence must visibly alternate across the loop rather than repeating one nearly static stride. Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.
|
||||
|
||||
## Visible Progress Plan
|
||||
|
||||
For every pet run, keep a visible checklist so the user can see where the work is up to. Create the checklist before starting, keep one step active at a time, and update it as each step finishes.
|
||||
|
||||
Use this checklist for every v2 pet run, replacing `<Pet>` with the pet's name or `your pet`:
|
||||
|
||||
1. Getting `<Pet>` ready.
|
||||
2. Imagining `<Pet>`'s main look.
|
||||
3. Picturing `<Pet>`'s poses.
|
||||
4. Hatching `<Pet>`.
|
||||
|
||||
What each step means:
|
||||
|
||||
- `Getting <Pet> ready.` Choose or confirm the pet name, description, source images, style preset, style notes, and working folder. For bare brand/product/company requests, first run the brand discovery worker and capture the compact brand brief, source URLs, and avatar seed.
|
||||
- `Imagining <Pet>'s main look.` Generate the pet's main reference image. This becomes the visual source of truth.
|
||||
- `Picturing <Pet>'s poses.` Generate and approve rows `0-8`, write the pet-specific look mechanics plan, then generate rows `9-10`. Only mirror `running-left` if `running-right` clearly works when flipped.
|
||||
- `Hatching <Pet>.` Assemble the 8x11 atlas, review standard motion plus all 16 look directions, fix every failed cell or row, package `spriteVersionNumber: 2`, and report the output paths.
|
||||
|
||||
Only mark a step complete when the real file, image, or decision exists. If this is a repair run, start from the first relevant step instead of restarting the whole checklist.
|
||||
|
||||
## Time Budget And Convergence
|
||||
|
||||
Aim to complete a normal pet run within 30 minutes while preserving every mandatory acceptance criterion. Treat this as a planning target and an incentive to maximize validated progress per minute, not as permission to weaken QA or package a failing pet.
|
||||
|
||||
At the start of the run, allocate an approximate budget:
|
||||
|
||||
- preparation: 2 minutes
|
||||
- base image: 3 minutes
|
||||
- standard rows: 10 minutes
|
||||
- look directions: 8 minutes
|
||||
- final QA and packaging: 5 minutes
|
||||
- buffer: 2 minutes
|
||||
|
||||
Run independent generation jobs concurrently up to the worker limit, start deterministic checks as soon as each dependency is ready, and record actual stage plus repair time. Prefer character and prop constructions that naturally satisfy cell geometry, transparency, component connectivity, and direction semantics; identify likely conflicts such as open interior gaps, detached parts, thin connectors, asymmetric props, or ambiguous faces before row generation.
|
||||
|
||||
After every failed attempt:
|
||||
|
||||
1. Classify the failure as visual semantics, identity, source-edge geometry, component connectivity, extraction, chroma, continuity, or final visual QA.
|
||||
2. State the concrete evidence and the root condition the next action will change.
|
||||
3. Use a deterministic correction for deterministic failures before regenerating imagery.
|
||||
4. Regenerate only when the source visual is genuinely wrong, and preserve every property that already passed.
|
||||
5. Compare the new result with the previous one. A repair counts as progress only when it reduces the number or severity of failures without breaking a previously passing gate.
|
||||
|
||||
If the same root failure recurs twice, stop varying the prompt and change strategy: strengthen the cardinal pose families or row-level direction instructions, simplify the pose or prop construction, change the deterministic extraction method, or redesign the problematic visual feature. If a repair merely moves a failure to another cell or gate, treat that as a cycle and change strategy immediately.
|
||||
|
||||
Use elapsed-time checkpoints:
|
||||
|
||||
- At 15 minutes, verify the run is on pace and that the remaining dependency path is bounded.
|
||||
- At 25 minutes, prioritize the shortest quality-preserving path through remaining blockers and avoid optional polish.
|
||||
- At 30 minutes, continue only when the remaining work is clearly converging and bounded, such as final validation, one targeted repair, or packaging.
|
||||
- Keep recording elapsed time, retries, validation failures, and QA cost throughout the run, but do not pause or stop solely because elapsed time crosses 45 or 60 minutes. Continue until the pet passes, the user cancels, or a genuine external blocker prevents further progress.
|
||||
|
||||
Never use the time target to skip blind direction QA, labeled semantics, continuity review, atlas validation, despill validation, final visual QA, or any other acceptance criterion.
|
||||
|
||||
## Default Workflow
|
||||
|
||||
1. Prepare a pet run folder and imagegen job manifest:
|
||||
|
||||
```bash
|
||||
SKILL_DIR="${CODEX_HOME:-$HOME/.codex}/skills/hatch-pet"
|
||||
"$PYTHON" "$SKILL_DIR/scripts/prepare_pet_run.py" \
|
||||
--pet-name "<Name>" \
|
||||
--description "<one sentence>" \
|
||||
--reference /absolute/path/to/reference.png \
|
||||
--output-dir /absolute/path/to/run \
|
||||
--pet-notes "<stable pet description>" \
|
||||
--brand-discovery-file /absolute/path/to/brand-discovery.md \
|
||||
--brand-name "<optional researched brand name>" \
|
||||
--brand-brief "<optional compact researched brand cue sentence>" \
|
||||
--brand-source "https://example.com/source" \
|
||||
--style-preset auto \
|
||||
--style-notes "<optional freeform style notes>" \
|
||||
--force
|
||||
```
|
||||
|
||||
All arguments above are optional except any flags needed to express user constraints. For text-only requests, pass the concept through `--pet-notes` and omit `--reference`; `prepare_pet_run.py` will infer a name, description, chroma key, and output directory as needed.
|
||||
For brand-only requests, run the discovery worker first, save the markdown brief, then pass the brief path through `--brand-discovery-file`, `avatar_seed` through `--pet-notes`, `brand_name` through `--brand-name`, `brand_brief` through `--brand-brief`, and each source URL through repeated `--brand-source`.
|
||||
|
||||
2. Inspect `imagegen-jobs.json` for the next ready `$imagegen` jobs. A job is ready when its `status` is not `complete` and every id in `depends_on` is already complete. Prefer reading the manifest directly with `jq` or the editor instead of adding helper scripts for status display:
|
||||
|
||||
```bash
|
||||
jq '.jobs[] | {id, kind, status, depends_on, prompt_file, retry_prompt_file, input_images, output_path, derivation_policy}' /absolute/path/to/run/imagegen-jobs.json
|
||||
```
|
||||
|
||||
3. Generate visual jobs with lightweight workers by default:
|
||||
|
||||
- Generate and copy `base` first, using a lightweight base worker.
|
||||
- Generate and copy `idle` and `running-right` next as the identity and gait check, using one lightweight worker per row.
|
||||
- Inspect `running-right`; mirror `running-left` only when visual identity, prop placement, markings, lighting, and direction semantics remain correct.
|
||||
- Generate `running-left` normally with a lightweight worker when mirroring would change meaning or identity.
|
||||
- Generate the remaining rows with lightweight workers, using every input image listed for each job.
|
||||
- After standard-row QA, generate `look-cardinals` as one four-pose strip, extract it into `decoded/look-anchors/000.png`, `090.png`, `180.png`, and `270.png`, and approve all four. The `090` and `270` anchors must be unmistakable in viewer/screen coordinates and visibly oppose each other.
|
||||
- Generate look row 9 as one coherent eight-pose synthesis from the approved cardinal strip, interpolating each intermediate direction as an even step between the adjacent cardinal pose families. Deterministically recover the eight ordered pose groups, crop them, normalize them with one shared scale and baseline, and then run final-cell edge diagnostics plus labeled per-direction QA immediately, before row 10 or final atlas assembly.
|
||||
- Only after row 9 passes, generate row 10 as one coherent synthesis using the approved cardinal strip and completed row 9.
|
||||
|
||||
Keep up to three generation workers active whenever three independent jobs are ready and worker capacity permits. Backfill an available slot immediately instead of waiting for a fixed wave to finish. Use two or one worker when the dependency graph exposes fewer ready jobs. Do not exceed three generation workers without explicit user direction.
|
||||
|
||||
For each ready visual job, invoke `$imagegen` with the prompt file listed in `imagegen-jobs.json`, every listed input image with its role label, and the default built-in `image_gen` path unless `$imagegen` itself routes otherwise. The parent agent must keep its own image handling minimal: do not open every generated base or row in the parent rollout. Workers return only the selected source path and a one-sentence QA note; the parent records the selected source path in the manifest.
|
||||
|
||||
`prepare_pet_run.py` creates matching layout guides under `references/layout-guides/` for the nine standard rows, two look rows, and four-cardinal strip, and both look rows. Visual jobs attach the matching guide as a layout-only input so the model can follow the correct frame count, spacing, centering, and safe padding. Treat these guides as invisible construction references: generated strips must not include visible boxes, borders, center marks, labels, guide colors, or the guide background.
|
||||
|
||||
When generating row strips, keep the identity lock in the row prompt authoritative. Preserve the same style, face, markings, palette, materials, prop design, body proportions, and silhouette from the canonical base. Row jobs attach the layout guide and canonical base by default; the decoded base is kept in the run folder for deterministic processing rather than sent as a redundant generation input.
|
||||
|
||||
If `$imagegen` returns a transport-level `Bad Request` for a row, retry that same row once with its generated `retry_prompt_file`. The retry prompt preserves the row id, frame count, chroma key, canonical-base identity, and state action. Keep the canonical base attached. If the retry still fails, stop and report the failing row and prompt paths instead of switching to any other generation path.
|
||||
|
||||
4. After selecting a generated output for a job, copy it into the decoded output path. For `base`, also create the canonical identity reference:
|
||||
|
||||
```bash
|
||||
RUN_DIR=/absolute/path/to/run
|
||||
JOB_ID=<job-id>
|
||||
SOURCE=/absolute/path/to/generated-output.png
|
||||
OUTPUT_REL=$(jq -r --arg id "$JOB_ID" '.jobs[] | select(.id == $id) | .output_path' "$RUN_DIR/imagegen-jobs.json")
|
||||
mkdir -p "$(dirname "$RUN_DIR/$OUTPUT_REL")"
|
||||
cp "$SOURCE" "$RUN_DIR/$OUTPUT_REL"
|
||||
```
|
||||
|
||||
```bash
|
||||
if [ "$JOB_ID" = "base" ]; then mkdir -p "$RUN_DIR/references"; cp "$RUN_DIR/$OUTPUT_REL" "$RUN_DIR/references/canonical-base.png"; fi
|
||||
```
|
||||
|
||||
For every standard `row-strip` job, immediately extract and inspect only that row before marking the job complete. This overlaps deterministic QA and any repair with generation of other ready rows instead of waiting for all nine rows:
|
||||
|
||||
```bash
|
||||
ROW_QA_DIR="$RUN_DIR/qa/rows/$JOB_ID"
|
||||
"$PYTHON" "$SKILL_DIR/scripts/extract_strip_frames.py" \
|
||||
--decoded-dir "$RUN_DIR/decoded" \
|
||||
--output-dir "$ROW_QA_DIR/frames" \
|
||||
--states "$JOB_ID" \
|
||||
--method auto
|
||||
"$PYTHON" "$SKILL_DIR/scripts/inspect_frames.py" \
|
||||
--frames-root "$ROW_QA_DIR/frames" \
|
||||
--json-out "$ROW_QA_DIR/review.json" \
|
||||
--states "$JOB_ID" \
|
||||
--require-components
|
||||
```
|
||||
|
||||
Treat errors as an immediate repair request. Inspect warnings before accepting the row; do not defer a known clipping, component, or extraction problem to final atlas QA. Chroma cleanup belongs to the deterministic post-assembly despill pass and must not trigger row regeneration. If the only failure is component extraction and the source strip itself has stable scale and placement, use the existing `stable-slots` correction with `--allow-stable-slots` instead of regenerating imagery.
|
||||
|
||||
For `look-cardinals`, extract and validate all four anchors before marking the job complete:
|
||||
|
||||
```bash
|
||||
CHROMA_KEY=$(jq -r '.chroma_key.hex' "$RUN_DIR/pet_request.json")
|
||||
"$PYTHON" "$SKILL_DIR/scripts/extract_cardinal_anchors.py" \
|
||||
--strip "$RUN_DIR/decoded/look-cardinals.png" \
|
||||
--output-dir "$RUN_DIR/decoded/look-anchors" \
|
||||
--chroma-key "$CHROMA_KEY" \
|
||||
--json-out "$RUN_DIR/qa/cardinal-anchors.json"
|
||||
"$PYTHON" "$SKILL_DIR/scripts/compose_cardinal_anchor_strip.py" \
|
||||
--anchors-dir "$RUN_DIR/decoded/look-anchors" \
|
||||
--output "$RUN_DIR/decoded/look-anchors-approved.png"
|
||||
```
|
||||
|
||||
Approve the four extracted anchors semantically at final pet size. If one cardinal fails, regenerate that individual anchor with `prompts/look-anchor-repairs/<degree>.md`, replace only its extracted file, and rerun `compose_cardinal_anchor_strip.py`. Both final look rows use the approved cardinal strip, and row 10 additionally uses completed row 9. Mark the job complete only after its required deterministic and visual checks pass:
|
||||
|
||||
```bash
|
||||
UPDATED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
TMP_MANIFEST=$(mktemp)
|
||||
jq --arg id "$JOB_ID" --arg source "$SOURCE" --arg at "$UPDATED_AT" '(.jobs[] | select(.id == $id)) += {status: "complete", source_path: $source, completed_at: $at}' "$RUN_DIR/imagegen-jobs.json" > "$TMP_MANIFEST"
|
||||
mv "$TMP_MANIFEST" "$RUN_DIR/imagegen-jobs.json"
|
||||
```
|
||||
|
||||
After `decoded/look-anchors-approved.png` exists and all four cardinals have passed semantic review, mark `look-cardinals` complete. Row 9 then becomes ready immediately.
|
||||
|
||||
If the copied source is under `${CODEX_HOME:-$HOME/.codex}/generated_images`, delete the original generated file after the decoded copy exists:
|
||||
|
||||
```bash
|
||||
GENERATED_ROOT="${CODEX_HOME:-$HOME/.codex}/generated_images"
|
||||
case "$SOURCE" in
|
||||
"$GENERATED_ROOT"/*)
|
||||
rm -f "$SOURCE"
|
||||
rmdir "$(dirname "$SOURCE")" 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
```
|
||||
|
||||
5. Derive `running-left` only when it is visually safe:
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/derive_running_left_from_running_right.py" \
|
||||
--run-dir /absolute/path/to/run \
|
||||
--confirm-appropriate-mirror \
|
||||
--decision-note "<why mirroring preserves this pet's identity>"
|
||||
```
|
||||
|
||||
That script mirrors each generated frame slot in place so the leftward row preserves the rightward row's temporal order. Do not replace it with a whole-strip mirror that reverses animation timing.
|
||||
|
||||
6. When all nine incrementally validated standard row jobs are complete, build and review the intermediate rows `0-8`:
|
||||
|
||||
```bash
|
||||
RUN_DIR=/absolute/path/to/run
|
||||
mkdir -p "$RUN_DIR/final" "$RUN_DIR/qa"
|
||||
```
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/extract_strip_frames.py" \
|
||||
--decoded-dir "$RUN_DIR/decoded" \
|
||||
--output-dir "$RUN_DIR/frames" \
|
||||
--states all \
|
||||
--method auto
|
||||
```
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/inspect_frames.py" \
|
||||
--frames-root "$RUN_DIR/frames" \
|
||||
--json-out "$RUN_DIR/qa/review.json" \
|
||||
--require-components
|
||||
```
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/compose_atlas.py" \
|
||||
--frames-root "$RUN_DIR/frames" \
|
||||
--output "$RUN_DIR/final/spritesheet.png" \
|
||||
--webp-output "$RUN_DIR/final/spritesheet.webp"
|
||||
```
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/make_contact_sheet.py" \
|
||||
"$RUN_DIR/final/spritesheet.webp" \
|
||||
--output "$RUN_DIR/qa/contact-sheet.png"
|
||||
```
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/render_animation_previews.py" \
|
||||
--frames-root "$RUN_DIR/frames" \
|
||||
--output-dir "$RUN_DIR/qa/previews"
|
||||
```
|
||||
|
||||
If the preview GIFs show size popping or baseline jumps caused by per-frame fit-to-cell extraction, and the original row strip itself had stable scale and placement, rerun frame extraction with the explicit row-stability mode and then re-run inspection, atlas composition, contact sheet generation, and previews:
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/extract_strip_frames.py" \
|
||||
--decoded-dir "$RUN_DIR/decoded" \
|
||||
--output-dir "$RUN_DIR/frames" \
|
||||
--states all \
|
||||
--method stable-slots
|
||||
```
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/inspect_frames.py" \
|
||||
--frames-root "$RUN_DIR/frames" \
|
||||
--json-out "$RUN_DIR/qa/review.json" \
|
||||
--require-components \
|
||||
--allow-stable-slots
|
||||
```
|
||||
|
||||
Use `stable-slots` as a deliberate QA-driven correction, not the default. It should reduce extraction-induced motion pops without hiding clipped wide poses or bad source strips.
|
||||
|
||||
Expected intermediate output before the required v2 look stage:
|
||||
|
||||
```text
|
||||
run/
|
||||
pet_request.json
|
||||
imagegen-jobs.json
|
||||
prompts/
|
||||
decoded/
|
||||
frames/frames-manifest.json
|
||||
final/spritesheet.webp
|
||||
qa/contact-sheet.png
|
||||
qa/previews/*.gif
|
||||
qa/review.json
|
||||
```
|
||||
|
||||
Inspect `qa/contact-sheet.png` and `qa/previews/*.gif` before generating look rows. `qa/review.json` plus visual motion review are the intermediate gates. The standard contact sheet intentionally predates chroma cleanup, so visible key-color fringe there is not a failure; judge chroma only on the cleaned final v2 atlas. Block progress if any standard row changes identity, style, prop handedness, or silhouette, or if playback pops, reverses cadence, faces the wrong direction, or is visually inert. Do not package or clean up yet.
|
||||
|
||||
## Required V2 Look-Direction Stage
|
||||
|
||||
Every new pet must complete this stage. After standard-row QA passes, write `qa/look-mechanics.md`, approve the four cardinals, synthesize and validate the complete `look-row-9`, then synthesize `look-row-10`. Row 10 becomes ready only after row 9 is deterministically registered, clears post-registration edge checks, and has no semantic or continuity hard failure; reviewed warnings may remain. It uses row 9 plus the approved cardinal strip as continuity evidence.
|
||||
|
||||
Before either look row, run the prepared `look-cardinals` strip job, extract its four cells with `extract_cardinal_anchors.py`, and approve them. Do not let a two-row sweep invent its own left/right basis. `090` must point toward the viewer's screen-right edge and `270` toward the viewer's screen-left edge; for faces, the nose tip and pupils must cross to the corresponding side of the head center. If one cardinal is ambiguous, regenerate only that anchor before continuing.
|
||||
|
||||
After copying row 9 into `decoded/look-row-9.png`, register and edge-check it with the same transform used by final assembly:
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/assemble_extended_atlas.py" \
|
||||
--base-atlas "$RUN_DIR/final/spritesheet.webp" \
|
||||
--look-row-9 "$RUN_DIR/decoded/look-row-9.png" \
|
||||
--neutral-cell "$RUN_DIR/frames/idle/00.png" \
|
||||
--chroma-key "$CHROMA_KEY" \
|
||||
--chroma-threshold 96 \
|
||||
--registered-row-output "$RUN_DIR/qa/look-row-9-registered.png" \
|
||||
--registration-manifest-output "$RUN_DIR/qa/look-row-9-registration.json"
|
||||
```
|
||||
|
||||
Inspect the eight registered cells at normal pet size in `000` through `157.5` order. Record the row-9 semantic and adjacent-continuity review, resynthesize the complete row for any hard failure, and mark `look-row-9` complete only after this check passes. That completion makes row 10 ready in `imagegen-jobs.json`.
|
||||
|
||||
Generate only the additional look-direction visuals with `$imagegen`:
|
||||
|
||||
- Required for new pets: two coherently synthesized 8-frame row strips, one for row 9 and one for row 10.
|
||||
- Always include the canonical base reference and approved 8x9 contact sheet.
|
||||
- Keep the body scale, baseline, head size, face, materials, palette, markings, and props consistent with the standard atlas.
|
||||
- Before prompting, write a short look mechanics decision for this specific pet. First ask: **what is the best natural motion for this character when looking around?** Describe what stays anchored, what leads the gaze, what follows, and what bends, shifts, turns, squashes, stretches, or deforms. Include eyes and props: decide whether eyes rotate as physical eyeballs, irises move on a fixed surface, eyelids reshape, pupils slide, props stay stable, props lag slightly, or props move with the body. Use the character's physical construction as the guide: flexible wire should bend, soft bodies should deform, separate heads should turn, ears/fur/antennae may follow through, physical eyeballs should rotate as whole eye globes in their sockets, flat screen or sticker eyes may change their drawn features on a fixed surface, and rigid or screen-like characters may stay body-locked while facial features move.
|
||||
- Define a motion budget before generation: each 22.5-degree step should move the same parts by roughly the same visual amount, with no single adjacent pair doing a larger bend, scale change, prop shift, or silhouette change unless the mechanics decision explicitly calls out that asymmetry. Generate row 9 first along `000 -> 090 -> 180`, then give completed row 9 to row 10 so `180` begins exactly one step after `157.5`. Row 10 follows `180 -> 270 -> 000`, and `337.5` in row 10 must land one step before the approved `000` in row 9.
|
||||
- Do not use whole-sprite rotation, whole-cell rotation, skewing, or affine tilting to fake gaze direction. A direction row built by rotating the entire pet is failed unless the pet is literally a rotating rigid object and the look mechanics decision explicitly says the whole object should rotate. Whole-body tilt that makes the item/background appear to rock left or right is not natural look behavior for ordinary pets.
|
||||
- Generate a coherent 16-pose gaze set, not 16 unrelated variants. Each direction should feel like a point on one continuous arc around the clock.
|
||||
- The look mechanics decision must name the natural pose family for each cardinal direction before generation, including which body side becomes more visible, which features become occluded, and how any held prop follows or lags. Do not let the generator infer all directions from one front-facing pose. For characters with a face or head, leftward directions must visibly turn or bend the face/head left, rightward directions must visibly turn or bend right, up/down directions must use the eyes, eyelids, head angle, neck, and upper body as physically appropriate, and diagonals must interpolate between those pose families. A set where every cell remains essentially front-facing, or where all leftward cells still read as front/right-facing, is failed.
|
||||
- Adjacent direction cells must have continuous body movement. Compare every neighboring pair in direction order, including `157.5 -> 180`, `337.5 -> 000`, and any row-strip boundary. Anchored parts must not jump, flip sides, or teleport between adjacent states; if a body part moves laterally, bends, stretches, or rotates, its position should progress gradually across the intervening directions.
|
||||
- Do not mirror, re-center, or independently regenerate adjacent direction cells in a way that changes the pet's body registration. Keep a stable anchor, usually the feet/base/torso/lower body or the natural grounded part of the character, and let only the intended look mechanics change around it.
|
||||
- Every look cell must be visually distinguishable from the neutral/resting frame at final pet size. A direction cell that reads as front-facing, idle, or neutral is failed even when it is non-empty, transparent, and in the correct row/column.
|
||||
- Cardinal directions must be semantically unmistakable at final pet size, not only numerically or geometrically different. `000` must clearly read as looking up, `090` as looking right, `180` as looking down, and `270` as looking left using the pet's natural mechanism. If the pet has no pupils or physical eyeballs, the head, face surface, eyelids, antennae, ears, or body bend must carry the direction clearly enough that a viewer can identify the cardinal without labels.
|
||||
- Diagonal and intermediate directions should broadly occupy the intended quadrant and advance naturally through the ordered loop. Minor pupil, nose, eyelid, or feature-placement deviations are not failures by themselves. Reject only gross wrong-quadrant poses, visible reversals, or intermediate cells that break the coherent motion family.
|
||||
- For eyeless object pets, do not default to literal whole-object rotation just because the object is rigid. First identify whether the object has a natural front, display face, playable surface, readable silhouette, or iconic viewing angle. Preserve that primary readable face unless the user explicitly asks for turntable rotation. Express look direction through subtle object-specific body language: small lean, neck/tip aim, hinge, yaw, pitch, bend, vibration, squash, follow-through, or attached-part motion. The direction should read as attention or orientation, not as the object spinning through all clock angles.
|
||||
- Preserve the pet's original eye design in look-direction cells. Do not paint new round "googly" eyes, replacement eye whites, floating pupils, detached eye dots, or a second eye layer on top of the source eyes. Eye motion must follow the look mechanics decision. If the pet has physical eyeballs, rotate or redraw the whole eyeball surface so the sclera/eye white, iris, pupil, eyelids, rim, and highlights change together as one physical eye; do not slide only the iris or pupil across a fixed eye white. If the pet has flat printed, sticker, or screen eyes, keep the surface fixed and move/redraw only the features that would physically change on that surface. Do not use procedural pupil/iris compositing unless it is clipped to the original eye aperture and visibly remains inside the head silhouette in every direction. If the original eye design cannot be preserved cleanly, regenerate the whole look cell with the original eye construction instead of compositing new eyes over it.
|
||||
- Eyes may lead the gaze, but pupil-only motion is an exception, not the default. Use it only when the look mechanics decision explains why whole-eye rotation, eyelid reshaping, body, head, or feature movement would be unnatural for that specific design. Large-eye pets, cyclops pets, and round rigid-body pets with physical eyeballs usually should rotate the whole eye globes, not use pupil-only or googly-eye sliding. Screen-face pets and printed-eye pets may be body-locked with feature motion only. Separate head/body pets should usually combine eye movement with head turn, head tilt, ear/fur/upper-body follow-through, and a stable torso. Rigid object mascots may hinge, flex, slide, or shift attached features without rotating the whole sprite. Flexible wire or paperclip-like mascots should usually keep the feet/base anchored while the upper loop or face area bends toward the target and held props remain stable or lag subtly. Blob or organic pets should usually keep a base anchored while the face/head area stretches subtly toward the target. Other pet types should get their own similarly grounded mechanics.
|
||||
- Human or humanoid pets need persona-preserving look mechanics. Do not use broad non-rigid raster warps that stretch the skull, brows, mouth, hoodie, hands, or held props just to make a direction read. The eyes should usually lead the gaze with visible eye, eyelid, and eyebrow participation, then the head/neck and upper body should follow subtly; a humanoid row where the head moves but the eyes stay locked in one expression is failed unless the mechanics decision gives a specific physical reason. Use small eye rotation, eyelid/eyebrow changes, head/neck turn, and restrained upper-body follow-through while preserving facial proportions and expression. Programmatic repairs must move anatomical parts with rigid or near-rigid part motion, not displacement fields that change facial feature spacing. For pets with held, worn, or attached props, infer each prop's physical constraints before generating look directions: where it is anchored, whether it is rigid or flexible, whether it leads or lags the body, and how it should occlude or be occluded as the character turns. Props near the face may become more side-on, partly hidden by the head, or reveal different contact points; hand-held tools may swing or lag subtly while staying attached; worn props should follow the body; flexible cords or straps should arc continuously. Do not keep the prop and character in the same front-facing relationship across all look directions. Before packaging a humanoid pet, inspect the normal-size neutral and cardinal cells together and reject identity or facial-proportion drift, or a `270` cardinal that does not unmistakably read as left.
|
||||
- For every pet, use cardinal anchors instead of trusting a two-row sweep to preserve left/right semantics. Generate `000`, `090`, `180`, and `270` together as one strip, then extract and approve them. The final look rows use those four pose families for direction meaning and interpolate the intermediate directions as a coherent arc. Define directions in viewer/screen coordinates, never character-relative coordinates. Do not require exact pupil or nose placement on intermediate poses; use the ordered loop and overall quadrant motion as the primary evidence.
|
||||
- Keep motion subtle and pet-safe: preserve volume, baseline, silhouette readability, identity, and material believability. The look pose may involve head, eyes, face, upper body, appendages, or body deformation only when those parts would naturally participate.
|
||||
- Do not add labels, degree text, arrows, clocks, guide marks, shadows, glows, scenery, or detached effects.
|
||||
|
||||
Direction order is fixed:
|
||||
|
||||
```text
|
||||
row 9: 000, 022.5, 045, 067.5, 090, 112.5, 135, 157.5
|
||||
row 10: 180, 202.5, 225, 247.5, 270, 292.5, 315, 337.5
|
||||
```
|
||||
|
||||
`000` means looking up / 12 o'clock. Neutral/front is the pointer deadzone and should fall back to idle unless the target renderer explicitly uses a neutral cell.
|
||||
|
||||
### Direction Acceptance Policy
|
||||
|
||||
Judge the completed 16-pose loop as an animation family. Cardinals must match their single axis exactly. Intermediate directions should preserve the intended axes, but isolated blind-review uncertainty is evidence for labeled loop review rather than an automatic regeneration trigger.
|
||||
|
||||
Hard failures require row regeneration:
|
||||
|
||||
- a cardinal anchor is wrong or ambiguous: `000` up, `090` screen-right, `180` down, or `270` screen-left
|
||||
- a blind cardinal classification contradicts or cannot confirm `000` up, `090` screen-right, `180` down, or `270` screen-left
|
||||
- labeled normal-size review confirms that an intermediate pose points into the wrong principal quadrant, reverses the loop, or loses a required axis badly enough to read as a different direction
|
||||
- the ordered loop visibly reverses, backtracks, crosses into the wrong principal quadrant, or contains a conspicuous snap, identity change, scale pop, registration jump, or broken prop attachment
|
||||
- the source or atlas has a deterministic structural failure, or visual review confirms clipping, an accidental transparent interior hole, a seam band, replacement eyes, or a materially broken sprite
|
||||
- whole-sprite rotation, deformation, or eye mechanics visibly break the pet's identity or make the motion feel incoherent
|
||||
|
||||
Review warnings do not require regeneration by themselves:
|
||||
|
||||
- an intermediate pose is similar to a neighbor, a diagonal cue is subtle, or the pet uses less body movement than the ideal mechanics plan
|
||||
- blind reviewers disagree, return `ambiguous`, or produce an opposite-sign majority for an intermediate direction, provided labeled normal-size review confirms the intended direction and the ordered loop remains coherent
|
||||
- continuity metrics report a diff, center, area, or alpha-hole candidate without a visible snap, pop, seam, or broken silhouette in the QA sheet or animation loop
|
||||
|
||||
Before accepting the v2 atlas, create a focused direction QA sheet showing the neutral/rest frame next to all 16 look cells, labeled by degree and expected direction, at approximately the in-app display size. Run the adjacent continuity measurement separately and treat its findings as motion-review evidence, not automatic direction failures.
|
||||
|
||||
Perform an explicit semantic review for every direction and record `pass`, `warning`, or `fail`, plus separate visible evidence for its horizontal and vertical axes. A warning may accept blind-review uncertainty for an intermediate pose when labeled normal-size review confirms the intended axes and the ordered loop remains coherent. It may not waive a wrong or ambiguous cardinal, a labeled wrong-quadrant pose, or a visible reversal. If a direction receives `fail`, strengthen the containing row's instructions and resynthesize that complete coherent row. Never replace the final normalized cell directly.
|
||||
|
||||
Look rows must have transparent backgrounds after assembly. Do not accept or install the pet if `qa/look-directions.png` or `qa/contact-sheet-extended.png` shows chroma-key panels behind any look cell. If generated look rows contain slight chroma-key lighting variation, rerun assembly with a wider `--chroma-threshold` instead of packaging the opaque key color. Validation must pass without opaque chroma-key-pixel errors.
|
||||
|
||||
Extended look cells must also keep the same practical scale and body registration as the neutral/default pet. Do not accept a direction set where neutral/default is noticeably larger than the look cells, where the look cells appear to float above the baseline, or where the pet slides left/right within its 192x208 cell while only changing gaze. Extended assembly recovers each pose group from the complete original-resolution row and computes one shared scale from height plus every pose's left and right extents around the shared lower-body anchor, so asymmetric poses remain inside the final cell after alignment. It resizes each original crop exactly once and never enlarges an already-resampled cell. The neutral frame supplies the target body height, lower-body anchor, and baseline. Pass `--neutral-cell` when an external neutral frame is available; otherwise the assembler falls back to the populated neutral/default slot or first visible idle frame in the base atlas. If the focused QA sheet still shows scale or placement drift, repair before packaging.
|
||||
|
||||
Assemble the extended atlas from two generated row strips:
|
||||
|
||||
Use the run's selected chroma key for every assembly path; omitting it falls back to green and can misclassify a magenta background as clipped sprite pixels.
|
||||
|
||||
```bash
|
||||
CHROMA_KEY=$(jq -r '.chroma_key.hex' "$RUN_DIR/pet_request.json")
|
||||
```
|
||||
|
||||
Extended assembly reuses the approved registered row-9 cells and persisted scale exactly. It removes the chroma background from row 10, detects its eight separated pose groups, preserves their left-to-right order, crops each complete pose without fixed-slot slicing, and fits them against the same neutral-frame scale, lower-body anchor, and baseline. Only then does it apply the near-edge clipping check to row 10's normalized `192x208` cells. If pose-group recovery is ambiguous, or if row 10 cannot fit the approved row-9 transform without failing the post-registration edge check, resynthesize row 10; do not rescale row 9, patch an individual final cell, or relax the threshold for acceptance.
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/assemble_extended_atlas.py" \
|
||||
--base-atlas "$RUN_DIR/final/spritesheet.webp" \
|
||||
--registered-row-9 "$RUN_DIR/qa/look-row-9-registered.png" \
|
||||
--row-9-registration "$RUN_DIR/qa/look-row-9-registration.json" \
|
||||
--look-row-10 "$RUN_DIR/decoded/look-row-10.png" \
|
||||
--neutral-cell "$RUN_DIR/frames/idle/00.png" \
|
||||
--chroma-key "$CHROMA_KEY" \
|
||||
--chroma-threshold 96 \
|
||||
--output "$RUN_DIR/final/spritesheet-extended.png" \
|
||||
--webp-output "$RUN_DIR/final/spritesheet-extended.webp" \
|
||||
--manifest-output "$RUN_DIR/final/spritesheet-extended.json"
|
||||
```
|
||||
|
||||
For repair or upgrade of a user-provided 16-cell source that was already approved as one coherent set, individual-cell assembly remains available. Do not use this path for newly generated repair cells:
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/assemble_extended_atlas.py" \
|
||||
--base-atlas "$RUN_DIR/final/spritesheet.webp" \
|
||||
--look-cells-dir /absolute/path/to/look-cells \
|
||||
--neutral-cell "$RUN_DIR/frames/idle/00.png" \
|
||||
--chroma-key "$CHROMA_KEY" \
|
||||
--chroma-threshold 96 \
|
||||
--output "$RUN_DIR/final/spritesheet-extended.png" \
|
||||
--webp-output "$RUN_DIR/final/spritesheet-extended.webp" \
|
||||
--manifest-output "$RUN_DIR/final/spritesheet-extended.json"
|
||||
```
|
||||
|
||||
Run the single deterministic edge-local spill-suppression pass on the assembled v2 atlas, then validate and make a contact sheet:
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/despill_chroma_edges.py" \
|
||||
"$RUN_DIR/final/spritesheet-extended.png" \
|
||||
--output "$RUN_DIR/final/spritesheet-extended.png" \
|
||||
--webp-output "$RUN_DIR/final/spritesheet-extended.webp" \
|
||||
--chroma-key "$CHROMA_KEY" \
|
||||
--json-out "$RUN_DIR/qa/chroma-despill-extended.json"
|
||||
```
|
||||
|
||||
Treat `qa/chroma-despill-extended.json` as the authoritative chroma result. When it has `ok: true` and `validate_atlas.py --require-v2` passes, do not fail visual QA for perceived magenta fringe, regenerate any row, rerun despill, tune thresholds, or create an additional chroma-repair script. If either deterministic check fails, stop with a pipeline failure instead of retrying image generation.
|
||||
|
||||
This is the only chroma-cleanup invocation in the workflow. The intermediate 8×9 atlas is never despilled; rows `0-8` and the newly assembled look rows `9-10` are cleaned together exactly once in the completed 8×11 atlas.
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/validate_atlas.py" \
|
||||
"$RUN_DIR/final/spritesheet-extended.webp" \
|
||||
--json-out "$RUN_DIR/final/validation-extended.json" \
|
||||
--chroma-key "$CHROMA_KEY" \
|
||||
--require-v2
|
||||
```
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/make_contact_sheet.py" \
|
||||
"$RUN_DIR/final/spritesheet-extended.webp" \
|
||||
--output "$RUN_DIR/qa/contact-sheet-extended.png"
|
||||
```
|
||||
|
||||
Create the focused direction QA sheet:
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/make_direction_qa_sheet.py" \
|
||||
"$RUN_DIR/final/spritesheet-extended.webp" \
|
||||
--output "$RUN_DIR/qa/look-directions.png"
|
||||
```
|
||||
|
||||
Create the blind horizontal-and-vertical axis challenge and keep its answer key away from the visual QA worker:
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/make_direction_blind_qa_sheet.py" \
|
||||
"$RUN_DIR/final/spritesheet-extended.webp" \
|
||||
--output "$RUN_DIR/qa/direction-blind-pairs.png" \
|
||||
--answer-key "$RUN_DIR/qa/direction-blind-answer-key.json"
|
||||
```
|
||||
|
||||
Give three fresh isolated workers only `qa/direction-blind-pairs.png`. Each row states whether to classify the horizontal or vertical axis. Every worker must classify A and B as `screen-left`, `screen-right`, `up`, `down`, or `ambiguous` as appropriate, without seeing degree labels, expected directions, the labeled direction sheet, the answer key, or another worker's verdicts. Write their classifications separately, then combine them by strict per-cell majority:
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/combine_direction_blind_verdicts.py" \
|
||||
--verdicts "$RUN_DIR/qa/direction-blind-verdicts-1.json" \
|
||||
--verdicts "$RUN_DIR/qa/direction-blind-verdicts-2.json" \
|
||||
--verdicts "$RUN_DIR/qa/direction-blind-verdicts-3.json" \
|
||||
--json-out "$RUN_DIR/qa/direction-blind-verdicts.json"
|
||||
```
|
||||
|
||||
Apply the hidden answer key only to the consensus verdict:
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/validate_direction_blind_verdicts.py" \
|
||||
--answer-key "$RUN_DIR/qa/direction-blind-answer-key.json" \
|
||||
--verdicts "$RUN_DIR/qa/direction-blind-verdicts.json" \
|
||||
--json-out "$RUN_DIR/qa/direction-blind-validation.json"
|
||||
```
|
||||
|
||||
The hidden answer key contains seven horizontal pairs and seven vertical pairs. The cardinal pairs (`000` vs `180` and `090` vs `270`) are hard gates: a mismatch or ambiguous majority keeps validation at `ok: false`. All intermediate pairs are review gates: mismatches, same-direction votes, and ambiguous majorities are preserved as warnings while validation remains `ok: true`. The blind pass is mandatory, but intermediate warnings are resolved by labeled normal-size loop review instead of repeated regeneration by default.
|
||||
|
||||
### Blind Review Severity Resolution
|
||||
|
||||
After receiving a blind or final visual QA `pass`/`fail` result:
|
||||
|
||||
1. If it passes, continue immediately.
|
||||
2. If it fails, inspect the worker's semantic reasons, repair note, labeled direction sheet, `qa/direction-semantics.json`, and `qa/look-continuity.json` before regenerating anything.
|
||||
3. Classify the failure as `major` or `minor`:
|
||||
- `major`: wrong or ambiguous cardinal; labeled normal-size review confirms a wrong principal quadrant or visible reversal; conspicuous snap, scale pop, identity change, broken attachment, clipping, interior seam/hole, or deterministic validation failure.
|
||||
- `minor`: exact pupil or nose placement differs from the numerical ideal; a near-vertical horizontal cue is subtle; isolated reviewers disagree or return `ambiguous`; an intermediate blind majority conflicts but the labeled ordered loop still reads correctly; continuity metrics warn without a visible defect.
|
||||
4. Major failures require repair. Minor failures may be overridden and the installation pipeline continues.
|
||||
5. Record every override in `qa/blind-review-resolution.json` with `decision: "accept"`, `severity: "minor"`, the failed checks, the labeled/continuity evidence that makes them acceptable, and `reviewed_by: "parent"` or `"user"`. Never override a major failure.
|
||||
|
||||
An override is a deliberate visual judgment, not a way to silence missing evidence. The blind sheet, consensus verdicts, validation output, labeled semantics, continuity report, and resolution file all remain in the final QA artifacts.
|
||||
|
||||
Measure adjacent direction continuity:
|
||||
|
||||
```bash
|
||||
"$PYTHON" "$SKILL_DIR/scripts/measure_direction_continuity.py" \
|
||||
"$RUN_DIR/final/spritesheet-extended.webp" \
|
||||
--json-out "$RUN_DIR/qa/look-continuity.json"
|
||||
```
|
||||
|
||||
Visually QA `qa/contact-sheet-extended.png`, `qa/look-directions.png`, and `qa/look-continuity.json` before accepting. Inspect the 16 normal-size look cells as an ordered loop, not only as isolated stills. For every direction label, compare the expected direction to the visible gaze/body direction and record `pass`, `warning`, or `fail` in `qa/direction-semantics.json`. Reject only the hard failures in the Direction Acceptance Policy. Record subtler semantic or metric concerns as warnings and accept them when the loop remains cohesive, readable, identity-preserving, and visually pleasing at normal pet size.
|
||||
|
||||
If a blind or final visual QA worker returns `fail`, apply Blind Review Severity Resolution before queuing a repair. Continue packaging when the failure is minor and `qa/blind-review-resolution.json` records the accepted override.
|
||||
|
||||
Only after all deterministic and visual QA passes, package the approved extended spritesheet as a v2 pet. `spriteVersionNumber: 2` is mandatory; without it the app defaults to the 9-row v1 contract and rejects the 2288-pixel-tall asset.
|
||||
|
||||
```bash
|
||||
PET_ID=$(jq -r '.pet_id' "$RUN_DIR/pet_request.json")
|
||||
DISPLAY_NAME=$(jq -r '.display_name' "$RUN_DIR/pet_request.json")
|
||||
DESCRIPTION=$(jq -r '.description' "$RUN_DIR/pet_request.json")
|
||||
PET_DIR="${CODEX_HOME:-$HOME/.codex}/pets/$PET_ID"
|
||||
mkdir -p "$PET_DIR"
|
||||
cp "$RUN_DIR/final/spritesheet-extended.webp" "$PET_DIR/spritesheet.webp"
|
||||
jq -n --arg id "$PET_ID" --arg displayName "$DISPLAY_NAME" --arg description "$DESCRIPTION" \
|
||||
'{id: $id, displayName: $displayName, description: $description, spriteVersionNumber: 2, spritesheetPath: "spritesheet.webp"}' \
|
||||
> "$PET_DIR/pet.json"
|
||||
```
|
||||
|
||||
Write `qa/run-summary.json` after packaging:
|
||||
|
||||
```bash
|
||||
jq -n --arg run_dir "$RUN_DIR" --arg spritesheet "$RUN_DIR/final/spritesheet-extended.webp" --arg validation "$RUN_DIR/final/validation-extended.json" --arg chroma_despill "$RUN_DIR/qa/chroma-despill-extended.json" --arg contact_sheet "$RUN_DIR/qa/contact-sheet-extended.png" --arg direction_sheet "$RUN_DIR/qa/look-directions.png" --arg direction_semantics "$RUN_DIR/qa/direction-semantics.json" --arg blind_direction_validation "$RUN_DIR/qa/direction-blind-validation.json" --arg blind_review_resolution "$RUN_DIR/qa/blind-review-resolution.json" --arg continuity "$RUN_DIR/qa/look-continuity.json" --arg review "$RUN_DIR/qa/review.json" --arg package "$PET_DIR" '{ok: true, spriteVersionNumber: 2, run_dir: $run_dir, spritesheet: $spritesheet, validation: $validation, chroma_despill: $chroma_despill, contact_sheet: $contact_sheet, direction_sheet: $direction_sheet, direction_semantics: $direction_semantics, blind_direction_validation: $blind_direction_validation, blind_review_resolution: $blind_review_resolution, continuity: $continuity, review: $review, package: $package}' > "$RUN_DIR/qa/run-summary.json"
|
||||
```
|
||||
|
||||
After all QA and packaging succeed, keep `pet_request.json`, `final/spritesheet-extended.webp`, `final/validation-extended.json`, `qa/chroma-despill-extended.json`, `qa/contact-sheet-extended.png`, `qa/look-directions.png`, `qa/direction-semantics.json`, `qa/direction-blind-pairs.png`, `qa/direction-blind-answer-key.json`, `qa/direction-blind-verdicts.json`, `qa/direction-blind-validation.json`, `qa/blind-review-resolution.json` when an override was used, `qa/look-continuity.json`, `qa/previews/`, `qa/review.json`, and `qa/run-summary.json`. Remove prompts, layout guides, generated row strips, extracted frames, PNG intermediates, the 8x9 intermediate atlas, and the imagegen job manifest unless the user wants debug artifacts.
|
||||
|
||||
## Lightweight Visual Workers
|
||||
|
||||
Use lightweight subagents for image-heavy work by default. This bounds each `$imagegen` rollout to one selected image, keeps contact-sheet vision payloads out of the parent thread, and reduces cost while preserving the full v2 contract.
|
||||
|
||||
## Subagent Delegation
|
||||
|
||||
Use lightweight workers unless the user specifically prohibits delegation.
|
||||
|
||||
Parent responsibilities:
|
||||
|
||||
- run the brand discovery worker before preparation when the user provides a bare brand/product/company/prospect name
|
||||
- prepare the run and inspect `imagegen-jobs.json`
|
||||
- assign the base job, all standard rows, the four-cardinal strip, coherent look rows, blind direction QA, and final contact-sheet QA to lightweight workers
|
||||
- copy selected worker outputs into their decoded paths and mark jobs complete in `imagegen-jobs.json`
|
||||
- create `references/canonical-base.png` from the selected base output
|
||||
- run the approved `running-left` mirror derivation when appropriate
|
||||
- write the pet-specific look mechanics plan after standard-row QA
|
||||
- approve the cardinal semantics and compose `decoded/look-anchors-approved.png`
|
||||
- require immediate deterministic registration, post-registration edge QA, and labeled semantic QA after each coherent look-row generation
|
||||
- run deterministic v2 assembly, packaging, repair regeneration, and cleanup
|
||||
|
||||
Base worker responsibilities:
|
||||
|
||||
- handle only the `base` job
|
||||
- read `prompts/base-pet.md` and use any listed reference images
|
||||
- use `$imagegen` only
|
||||
- honor any compact brand inspiration line in the prompt as broad visual/personality guidance, without copying logos, readable marks, UI screenshots, slogans, or text
|
||||
- return only `selected_source=/absolute/path/to/selected-output.png` and `qa_note=<one sentence>`
|
||||
|
||||
Row worker responsibilities:
|
||||
|
||||
- handle exactly one row job
|
||||
- read the row prompt and use all listed input images
|
||||
- use `$imagegen` only; do not draw, edit, tile, or synthesize sprites locally
|
||||
- perform a quick visual sanity check for frame count, identity, chroma background, spacing, clipping, and detached effects
|
||||
- enforce the row prompt's transparency and effects rules, including no detached effects, no wave marks for `waving`, no speed lines or dust for directional running rows, no literal foot-running for the non-directional `running` row, and only attached opaque sprite-like tears/smoke/stars when allowed by the state prompt
|
||||
- for a `look-row-strip`, synthesize the complete row as one coherent family from the approved cardinals and never independently restyle individual cells
|
||||
- for a `look-row-strip`, verify the output contains eight separated pose groups in the required order with no overlap or outer-canvas clipping; deterministic assembly owns exact cell cropping, one shared scale and baseline, recentering, and final-cell edge validation
|
||||
- return only `selected_source=/absolute/path/to/selected-output.png` and `qa_note=<one sentence>`
|
||||
|
||||
Blind direction QA worker responsibilities:
|
||||
|
||||
- inspect only `qa/direction-blind-pairs.png`; do not provide the labeled direction sheet, atlas, prompt, degree order, prior verdicts, or hidden answer key
|
||||
- classify A and B for every pair independently on the axis named in the sheet: `screen-left`, `screen-right`, `up`, `down`, or `ambiguous`, using visible pupils, nose, face surface, head turn, or the pet's natural aiming feature
|
||||
- never infer from pair order; use `ambiguous` honestly when the requested axis is unreadable. Cardinal ambiguity blocks packaging; intermediate ambiguity becomes labeled-review evidence.
|
||||
- never inspect or receive another blind worker's classifications; the parent combines exactly three isolated verdict files with `combine_direction_blind_verdicts.py`
|
||||
- return JSON-ready pair classifications only; do not edit files or inspect unrelated artifacts
|
||||
|
||||
Final visual QA worker responsibilities:
|
||||
|
||||
- inspect the standard and extended contact sheets, direction QA sheet, row GIFs, semantic verdicts, continuity results, and v2 validation
|
||||
- verify all 11 rows match the Codex app contract and the same pet identity
|
||||
- return a compact result: `visual_qa=pass` or `visual_qa=fail`, plus row-specific repair notes when failing
|
||||
- do not edit files, queue repairs, package, or clean up
|
||||
|
||||
Model choice for workers:
|
||||
|
||||
- Prefer a smaller capable model for brand discovery, since it returns a compact research brief rather than doing orchestration.
|
||||
- Prefer a smaller capable model for visual workers, such as `gpt-5.4-mini` with medium reasoning, when model override is available.
|
||||
- Use the parent/default model only for orchestration or when a smaller worker model is unavailable.
|
||||
- Dynamically keep up to three generation workers active while at least three independent jobs are ready and capacity permits; backfill slots as workers finish. Use fewer workers when dependencies expose fewer jobs. Run final visual QA as a single worker after deterministic image processing. Close workers after their result has been consumed.
|
||||
- Once `look-cardinals` passes, start row 9 immediately. Start row 10 only after row 9 has passed deterministic registration, post-registration edge, semantic, and continuity QA; give row 10 the completed row 9 strip as continuity evidence.
|
||||
|
||||
Use this base worker prompt:
|
||||
|
||||
```text
|
||||
Generate the hatch-pet base image.
|
||||
|
||||
Run dir: <absolute run dir>
|
||||
Job id: base
|
||||
Prompt file: <absolute base prompt file>
|
||||
Input images:
|
||||
- <absolute path> — <role>
|
||||
|
||||
Use $imagegen only. Read the base prompt and attach every listed input image. If the prompt contains brand inspiration, use it only as broad mascot-safe guidance; do not copy logos, readable marks, UI screenshots, slogans, or text. Before returning, visually check that the result is one centered full-body pet on a flat chroma background, with no text, scenery, shadows, or detached effects.
|
||||
|
||||
Do not edit manifests, copy into decoded, mark jobs complete, generate rows, run image-processing scripts, repair, package, or open unrelated files.
|
||||
Do not include Markdown image previews, base64, or extra attachments in the final response.
|
||||
|
||||
Return exactly:
|
||||
selected_source=/absolute/path/to/selected-output.png
|
||||
qa_note=<one sentence>
|
||||
```
|
||||
|
||||
Use this cardinal-strip worker prompt:
|
||||
|
||||
```text
|
||||
Generate one hatch-pet four-cardinal anchor strip.
|
||||
|
||||
Run dir: <absolute run dir>
|
||||
Job id: look-cardinals
|
||||
Prompt file: <absolute prompt file>
|
||||
Input images:
|
||||
- <absolute path> — <role>
|
||||
|
||||
Use $imagegen only. Read the cardinal-strip prompt and attach every listed input image. Read `qa/look-mechanics.md`. Screen-left and screen-right are viewer/image coordinates, never character-relative coordinates. Before returning, verify all four slots in order without relying on their labels: for a face, cite the nose-tip and pupil positions relative to the head center; for other pets, cite the natural aiming feature. Any ambiguous cardinal fails the strip.
|
||||
|
||||
Do not edit manifests, copy files, generate rows, assemble, package, or inspect unrelated files. Do not include image previews or attachments in the final response.
|
||||
|
||||
Return exactly:
|
||||
selected_source=/absolute/path/to/selected-output.png
|
||||
qa_note=<one sentence with concrete landmark evidence for all four cardinals>
|
||||
```
|
||||
|
||||
Use this row worker prompt:
|
||||
|
||||
```text
|
||||
Generate one hatch-pet row.
|
||||
|
||||
Run dir: <absolute run dir>
|
||||
Row id: <row-id>
|
||||
Prompt file: <absolute prompt file>
|
||||
Retry prompt file: <absolute retry prompt file>
|
||||
Input images:
|
||||
- <absolute path> — <role>
|
||||
- <absolute path> — <role>
|
||||
|
||||
Use $imagegen only. Read the row prompt and attach every listed input image. For a `look-row-strip` job, also read and obey `qa/look-mechanics.md`; use the approved cardinal strip for direction meaning and draw all eight cells together as one coherent family with even intermediate steps. Never paste, reuse, or independently restyle individual cells. If imagegen returns Bad Request, retry once with the retry prompt and the same input images.
|
||||
|
||||
Before returning, visually check: exact frame count, same pet identity as canonical base, flat chroma background, complete separated unclipped poses, and no detached effects or guide marks. For a `look-row-strip`, verify there are eight separated pose groups in the required left-to-right order, neighboring poses do not overlap, no foreground is cropped at the outer canvas edge, and the generated family keeps a consistent scale and baseline. Exact cell cropping, shared-scale normalization, recentering, and final-cell edge validation happen deterministically after generation. The prompt's transparency and effects rules are mandatory: no detached effects, no wave marks for `waving`, no speed lines or dust for directional running rows, no literal foot-running for the non-directional `running` row, and only attached opaque sprite-like tears/smoke/stars when allowed by the state prompt.
|
||||
|
||||
Do not edit manifests, copy into decoded, mark jobs complete, mirror rows, run image-processing scripts, repair, package, or open unrelated files.
|
||||
Do not include Markdown image previews, base64, or extra attachments in the final response.
|
||||
|
||||
Return exactly:
|
||||
selected_source=/absolute/path/to/selected-output.png
|
||||
qa_note=<one sentence>
|
||||
```
|
||||
|
||||
Use this blind direction QA worker prompt in a fresh worker that has not seen the labeled direction sheet. Spawn it without prior conversation context when the worker system supports context isolation (for example, `fork_turns="none"`):
|
||||
|
||||
```text
|
||||
Classify one required gaze axis in an unlabeled hatch-pet A/B challenge.
|
||||
|
||||
Blind sheet: <absolute run dir>/qa/direction-blind-pairs.png
|
||||
|
||||
Inspect only this sheet. Do not open the atlas, labeled direction sheet, prompts, prior QA, degree order, answer key, or any other file.
|
||||
|
||||
Each row contains two normal-size pet cells labeled A and B and identifies the axis to judge. For a horizontal row, classify each cell as exactly `screen-left`, `screen-right`, or `ambiguous`. For a vertical row, classify each cell as exactly `up`, `down`, or `ambiguous`.
|
||||
|
||||
Judge only what is readable at the displayed pet size. Use visible landmarks such as pupils, nose tip relative to head center, face surface, head turn, eyelids, or the pet’s natural aiming feature. If the requested axis is not definite without enlarging or guessing, classify it as `ambiguous`; do not invent confidence.
|
||||
|
||||
Do not infer from A/B order. If A and B point the same way, report the same classification; do not force one left and one right.
|
||||
|
||||
Return exactly one JSON object and nothing else:
|
||||
{"pairs":[{"pair":"horizontal-1|vertical-1","A":"screen-left|screen-right|up|down|ambiguous","B":"screen-left|screen-right|up|down|ambiguous","reason":"short landmark evidence"}]}
|
||||
|
||||
Include every pair shown in the sheet.
|
||||
```
|
||||
|
||||
Use this final visual QA worker prompt:
|
||||
|
||||
```text
|
||||
Visually QA one finalized hatch-pet contact sheet.
|
||||
|
||||
Run dir: <absolute run dir>
|
||||
Contact sheet: <absolute run dir>/qa/contact-sheet.png
|
||||
V2 contact sheet: <absolute run dir>/qa/contact-sheet-extended.png
|
||||
Focused direction QA sheet: <absolute run dir>/qa/look-directions.png
|
||||
Direction semantics JSON: <absolute run dir>/qa/direction-semantics.json
|
||||
Blind direction validation JSON: <absolute run dir>/qa/direction-blind-validation.json
|
||||
Look continuity JSON: <absolute run dir>/qa/look-continuity.json
|
||||
Preview dir: <absolute run dir>/qa/previews
|
||||
Review JSON: <absolute run dir>/qa/review.json
|
||||
V2 validation JSON: <absolute run dir>/final/validation-extended.json
|
||||
|
||||
Inspect the contact sheet and the preview GIFs visually. Confirm the same pet identity, style, palette, silhouette, face, proportions, and props across all rows:
|
||||
0 idle, 1 running-right, 2 running-left, 3 waving, 4 jumping, 5 failed, 6 waiting, 7 running, 8 review.
|
||||
|
||||
Require `qa/direction-blind-validation.json` to have `ok: true`, or require an explicit accepted minor override in `qa/blind-review-resolution.json`. Cardinal mismatches or ambiguity are major and block packaging. For intermediate warnings or a worker-level fail, inspect the labeled normal-size pose and ordered loop; accept when the issue is minor and there is no wrong-quadrant pose or reversal.
|
||||
|
||||
Inspect the 16 direction cells as a labeled ordered loop against the neutral frame and review `qa/look-continuity.json`. Produce a `pass`, `warning`, or `fail` semantic verdict for every expected direction: `000 up`, `022.5 up-right`, `045 up-right`, `067.5 up-right`, `090 right`, `112.5 down-right`, `135 down-right`, `157.5 down-right`, `180 down`, `202.5 down-left`, `225 down-left`, `247.5 down-left`, `270 left`, `292.5 up-left`, `315 up-left`, and `337.5 up-left`. Record separate horizontal and vertical landmark evidence for every diagonal. Fail wrong or ambiguous cardinals, labeled wrong-quadrant poses, and visible reversals. Record blind uncertainty on intermediate poses as warnings when labeled review and loop context confirm the intended direction.
|
||||
|
||||
Fail rows with identity drift, missing/blank frames, copied guide marks, white/nontransparent backgrounds, cropped bodies, slot overlap, detached effects, shadows/glows/smears/dust, motion that does not match the row state, unintended size popping, wrong facing direction, reversed or non-alternating gait, or idle loops that are effectively static. Judge chroma only on the cleaned extended contact sheet, not the pre-cleanup standard contact sheet. Do not fail or retry a row for magenta/chroma fringe after the final despill report and v2 atlas validation pass; those deterministic results are authoritative.
|
||||
|
||||
Do not edit files, queue repairs, package, clean up, or inspect unrelated files.
|
||||
|
||||
Return exactly:
|
||||
visual_qa=pass|fail
|
||||
qa_note=<one sentence summary>
|
||||
direction_semantics=<semicolon-separated labels with pass/warning/fail and short visual reason>
|
||||
review_warnings=<semicolon-separated accepted warnings, or none>
|
||||
repair_rows=<comma-separated row ids, or none>
|
||||
repair_notes=<short row-specific notes, or none>
|
||||
```
|
||||
|
||||
## Repair Workflow
|
||||
|
||||
If frame inspection or final visual QA fails, read `qa/review.json`, regenerate the smallest failing row, copy the replacement row into the same decoded output path, and keep that job marked complete with the new `source_path` and `completed_at`. Repair the failed row, not the whole sheet.
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep `$imagegen` as the primary generation layer.
|
||||
- For brand/product/company/prospect requests without a concrete avatar description or reference image, run brand discovery before base generation and pass only the compact brief into the run.
|
||||
- Use `$imagegen` as the only visual generation layer. Do not invoke image APIs, image CLIs, local raster generators, or one-off generation scripts from this skill.
|
||||
- Keep reference images attached/visible for `$imagegen` whenever the chosen path supports references.
|
||||
- Attach the row's `references/layout-guides/<state>.png` image to every row-strip job as a layout-only guide, and do not accept outputs that copy guide pixels.
|
||||
- Use lightweight visual workers for base generation, row-strip visual generation, and final contact-sheet QA by default; the parent owns manifest updates, deterministic image scripts, packaging, and cleanup.
|
||||
- Generate every normal visual job with `$imagegen`: base plus all row strips that are not explicitly approved `running-left` mirror derivations.
|
||||
- Treat only the base job as eligible for prompt-only generation; every row job must attach its listed grounding images.
|
||||
- Generate `running-right` before deciding whether `running-left` can be mirrored.
|
||||
- When `running-left` is mirrored, preserve frame order and timing semantics; derive it through the deterministic script instead of mirroring an entire strip wholesale.
|
||||
- Do not derive or reuse `waiting`, `running`, `failed`, `review`, `jumping`, or `waving` from another state; each has distinct app semantics and must be generated as its own row.
|
||||
- Generate look row 9 directly from the approved cardinal strip, then generate row 10 only after row 9 clears deterministic registration and post-registration edge QA and has no semantic or continuity hard failure. Reviewed warnings do not block row 10. Both rows attach the cardinal strip, and row 10 must also attach completed row 9.
|
||||
- Final look rows must each originate from one coherent 8-frame row generation. Individually generated repair cells may never be copied into the final atlas.
|
||||
- If one look direction fails, strengthen the containing row's direction instructions and resynthesize the complete row. Do not patch the final cell directly, even when deterministic assembly supports individual-cell input.
|
||||
- Deterministically register each coherent look row, then run final-cell edge diagnostics and explicit labeled semantics immediately after generation, before expensive final atlas assembly. Run blind horizontal-and-vertical axis QA as soon as both coherent rows exist.
|
||||
- Never substitute locally drawn, tiled, transformed, or code-generated row strips for missing `$imagegen` outputs.
|
||||
- Only mark a visual job complete after its selected output has been copied into the decoded output path.
|
||||
- Never mark a failed coherent row, diagnostic iteration, or one-off repair cell as packaging eligible.
|
||||
- Do not rely on generated images for exact atlas geometry; use this skill's deterministic image scripts.
|
||||
- Use the chroma key stored in `pet_request.json`; do not force a fixed green screen.
|
||||
- Keep the pet's silhouette, face, materials, palette, style, and props consistent across all rows.
|
||||
- Treat visual identity or style drift as a blocker even when deterministic validation has no errors.
|
||||
- Treat a contact sheet that shows cropped references, repeated tiles, white cell backgrounds, or non-sprite fragments as failed.
|
||||
- Treat preview GIFs that show extraction-induced size popping, reversed directional timing, wrong facing direction, or inert idle loops as failed.
|
||||
- Apply the Direction Acceptance Policy to look cells. Cardinals are hard gates. Intermediate blind uncertainty is a warning unless labeled normal-size review confirms a wrong quadrant, missing axis, or loop reversal.
|
||||
- Treat a missing explicit per-direction semantic gaze review as failed even when the focused QA sheet and continuity JSON exist.
|
||||
- Treat missing `qa/direction-semantics.json` as failed. The file must include every expected direction with `verdict`, `expected`, `observed`, and `reason` fields. Packaging requires no `fail` verdicts; reviewed `warning` verdicts are allowed.
|
||||
- Treat missing or failed `qa/direction-blind-validation.json` as failed. Each of the three isolated blind reviewers must see only the randomized A/B sheet, never degree labels, the answer key, or another verdict. Cardinal mismatches or ambiguity fail validation; intermediate mismatches or ambiguity remain review warnings.
|
||||
- Never use the same worker for blind A/B classification after it has seen the labeled direction sheet or direction prompts. Label-conditioned classification is not independent evidence.
|
||||
- Do not let the parent agent self-approve a repaired look direction. Run an independent final visual QA worker on `qa/look-directions.png` or ask the user to inspect it before packaging.
|
||||
- After an independent blind or final QA fail, the parent may override only a minor issue under Blind Review Severity Resolution. Record the evidence in `qa/blind-review-resolution.json`; never override a major failure.
|
||||
- For humanoid cardinal verdicts, record concrete screen-coordinate landmark evidence in `qa/direction-semantics.json`. Intermediate verdicts may use holistic head, face, posture, and ordered-loop evidence; exact pupil or nose placement is advisory rather than mandatory.
|
||||
- Treat look rows that rotate, skew, or tilt the whole sprite to fake gaze as failed unless the pet is literally a rotating object and the look mechanics decision explicitly justifies whole-object rotation.
|
||||
- Treat pupil-only motion or underused natural mechanics as a warning unless it visibly breaks identity, direction meaning, or loop cohesion.
|
||||
- Treat adjacent continuity metrics as review evidence. Fail only when visual QA confirms a conspicuous snap, pop, registration jump, identity change, broken silhouette, or semantic discontinuity.
|
||||
- Treat forbidden detached effects, shadows, glows, smears, dust, landing marks, wave marks, speed lines, or motion trails as failed rows. Chroma-key-adjacent generation artifacts are handled only by the single deterministic despill pass and never trigger image retries after that pass reports success.
|
||||
- Treat `qa/review.json` errors as blockers. Warnings require visual review.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Final atlas is PNG or WebP, exactly `1536x2288`, and based on `192x208` cells. The `1536x1872` standard atlas is intermediate-only.
|
||||
- `pet.json` contains `spriteVersionNumber: 2`, the extended despill report has `ok: true`, and the packaged spritesheet passes `validate_atlas.py --require-v2` with the run's chroma key. These deterministic results close chroma QA; no separate visual chroma-fringe gate or image retry is allowed.
|
||||
- Used cells are non-empty and unused cells are fully transparent.
|
||||
- Atlas follows the row/frame counts in `references/animation-rows.md`.
|
||||
- The four-cardinal strip has been deterministically extracted, its clipping report passes, and all four anchors are semantically approved before look-row generation.
|
||||
- Both coherent look rows use `decoded/look-anchors-approved.png` as the direction basis, interpolate all intermediate directions as even 22.5-degree steps, and preserve the fixed clockwise order.
|
||||
- Deterministic pose-group registration, post-registration final-cell edge diagnostics, and labeled per-direction semantic QA pass immediately on each coherent source row before final atlas assembly; blind horizontal-and-vertical axis QA runs after both rows exist.
|
||||
- Contact sheet and per-row motion previews have been produced and inspected by a lightweight visual QA worker.
|
||||
- A focused neutral-plus-16-directions QA sheet has been produced and inspected before packaging.
|
||||
- A randomized unlabeled horizontal-and-vertical axis pair sheet has been classified by three isolated blind workers and combined by strict majority. Both cardinal pairs pass. `qa/direction-blind-validation.json` has `ok: true`, or a worker-level/intermediate failure has an accepted minor resolution in `qa/blind-review-resolution.json` backed by labeled and continuity evidence.
|
||||
- Every expected direction has an explicit `pass`, `warning`, or `fail` semantic verdict with horizontal and vertical axis evidence where applicable; no wrong cardinal, labeled wrong-quadrant pose, or visible reversal remains.
|
||||
- `qa/direction-semantics.json` records verdicts for all 16 directions from an independent visual QA worker or explicit user inspection, including review notes for accepted warnings.
|
||||
- `qa/look-continuity.json` has been reviewed; metric warnings are acceptable when the normal-size ordered loop has no visible snap, pop, identity change, or semantic discontinuity.
|
||||
- `qa/review.json` has no errors.
|
||||
- Row-by-row review confirms the animation cycles are complete enough for the Codex app.
|
||||
- Motion previews do not show unintended size popping, reversed directional cadence, or wrong row semantics.
|
||||
- Look directions follow the fixed clockwise order and form a cohesive, readable loop at normal pet size. Cardinals must be unmistakable. Intermediate blind uncertainty is acceptable as a reviewed warning when labeled normal-size review confirms the intended direction and the loop does not reverse.
|
||||
- Non-pixel styles are accepted when readable at pet size and consistent across rows.
|
||||
- `${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/pet.json` and `${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/spritesheet.webp` are staged together for custom pets.
|
||||
4
04_卡火(火)/火炬_全栈消息/卡若宠物制作/agents/openai.yaml
Normal file
4
04_卡火(火)/火炬_全栈消息/卡若宠物制作/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Hatch Pet"
|
||||
short_description: "Hatch style-flexible Codex pets"
|
||||
default_prompt: "Use $hatch-pet to create a Codex-compatible v2 pet with all standard animations and 16 look directions."
|
||||
34
04_卡火(火)/火炬_全栈消息/卡若宠物制作/references/animation-rows.md
Normal file
34
04_卡火(火)/火炬_全栈消息/卡若宠物制作/references/animation-rows.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# V2 Animation Rows
|
||||
|
||||
Every newly hatched pet uses an 8-column x 11-row atlas with 192x208 cells. The final atlas is 1536x2288 and uses `spriteVersionNumber: 2`.
|
||||
|
||||
| Row | State | Used columns | Durations |
|
||||
| --- | ----------------- | -----------: | ------------------------------------------------------ |
|
||||
| 0 | idle | 0-5 | 280, 110, 110, 140, 140, 320 ms |
|
||||
| 1 | running-right | 0-7 | 120 ms each, final 220 ms |
|
||||
| 2 | running-left | 0-7 | 120 ms each, final 220 ms |
|
||||
| 3 | waving | 0-3 | 140 ms each, final 280 ms |
|
||||
| 4 | jumping | 0-4 | 140 ms each, final 280 ms |
|
||||
| 5 | failed | 0-7 | 140 ms each, final 240 ms |
|
||||
| 6 | waiting | 0-5 | 150 ms each, final 260 ms |
|
||||
| 7 | running | 0-5 | 120 ms each, final 220 ms |
|
||||
| 8 | review | 0-5 | 150 ms each, final 280 ms |
|
||||
| 9 | look directions A | 0-7 | 000, 022.5, 045, 067.5, 090, 112.5, 135, 157.5 degrees |
|
||||
| 10 | look directions B | 0-7 | 180, 202.5, 225, 247.5, 270, 292.5, 315, 337.5 degrees |
|
||||
|
||||
Unused cells after each standard animation row's final used column must be fully transparent. All look-row cells are used.
|
||||
|
||||
`000` degrees means looking up / 12 o'clock. Neutral/front is the pointer deadzone and falls back to the normal idle animation.
|
||||
|
||||
## Row Purposes
|
||||
|
||||
- `idle`: calm, low-distraction breathing/blinking loop and reduced-motion first frame.
|
||||
- `running-right`: locomotion to the right with a readable alternating cadence.
|
||||
- `running-left`: locomotion to the left; mirror only when identity and prop handedness remain correct, preserving frame order.
|
||||
- `waving`: greeting or attention gesture with a clear start, raised gesture, and return.
|
||||
- `jumping`: anticipation, lift, peak, descent, and settle.
|
||||
- `failed`: readable error, sad, or deflated reaction without noisy detached effects.
|
||||
- `waiting`: expectant asking pose for approval, help, or user input.
|
||||
- `running`: active task work or processing, not literal foot-running.
|
||||
- `review`: focused inspection of completed output.
|
||||
- rows `9-10`: one continuous clockwise 16-pose look loop using pet-specific eye, head, body, appendage, and prop mechanics.
|
||||
46
04_卡火(火)/火炬_全栈消息/卡若宠物制作/references/codex-pet-contract.md
Normal file
46
04_卡火(火)/火炬_全栈消息/卡若宠物制作/references/codex-pet-contract.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Codex V2 Pet Contract
|
||||
|
||||
## Sprite Atlas
|
||||
|
||||
- Version: `spriteVersionNumber: 2`.
|
||||
- Format: PNG or WebP.
|
||||
- Dimensions: `1536x2288`.
|
||||
- Grid: 8 columns x 11 rows.
|
||||
- Cell: `192x208`.
|
||||
- Background: transparent.
|
||||
- Rows `0-8`: standard animation states.
|
||||
- Rows `9-10`: 16 clockwise look directions.
|
||||
- Unused standard-row cells: fully transparent.
|
||||
|
||||
The 8x9 `1536x1872` atlas is an intermediate assembly artifact only. Never package it as a newly hatched pet.
|
||||
|
||||
## Look Directions
|
||||
|
||||
- Row `9`: `000`, `022.5`, `045`, `067.5`, `090`, `112.5`, `135`, `157.5` degrees.
|
||||
- Row `10`: `180`, `202.5`, `225`, `247.5`, `270`, `292.5`, `315`, `337.5` degrees.
|
||||
- `000` means up / 12 o'clock, not neutral/front.
|
||||
- Neutral/front is the no-vector deadzone and falls back to idle.
|
||||
|
||||
## Local Custom Pet Package
|
||||
|
||||
Place files under:
|
||||
|
||||
```text
|
||||
${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/
|
||||
├── pet.json
|
||||
└── spritesheet.webp
|
||||
```
|
||||
|
||||
Required manifest shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "pet-name",
|
||||
"displayName": "Pet Name",
|
||||
"description": "One short sentence.",
|
||||
"spriteVersionNumber": 2,
|
||||
"spritesheetPath": "spritesheet.webp"
|
||||
}
|
||||
```
|
||||
|
||||
The app derives the 11-row layout and look-direction behavior from `spriteVersionNumber: 2`. Omitting it defaults the pet to v1 and causes the 2288-pixel-tall spritesheet to be rejected.
|
||||
42
04_卡火(火)/火炬_全栈消息/卡若宠物制作/references/qa-rubric.md
Normal file
42
04_卡火(火)/火炬_全栈消息/卡若宠物制作/references/qa-rubric.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# V2 Pet QA Rubric
|
||||
|
||||
Do not package a pet until every section passes.
|
||||
|
||||
## Geometry And Package
|
||||
|
||||
- Final atlas is exactly `1536x2288`, 8 columns x 11 rows, with `192x208` cells.
|
||||
- `pet.json` contains `spriteVersionNumber: 2` and points to the packaged spritesheet.
|
||||
- Used cells are non-empty; unused standard-row cells are transparent.
|
||||
- Fully transparent pixels have zero RGB residue.
|
||||
- The 8x9 intermediate atlas is never packaged.
|
||||
- `qa/review.json` has no errors.
|
||||
- Standard rows use component extraction unless `stable-slots` was deliberately approved after playback review.
|
||||
- Coherent look rows recover their ordered pose groups and pass near-edge clipping checks after shared-scale registration into final cells.
|
||||
|
||||
## Character And Style
|
||||
|
||||
- Silhouette, proportions, face, expression language, material, palette, lighting, markings, and props remain the same across all 11 rows.
|
||||
- The pet reads clearly inside a `192x208` cell in the chosen style.
|
||||
- No frame introduces an unintended character, object, logo, text, scene, or effect.
|
||||
|
||||
## Standard Animation
|
||||
|
||||
- Rows `0-8` contain the exact required frame counts and recognizable state semantics.
|
||||
- Loops do not pop, reverse cadence, face the wrong direction, or remain effectively static.
|
||||
- The first idle frame works as a reduced-motion still.
|
||||
- `waiting`, `running`, `review`, and `failed` remain visually distinct.
|
||||
|
||||
## Look Directions
|
||||
|
||||
- All 16 directions are present in fixed clockwise order and visibly distinct from neutral/rest.
|
||||
- Cardinal directions read unmistakably as up, right, down, and left; diagonals and intermediates read in the correct quadrant.
|
||||
- `qa/look-directions.png` includes full-body and zoomed head/upper-body views.
|
||||
- `qa/direction-semantics.json` records `pass`, `expected`, `observed`, and `reason` for every direction.
|
||||
- `qa/look-continuity.json` has no unexplained holes, center jumps, area jumps, or local difference outliers.
|
||||
- Eyes, eyelids, head, body, appendages, and props follow the pet-specific look mechanics plan.
|
||||
- No whole-sprite rotation, replacement/googly eyes, visual clipping, seam bands, or transparent interior holes.
|
||||
- A repaired direction is approved by an independent visual QA worker or explicit user inspection, not the repairing parent alone.
|
||||
|
||||
## Repair Policy
|
||||
|
||||
Repair the smallest packaging-eligible scope: one standard row or one complete coherent look row. Never mix an individually generated repair cell into a new pet's final look row. Re-run assembly, deterministic validation, direction QA, continuity measurement, and semantic review after every relevant repair.
|
||||
654
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/assemble_extended_atlas.py
Executable file
654
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/assemble_extended_atlas.py
Executable file
@@ -0,0 +1,654 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble a standard Codex pet atlas plus 16 look-direction frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from extract_strip_frames import component_frame_groups, component_group_image
|
||||
|
||||
COLUMNS = 8
|
||||
STANDARD_ROWS = 9
|
||||
EXTENDED_ROWS = 11
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
|
||||
STANDARD_ATLAS_HEIGHT = STANDARD_ROWS * CELL_HEIGHT
|
||||
EXTENDED_ATLAS_HEIGHT = EXTENDED_ROWS * CELL_HEIGHT
|
||||
LOOK_DIRECTION_LABELS = [
|
||||
"000",
|
||||
"022.5",
|
||||
"045",
|
||||
"067.5",
|
||||
"090",
|
||||
"112.5",
|
||||
"135",
|
||||
"157.5",
|
||||
"180",
|
||||
"202.5",
|
||||
"225",
|
||||
"247.5",
|
||||
"270",
|
||||
"292.5",
|
||||
"315",
|
||||
"337.5",
|
||||
]
|
||||
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
|
||||
MIN_DETACHED_COMPONENT_PIXELS = 128
|
||||
DEFAULT_EDGE_MARGIN = 2
|
||||
DEFAULT_EDGE_PIXEL_THRESHOLD = 24
|
||||
|
||||
|
||||
class CellGeometry:
|
||||
def __init__(self, height: int, lower_center_x: float, bottom: int) -> None:
|
||||
self.height = height
|
||||
self.lower_center_x = lower_center_x
|
||||
self.bottom = bottom
|
||||
|
||||
|
||||
def edge_alpha_count(image: Image.Image, margin: int) -> int:
|
||||
alpha = image.getchannel("A")
|
||||
width, height = alpha.size
|
||||
total = 0
|
||||
for box in (
|
||||
(0, 0, width, margin),
|
||||
(0, height - margin, width, height),
|
||||
(0, 0, margin, height),
|
||||
(width - margin, 0, width, height),
|
||||
):
|
||||
total += sum(alpha.crop(box).histogram()[1:])
|
||||
return total
|
||||
|
||||
|
||||
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
||||
if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
|
||||
raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
|
||||
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
|
||||
|
||||
|
||||
def color_distance(
|
||||
red: int,
|
||||
green: int,
|
||||
blue: int,
|
||||
key: tuple[int, int, int],
|
||||
) -> float:
|
||||
return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2)
|
||||
|
||||
|
||||
def remove_chroma_background(
|
||||
image: Image.Image,
|
||||
chroma_key: tuple[int, int, int],
|
||||
threshold: float,
|
||||
) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
pixels = rgba.load()
|
||||
for y in range(rgba.height):
|
||||
for x in range(rgba.width):
|
||||
red, green, blue, alpha = pixels[x, y]
|
||||
if color_distance(red, green, blue, chroma_key) <= threshold:
|
||||
pixels[x, y] = (0, 0, 0, 0)
|
||||
return rgba
|
||||
|
||||
|
||||
def fit_to_cell(image: Image.Image) -> Image.Image:
|
||||
bbox = image.getbbox()
|
||||
target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
||||
if bbox is None:
|
||||
return target
|
||||
|
||||
sprite = image.crop(bbox).convert("RGBA")
|
||||
max_width = CELL_WIDTH - 10
|
||||
max_height = CELL_HEIGHT - 10
|
||||
scale = min(max_width / sprite.width, max_height / sprite.height, 1.0)
|
||||
if scale != 1.0:
|
||||
sprite = sprite.resize(
|
||||
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
left = (CELL_WIDTH - sprite.width) // 2
|
||||
top = (CELL_HEIGHT - sprite.height) // 2
|
||||
target.alpha_composite(sprite, (left, top))
|
||||
return remove_small_detached_components(target)
|
||||
|
||||
|
||||
def remove_small_detached_components(image: Image.Image) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
alpha = rgba.getchannel("A")
|
||||
width, height = rgba.size
|
||||
visited: set[tuple[int, int]] = set()
|
||||
components: list[list[tuple[int, int]]] = []
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
if (x, y) in visited or alpha.getpixel((x, y)) <= 16:
|
||||
continue
|
||||
|
||||
component: list[tuple[int, int]] = []
|
||||
stack = [(x, y)]
|
||||
visited.add((x, y))
|
||||
while stack:
|
||||
current_x, current_y = stack.pop()
|
||||
component.append((current_x, current_y))
|
||||
for next_x, next_y in (
|
||||
(current_x - 1, current_y),
|
||||
(current_x + 1, current_y),
|
||||
(current_x, current_y - 1),
|
||||
(current_x, current_y + 1),
|
||||
):
|
||||
if (
|
||||
next_x < 0
|
||||
or next_x >= width
|
||||
or next_y < 0
|
||||
or next_y >= height
|
||||
or (next_x, next_y) in visited
|
||||
or alpha.getpixel((next_x, next_y)) <= 16
|
||||
):
|
||||
continue
|
||||
visited.add((next_x, next_y))
|
||||
stack.append((next_x, next_y))
|
||||
components.append(component)
|
||||
|
||||
if not components:
|
||||
return rgba
|
||||
|
||||
largest = max(len(component) for component in components)
|
||||
pixels = rgba.load()
|
||||
for component in components:
|
||||
if len(component) == largest or len(component) >= MIN_DETACHED_COMPONENT_PIXELS:
|
||||
continue
|
||||
for x, y in component:
|
||||
pixels[x, y] = (0, 0, 0, 0)
|
||||
return rgba
|
||||
|
||||
|
||||
def opaque_points(image: Image.Image) -> list[tuple[int, int]]:
|
||||
alpha = image.getchannel("A")
|
||||
width, height = image.size
|
||||
return [(x, y) for y in range(height) for x in range(width) if alpha.getpixel((x, y)) > 16]
|
||||
|
||||
|
||||
def lower_band_points(
|
||||
points: list[tuple[int, int]], top: int, bottom: int
|
||||
) -> list[tuple[int, int]]:
|
||||
threshold = top + (bottom - top) * 0.72
|
||||
return [(x, y) for x, y in points if y >= threshold] or points
|
||||
|
||||
|
||||
def cell_geometry(cell: Image.Image) -> CellGeometry | None:
|
||||
points = opaque_points(cell)
|
||||
if not points:
|
||||
return None
|
||||
|
||||
ys = [y for _, y in points]
|
||||
top = min(ys)
|
||||
bottom = max(ys) + 1
|
||||
lower_points = lower_band_points(points, top, bottom)
|
||||
return CellGeometry(
|
||||
height=bottom - top,
|
||||
lower_center_x=sum(x for x, _ in lower_points) / len(lower_points),
|
||||
bottom=bottom,
|
||||
)
|
||||
|
||||
|
||||
def normalize_cell_to_geometry(
|
||||
cell: Image.Image,
|
||||
target: CellGeometry,
|
||||
scale: float,
|
||||
) -> Image.Image:
|
||||
bbox = cell.getbbox()
|
||||
if bbox is None:
|
||||
return cell
|
||||
|
||||
source_geometry = cell_geometry(cell)
|
||||
if source_geometry is None or source_geometry.height <= 0:
|
||||
return cell
|
||||
|
||||
left, top, right, bottom = bbox
|
||||
crop = cell.crop(bbox)
|
||||
scaled_width = max(1, round(crop.width * scale))
|
||||
scaled_height = max(1, round(crop.height * scale))
|
||||
if crop.size != (scaled_width, scaled_height):
|
||||
crop = crop.resize((scaled_width, scaled_height), Image.Resampling.LANCZOS)
|
||||
|
||||
local_lower_center_x = source_geometry.lower_center_x - left
|
||||
target_left = round(target.lower_center_x - local_lower_center_x * scale)
|
||||
target_top = target.bottom - scaled_height
|
||||
|
||||
output = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
||||
output.alpha_composite(crop, (target_left, target_top))
|
||||
return remove_small_detached_components(output)
|
||||
|
||||
|
||||
def normalize_cells_to_reference(
|
||||
cells: list[Image.Image],
|
||||
reference_cell: Image.Image,
|
||||
scale: float | None = None,
|
||||
) -> list[Image.Image]:
|
||||
target = cell_geometry(reference_cell)
|
||||
if target is None:
|
||||
raise SystemExit("neutral reference cell must contain visible pixels")
|
||||
|
||||
if scale is None:
|
||||
scale = normalization_scale(cells, target)
|
||||
return [normalize_cell_to_geometry(cell, target, scale) for cell in cells]
|
||||
|
||||
|
||||
def normalization_scale(cells: list[Image.Image], target: CellGeometry) -> float:
|
||||
|
||||
geometries = [cell_geometry(cell) for cell in cells]
|
||||
if any(geometry is None for geometry in geometries):
|
||||
raise SystemExit("look direction cells must contain visible pixels")
|
||||
|
||||
visible_geometries = [geometry for geometry in geometries if geometry is not None]
|
||||
max_height = max(geometry.height for geometry in visible_geometries)
|
||||
max_width = max(cell.getbbox()[2] - cell.getbbox()[0] for cell in cells)
|
||||
scale_limits = [
|
||||
target.height / max_height,
|
||||
(CELL_WIDTH - 10) / max_width,
|
||||
(CELL_HEIGHT - 10) / max_height,
|
||||
1.0,
|
||||
]
|
||||
margin = 5
|
||||
for cell, geometry in zip(cells, geometries):
|
||||
if geometry is None:
|
||||
continue
|
||||
left, _, right, _ = cell.getbbox()
|
||||
local_lower_center_x = geometry.lower_center_x - left
|
||||
left_extent = local_lower_center_x
|
||||
right_extent = right - left - local_lower_center_x
|
||||
if left_extent > 0:
|
||||
scale_limits.append((target.lower_center_x - margin) / left_extent)
|
||||
if right_extent > 0:
|
||||
scale_limits.append((CELL_WIDTH - margin - target.lower_center_x) / right_extent)
|
||||
scale_limits.append((target.bottom - margin) / geometry.height)
|
||||
return min(scale_limits)
|
||||
|
||||
|
||||
def clear_transparent_rgb(image: Image.Image) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
data = bytearray(rgba.tobytes())
|
||||
for index in range(0, len(data), 4):
|
||||
if data[index + 3] == 0:
|
||||
data[index] = 0
|
||||
data[index + 1] = 0
|
||||
data[index + 2] = 0
|
||||
return Image.frombytes("RGBA", rgba.size, bytes(data))
|
||||
|
||||
|
||||
def load_base_rows(base_atlas_path: Path) -> Image.Image:
|
||||
with Image.open(base_atlas_path) as opened:
|
||||
base = opened.convert("RGBA")
|
||||
if base.width != ATLAS_WIDTH or base.height not in {
|
||||
STANDARD_ATLAS_HEIGHT,
|
||||
EXTENDED_ATLAS_HEIGHT,
|
||||
}:
|
||||
raise SystemExit(
|
||||
f"base atlas must be 1536x1872 or 1536x2288; got {base.width}x{base.height}"
|
||||
)
|
||||
|
||||
extended = Image.new("RGBA", (ATLAS_WIDTH, EXTENDED_ATLAS_HEIGHT), (0, 0, 0, 0))
|
||||
standard_region = base.crop((0, 0, ATLAS_WIDTH, STANDARD_ATLAS_HEIGHT))
|
||||
extended.alpha_composite(standard_region, (0, 0))
|
||||
return extended
|
||||
|
||||
|
||||
def extract_row_strip_cells(
|
||||
row_strip_path: Path,
|
||||
chroma_key: tuple[int, int, int],
|
||||
threshold: float,
|
||||
) -> list[Image.Image]:
|
||||
with Image.open(row_strip_path) as opened:
|
||||
strip = remove_chroma_background(opened, chroma_key, threshold)
|
||||
|
||||
groups = component_frame_groups(strip, COLUMNS)
|
||||
if groups is None:
|
||||
raise SystemExit(
|
||||
f"could not identify {COLUMNS} ordered pose groups in {row_strip_path}; "
|
||||
"resynthesize the complete source row with separated poses"
|
||||
)
|
||||
return [component_group_image(strip, group) for group in groups]
|
||||
|
||||
|
||||
def validate_normalized_look_cells(
|
||||
cells: list[Image.Image],
|
||||
direction_offset: int,
|
||||
edge_margin: int,
|
||||
edge_pixel_threshold: int,
|
||||
) -> None:
|
||||
for index, cell in enumerate(cells):
|
||||
edge_pixels = edge_alpha_count(cell, edge_margin)
|
||||
if edge_pixels > edge_pixel_threshold:
|
||||
label = LOOK_DIRECTION_LABELS[direction_offset + index]
|
||||
raise SystemExit(
|
||||
f"look direction {label} has {edge_pixels} non-transparent pixels near "
|
||||
"its final cell edge after deterministic registration; resynthesize the "
|
||||
"complete source row"
|
||||
)
|
||||
|
||||
|
||||
def normalized_label(value: str) -> str:
|
||||
return value.lower().replace(".", "").replace("-", "").replace("_", "")
|
||||
|
||||
|
||||
def labels_for_path(path: Path) -> list[str]:
|
||||
normalized_stem = normalized_label(path.stem)
|
||||
exact_matches = [
|
||||
label for label in LOOK_DIRECTION_LABELS if normalized_label(label) == normalized_stem
|
||||
]
|
||||
if exact_matches:
|
||||
return exact_matches
|
||||
|
||||
numeric_tokens = [float(token) for token in re.findall(r"\d+(?:\.\d+)?", path.stem)]
|
||||
return [
|
||||
label
|
||||
for label in LOOK_DIRECTION_LABELS
|
||||
if any(float(label) == token for token in numeric_tokens)
|
||||
]
|
||||
|
||||
|
||||
def image_files(path: Path) -> list[Path]:
|
||||
return sorted(p for p in path.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES)
|
||||
|
||||
|
||||
def load_look_cells_from_dir(
|
||||
cells_dir: Path,
|
||||
chroma_key: tuple[int, int, int],
|
||||
threshold: float,
|
||||
) -> list[Image.Image]:
|
||||
files = image_files(cells_dir)
|
||||
cells_by_label: dict[str, Path] = {}
|
||||
for path in files:
|
||||
for label in labels_for_path(path):
|
||||
cells_by_label.setdefault(label, path)
|
||||
|
||||
if len(cells_by_label) == len(LOOK_DIRECTION_LABELS):
|
||||
ordered_files = [cells_by_label[label] for label in LOOK_DIRECTION_LABELS]
|
||||
elif len(files) >= len(LOOK_DIRECTION_LABELS):
|
||||
ordered_files = files[: len(LOOK_DIRECTION_LABELS)]
|
||||
else:
|
||||
raise SystemExit(
|
||||
f"look cells dir must contain 16 labeled or sortable images; found {len(files)}"
|
||||
)
|
||||
|
||||
cells: list[Image.Image] = []
|
||||
for path in ordered_files:
|
||||
with Image.open(path) as opened:
|
||||
cell = remove_chroma_background(opened, chroma_key, threshold)
|
||||
cells.append(cell)
|
||||
return cells
|
||||
|
||||
|
||||
def load_look_cells(
|
||||
args: argparse.Namespace,
|
||||
chroma_key: tuple[int, int, int],
|
||||
) -> list[Image.Image]:
|
||||
if args.look_cells_dir:
|
||||
return load_look_cells_from_dir(
|
||||
Path(args.look_cells_dir).expanduser().resolve(),
|
||||
chroma_key,
|
||||
args.chroma_threshold,
|
||||
)
|
||||
|
||||
if not args.look_row_9:
|
||||
raise SystemExit("provide either --look-cells-dir or --look-row-9")
|
||||
|
||||
row_9_cells = extract_row_strip_cells(
|
||||
Path(args.look_row_9).expanduser().resolve(),
|
||||
chroma_key,
|
||||
args.chroma_threshold,
|
||||
)
|
||||
if not args.look_row_10:
|
||||
return row_9_cells
|
||||
|
||||
row_10_cells = extract_row_strip_cells(
|
||||
Path(args.look_row_10).expanduser().resolve(),
|
||||
chroma_key,
|
||||
args.chroma_threshold,
|
||||
)
|
||||
return [*row_9_cells, *row_10_cells]
|
||||
|
||||
|
||||
def load_registered_row(path: Path) -> list[Image.Image]:
|
||||
with Image.open(path) as opened:
|
||||
row = opened.convert("RGBA")
|
||||
if row.size != (ATLAS_WIDTH, CELL_HEIGHT):
|
||||
raise SystemExit(
|
||||
f"registered row must be {ATLAS_WIDTH}x{CELL_HEIGHT}; got {row.width}x{row.height}"
|
||||
)
|
||||
return [
|
||||
row.crop(
|
||||
(
|
||||
column * CELL_WIDTH,
|
||||
0,
|
||||
(column + 1) * CELL_WIDTH,
|
||||
CELL_HEIGHT,
|
||||
)
|
||||
)
|
||||
for column in range(COLUMNS)
|
||||
]
|
||||
|
||||
|
||||
def load_registration_scale(path: Path) -> float:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
scale = data.get("scale")
|
||||
if not isinstance(scale, int | float) or scale <= 0:
|
||||
raise SystemExit(f"registration manifest has invalid scale: {scale!r}")
|
||||
return float(scale)
|
||||
|
||||
|
||||
def load_neutral_cell(
|
||||
neutral_cell_path: Path | None,
|
||||
atlas: Image.Image,
|
||||
chroma_key: tuple[int, int, int],
|
||||
threshold: float,
|
||||
) -> Image.Image:
|
||||
if neutral_cell_path is None:
|
||||
return base_neutral_cell(atlas)
|
||||
|
||||
with Image.open(neutral_cell_path) as opened:
|
||||
return fit_to_cell(remove_chroma_background(opened, chroma_key, threshold))
|
||||
|
||||
|
||||
def atlas_cell(atlas: Image.Image, row: int, column: int) -> Image.Image:
|
||||
return atlas.crop(
|
||||
(
|
||||
column * CELL_WIDTH,
|
||||
row * CELL_HEIGHT,
|
||||
(column + 1) * CELL_WIDTH,
|
||||
(row + 1) * CELL_HEIGHT,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def base_neutral_cell(atlas: Image.Image) -> Image.Image:
|
||||
for column in [6, 0, 1, 2, 3, 4, 5, 7]:
|
||||
cell = atlas_cell(atlas, 0, column)
|
||||
if cell_geometry(cell) is not None:
|
||||
return cell
|
||||
|
||||
raise SystemExit("base atlas must contain a visible idle or neutral frame")
|
||||
|
||||
|
||||
def paste_look_cells(atlas: Image.Image, cells: list[Image.Image]) -> None:
|
||||
if len(cells) != len(LOOK_DIRECTION_LABELS):
|
||||
raise SystemExit(f"expected 16 look cells, got {len(cells)}")
|
||||
for index, cell in enumerate(cells):
|
||||
row = STANDARD_ROWS + index // COLUMNS
|
||||
column = index % COLUMNS
|
||||
atlas.alpha_composite(cell, (column * CELL_WIDTH, row * CELL_HEIGHT))
|
||||
|
||||
|
||||
def paste_neutral_cell(
|
||||
atlas: Image.Image,
|
||||
neutral: Image.Image,
|
||||
) -> None:
|
||||
atlas.alpha_composite(neutral, (6 * CELL_WIDTH, 0))
|
||||
|
||||
|
||||
def write_manifest(path: Path, atlas_path: Path) -> None:
|
||||
manifest = {
|
||||
"spritesheetPath": atlas_path.name,
|
||||
"spritesheetLayout": {
|
||||
"columns": COLUMNS,
|
||||
"rows": EXTENDED_ROWS,
|
||||
"cellWidth": CELL_WIDTH,
|
||||
"cellHeight": CELL_HEIGHT,
|
||||
"lookDirectionCount": len(LOOK_DIRECTION_LABELS),
|
||||
"neutralLookFrame": {"rowIndex": 0, "columnIndex": 6},
|
||||
},
|
||||
"lookDirections": [
|
||||
{
|
||||
"degrees": float(label),
|
||||
"rowIndex": STANDARD_ROWS + index // COLUMNS,
|
||||
"columnIndex": index % COLUMNS,
|
||||
}
|
||||
for index, label in enumerate(LOOK_DIRECTION_LABELS)
|
||||
],
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def save_registered_row(cells: list[Image.Image], path: Path) -> None:
|
||||
if len(cells) != COLUMNS:
|
||||
raise SystemExit(f"expected {COLUMNS} registered look cells, got {len(cells)}")
|
||||
|
||||
row = Image.new("RGBA", (ATLAS_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
||||
for column, cell in enumerate(cells):
|
||||
row.alpha_composite(cell, (column * CELL_WIDTH, 0))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
clear_transparent_rgb(row).save(path)
|
||||
print(f"wrote {path}")
|
||||
|
||||
|
||||
def write_registration_manifest(path: Path, scale: float) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"scale": scale}, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"wrote {path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base-atlas", required=True)
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--look-cells-dir")
|
||||
source.add_argument("--look-row-9")
|
||||
source.add_argument("--registered-row-9")
|
||||
parser.add_argument("--look-row-10")
|
||||
parser.add_argument("--row-9-registration")
|
||||
parser.add_argument(
|
||||
"--neutral-cell",
|
||||
help="optional external neutral/default cell; defaults to the neutral cell in the base atlas",
|
||||
)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument(
|
||||
"--registered-row-output",
|
||||
help="validate and write registered row 9 before row 10 is generated",
|
||||
)
|
||||
parser.add_argument("--registration-manifest-output")
|
||||
parser.add_argument("--webp-output")
|
||||
parser.add_argument("--manifest-output")
|
||||
parser.add_argument("--chroma-key", default="#00FF00")
|
||||
parser.add_argument("--chroma-threshold", type=float, default=96.0)
|
||||
parser.add_argument("--edge-margin", type=int, default=DEFAULT_EDGE_MARGIN)
|
||||
parser.add_argument(
|
||||
"--edge-pixel-threshold",
|
||||
type=int,
|
||||
default=DEFAULT_EDGE_PIXEL_THRESHOLD,
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
chroma_key = parse_hex_color(args.chroma_key)
|
||||
atlas = load_base_rows(Path(args.base_atlas).expanduser().resolve())
|
||||
neutral = load_neutral_cell(
|
||||
Path(args.neutral_cell).expanduser().resolve() if args.neutral_cell else None,
|
||||
atlas,
|
||||
chroma_key,
|
||||
args.chroma_threshold,
|
||||
)
|
||||
if args.registered_row_9:
|
||||
if not args.look_row_10 or not args.row_9_registration:
|
||||
raise SystemExit("--registered-row-9 requires --look-row-10 and --row-9-registration")
|
||||
row_9_cells = load_registered_row(Path(args.registered_row_9).expanduser().resolve())
|
||||
row_10_cells = extract_row_strip_cells(
|
||||
Path(args.look_row_10).expanduser().resolve(),
|
||||
chroma_key,
|
||||
args.chroma_threshold,
|
||||
)
|
||||
row_10_cells = normalize_cells_to_reference(
|
||||
row_10_cells,
|
||||
neutral,
|
||||
load_registration_scale(Path(args.row_9_registration).expanduser().resolve()),
|
||||
)
|
||||
validate_normalized_look_cells(
|
||||
row_10_cells,
|
||||
COLUMNS,
|
||||
args.edge_margin,
|
||||
args.edge_pixel_threshold,
|
||||
)
|
||||
cells = [*row_9_cells, *row_10_cells]
|
||||
else:
|
||||
cells = load_look_cells(args, chroma_key)
|
||||
target = cell_geometry(neutral)
|
||||
if target is None:
|
||||
raise SystemExit("neutral reference cell must contain visible pixels")
|
||||
scale = normalization_scale(cells, target)
|
||||
cells = normalize_cells_to_reference(cells, neutral, scale)
|
||||
validate_normalized_look_cells(
|
||||
cells,
|
||||
0,
|
||||
args.edge_margin,
|
||||
args.edge_pixel_threshold,
|
||||
)
|
||||
|
||||
if not args.look_cells_dir and not args.look_row_10:
|
||||
if not args.registered_row_output:
|
||||
raise SystemExit("--look-row-9 without --look-row-10 requires --registered-row-output")
|
||||
save_registered_row(
|
||||
cells,
|
||||
Path(args.registered_row_output).expanduser().resolve(),
|
||||
)
|
||||
if not args.registration_manifest_output:
|
||||
raise SystemExit("--registered-row-output requires --registration-manifest-output")
|
||||
write_registration_manifest(
|
||||
Path(args.registration_manifest_output).expanduser().resolve(),
|
||||
scale,
|
||||
)
|
||||
return
|
||||
|
||||
if not args.output:
|
||||
raise SystemExit("--output is required when assembling the extended atlas")
|
||||
|
||||
paste_look_cells(atlas, cells)
|
||||
paste_neutral_cell(atlas, neutral)
|
||||
atlas = clear_transparent_rgb(atlas)
|
||||
|
||||
output = Path(args.output).expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
atlas.save(output)
|
||||
print(f"wrote {output}")
|
||||
|
||||
if args.webp_output:
|
||||
webp_output = Path(args.webp_output).expanduser().resolve()
|
||||
webp_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
atlas.save(webp_output, format="WEBP", lossless=True, quality=100, method=6, exact=True)
|
||||
print(f"wrote {webp_output}")
|
||||
|
||||
if args.manifest_output:
|
||||
manifest_output = Path(args.manifest_output).expanduser().resolve()
|
||||
write_manifest(
|
||||
manifest_output, Path(args.webp_output or args.output).expanduser().resolve()
|
||||
)
|
||||
print(f"wrote {manifest_output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Combine independent blind direction verdicts by strict per-cell majority."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_pairs(path: str) -> dict[str, dict[str, object]]:
|
||||
payload = json.loads(Path(path).expanduser().resolve().read_text(encoding="utf-8"))
|
||||
return {entry["pair"]: entry for entry in payload.get("pairs", [])}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--verdicts", action="append", required=True)
|
||||
parser.add_argument("--json-out", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
if len(args.verdicts) < 3 or len(args.verdicts) % 2 == 0:
|
||||
raise SystemExit("provide an odd number of at least three verdict files")
|
||||
|
||||
reviews = [load_pairs(path) for path in args.verdicts]
|
||||
pair_ids = set(reviews[0])
|
||||
if any(set(review) != pair_ids for review in reviews[1:]):
|
||||
raise SystemExit("all verdict files must contain the same pair ids")
|
||||
|
||||
threshold = len(reviews) // 2 + 1
|
||||
combined = []
|
||||
for pair_id in reviews[0]:
|
||||
result: dict[str, object] = {"pair": pair_id}
|
||||
vote_summary: dict[str, object] = {}
|
||||
for slot in ("A", "B"):
|
||||
votes = [review[pair_id].get(slot) for review in reviews]
|
||||
counts = Counter(votes)
|
||||
direction, count = counts.most_common(1)[0]
|
||||
result[slot] = direction if count >= threshold else "ambiguous"
|
||||
vote_summary[slot] = dict(counts)
|
||||
result["reason"] = "strict majority of independent blind reviews"
|
||||
result["votes"] = vote_summary
|
||||
combined.append(result)
|
||||
|
||||
output = Path(args.json_out).expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps({"pairs": combined}, indent=2) + "\n", encoding="utf-8")
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
169
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/compose_atlas.py
Normal file
169
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/compose_atlas.py
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compose or normalize a Codex pet spritesheet atlas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
COLUMNS = 8
|
||||
ROWS = 9
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
|
||||
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
|
||||
ATLAS_ASPECT_RATIO = ATLAS_WIDTH / ATLAS_HEIGHT
|
||||
ROW_SPECS = [
|
||||
("idle", 0, 6),
|
||||
("running-right", 1, 8),
|
||||
("running-left", 2, 8),
|
||||
("waving", 3, 4),
|
||||
("jumping", 4, 5),
|
||||
("failed", 5, 8),
|
||||
("waiting", 6, 6),
|
||||
("running", 7, 6),
|
||||
("review", 8, 6),
|
||||
]
|
||||
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
|
||||
|
||||
|
||||
def image_files(path: Path) -> list[Path]:
|
||||
return sorted(p for p in path.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES)
|
||||
|
||||
|
||||
def find_row_frames(root: Path, state: str, row_index: int) -> list[Path]:
|
||||
candidates = [
|
||||
root / state,
|
||||
root / f"row-{row_index}",
|
||||
root / f"row{row_index}",
|
||||
root / f"{row_index}-{state}",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.is_dir():
|
||||
files = image_files(candidate)
|
||||
if files:
|
||||
return files
|
||||
globs = [
|
||||
f"{state}_*",
|
||||
f"{state}-*",
|
||||
f"row{row_index}_*",
|
||||
f"row-{row_index}-*",
|
||||
]
|
||||
files: list[Path] = []
|
||||
for pattern in globs:
|
||||
files.extend(p for p in root.glob(pattern) if p.suffix.lower() in IMAGE_SUFFIXES)
|
||||
return sorted(set(files))
|
||||
|
||||
|
||||
def paste_centered(atlas: Image.Image, source: Image.Image, row: int, column: int) -> None:
|
||||
frame = source.convert("RGBA")
|
||||
if frame.size != (CELL_WIDTH, CELL_HEIGHT):
|
||||
frame.thumbnail((CELL_WIDTH, CELL_HEIGHT), Image.Resampling.LANCZOS)
|
||||
left = column * CELL_WIDTH + (CELL_WIDTH - frame.width) // 2
|
||||
top = row * CELL_HEIGHT + (CELL_HEIGHT - frame.height) // 2
|
||||
atlas.alpha_composite(frame, (left, top))
|
||||
|
||||
|
||||
def compose_from_source_atlas(path: Path, resize_source: bool) -> Image.Image:
|
||||
with Image.open(path) as opened:
|
||||
source = opened.convert("RGBA")
|
||||
if source.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
|
||||
if not resize_source:
|
||||
raise SystemExit(
|
||||
f"source atlas must be {ATLAS_WIDTH}x{ATLAS_HEIGHT}; got {source.width}x{source.height}"
|
||||
)
|
||||
source_ratio = source.width / source.height
|
||||
if abs(source_ratio - ATLAS_ASPECT_RATIO) > 0.02:
|
||||
raise SystemExit(
|
||||
"refusing to resize source atlas because its aspect ratio does not match "
|
||||
f"the Codex atlas ratio {ATLAS_ASPECT_RATIO:.3f}; got {source_ratio:.3f}. "
|
||||
"Generate exact atlas dimensions or use --frames-root."
|
||||
)
|
||||
source = source.resize((ATLAS_WIDTH, ATLAS_HEIGHT), Image.Resampling.LANCZOS)
|
||||
|
||||
atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
|
||||
for _state, row, frame_count in ROW_SPECS:
|
||||
for column in range(frame_count):
|
||||
left = column * CELL_WIDTH
|
||||
top = row * CELL_HEIGHT
|
||||
cell = source.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
|
||||
atlas.alpha_composite(cell, (left, top))
|
||||
return atlas
|
||||
|
||||
|
||||
def compose_from_frames(root: Path) -> Image.Image:
|
||||
atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
|
||||
for state, row, frame_count in ROW_SPECS:
|
||||
files = find_row_frames(root, state, row)
|
||||
if len(files) < frame_count:
|
||||
raise SystemExit(
|
||||
f"{state} row needs {frame_count} frames, found {len(files)} under {root}"
|
||||
)
|
||||
for column, frame_path in enumerate(files[:frame_count]):
|
||||
with Image.open(frame_path) as frame:
|
||||
paste_centered(atlas, frame, row, column)
|
||||
return atlas
|
||||
|
||||
|
||||
def clear_transparent_rgb(image: Image.Image) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
data = bytearray(rgba.tobytes())
|
||||
for index in range(0, len(data), 4):
|
||||
if data[index + 3] == 0:
|
||||
data[index] = 0
|
||||
data[index + 1] = 0
|
||||
data[index + 2] = 0
|
||||
return Image.frombytes("RGBA", rgba.size, bytes(data))
|
||||
|
||||
|
||||
def save_outputs(atlas: Image.Image, output: Path, webp_output: Path | None) -> None:
|
||||
atlas = clear_transparent_rgb(atlas)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
atlas.save(output)
|
||||
if webp_output is not None:
|
||||
webp_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
atlas.save(
|
||||
webp_output,
|
||||
format="WEBP",
|
||||
lossless=True,
|
||||
quality=100,
|
||||
method=6,
|
||||
exact=True,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--source-atlas")
|
||||
source.add_argument("--frames-root")
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--webp-output")
|
||||
parser.add_argument(
|
||||
"--resize-source",
|
||||
action="store_true",
|
||||
help="Resize a lower-resolution source atlas only when it already has the Codex atlas aspect ratio.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.source_atlas:
|
||||
atlas = compose_from_source_atlas(
|
||||
Path(args.source_atlas).expanduser().resolve(), args.resize_source
|
||||
)
|
||||
else:
|
||||
atlas = compose_from_frames(Path(args.frames_root).expanduser().resolve())
|
||||
|
||||
save_outputs(
|
||||
atlas,
|
||||
Path(args.output).expanduser().resolve(),
|
||||
Path(args.webp_output).expanduser().resolve() if args.webp_output else None,
|
||||
)
|
||||
print(f"wrote {Path(args.output).expanduser().resolve()}")
|
||||
if args.webp_output:
|
||||
print(f"wrote {Path(args.webp_output).expanduser().resolve()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compose approved cardinal reference cells."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
CELL_SIZE = (192, 208)
|
||||
CARDINALS = ("000", "090", "180", "270")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--anchors-dir", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
anchors_dir = Path(args.anchors_dir).expanduser().resolve()
|
||||
output = Path(args.output).expanduser().resolve()
|
||||
strip = Image.new("RGBA", (CELL_SIZE[0] * len(CARDINALS), CELL_SIZE[1]))
|
||||
|
||||
for index, direction in enumerate(CARDINALS):
|
||||
path = anchors_dir / f"{direction}.png"
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"missing approved cardinal reference: {path}")
|
||||
with Image.open(path) as opened:
|
||||
reference = opened.convert("RGBA")
|
||||
if reference.size != CELL_SIZE:
|
||||
raise SystemExit(f"{path} is {reference.size}; expected {CELL_SIZE}")
|
||||
if reference.getbbox() is None:
|
||||
raise SystemExit(f"approved cardinal reference is empty: {path}")
|
||||
strip.alpha_composite(reference, (index * CELL_SIZE[0], 0))
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
strip.save(output)
|
||||
print(f"wrote {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Conditionally derive running-left by mirroring the approved running-right strip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
RUNNING_FRAME_COUNT = 8
|
||||
|
||||
|
||||
def load_manifest(run_dir: Path) -> dict[str, object]:
|
||||
path = run_dir / "imagegen-jobs.json"
|
||||
if not path.exists():
|
||||
raise SystemExit(f"job manifest not found: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def job_list(manifest: dict[str, object]) -> list[dict[str, object]]:
|
||||
jobs = manifest.get("jobs")
|
||||
if not isinstance(jobs, list):
|
||||
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
|
||||
return [job for job in jobs if isinstance(job, dict)]
|
||||
|
||||
|
||||
def find_job(manifest: dict[str, object], job_id: str) -> dict[str, object]:
|
||||
for job in job_list(manifest):
|
||||
if job.get("id") == job_id:
|
||||
return job
|
||||
raise SystemExit(f"unknown job id: {job_id}")
|
||||
|
||||
|
||||
def image_metadata(path: Path) -> dict[str, object]:
|
||||
with Image.open(path) as image:
|
||||
image.verify()
|
||||
with Image.open(path) as image:
|
||||
return {
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"mode": image.mode,
|
||||
"format": image.format,
|
||||
}
|
||||
|
||||
|
||||
def manifest_relative(path: Path, run_dir: Path) -> str:
|
||||
return str(path.resolve().relative_to(run_dir.resolve()))
|
||||
|
||||
|
||||
def mirror_strip_preserving_frame_order(
|
||||
source: Image.Image,
|
||||
frame_count: int = RUNNING_FRAME_COUNT,
|
||||
) -> Image.Image:
|
||||
rgba = source.convert("RGBA")
|
||||
mirrored = Image.new("RGBA", rgba.size, (0, 0, 0, 0))
|
||||
slot_width = rgba.width / frame_count
|
||||
for index in range(frame_count):
|
||||
left = round(index * slot_width)
|
||||
right = round((index + 1) * slot_width)
|
||||
mirrored.alpha_composite(
|
||||
ImageOps.mirror(rgba.crop((left, 0, right, rgba.height))),
|
||||
(left, 0),
|
||||
)
|
||||
return mirrored
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--run-dir", required=True)
|
||||
parser.add_argument(
|
||||
"--confirm-appropriate-mirror",
|
||||
action="store_true",
|
||||
help="Required after visually confirming the rightward strip can be mirrored without identity/prop issues.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--decision-note",
|
||||
required=True,
|
||||
help="Short note explaining why mirroring is acceptable for this pet.",
|
||||
)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.confirm_appropriate_mirror:
|
||||
raise SystemExit("refusing to mirror without --confirm-appropriate-mirror")
|
||||
if not args.decision_note.strip():
|
||||
raise SystemExit("--decision-note must explain why mirroring is appropriate")
|
||||
|
||||
run_dir = Path(args.run_dir).expanduser().resolve()
|
||||
manifest_path = run_dir / "imagegen-jobs.json"
|
||||
manifest = load_manifest(run_dir)
|
||||
right_job = find_job(manifest, "running-right")
|
||||
left_job = find_job(manifest, "running-left")
|
||||
|
||||
if right_job.get("status") != "complete":
|
||||
raise SystemExit("running-right must be complete before deriving running-left")
|
||||
mirror_policy = left_job.get("mirror_policy")
|
||||
if (
|
||||
not isinstance(mirror_policy, dict)
|
||||
or mirror_policy.get("may_derive_from") != "running-right"
|
||||
):
|
||||
raise SystemExit("running-left is not configured for conditional mirroring")
|
||||
|
||||
source = run_dir / "decoded" / "running-right.png"
|
||||
output = run_dir / "decoded" / "running-left.png"
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"running-right decoded strip not found: {source}")
|
||||
if output.exists() and not args.force:
|
||||
raise SystemExit(f"{output} already exists; pass --force to replace it")
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.open(source) as image:
|
||||
mirrored = mirror_strip_preserving_frame_order(image)
|
||||
mirrored.save(output)
|
||||
|
||||
left_job["status"] = "complete"
|
||||
left_job["source_path"] = manifest_relative(source, run_dir)
|
||||
left_job["derived_from"] = "running-right"
|
||||
left_job["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
left_job["metadata"] = image_metadata(output)
|
||||
left_job["mirror_decision"] = {
|
||||
"approved": True,
|
||||
"approved_at": left_job["completed_at"],
|
||||
"note": args.decision_note.strip(),
|
||||
"transform": "framewise-horizontal-mirror-preserving-order",
|
||||
}
|
||||
for key in [
|
||||
"last_error",
|
||||
"repair_reason",
|
||||
"queued_at",
|
||||
]:
|
||||
left_job.pop(key, None)
|
||||
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"job_id": "running-left",
|
||||
"derived_from": "running-right",
|
||||
"output": str(output),
|
||||
"decision_note": args.decision_note.strip(),
|
||||
"transform": "framewise-horizontal-mirror-preserving-order",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
307
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/despill_chroma_edges.py
Executable file
307
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/despill_chroma_edges.py
Executable file
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Remove chroma-key matte contamination from transparent sprite edges."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
ALGORITHM = "edge-local-chroma-spill-suppression"
|
||||
|
||||
|
||||
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
||||
if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
|
||||
raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
|
||||
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
|
||||
|
||||
|
||||
def srgb_to_linear(value: float) -> float:
|
||||
if value <= 0.04045:
|
||||
return value / 12.92
|
||||
return ((value + 0.055) / 1.055) ** 2.4
|
||||
|
||||
|
||||
def linear_to_srgb(value: float) -> float:
|
||||
if value <= 0.0031308:
|
||||
return value * 12.92
|
||||
return 1.055 * value ** (1 / 2.4) - 0.055
|
||||
|
||||
|
||||
def edge_band(alpha: Image.Image, radius: int) -> list[bool]:
|
||||
visible = [value > 0 for value in alpha.getdata()]
|
||||
transparent = Image.new("L", alpha.size)
|
||||
transparent.putdata([0 if value else 255 for value in visible])
|
||||
expanded = transparent.filter(ImageFilter.MaxFilter(radius * 2 + 1))
|
||||
return [is_visible and nearby > 0 for is_visible, nearby in zip(visible, expanded.getdata())]
|
||||
|
||||
|
||||
def atlas_edge_band(alpha: Image.Image, radius: int) -> list[bool]:
|
||||
width, height = alpha.size
|
||||
boundary = edge_band(alpha, radius)
|
||||
if width % CELL_WIDTH or height % CELL_HEIGHT:
|
||||
return boundary
|
||||
|
||||
for top in range(0, height, CELL_HEIGHT):
|
||||
for left in range(0, width, CELL_WIDTH):
|
||||
cell = alpha.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
|
||||
for index, is_boundary in enumerate(edge_band(cell, radius)):
|
||||
if is_boundary:
|
||||
x = left + index % CELL_WIDTH
|
||||
y = top + index // CELL_WIDTH
|
||||
boundary[y * width + x] = True
|
||||
return boundary
|
||||
|
||||
|
||||
def chroma_similarity(
|
||||
color: tuple[float, float, float],
|
||||
key: tuple[float, float, float],
|
||||
) -> float:
|
||||
color_mean = sum(color) / 3
|
||||
key_mean = sum(key) / 3
|
||||
color_chroma = tuple(channel - color_mean for channel in color)
|
||||
key_chroma = tuple(channel - key_mean for channel in key)
|
||||
denominator = sum(channel * channel for channel in color_chroma) * sum(
|
||||
channel * channel for channel in key_chroma
|
||||
)
|
||||
if denominator <= 1e-12:
|
||||
return -1
|
||||
return (
|
||||
sum(
|
||||
color_channel * key_channel
|
||||
for color_channel, key_channel in zip(color_chroma, key_chroma)
|
||||
)
|
||||
/ denominator**0.5
|
||||
)
|
||||
|
||||
|
||||
def chroma_saturation(color: tuple[float, float, float]) -> float:
|
||||
maximum = max(color)
|
||||
if maximum <= 0:
|
||||
return 0
|
||||
return (maximum - min(color)) / maximum
|
||||
|
||||
|
||||
def suppress_boundary_spill(
|
||||
pixels: list[tuple[int, int, int, int]],
|
||||
*,
|
||||
size: tuple[int, int],
|
||||
boundary: list[bool],
|
||||
key_linear: tuple[float, float, float],
|
||||
strength: float,
|
||||
edge_radius: int,
|
||||
spill_tolerance: float,
|
||||
minimum_saturation: float,
|
||||
) -> tuple[list[tuple[int, int, int, int]], list[bool]]:
|
||||
width, height = size
|
||||
colors_linear = [
|
||||
tuple(srgb_to_linear(channel / 255) for channel in pixel[:3]) for pixel in pixels
|
||||
]
|
||||
similarity_threshold = 1 - min(spill_tolerance, 1)
|
||||
pending = [
|
||||
pixel[3] > 0
|
||||
and is_boundary
|
||||
and (
|
||||
pixel[3] < 250
|
||||
or (
|
||||
chroma_saturation(color) >= minimum_saturation
|
||||
and chroma_similarity(color, key_linear) >= similarity_threshold
|
||||
)
|
||||
)
|
||||
for pixel, color, is_boundary in zip(pixels, colors_linear, boundary)
|
||||
]
|
||||
filled = [pixel[3] > 0 and not is_pending for pixel, is_pending in zip(pixels, pending)]
|
||||
output = pixels.copy()
|
||||
suppressed = [False] * len(pixels)
|
||||
cell_width = CELL_WIDTH if width % CELL_WIDTH == 0 else width
|
||||
cell_height = CELL_HEIGHT if height % CELL_HEIGHT == 0 else height
|
||||
|
||||
for _ in range(edge_radius * 2 + 1):
|
||||
updates: list[tuple[int, tuple[float, float, float]]] = []
|
||||
for index, is_pending in enumerate(pending):
|
||||
if not is_pending:
|
||||
continue
|
||||
x = index % width
|
||||
y = index // width
|
||||
cell_left = x // cell_width * cell_width
|
||||
cell_top = y // cell_height * cell_height
|
||||
references = []
|
||||
for neighbor_y in range(
|
||||
max(cell_top, y - 1),
|
||||
min(cell_top + cell_height, y + 2),
|
||||
):
|
||||
for neighbor_x in range(
|
||||
max(cell_left, x - 1),
|
||||
min(cell_left + cell_width, x + 2),
|
||||
):
|
||||
neighbor = neighbor_y * width + neighbor_x
|
||||
if neighbor != index and filled[neighbor]:
|
||||
references.append(colors_linear[neighbor])
|
||||
if not references:
|
||||
continue
|
||||
|
||||
reference = tuple(
|
||||
sum(color[channel] for color in references) / len(references)
|
||||
for channel in range(3)
|
||||
)
|
||||
observed = colors_linear[index]
|
||||
cleaned = tuple(
|
||||
channel + (reference_channel - channel) * strength
|
||||
for channel, reference_channel in zip(observed, reference)
|
||||
)
|
||||
updates.append((index, cleaned))
|
||||
|
||||
if not updates:
|
||||
break
|
||||
for index, cleaned in updates:
|
||||
colors_linear[index] = cleaned
|
||||
filled[index] = True
|
||||
pending[index] = False
|
||||
output[index] = (
|
||||
*(round(linear_to_srgb(min(1, max(0, channel))) * 255) for channel in cleaned),
|
||||
pixels[index][3],
|
||||
)
|
||||
suppressed[index] = output[index] != pixels[index]
|
||||
|
||||
for index, is_pending in enumerate(pending):
|
||||
if not is_pending:
|
||||
continue
|
||||
observed = colors_linear[index]
|
||||
luminance = sum(observed) / 3
|
||||
cleaned = tuple(channel + (luminance - channel) * strength for channel in observed)
|
||||
output[index] = (
|
||||
*(round(linear_to_srgb(min(1, max(0, channel))) * 255) for channel in cleaned),
|
||||
pixels[index][3],
|
||||
)
|
||||
suppressed[index] = output[index] != pixels[index]
|
||||
|
||||
return output, suppressed
|
||||
|
||||
|
||||
def decontaminate_image(
|
||||
image: Image.Image,
|
||||
*,
|
||||
chroma_key: tuple[int, int, int],
|
||||
strength: float = 1,
|
||||
edge_radius: int = 5,
|
||||
spill_tolerance: float = 0.15,
|
||||
minimum_saturation: float = 0.1,
|
||||
) -> tuple[Image.Image, dict[str, object]]:
|
||||
if not 0 <= strength <= 1:
|
||||
raise ValueError("strength must be between 0 and 1")
|
||||
if edge_radius < 1:
|
||||
raise ValueError("edge_radius must be at least 1")
|
||||
if spill_tolerance < 0:
|
||||
raise ValueError("spill_tolerance must not be negative")
|
||||
if minimum_saturation < 0:
|
||||
raise ValueError("minimum_saturation must not be negative")
|
||||
|
||||
rgba = image.convert("RGBA")
|
||||
width, _ = rgba.size
|
||||
source = list(rgba.getdata())
|
||||
boundary = atlas_edge_band(rgba.getchannel("A"), edge_radius)
|
||||
key_linear = tuple(srgb_to_linear(channel / 255) for channel in chroma_key)
|
||||
output_pixels, suppressed = suppress_boundary_spill(
|
||||
source,
|
||||
size=rgba.size,
|
||||
boundary=boundary,
|
||||
key_linear=key_linear,
|
||||
strength=strength,
|
||||
edge_radius=edge_radius,
|
||||
spill_tolerance=spill_tolerance,
|
||||
minimum_saturation=minimum_saturation,
|
||||
)
|
||||
output_pixels = [
|
||||
(0, 0, 0, 0) if pixel[3] == 0 else output_pixel
|
||||
for pixel, output_pixel in zip(source, output_pixels)
|
||||
]
|
||||
decontaminated_pixels = sum(
|
||||
is_suppressed and pixel[3] < 255 for pixel, is_suppressed in zip(source, suppressed)
|
||||
)
|
||||
spill_suppressed_pixels = sum(suppressed)
|
||||
|
||||
changed_by_cell: dict[str, int] = {}
|
||||
for index, (source_pixel, output_pixel) in enumerate(zip(source, output_pixels)):
|
||||
if output_pixel != source_pixel:
|
||||
x = index % width
|
||||
y = index // width
|
||||
cell = f"r{y // CELL_HEIGHT}c{x // CELL_WIDTH}"
|
||||
changed_by_cell[cell] = changed_by_cell.get(cell, 0) + 1
|
||||
|
||||
output = Image.new("RGBA", rgba.size)
|
||||
output.putdata(output_pixels)
|
||||
return output, {
|
||||
"algorithm": ALGORITHM,
|
||||
"strength": strength,
|
||||
"edge_radius": edge_radius,
|
||||
"spill_tolerance": spill_tolerance,
|
||||
"minimum_saturation": minimum_saturation,
|
||||
"changed_pixels": sum(changed_by_cell.values()),
|
||||
"decontaminated_pixels": decontaminated_pixels,
|
||||
"spill_suppressed_pixels": spill_suppressed_pixels,
|
||||
"rejected_pixels": 0,
|
||||
"changed_by_cell": dict(
|
||||
sorted(changed_by_cell.items(), key=lambda item: item[1], reverse=True)
|
||||
),
|
||||
"alpha_preserved": True,
|
||||
}
|
||||
|
||||
|
||||
def save_image(image: Image.Image, path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.suffix.lower() == ".webp":
|
||||
image.save(path, format="WEBP", lossless=True, quality=100, method=6, exact=True)
|
||||
else:
|
||||
image.save(path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input")
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--webp-output")
|
||||
parser.add_argument("--json-out")
|
||||
parser.add_argument("--chroma-key", required=True)
|
||||
parser.add_argument("--strength", type=float, default=1)
|
||||
parser.add_argument("--edge-radius", type=int, default=5)
|
||||
parser.add_argument("--spill-tolerance", type=float, default=0.15)
|
||||
parser.add_argument("--minimum-saturation", type=float, default=0.1)
|
||||
args = parser.parse_args()
|
||||
|
||||
input_path = Path(args.input).expanduser().resolve()
|
||||
with Image.open(input_path) as opened:
|
||||
cleaned, report = decontaminate_image(
|
||||
opened,
|
||||
chroma_key=parse_hex_color(args.chroma_key),
|
||||
strength=args.strength,
|
||||
edge_radius=args.edge_radius,
|
||||
spill_tolerance=args.spill_tolerance,
|
||||
minimum_saturation=args.minimum_saturation,
|
||||
)
|
||||
|
||||
output_path = Path(args.output).expanduser().resolve()
|
||||
save_image(cleaned, output_path)
|
||||
if args.webp_output:
|
||||
save_image(cleaned, Path(args.webp_output).expanduser().resolve())
|
||||
|
||||
result = {
|
||||
"ok": True,
|
||||
"input": str(input_path),
|
||||
"output": str(output_path),
|
||||
"chroma_key": args.chroma_key.upper(),
|
||||
**report,
|
||||
}
|
||||
if args.json_out:
|
||||
json_path = Path(args.json_out).expanduser().resolve()
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_path.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
151
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/extract_cardinal_anchors.py
Executable file
151
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/extract_cardinal_anchors.py
Executable file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract and validate a four-pose cardinal anchor strip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
CARDINALS = ["000", "090", "180", "270"]
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
|
||||
|
||||
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
||||
if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
|
||||
raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
|
||||
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
|
||||
|
||||
|
||||
def color_distance(
|
||||
red: int,
|
||||
green: int,
|
||||
blue: int,
|
||||
key: tuple[int, int, int],
|
||||
) -> float:
|
||||
return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2)
|
||||
|
||||
|
||||
def remove_chroma_background(
|
||||
image: Image.Image,
|
||||
chroma_key: tuple[int, int, int],
|
||||
threshold: float,
|
||||
) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
pixels = rgba.load()
|
||||
for y in range(rgba.height):
|
||||
for x in range(rgba.width):
|
||||
red, green, blue, _alpha = pixels[x, y]
|
||||
if color_distance(red, green, blue, chroma_key) <= threshold:
|
||||
pixels[x, y] = (0, 0, 0, 0)
|
||||
return rgba
|
||||
|
||||
|
||||
def alpha_count(image: Image.Image) -> int:
|
||||
alpha = image if image.mode == "L" else image.getchannel("A")
|
||||
return sum(alpha.histogram()[1:])
|
||||
|
||||
|
||||
def edge_alpha_count(image: Image.Image, margin: int) -> int:
|
||||
alpha = image.getchannel("A")
|
||||
width, height = alpha.size
|
||||
return sum(
|
||||
alpha_count(alpha.crop(box))
|
||||
for box in (
|
||||
(0, 0, width, margin),
|
||||
(0, height - margin, width, height),
|
||||
(0, 0, margin, height),
|
||||
(width - margin, 0, width, height),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def fit_to_cell(image: Image.Image) -> Image.Image:
|
||||
bbox = image.getbbox()
|
||||
target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
||||
if bbox is None:
|
||||
return target
|
||||
sprite = image.crop(bbox)
|
||||
scale = min((CELL_WIDTH - 10) / sprite.width, (CELL_HEIGHT - 10) / sprite.height, 1.0)
|
||||
if scale != 1.0:
|
||||
sprite = sprite.resize(
|
||||
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
target.alpha_composite(
|
||||
sprite,
|
||||
((CELL_WIDTH - sprite.width) // 2, (CELL_HEIGHT - sprite.height) // 2),
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--strip", required=True)
|
||||
parser.add_argument("--output-dir", required=True)
|
||||
parser.add_argument("--json-out", required=True)
|
||||
parser.add_argument("--chroma-key", required=True)
|
||||
parser.add_argument("--chroma-threshold", type=float, default=96.0)
|
||||
parser.add_argument("--edge-margin", type=int, default=2)
|
||||
parser.add_argument("--edge-pixel-threshold", type=int, default=24)
|
||||
parser.add_argument("--min-used-pixels", type=int, default=400)
|
||||
args = parser.parse_args()
|
||||
|
||||
strip_path = Path(args.strip).expanduser().resolve()
|
||||
output_dir = Path(args.output_dir).expanduser().resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
with Image.open(strip_path) as opened:
|
||||
strip = remove_chroma_background(
|
||||
opened,
|
||||
parse_hex_color(args.chroma_key),
|
||||
args.chroma_threshold,
|
||||
)
|
||||
|
||||
slot_width = strip.width / len(CARDINALS)
|
||||
anchors = []
|
||||
errors = []
|
||||
for index, label in enumerate(CARDINALS):
|
||||
left = round(index * slot_width)
|
||||
right = round((index + 1) * slot_width)
|
||||
source_cell = strip.crop((left, 0, right, strip.height))
|
||||
used_pixels = alpha_count(source_cell)
|
||||
edge_pixels = edge_alpha_count(source_cell, args.edge_margin)
|
||||
output = output_dir / f"{label}.png"
|
||||
fit_to_cell(source_cell).save(output)
|
||||
if used_pixels < args.min_used_pixels:
|
||||
errors.append(f"{label} is empty or too sparse ({used_pixels} pixels)")
|
||||
if edge_pixels > args.edge_pixel_threshold:
|
||||
errors.append(
|
||||
f"{label} has {edge_pixels} non-transparent pixels near its source slot edge"
|
||||
)
|
||||
anchors.append(
|
||||
{
|
||||
"direction": label,
|
||||
"source_box": [left, 0, right, strip.height],
|
||||
"used_pixels": used_pixels,
|
||||
"edge_pixels": edge_pixels,
|
||||
"output": str(output),
|
||||
}
|
||||
)
|
||||
|
||||
result = {
|
||||
"ok": not errors,
|
||||
"strip": str(strip_path),
|
||||
"directions": CARDINALS,
|
||||
"errors": errors,
|
||||
"anchors": anchors,
|
||||
}
|
||||
json_out = Path(args.json_out).expanduser().resolve()
|
||||
json_out.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps({key: value for key, value in result.items() if key != "anchors"}, indent=2))
|
||||
raise SystemExit(0 if result["ok"] else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
407
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/extract_strip_frames.py
Normal file
407
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/extract_strip_frames.py
Normal file
@@ -0,0 +1,407 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract generated horizontal row strips into 192x208 sprite frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
ROW_FRAME_COUNTS = {
|
||||
"idle": 6,
|
||||
"running-right": 8,
|
||||
"running-left": 8,
|
||||
"waving": 4,
|
||||
"jumping": 5,
|
||||
"failed": 8,
|
||||
"waiting": 6,
|
||||
"running": 6,
|
||||
"review": 6,
|
||||
}
|
||||
|
||||
|
||||
def parse_states(raw: str) -> list[str]:
|
||||
if raw.strip().lower() == "all":
|
||||
return list(ROW_FRAME_COUNTS)
|
||||
states = [item.strip() for item in raw.split(",") if item.strip()]
|
||||
unknown = sorted(set(states) - set(ROW_FRAME_COUNTS))
|
||||
if unknown:
|
||||
raise SystemExit(f"unknown state(s): {', '.join(unknown)}")
|
||||
return states
|
||||
|
||||
|
||||
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
||||
if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
|
||||
raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
|
||||
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
|
||||
|
||||
|
||||
def load_chroma_key(decoded_dir: Path, override: str | None) -> tuple[int, int, int]:
|
||||
if override:
|
||||
return parse_hex_color(override)
|
||||
request_path = decoded_dir.parent / "pet_request.json"
|
||||
if request_path.is_file():
|
||||
request = json.loads(request_path.read_text(encoding="utf-8"))
|
||||
chroma_key = request.get("chroma_key")
|
||||
if isinstance(chroma_key, dict) and isinstance(chroma_key.get("hex"), str):
|
||||
return parse_hex_color(chroma_key["hex"])
|
||||
return parse_hex_color("#00FF00")
|
||||
|
||||
|
||||
def color_distance(
|
||||
red: int,
|
||||
green: int,
|
||||
blue: int,
|
||||
key: tuple[int, int, int],
|
||||
) -> float:
|
||||
return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2)
|
||||
|
||||
|
||||
def remove_chroma_background(
|
||||
image: Image.Image,
|
||||
chroma_key: tuple[int, int, int],
|
||||
threshold: float,
|
||||
) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
pixels = rgba.load()
|
||||
for y in range(rgba.height):
|
||||
for x in range(rgba.width):
|
||||
red, green, blue, alpha = pixels[x, y]
|
||||
if color_distance(red, green, blue, chroma_key) <= threshold:
|
||||
pixels[x, y] = (0, 0, 0, 0)
|
||||
return rgba
|
||||
|
||||
|
||||
def fit_to_cell(image: Image.Image) -> Image.Image:
|
||||
bbox = image.getbbox()
|
||||
target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
||||
if bbox is None:
|
||||
return target
|
||||
|
||||
sprite = image.crop(bbox)
|
||||
max_width = CELL_WIDTH - 10
|
||||
max_height = CELL_HEIGHT - 10
|
||||
scale = min(max_width / sprite.width, max_height / sprite.height, 1.0)
|
||||
if scale != 1.0:
|
||||
sprite = sprite.resize(
|
||||
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
left = (CELL_WIDTH - sprite.width) // 2
|
||||
top = (CELL_HEIGHT - sprite.height) // 2
|
||||
target.alpha_composite(sprite, (left, top))
|
||||
return target
|
||||
|
||||
|
||||
def fit_viewport_to_cell(image: Image.Image) -> Image.Image:
|
||||
target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
||||
if image.getbbox() is None:
|
||||
return target
|
||||
|
||||
viewport = image.copy()
|
||||
max_width = CELL_WIDTH - 10
|
||||
max_height = CELL_HEIGHT - 10
|
||||
scale = min(max_width / viewport.width, max_height / viewport.height, 1.0)
|
||||
if scale != 1.0:
|
||||
viewport = viewport.resize(
|
||||
(max(1, round(viewport.width * scale)), max(1, round(viewport.height * scale))),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
left = (CELL_WIDTH - viewport.width) // 2
|
||||
top = (CELL_HEIGHT - viewport.height) // 2
|
||||
target.alpha_composite(viewport, (left, top))
|
||||
return target
|
||||
|
||||
|
||||
def connected_components(image: Image.Image) -> list[dict[str, object]]:
|
||||
alpha = image.getchannel("A")
|
||||
width, height = image.size
|
||||
data = alpha.tobytes()
|
||||
visited = bytearray(width * height)
|
||||
components: list[dict[str, object]] = []
|
||||
|
||||
for start, alpha_value in enumerate(data):
|
||||
if alpha_value <= 16 or visited[start]:
|
||||
continue
|
||||
|
||||
stack = [start]
|
||||
visited[start] = 1
|
||||
pixels: list[int] = []
|
||||
min_x = width
|
||||
min_y = height
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
pixels.append(current)
|
||||
x = current % width
|
||||
y = current // width
|
||||
min_x = min(min_x, x)
|
||||
min_y = min(min_y, y)
|
||||
max_x = max(max_x, x)
|
||||
max_y = max(max_y, y)
|
||||
|
||||
if x > 0:
|
||||
neighbor = current - 1
|
||||
if not visited[neighbor] and data[neighbor] > 16:
|
||||
visited[neighbor] = 1
|
||||
stack.append(neighbor)
|
||||
if x + 1 < width:
|
||||
neighbor = current + 1
|
||||
if not visited[neighbor] and data[neighbor] > 16:
|
||||
visited[neighbor] = 1
|
||||
stack.append(neighbor)
|
||||
if y > 0:
|
||||
neighbor = current - width
|
||||
if not visited[neighbor] and data[neighbor] > 16:
|
||||
visited[neighbor] = 1
|
||||
stack.append(neighbor)
|
||||
if y + 1 < height:
|
||||
neighbor = current + width
|
||||
if not visited[neighbor] and data[neighbor] > 16:
|
||||
visited[neighbor] = 1
|
||||
stack.append(neighbor)
|
||||
|
||||
components.append(
|
||||
{
|
||||
"pixels": pixels,
|
||||
"area": len(pixels),
|
||||
"bbox": (min_x, min_y, max_x + 1, max_y + 1),
|
||||
"center_x": (min_x + max_x + 1) / 2,
|
||||
}
|
||||
)
|
||||
|
||||
return components
|
||||
|
||||
|
||||
def component_group_image(
|
||||
source: Image.Image,
|
||||
components: list[dict[str, object]],
|
||||
padding: int = 4,
|
||||
) -> Image.Image:
|
||||
width, height = source.size
|
||||
min_x = max(0, min(component["bbox"][0] for component in components) - padding)
|
||||
min_y = max(0, min(component["bbox"][1] for component in components) - padding)
|
||||
max_x = min(width, max(component["bbox"][2] for component in components) + padding)
|
||||
max_y = min(height, max(component["bbox"][3] for component in components) + padding)
|
||||
|
||||
output = Image.new("RGBA", (max_x - min_x, max_y - min_y), (0, 0, 0, 0))
|
||||
source_pixels = source.load()
|
||||
output_pixels = output.load()
|
||||
for component in components:
|
||||
for pixel_index in component["pixels"]:
|
||||
x = pixel_index % width
|
||||
y = pixel_index // width
|
||||
output_pixels[x - min_x, y - min_y] = source_pixels[x, y]
|
||||
return output
|
||||
|
||||
|
||||
def component_frame_groups(
|
||||
strip: Image.Image,
|
||||
frame_count: int,
|
||||
) -> list[list[dict[str, object]]] | None:
|
||||
components = connected_components(strip)
|
||||
if not components:
|
||||
return None
|
||||
|
||||
largest_area = max(component["area"] for component in components)
|
||||
seed_threshold = max(120, largest_area * 0.20)
|
||||
seeds = [component for component in components if component["area"] >= seed_threshold]
|
||||
if len(seeds) < frame_count:
|
||||
seeds = sorted(components, key=lambda component: component["area"], reverse=True)[
|
||||
:frame_count
|
||||
]
|
||||
if len(seeds) < frame_count:
|
||||
return None
|
||||
|
||||
seeds = sorted(
|
||||
sorted(seeds, key=lambda component: component["area"], reverse=True)[:frame_count],
|
||||
key=lambda component: component["center_x"],
|
||||
)
|
||||
seed_ids = {id(seed) for seed in seeds}
|
||||
groups: list[list[dict[str, object]]] = [[seed] for seed in seeds]
|
||||
noise_threshold = max(12, largest_area * 0.002)
|
||||
|
||||
for component in components:
|
||||
if id(component) in seed_ids or component["area"] < noise_threshold:
|
||||
continue
|
||||
nearest_index = min(
|
||||
range(len(seeds)),
|
||||
key=lambda index: abs(seeds[index]["center_x"] - component["center_x"]),
|
||||
)
|
||||
groups[nearest_index].append(component)
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
def extract_component_frames(strip: Image.Image, frame_count: int) -> list[Image.Image] | None:
|
||||
groups = component_frame_groups(strip, frame_count)
|
||||
if groups is None:
|
||||
return None
|
||||
return [fit_to_cell(component_group_image(strip, group)) for group in groups]
|
||||
|
||||
|
||||
def component_bounds(components: list[dict[str, object]]) -> tuple[int, int, int, int]:
|
||||
return (
|
||||
min(component["bbox"][0] for component in components),
|
||||
min(component["bbox"][1] for component in components),
|
||||
max(component["bbox"][2] for component in components),
|
||||
max(component["bbox"][3] for component in components),
|
||||
)
|
||||
|
||||
|
||||
def extract_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Image]:
|
||||
slot_width = strip.width / frame_count
|
||||
frames = []
|
||||
for index in range(frame_count):
|
||||
left = round(index * slot_width)
|
||||
right = round((index + 1) * slot_width)
|
||||
crop = strip.crop((left, 0, right, strip.height))
|
||||
frames.append(fit_to_cell(crop))
|
||||
return frames
|
||||
|
||||
|
||||
def extract_stable_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Image]:
|
||||
groups = component_frame_groups(strip, frame_count)
|
||||
padding = 4
|
||||
if groups is not None:
|
||||
bboxes = [component_bounds(group) for group in groups]
|
||||
shared_top = max(0, min(bbox[1] for bbox in bboxes) - padding)
|
||||
shared_bottom = min(strip.height, max(bbox[3] for bbox in bboxes) + padding)
|
||||
viewport_width = max(bbox[2] - bbox[0] for bbox in bboxes) + padding * 2
|
||||
viewport_height = max(1, shared_bottom - shared_top)
|
||||
frames = []
|
||||
for group, bbox in zip(groups, bboxes):
|
||||
grouped = component_group_image(strip, group, padding=padding)
|
||||
grouped_top = max(0, bbox[1] - padding)
|
||||
viewport = Image.new(
|
||||
"RGBA",
|
||||
(viewport_width, viewport_height),
|
||||
(0, 0, 0, 0),
|
||||
)
|
||||
left = (viewport_width - grouped.width) // 2
|
||||
viewport.alpha_composite(grouped, (left, grouped_top - shared_top))
|
||||
frames.append(fit_viewport_to_cell(viewport))
|
||||
return frames
|
||||
|
||||
bbox = strip.getbbox()
|
||||
if bbox is None:
|
||||
return [
|
||||
Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0)) for _ in range(frame_count)
|
||||
]
|
||||
|
||||
shared_top = max(0, bbox[1] - padding)
|
||||
shared_bottom = min(strip.height, bbox[3] + padding)
|
||||
slot_width = strip.width / frame_count
|
||||
frames = []
|
||||
for index in range(frame_count):
|
||||
left = round(index * slot_width)
|
||||
right = round((index + 1) * slot_width)
|
||||
crop = strip.crop((left, shared_top, right, shared_bottom))
|
||||
frames.append(fit_viewport_to_cell(crop))
|
||||
return frames
|
||||
|
||||
|
||||
def extract_state(
|
||||
strip_path: Path,
|
||||
state: str,
|
||||
output_root: Path,
|
||||
chroma_key: tuple[int, int, int],
|
||||
threshold: float,
|
||||
method: str,
|
||||
) -> dict[str, object]:
|
||||
frame_count = ROW_FRAME_COUNTS[state]
|
||||
with Image.open(strip_path) as opened:
|
||||
strip = remove_chroma_background(opened, chroma_key, threshold)
|
||||
|
||||
state_dir = output_root / state
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
frames = None
|
||||
used_method = method
|
||||
if method in {"auto", "components"}:
|
||||
frames = extract_component_frames(strip, frame_count)
|
||||
if frames is None and method == "components":
|
||||
raise SystemExit(f"could not find {frame_count} sprite components in {strip_path}")
|
||||
if frames is not None:
|
||||
used_method = "components"
|
||||
|
||||
if frames is None:
|
||||
if method == "stable-slots":
|
||||
frames = extract_stable_slot_frames(strip, frame_count)
|
||||
used_method = "stable-slots"
|
||||
else:
|
||||
frames = extract_slot_frames(strip, frame_count)
|
||||
used_method = "slots"
|
||||
|
||||
outputs = []
|
||||
for index, frame in enumerate(frames):
|
||||
output = state_dir / f"{index:02d}.png"
|
||||
frame.save(output)
|
||||
outputs.append(str(output))
|
||||
return {"state": state, "frames": outputs, "method": used_method}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--decoded-dir", required=True)
|
||||
parser.add_argument("--output-dir", required=True)
|
||||
parser.add_argument("--states", default="all")
|
||||
parser.add_argument("--chroma-key", help="Override chroma key as #RRGGBB.")
|
||||
parser.add_argument("--key-threshold", type=float, default=96.0)
|
||||
parser.add_argument(
|
||||
"--method",
|
||||
choices=("auto", "components", "slots", "stable-slots"),
|
||||
default="auto",
|
||||
help="Use connected sprite components when possible, raw equal slots, or row-stable slot viewports.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
decoded_dir = Path(args.decoded_dir).expanduser().resolve()
|
||||
output_dir = Path(args.output_dir).expanduser().resolve()
|
||||
chroma_key = load_chroma_key(decoded_dir, args.chroma_key)
|
||||
states = parse_states(args.states)
|
||||
manifest = []
|
||||
for state in states:
|
||||
strip_path = decoded_dir / f"{state}.png"
|
||||
if not strip_path.is_file():
|
||||
raise SystemExit(f"missing generated strip for {state}: {strip_path}")
|
||||
manifest.append(
|
||||
extract_state(
|
||||
strip_path,
|
||||
state,
|
||||
output_dir,
|
||||
chroma_key,
|
||||
args.key_threshold,
|
||||
args.method,
|
||||
)
|
||||
)
|
||||
|
||||
(output_dir / "frames-manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"chroma_key": {
|
||||
"hex": f"#{chroma_key[0]:02X}{chroma_key[1]:02X}{chroma_key[2]:02X}",
|
||||
"rgb": list(chroma_key),
|
||||
"threshold": args.key_threshold,
|
||||
},
|
||||
"rows": manifest,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps({"ok": True, "frames_root": str(output_dir), "states": states}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
270
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/inspect_frames.py
Normal file
270
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/inspect_frames.py
Normal file
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inspect extracted Codex pet frames before atlas composition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from statistics import median
|
||||
|
||||
from PIL import Image
|
||||
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
ROW_FRAME_COUNTS = {
|
||||
"idle": 6,
|
||||
"running-right": 8,
|
||||
"running-left": 8,
|
||||
"waving": 4,
|
||||
"jumping": 5,
|
||||
"failed": 8,
|
||||
"waiting": 6,
|
||||
"running": 6,
|
||||
"review": 6,
|
||||
}
|
||||
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
|
||||
|
||||
|
||||
def parse_states(raw: str) -> list[str]:
|
||||
if raw.strip().lower() == "all":
|
||||
return list(ROW_FRAME_COUNTS)
|
||||
states = [item.strip() for item in raw.split(",") if item.strip()]
|
||||
unknown = sorted(set(states) - set(ROW_FRAME_COUNTS))
|
||||
if unknown:
|
||||
raise SystemExit(f"unknown state(s): {', '.join(unknown)}")
|
||||
return states
|
||||
|
||||
|
||||
def alpha_nonzero_count(image: Image.Image) -> int:
|
||||
alpha = image if image.mode == "L" else image.getchannel("A")
|
||||
return sum(alpha.histogram()[1:])
|
||||
|
||||
|
||||
def edge_alpha_count(image: Image.Image, margin: int) -> int:
|
||||
alpha = image.getchannel("A")
|
||||
width, height = alpha.size
|
||||
total = 0
|
||||
for box in (
|
||||
(0, 0, width, margin),
|
||||
(0, height - margin, width, height),
|
||||
(0, 0, margin, height),
|
||||
(width - margin, 0, width, height),
|
||||
):
|
||||
total += alpha_nonzero_count(alpha.crop(box))
|
||||
return total
|
||||
|
||||
|
||||
def color_distance(left: tuple[int, int, int], right: tuple[int, int, int]) -> float:
|
||||
return math.sqrt(sum((left[index] - right[index]) ** 2 for index in range(3)))
|
||||
|
||||
|
||||
def chroma_adjacent_count(
|
||||
image: Image.Image,
|
||||
chroma_key: tuple[int, int, int] | None,
|
||||
threshold: float,
|
||||
) -> int:
|
||||
if chroma_key is None:
|
||||
return 0
|
||||
rgba = image.convert("RGBA")
|
||||
data = rgba.tobytes()
|
||||
count = 0
|
||||
for index in range(0, len(data), 4):
|
||||
red, green, blue, alpha = data[index : index + 4]
|
||||
if alpha > 16 and color_distance((red, green, blue), chroma_key) <= threshold:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def frame_files(state_dir: Path) -> list[Path]:
|
||||
if not state_dir.is_dir():
|
||||
return []
|
||||
return sorted(path for path in state_dir.iterdir() if path.suffix.lower() in IMAGE_SUFFIXES)
|
||||
|
||||
|
||||
def load_manifest(frames_root: Path) -> dict[str, dict[str, object]]:
|
||||
manifest_path = frames_root / "frames-manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
return {}
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
rows = manifest.get("rows", [])
|
||||
if not isinstance(rows, list):
|
||||
return {}
|
||||
return {
|
||||
row["state"]: row
|
||||
for row in rows
|
||||
if isinstance(row, dict) and isinstance(row.get("state"), str)
|
||||
}
|
||||
|
||||
|
||||
def load_chroma_key(frames_root: Path) -> tuple[int, int, int] | None:
|
||||
manifest_path = frames_root / "frames-manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
return None
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
chroma_key = manifest.get("chroma_key")
|
||||
if not isinstance(chroma_key, dict):
|
||||
return None
|
||||
rgb = chroma_key.get("rgb")
|
||||
if (
|
||||
not isinstance(rgb, list)
|
||||
or len(rgb) != 3
|
||||
or not all(isinstance(value, int) for value in rgb)
|
||||
):
|
||||
return None
|
||||
return (rgb[0], rgb[1], rgb[2])
|
||||
|
||||
|
||||
def inspect_state(
|
||||
frames_root: Path,
|
||||
state: str,
|
||||
expected_count: int,
|
||||
manifest_rows: dict[str, dict[str, object]],
|
||||
chroma_key: tuple[int, int, int] | None,
|
||||
args: argparse.Namespace,
|
||||
) -> dict[str, object]:
|
||||
state_dir = frames_root / state
|
||||
files = frame_files(state_dir)
|
||||
row_errors: list[str] = []
|
||||
row_warnings: list[str] = []
|
||||
frames: list[dict[str, object]] = []
|
||||
areas: list[int] = []
|
||||
manifest_row = manifest_rows.get(state, {})
|
||||
method = manifest_row.get("method")
|
||||
|
||||
if len(files) != expected_count:
|
||||
row_errors.append(f"expected {expected_count} frame files for {state}, found {len(files)}")
|
||||
|
||||
if args.require_components and method and method != "components":
|
||||
if method == "stable-slots" and args.allow_stable_slots:
|
||||
row_warnings.append(
|
||||
f"{state} used extraction method stable-slots; confirm motion playback remains stable and unclipped"
|
||||
)
|
||||
else:
|
||||
row_errors.append(
|
||||
f"{state} used extraction method {method}; regenerate the row or inspect slot slicing"
|
||||
)
|
||||
elif method and method != "components":
|
||||
row_warnings.append(
|
||||
f"{state} used extraction method {method}; component extraction is preferred"
|
||||
)
|
||||
|
||||
for index, frame_path in enumerate(files[:expected_count]):
|
||||
with Image.open(frame_path) as opened:
|
||||
frame = opened.convert("RGBA")
|
||||
nontransparent = alpha_nonzero_count(frame)
|
||||
bbox = frame.getbbox()
|
||||
edge_pixels = edge_alpha_count(frame, args.edge_margin)
|
||||
chroma_adjacent_pixels = chroma_adjacent_count(
|
||||
frame,
|
||||
chroma_key,
|
||||
args.chroma_adjacent_threshold,
|
||||
)
|
||||
info = {
|
||||
"index": index,
|
||||
"file": str(frame_path),
|
||||
"width": frame.width,
|
||||
"height": frame.height,
|
||||
"nontransparent_pixels": nontransparent,
|
||||
"bbox": list(bbox) if bbox else None,
|
||||
"edge_pixels": edge_pixels,
|
||||
"chroma_adjacent_pixels": chroma_adjacent_pixels,
|
||||
}
|
||||
frames.append(info)
|
||||
areas.append(nontransparent)
|
||||
|
||||
if frame.size != (CELL_WIDTH, CELL_HEIGHT):
|
||||
row_errors.append(
|
||||
f"{state} frame {index:02d} is {frame.width}x{frame.height}; expected {CELL_WIDTH}x{CELL_HEIGHT}"
|
||||
)
|
||||
if nontransparent < args.min_used_pixels:
|
||||
row_errors.append(
|
||||
f"{state} frame {index:02d} is empty or too sparse ({nontransparent} pixels)"
|
||||
)
|
||||
if edge_pixels > args.edge_pixel_threshold:
|
||||
row_warnings.append(
|
||||
f"{state} frame {index:02d} has {edge_pixels} non-transparent pixels near the cell edge"
|
||||
)
|
||||
if chroma_adjacent_pixels > args.chroma_adjacent_pixel_threshold:
|
||||
row_errors.append(
|
||||
f"{state} frame {index:02d} has {chroma_adjacent_pixels} non-transparent pixels close to the chroma key"
|
||||
)
|
||||
|
||||
if areas:
|
||||
row_median = median(areas)
|
||||
for index, area in enumerate(areas[:expected_count]):
|
||||
if row_median > 0 and area < row_median * args.small_outlier_ratio:
|
||||
row_warnings.append(
|
||||
f"{state} frame {index:02d} is much smaller than the row median ({area} vs {row_median:.0f})"
|
||||
)
|
||||
if row_median > 0 and area > row_median * args.large_outlier_ratio:
|
||||
row_warnings.append(
|
||||
f"{state} frame {index:02d} is much larger than the row median ({area} vs {row_median:.0f})"
|
||||
)
|
||||
|
||||
return {
|
||||
"state": state,
|
||||
"expected_frames": expected_count,
|
||||
"actual_frames": len(files),
|
||||
"extraction_method": method,
|
||||
"ok": not row_errors,
|
||||
"errors": row_errors,
|
||||
"warnings": row_warnings,
|
||||
"frames": frames,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--frames-root", required=True)
|
||||
parser.add_argument("--json-out", required=True)
|
||||
parser.add_argument("--states", default="all")
|
||||
parser.add_argument("--min-used-pixels", type=int, default=400)
|
||||
parser.add_argument("--edge-margin", type=int, default=2)
|
||||
parser.add_argument("--edge-pixel-threshold", type=int, default=24)
|
||||
parser.add_argument("--chroma-adjacent-threshold", type=float, default=150.0)
|
||||
parser.add_argument("--chroma-adjacent-pixel-threshold", type=int, default=800)
|
||||
parser.add_argument("--small-outlier-ratio", type=float, default=0.35)
|
||||
parser.add_argument("--large-outlier-ratio", type=float, default=2.75)
|
||||
parser.add_argument(
|
||||
"--require-components",
|
||||
action="store_true",
|
||||
help="Fail rows that fell back to equal-slot extraction.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-stable-slots",
|
||||
action="store_true",
|
||||
help="Permit explicitly chosen stable-slots extraction while still warning for visual review.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
frames_root = Path(args.frames_root).expanduser().resolve()
|
||||
manifest_rows = load_manifest(frames_root)
|
||||
chroma_key = load_chroma_key(frames_root)
|
||||
states = parse_states(args.states)
|
||||
rows = [
|
||||
inspect_state(frames_root, state, count, manifest_rows, chroma_key, args)
|
||||
for state, count in ROW_FRAME_COUNTS.items()
|
||||
if state in states
|
||||
]
|
||||
errors = [error for row in rows for error in row["errors"]]
|
||||
warnings = [warning for row in rows for warning in row["warnings"]]
|
||||
result = {
|
||||
"ok": not errors,
|
||||
"frames_root": str(frames_root),
|
||||
"states": states,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
json_out = Path(args.json_out).expanduser().resolve()
|
||||
json_out.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps({k: v for k, v in result.items() if k != "rows"}, indent=2))
|
||||
raise SystemExit(0 if result["ok"] else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
114
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/make_contact_sheet.py
Normal file
114
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/make_contact_sheet.py
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a labeled contact sheet from a Codex pet atlas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
COLUMNS = 8
|
||||
ROWS = 9
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
LABEL_HEIGHT = 22
|
||||
ROW_NAMES = [
|
||||
"idle",
|
||||
"running-right",
|
||||
"running-left",
|
||||
"waving",
|
||||
"jumping",
|
||||
"failed",
|
||||
"waiting",
|
||||
"running",
|
||||
"review",
|
||||
"look 000-157.5",
|
||||
"look 180-337.5",
|
||||
]
|
||||
USED_COUNTS = [6, 8, 8, 4, 5, 8, 6, 6, 6, 8, 8]
|
||||
EXTENDED_NEUTRAL_LOOK_FRAME = (0, 6)
|
||||
|
||||
|
||||
def is_used_cell(rows: int, row: int, column: int) -> bool:
|
||||
return column < USED_COUNTS[row] or (
|
||||
rows == 11 and (row, column) == EXTENDED_NEUTRAL_LOOK_FRAME
|
||||
)
|
||||
|
||||
|
||||
def frame_count_label(rows: int, row: int) -> str:
|
||||
if rows == 11 and row == EXTENDED_NEUTRAL_LOOK_FRAME[0]:
|
||||
return "6 + neutral"
|
||||
return f"{USED_COUNTS[row]} frames"
|
||||
|
||||
|
||||
def checker(size: tuple[int, int], square: int = 16) -> Image.Image:
|
||||
image = Image.new("RGB", size, "#ffffff")
|
||||
draw = ImageDraw.Draw(image)
|
||||
for y in range(0, size[1], square):
|
||||
for x in range(0, size[0], square):
|
||||
if (x // square + y // square) % 2:
|
||||
draw.rectangle((x, y, x + square - 1, y + square - 1), fill="#e8e8e8")
|
||||
return image
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("atlas")
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--scale", type=float, default=0.5)
|
||||
args = parser.parse_args()
|
||||
|
||||
with Image.open(Path(args.atlas).expanduser().resolve()) as opened:
|
||||
atlas = opened.convert("RGBA")
|
||||
rows = atlas.height // CELL_HEIGHT
|
||||
if atlas.width != COLUMNS * CELL_WIDTH or rows not in {9, 11}:
|
||||
raise SystemExit(f"atlas must be 1536x1872 or 1536x2288; got {atlas.width}x{atlas.height}")
|
||||
|
||||
cell_w = max(1, round(CELL_WIDTH * args.scale))
|
||||
cell_h = max(1, round(CELL_HEIGHT * args.scale))
|
||||
width = COLUMNS * cell_w
|
||||
height = rows * (cell_h + LABEL_HEIGHT)
|
||||
sheet = Image.new("RGB", (width, height), "#f7f7f7")
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
font = ImageFont.load_default()
|
||||
|
||||
for row in range(rows):
|
||||
y = row * (cell_h + LABEL_HEIGHT)
|
||||
draw.rectangle((0, y, width, y + LABEL_HEIGHT - 1), fill="#111111")
|
||||
draw.text((6, y + 5), f"row {row}: {ROW_NAMES[row]}", fill="#ffffff", font=font)
|
||||
draw.text(
|
||||
(width - 92, y + 5),
|
||||
frame_count_label(rows, row),
|
||||
fill="#ffffff",
|
||||
font=font,
|
||||
)
|
||||
for column in range(COLUMNS):
|
||||
crop = atlas.crop(
|
||||
(
|
||||
column * CELL_WIDTH,
|
||||
row * CELL_HEIGHT,
|
||||
(column + 1) * CELL_WIDTH,
|
||||
(row + 1) * CELL_HEIGHT,
|
||||
)
|
||||
)
|
||||
crop = crop.resize((cell_w, cell_h), Image.Resampling.LANCZOS)
|
||||
bg = checker((cell_w, cell_h))
|
||||
bg.paste(crop, (0, 0), crop)
|
||||
x = column * cell_w
|
||||
sheet.paste(bg, (x, y + LABEL_HEIGHT))
|
||||
outline = "#18a058" if is_used_cell(rows, row, column) else "#cc3344"
|
||||
draw.rectangle(
|
||||
(x, y + LABEL_HEIGHT, x + cell_w - 1, y + LABEL_HEIGHT + cell_h - 1),
|
||||
outline=outline,
|
||||
)
|
||||
draw.text((x + 4, y + LABEL_HEIGHT + 4), str(column), fill="#111111", font=font)
|
||||
|
||||
output = Path(args.output).expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
sheet.save(output)
|
||||
print(f"wrote {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
177
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/make_direction_blind_qa_sheet.py
Executable file
177
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/make_direction_blind_qa_sheet.py
Executable file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create an unlabeled A/B direction-pair sheet and a hidden answer key."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
COLUMNS = 8
|
||||
ROWS = 11
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
LOOK_ROW_INDEX = 9
|
||||
LABEL_HEIGHT = 28
|
||||
PAIR_COLUMNS = 2
|
||||
|
||||
LOOK_DIRECTION_LABELS = [
|
||||
"000",
|
||||
"022.5",
|
||||
"045",
|
||||
"067.5",
|
||||
"090",
|
||||
"112.5",
|
||||
"135",
|
||||
"157.5",
|
||||
"180",
|
||||
"202.5",
|
||||
"225",
|
||||
"247.5",
|
||||
"270",
|
||||
"292.5",
|
||||
"315",
|
||||
"337.5",
|
||||
]
|
||||
|
||||
AXIS_PAIRS = [
|
||||
("horizontal", "022.5", "screen-right", "337.5", "screen-left"),
|
||||
("horizontal", "045", "screen-right", "315", "screen-left"),
|
||||
("horizontal", "067.5", "screen-right", "292.5", "screen-left"),
|
||||
("horizontal", "090", "screen-right", "270", "screen-left"),
|
||||
("horizontal", "112.5", "screen-right", "247.5", "screen-left"),
|
||||
("horizontal", "135", "screen-right", "225", "screen-left"),
|
||||
("horizontal", "157.5", "screen-right", "202.5", "screen-left"),
|
||||
("vertical", "000", "up", "180", "down"),
|
||||
("vertical", "022.5", "up", "157.5", "down"),
|
||||
("vertical", "045", "up", "135", "down"),
|
||||
("vertical", "067.5", "up", "112.5", "down"),
|
||||
("vertical", "337.5", "up", "202.5", "down"),
|
||||
("vertical", "315", "up", "225", "down"),
|
||||
("vertical", "292.5", "up", "247.5", "down"),
|
||||
]
|
||||
|
||||
|
||||
def atlas_cell(atlas: Image.Image, label: str) -> Image.Image:
|
||||
index = LOOK_DIRECTION_LABELS.index(label)
|
||||
row = LOOK_ROW_INDEX + index // COLUMNS
|
||||
column = index % COLUMNS
|
||||
return atlas.crop(
|
||||
(
|
||||
column * CELL_WIDTH,
|
||||
row * CELL_HEIGHT,
|
||||
(column + 1) * CELL_WIDTH,
|
||||
(row + 1) * CELL_HEIGHT,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def paste_cell(
|
||||
sheet: Image.Image,
|
||||
cell: Image.Image,
|
||||
*,
|
||||
label: str,
|
||||
column: int,
|
||||
row: int,
|
||||
) -> None:
|
||||
x = column * CELL_WIDTH
|
||||
y = row * (CELL_HEIGHT + LABEL_HEIGHT)
|
||||
sheet.alpha_composite(
|
||||
Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (242, 242, 242, 255)),
|
||||
(x, y + LABEL_HEIGHT),
|
||||
)
|
||||
sheet.alpha_composite(cell, (x, y + LABEL_HEIGHT))
|
||||
ImageDraw.Draw(sheet).text((x + 6, y + 8), label, fill=(0, 0, 0, 255))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("atlas")
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--answer-key", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
atlas_path = Path(args.atlas).expanduser().resolve()
|
||||
with Image.open(atlas_path) as opened:
|
||||
atlas = opened.convert("RGBA")
|
||||
if atlas.size != (COLUMNS * CELL_WIDTH, ROWS * CELL_HEIGHT):
|
||||
raise SystemExit(f"extended atlas must be 1536x2288; got {atlas.width}x{atlas.height}")
|
||||
|
||||
seed = int.from_bytes(hashlib.sha256(atlas.tobytes()).digest()[:8], "big")
|
||||
rng = random.Random(seed)
|
||||
sheet = Image.new(
|
||||
"RGBA",
|
||||
(
|
||||
PAIR_COLUMNS * CELL_WIDTH,
|
||||
len(AXIS_PAIRS) * (CELL_HEIGHT + LABEL_HEIGHT),
|
||||
),
|
||||
(255, 255, 255, 255),
|
||||
)
|
||||
answers: list[dict[str, object]] = []
|
||||
|
||||
axis_indexes = {"horizontal": 0, "vertical": 0}
|
||||
for row, (axis, first_label, first_direction, second_label, second_direction) in enumerate(
|
||||
AXIS_PAIRS
|
||||
):
|
||||
axis_indexes[axis] += 1
|
||||
pair_id = f"{axis}-{axis_indexes[axis]}"
|
||||
pair = [
|
||||
(first_label, first_direction),
|
||||
(second_label, second_direction),
|
||||
]
|
||||
rng.shuffle(pair)
|
||||
cells: list[tuple[str, str, Image.Image]] = []
|
||||
for source_label, expected_direction in pair:
|
||||
cells.append((source_label, expected_direction, atlas_cell(atlas, source_label)))
|
||||
|
||||
label = f"{axis.title()} pair {axis_indexes[axis]}"
|
||||
paste_cell(sheet, cells[0][2], label=f"{label} A", column=0, row=row)
|
||||
paste_cell(sheet, cells[1][2], label=f"{label} B", column=1, row=row)
|
||||
answers.append(
|
||||
{
|
||||
"pair": pair_id,
|
||||
"axis": axis,
|
||||
"gate": (
|
||||
"hard"
|
||||
if {first_label, second_label} in ({"000", "180"}, {"090", "270"})
|
||||
else "review"
|
||||
),
|
||||
"A": {
|
||||
"expected_direction": cells[0][1],
|
||||
"source_direction": cells[0][0],
|
||||
},
|
||||
"B": {
|
||||
"expected_direction": cells[1][1],
|
||||
"source_direction": cells[1][0],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
output = Path(args.output).expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
sheet.convert("RGB").save(output)
|
||||
answer_key = Path(args.answer_key).expanduser().resolve()
|
||||
answer_key.parent.mkdir(parents=True, exist_ok=True)
|
||||
answer_key.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 3,
|
||||
"atlas_sha256": hashlib.sha256(atlas_path.read_bytes()).hexdigest(),
|
||||
"instructions": "Do not provide this answer key to the blind visual QA reviewer.",
|
||||
"pairs": answers,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"wrote {output}")
|
||||
print(f"wrote {answer_key}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
170
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/make_direction_qa_sheet.py
Executable file
170
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/make_direction_qa_sheet.py
Executable file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a focused QA sheet for extended pet look directions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
COLUMNS = 8
|
||||
ROWS = 11
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
LOOK_ROW_INDEX = 9
|
||||
NEUTRAL_ROW_INDEX = 0
|
||||
NEUTRAL_COLUMN_INDEX = 6
|
||||
LABEL_HEIGHT = 26
|
||||
FOCUS_PADDING = 18
|
||||
LOOK_DIRECTION_LABELS = [
|
||||
("000", "up"),
|
||||
("022.5", "up-right"),
|
||||
("045", "up-right"),
|
||||
("067.5", "up-right"),
|
||||
("090", "right"),
|
||||
("112.5", "down-right"),
|
||||
("135", "down-right"),
|
||||
("157.5", "down-right"),
|
||||
("180", "down"),
|
||||
("202.5", "down-left"),
|
||||
("225", "down-left"),
|
||||
("247.5", "down-left"),
|
||||
("270", "left"),
|
||||
("292.5", "up-left"),
|
||||
("315", "up-left"),
|
||||
("337.5", "up-left"),
|
||||
]
|
||||
|
||||
|
||||
def paste_labeled_cell(
|
||||
sheet: Image.Image,
|
||||
atlas: Image.Image,
|
||||
*,
|
||||
label: str,
|
||||
row_index: int,
|
||||
column_index: int,
|
||||
output_column: int,
|
||||
output_row: int,
|
||||
) -> None:
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
x = output_column * CELL_WIDTH
|
||||
y = output_row * (CELL_HEIGHT + LABEL_HEIGHT)
|
||||
cell = atlas.crop(
|
||||
(
|
||||
column_index * CELL_WIDTH,
|
||||
row_index * CELL_HEIGHT,
|
||||
(column_index + 1) * CELL_WIDTH,
|
||||
(row_index + 1) * CELL_HEIGHT,
|
||||
)
|
||||
)
|
||||
background = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (242, 242, 242, 255))
|
||||
sheet.alpha_composite(background, (x, y + LABEL_HEIGHT))
|
||||
sheet.alpha_composite(cell, (x, y + LABEL_HEIGHT))
|
||||
draw.text((x + 6, y + 7), label, fill=(0, 0, 0, 255))
|
||||
|
||||
|
||||
def focused_head_cell(cell: Image.Image) -> Image.Image:
|
||||
bbox = cell.getbbox()
|
||||
if bbox is None:
|
||||
return cell
|
||||
|
||||
left, top, right, bottom = bbox
|
||||
sprite_height = bottom - top
|
||||
focus_bottom = top + max(1, int(sprite_height * 0.52))
|
||||
crop_box = (
|
||||
max(0, left - FOCUS_PADDING),
|
||||
max(0, top - FOCUS_PADDING),
|
||||
min(CELL_WIDTH, right + FOCUS_PADDING),
|
||||
min(CELL_HEIGHT, focus_bottom + FOCUS_PADDING),
|
||||
)
|
||||
crop = cell.crop(crop_box)
|
||||
focused = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
||||
crop.thumbnail((CELL_WIDTH, CELL_HEIGHT), Image.Resampling.LANCZOS)
|
||||
focused.alpha_composite(
|
||||
crop,
|
||||
((CELL_WIDTH - crop.width) // 2, (CELL_HEIGHT - crop.height) // 2),
|
||||
)
|
||||
return focused
|
||||
|
||||
|
||||
def paste_labeled_focus_cell(
|
||||
sheet: Image.Image,
|
||||
atlas: Image.Image,
|
||||
*,
|
||||
label: str,
|
||||
row_index: int,
|
||||
column_index: int,
|
||||
output_column: int,
|
||||
output_row: int,
|
||||
) -> None:
|
||||
cell = atlas.crop(
|
||||
(
|
||||
column_index * CELL_WIDTH,
|
||||
row_index * CELL_HEIGHT,
|
||||
(column_index + 1) * CELL_WIDTH,
|
||||
(row_index + 1) * CELL_HEIGHT,
|
||||
)
|
||||
)
|
||||
focused = focused_head_cell(cell)
|
||||
background = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (242, 242, 242, 255))
|
||||
x = output_column * CELL_WIDTH
|
||||
y = output_row * (CELL_HEIGHT + LABEL_HEIGHT)
|
||||
sheet.alpha_composite(background, (x, y + LABEL_HEIGHT))
|
||||
sheet.alpha_composite(focused, (x, y + LABEL_HEIGHT))
|
||||
ImageDraw.Draw(sheet).text((x + 6, y + 7), label, fill=(0, 0, 0, 255))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("atlas")
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
with Image.open(Path(args.atlas).expanduser().resolve()) as opened:
|
||||
atlas = opened.convert("RGBA")
|
||||
if atlas.size != (COLUMNS * CELL_WIDTH, ROWS * CELL_HEIGHT):
|
||||
raise SystemExit(f"extended atlas must be 1536x2288; got {atlas.width}x{atlas.height}")
|
||||
|
||||
sheet = Image.new(
|
||||
"RGBA",
|
||||
(COLUMNS * CELL_WIDTH, 5 * (CELL_HEIGHT + LABEL_HEIGHT)),
|
||||
(255, 255, 255, 255),
|
||||
)
|
||||
paste_labeled_cell(
|
||||
sheet,
|
||||
atlas,
|
||||
label="neutral",
|
||||
row_index=NEUTRAL_ROW_INDEX,
|
||||
column_index=NEUTRAL_COLUMN_INDEX,
|
||||
output_column=0,
|
||||
output_row=0,
|
||||
)
|
||||
for index, (label, expected_direction) in enumerate(LOOK_DIRECTION_LABELS):
|
||||
paste_labeled_cell(
|
||||
sheet,
|
||||
atlas,
|
||||
label=f"{label} {expected_direction}",
|
||||
row_index=LOOK_ROW_INDEX + index // COLUMNS,
|
||||
column_index=index % COLUMNS,
|
||||
output_column=index % COLUMNS,
|
||||
output_row=1 + index // COLUMNS,
|
||||
)
|
||||
paste_labeled_focus_cell(
|
||||
sheet,
|
||||
atlas,
|
||||
label=f"zoom {label} {expected_direction}",
|
||||
row_index=LOOK_ROW_INDEX + index // COLUMNS,
|
||||
column_index=index % COLUMNS,
|
||||
output_column=index % COLUMNS,
|
||||
output_row=3 + index // COLUMNS,
|
||||
)
|
||||
|
||||
output = Path(args.output).expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
sheet.convert("RGB").save(output)
|
||||
print(f"wrote {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
199
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/measure_direction_continuity.py
Normal file
199
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/measure_direction_continuity.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure adjacent-pair continuity for extended pet look directions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
COLUMNS = 8
|
||||
ROWS = 11
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
LOOK_ROW_INDEX = 9
|
||||
LOOK_DIRECTION_LABELS = [
|
||||
"000",
|
||||
"022.5",
|
||||
"045",
|
||||
"067.5",
|
||||
"090",
|
||||
"112.5",
|
||||
"135",
|
||||
"157.5",
|
||||
"180",
|
||||
"202.5",
|
||||
"225",
|
||||
"247.5",
|
||||
"270",
|
||||
"292.5",
|
||||
"315",
|
||||
"337.5",
|
||||
]
|
||||
|
||||
|
||||
def nontransparent_pixels(image: Image.Image) -> int:
|
||||
return sum(1 for alpha in image.getchannel("A").getdata() if alpha > 16)
|
||||
|
||||
|
||||
def center_for_bbox(bbox: tuple[int, int, int, int] | None) -> tuple[float, float] | None:
|
||||
if bbox is None:
|
||||
return None
|
||||
left, top, right, bottom = bbox
|
||||
return ((left + right) / 2, (top + bottom) / 2)
|
||||
|
||||
|
||||
def cell_from_atlas(atlas: Image.Image, index: int) -> Image.Image:
|
||||
row = LOOK_ROW_INDEX + index // COLUMNS
|
||||
column = index % COLUMNS
|
||||
return atlas.crop(
|
||||
(
|
||||
column * CELL_WIDTH,
|
||||
row * CELL_HEIGHT,
|
||||
(column + 1) * CELL_WIDTH,
|
||||
(row + 1) * CELL_HEIGHT,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def transparent_hole_rows(image: Image.Image) -> list[dict[str, int]]:
|
||||
alpha = list(image.getchannel("A").getdata())
|
||||
rows = [alpha[index * CELL_WIDTH : (index + 1) * CELL_WIDTH] for index in range(CELL_HEIGHT)]
|
||||
holes = []
|
||||
for y in range(1, CELL_HEIGHT - 1):
|
||||
prev_xs = [x for x, value in enumerate(rows[y - 1]) if value > 16]
|
||||
next_xs = [x for x, value in enumerate(rows[y + 1]) if value > 16]
|
||||
if not prev_xs or not next_xs:
|
||||
continue
|
||||
left = max(min(prev_xs), min(next_xs))
|
||||
right = min(max(prev_xs), max(next_xs))
|
||||
if right <= left:
|
||||
continue
|
||||
span = rows[y][left : right + 1]
|
||||
transparent_pixels = sum(1 for value in span if value <= 16)
|
||||
if len(span) >= 64 and transparent_pixels > max(32, int(len(span) * 0.25)):
|
||||
holes.append(
|
||||
{
|
||||
"row": y,
|
||||
"transparentPixels": transparent_pixels,
|
||||
"spanPixels": len(span),
|
||||
}
|
||||
)
|
||||
return holes
|
||||
|
||||
|
||||
def pair_metric(first: Image.Image, second: Image.Image) -> dict[str, float | int | None]:
|
||||
first_bbox = first.getbbox()
|
||||
second_bbox = second.getbbox()
|
||||
first_center = center_for_bbox(first_bbox)
|
||||
second_center = center_for_bbox(second_bbox)
|
||||
first_pixels = nontransparent_pixels(first)
|
||||
second_pixels = nontransparent_pixels(second)
|
||||
diff = ImageChops.difference(first, second)
|
||||
diff_pixels = nontransparent_pixels(diff)
|
||||
|
||||
if first_center is None or second_center is None:
|
||||
center_delta = None
|
||||
else:
|
||||
center_delta = (
|
||||
(first_center[0] - second_center[0]) ** 2 + (first_center[1] - second_center[1]) ** 2
|
||||
) ** 0.5
|
||||
|
||||
if first_pixels == 0 or second_pixels == 0:
|
||||
area_ratio = None
|
||||
else:
|
||||
area_ratio = max(first_pixels, second_pixels) / min(first_pixels, second_pixels)
|
||||
|
||||
return {
|
||||
"firstPixels": first_pixels,
|
||||
"secondPixels": second_pixels,
|
||||
"diffPixels": diff_pixels,
|
||||
"centerDelta": center_delta,
|
||||
"areaRatio": area_ratio,
|
||||
}
|
||||
|
||||
|
||||
def median(values: list[float]) -> float:
|
||||
if not values:
|
||||
return 0
|
||||
return statistics.median(values)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("atlas")
|
||||
parser.add_argument("--json-out", required=True)
|
||||
parser.add_argument("--diff-outlier-ratio", type=float, default=1.45)
|
||||
parser.add_argument("--center-delta-warning", type=float, default=8)
|
||||
parser.add_argument("--area-ratio-warning", type=float, default=1.15)
|
||||
args = parser.parse_args()
|
||||
|
||||
with Image.open(Path(args.atlas).expanduser().resolve()) as opened:
|
||||
atlas = opened.convert("RGBA")
|
||||
if atlas.size != (COLUMNS * CELL_WIDTH, ROWS * CELL_HEIGHT):
|
||||
raise SystemExit(f"extended atlas must be 1536x2288; got {atlas.width}x{atlas.height}")
|
||||
|
||||
cells = [cell_from_atlas(atlas, index) for index in range(len(LOOK_DIRECTION_LABELS))]
|
||||
pairs = []
|
||||
for index, label in enumerate(LOOK_DIRECTION_LABELS):
|
||||
next_index = (index + 1) % len(LOOK_DIRECTION_LABELS)
|
||||
next_label = LOOK_DIRECTION_LABELS[next_index]
|
||||
pairs.append(
|
||||
{
|
||||
"from": label,
|
||||
"to": next_label,
|
||||
**pair_metric(cells[index], cells[next_index]),
|
||||
}
|
||||
)
|
||||
|
||||
diff_values = [float(pair["diffPixels"]) for pair in pairs]
|
||||
median_diff = median(diff_values)
|
||||
warnings = []
|
||||
alpha_holes = []
|
||||
for label, cell in zip(LOOK_DIRECTION_LABELS, cells, strict=True):
|
||||
holes = transparent_hole_rows(cell)
|
||||
if holes:
|
||||
alpha_holes.append({"direction": label, "holes": holes})
|
||||
preview = ", ".join(f"y={hole['row']}" for hole in holes[:4])
|
||||
suffix = "" if len(holes) <= 4 else f", +{len(holes) - 4} more"
|
||||
warnings.append(f"{label} has transparent interior hole rows ({preview}{suffix})")
|
||||
|
||||
for index, pair in enumerate(pairs):
|
||||
diff_pixels = float(pair["diffPixels"])
|
||||
center_delta = pair["centerDelta"]
|
||||
area_ratio = pair["areaRatio"]
|
||||
pair_label = f"{pair['from']}->{pair['to']}"
|
||||
neighbor_diff = statistics.mean(
|
||||
[
|
||||
diff_values[(index - 1) % len(diff_values)],
|
||||
diff_values[(index + 1) % len(diff_values)],
|
||||
]
|
||||
)
|
||||
if neighbor_diff and diff_pixels > neighbor_diff * args.diff_outlier_ratio:
|
||||
warnings.append(
|
||||
f"{pair_label} diff is a local outlier ({diff_pixels:.0f} pixels vs neighbor average {neighbor_diff:.0f})"
|
||||
)
|
||||
if isinstance(center_delta, float) and center_delta > args.center_delta_warning:
|
||||
warnings.append(f"{pair_label} center shift is high ({center_delta:.1f}px)")
|
||||
if isinstance(area_ratio, float) and area_ratio > args.area_ratio_warning:
|
||||
warnings.append(f"{pair_label} sprite area ratio is high ({area_ratio:.2f})")
|
||||
|
||||
result = {
|
||||
"ok": True,
|
||||
"reviewRequired": bool(warnings),
|
||||
"medianDiffPixels": median_diff,
|
||||
"warnings": warnings,
|
||||
"alphaHoles": alpha_holes,
|
||||
"pairs": pairs,
|
||||
}
|
||||
output = Path(args.json_out).expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1158
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/prepare_pet_run.py
Executable file
1158
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/prepare_pet_run.py
Executable file
File diff suppressed because it is too large
Load Diff
78
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/render_animation_previews.py
Normal file
78
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/render_animation_previews.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render lightweight animated QA previews from extracted Codex pet frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROW_DURATIONS = {
|
||||
"idle": [280, 110, 110, 140, 140, 320],
|
||||
"running-right": [120, 120, 120, 120, 120, 120, 120, 220],
|
||||
"running-left": [120, 120, 120, 120, 120, 120, 120, 220],
|
||||
"waving": [140, 140, 140, 280],
|
||||
"jumping": [140, 140, 140, 140, 280],
|
||||
"failed": [140, 140, 140, 140, 140, 140, 140, 240],
|
||||
"waiting": [150, 150, 150, 150, 150, 260],
|
||||
"running": [120, 120, 120, 120, 120, 220],
|
||||
"review": [150, 150, 150, 150, 150, 280],
|
||||
}
|
||||
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
|
||||
|
||||
|
||||
def frame_files(state_dir: Path) -> list[Path]:
|
||||
if not state_dir.is_dir():
|
||||
return []
|
||||
return sorted(path for path in state_dir.iterdir() if path.suffix.lower() in IMAGE_SUFFIXES)
|
||||
|
||||
|
||||
def load_frames(frames_root: Path, state: str, expected_count: int) -> list[Image.Image]:
|
||||
files = frame_files(frames_root / state)
|
||||
if len(files) != expected_count:
|
||||
raise SystemExit(
|
||||
f"{state} preview needs {expected_count} frames, found {len(files)} under {frames_root / state}"
|
||||
)
|
||||
frames = []
|
||||
for path in files:
|
||||
with Image.open(path) as opened:
|
||||
frames.append(opened.convert("RGBA"))
|
||||
return frames
|
||||
|
||||
|
||||
def save_preview(frames: list[Image.Image], durations: list[int], output: Path) -> None:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
frames[0].save(
|
||||
output,
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
duration=durations,
|
||||
loop=0,
|
||||
disposal=2,
|
||||
optimize=False,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--frames-root", required=True)
|
||||
parser.add_argument("--output-dir", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
frames_root = Path(args.frames_root).expanduser().resolve()
|
||||
output_dir = Path(args.output_dir).expanduser().resolve()
|
||||
previews = []
|
||||
for state, durations in ROW_DURATIONS.items():
|
||||
frames = load_frames(frames_root, state, len(durations))
|
||||
output = output_dir / f"{state}.gif"
|
||||
save_preview(frames, durations, output)
|
||||
previews.append({"state": state, "path": str(output), "frames": len(frames)})
|
||||
|
||||
result = {"ok": True, "output_dir": str(output_dir), "previews": previews}
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
294
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/validate_atlas.py
Executable file
294
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/validate_atlas.py
Executable file
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a Codex pet spritesheet atlas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
COLUMNS = 8
|
||||
ROWS = 9
|
||||
EXTENDED_ROWS = 11
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
|
||||
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
|
||||
EXTENDED_ATLAS_HEIGHT = EXTENDED_ROWS * CELL_HEIGHT
|
||||
ROW_BY_INDEX = {
|
||||
0: ("idle", 6),
|
||||
1: ("running-right", 8),
|
||||
2: ("running-left", 8),
|
||||
3: ("waving", 4),
|
||||
4: ("jumping", 5),
|
||||
5: ("failed", 8),
|
||||
6: ("waiting", 6),
|
||||
7: ("running", 6),
|
||||
8: ("review", 6),
|
||||
9: ("look-000-to-157.5", 8),
|
||||
10: ("look-180-to-337.5", 8),
|
||||
}
|
||||
EXTENDED_NEUTRAL_LOOK_FRAME = (0, 6)
|
||||
|
||||
|
||||
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
||||
if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
|
||||
raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
|
||||
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
|
||||
|
||||
|
||||
def alpha_nonzero_count(image: Image.Image) -> int:
|
||||
alpha = image.getchannel("A")
|
||||
return sum(alpha.histogram()[1:])
|
||||
|
||||
|
||||
def transparent_rgb_residue_count(image: Image.Image) -> int:
|
||||
rgba = image.convert("RGBA")
|
||||
data = rgba.tobytes()
|
||||
count = 0
|
||||
for index in range(0, len(data), 4):
|
||||
red, green, blue, alpha = data[index : index + 4]
|
||||
if alpha == 0 and (red or green or blue):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def color_distance(
|
||||
red: int,
|
||||
green: int,
|
||||
blue: int,
|
||||
key: tuple[int, int, int],
|
||||
) -> float:
|
||||
return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2)
|
||||
|
||||
|
||||
def opaque_chroma_key_count(
|
||||
image: Image.Image,
|
||||
chroma_key: tuple[int, int, int],
|
||||
threshold: float,
|
||||
) -> int:
|
||||
rgba = image.convert("RGBA")
|
||||
data = rgba.tobytes()
|
||||
count = 0
|
||||
for index in range(0, len(data), 4):
|
||||
red, green, blue, alpha = data[index : index + 4]
|
||||
if alpha > 16 and color_distance(red, green, blue, chroma_key) <= threshold:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def is_chroma_contaminated(
|
||||
color: tuple[int, int, int],
|
||||
chroma_key: tuple[int, int, int],
|
||||
distance_threshold: float,
|
||||
) -> bool:
|
||||
return color_distance(*color, chroma_key) <= distance_threshold
|
||||
|
||||
|
||||
def chroma_fringe_count(
|
||||
image: Image.Image,
|
||||
*,
|
||||
chroma_key: tuple[int, int, int],
|
||||
distance_threshold: float,
|
||||
edge_radius: int,
|
||||
alpha_minimum: int,
|
||||
) -> int:
|
||||
rgba = image.convert("RGBA")
|
||||
alpha = rgba.getchannel("A")
|
||||
visible = [value > 0 for value in alpha.getdata()]
|
||||
transparent = Image.new("L", alpha.size)
|
||||
transparent.putdata([255 if not value else 0 for value in visible])
|
||||
expanded = transparent.filter(ImageFilter.MaxFilter(edge_radius * 2 + 1))
|
||||
return sum(
|
||||
alpha_value >= alpha_minimum
|
||||
and nearby_transparency > 0
|
||||
and is_chroma_contaminated(
|
||||
color[:3],
|
||||
chroma_key,
|
||||
distance_threshold,
|
||||
)
|
||||
for color, alpha_value, nearby_transparency in zip(
|
||||
rgba.getdata(), alpha.getdata(), expanded.getdata()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("atlas")
|
||||
parser.add_argument("--json-out")
|
||||
parser.add_argument("--min-used-pixels", type=int, default=50)
|
||||
parser.add_argument("--near-opaque-threshold", type=float, default=0.95)
|
||||
parser.add_argument("--chroma-key", default="#00FF00")
|
||||
parser.add_argument("--chroma-leak-threshold", type=float, default=36.0)
|
||||
parser.add_argument("--max-chroma-leak-pixels", type=int, default=400)
|
||||
parser.add_argument("--chroma-fringe-threshold", type=float, default=96.0)
|
||||
parser.add_argument("--chroma-fringe-edge-radius", type=int, default=2)
|
||||
parser.add_argument("--chroma-fringe-alpha-minimum", type=int, default=16)
|
||||
parser.add_argument("--max-chroma-fringe-pixels", type=int, default=0)
|
||||
parser.add_argument("--allow-opaque", action="store_true")
|
||||
parser.add_argument("--allow-near-opaque-used-cells", action="store_true")
|
||||
parser.add_argument("--allow-chroma-leak", action="store_true")
|
||||
parser.add_argument("--allow-chroma-fringe", action="store_true")
|
||||
parser.add_argument("--require-v2", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
atlas_path = Path(args.atlas).expanduser().resolve()
|
||||
chroma_key = parse_hex_color(args.chroma_key)
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
near_opaque_used_cells: dict[str, list[int]] = defaultdict(list)
|
||||
cells: list[dict[str, object]] = []
|
||||
|
||||
try:
|
||||
with Image.open(atlas_path) as opened:
|
||||
source_mode = opened.mode
|
||||
source_format = opened.format
|
||||
image = opened.convert("RGBA")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result = {"ok": False, "errors": [f"could not open atlas: {exc}"], "warnings": []}
|
||||
print(json.dumps(result, indent=2))
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
expected_heights = (
|
||||
{EXTENDED_ATLAS_HEIGHT}
|
||||
if args.require_v2
|
||||
else {
|
||||
ATLAS_HEIGHT,
|
||||
EXTENDED_ATLAS_HEIGHT,
|
||||
}
|
||||
)
|
||||
if image.width != ATLAS_WIDTH or image.height not in expected_heights:
|
||||
expected = (
|
||||
f"{ATLAS_WIDTH}x{EXTENDED_ATLAS_HEIGHT} for a v2 pet"
|
||||
if args.require_v2
|
||||
else f"{ATLAS_WIDTH}x{ATLAS_HEIGHT} or {ATLAS_WIDTH}x{EXTENDED_ATLAS_HEIGHT}"
|
||||
)
|
||||
errors.append(f"expected {expected}, got {image.width}x{image.height}")
|
||||
|
||||
if source_format not in {"PNG", "WEBP"}:
|
||||
errors.append(f"expected PNG or WebP, got {source_format}")
|
||||
|
||||
if "A" not in source_mode and not args.allow_opaque:
|
||||
errors.append("atlas does not have an alpha channel")
|
||||
|
||||
row_count = image.height // CELL_HEIGHT
|
||||
is_extended_atlas = image.height == EXTENDED_ATLAS_HEIGHT
|
||||
for row_index in range(row_count):
|
||||
state, frame_count = ROW_BY_INDEX[row_index]
|
||||
for column_index in range(COLUMNS):
|
||||
left = column_index * CELL_WIDTH
|
||||
top = row_index * CELL_HEIGHT
|
||||
cell = image.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
|
||||
nontransparent = alpha_nonzero_count(cell)
|
||||
used = column_index < frame_count or (
|
||||
is_extended_atlas and (row_index, column_index) == EXTENDED_NEUTRAL_LOOK_FRAME
|
||||
)
|
||||
cell_info = {
|
||||
"state": state,
|
||||
"row": row_index,
|
||||
"column": column_index,
|
||||
"used": used,
|
||||
"nontransparent_pixels": nontransparent,
|
||||
}
|
||||
chroma_leak_pixels = opaque_chroma_key_count(
|
||||
cell,
|
||||
chroma_key,
|
||||
args.chroma_leak_threshold,
|
||||
)
|
||||
cell_info["opaque_chroma_key_pixels"] = chroma_leak_pixels
|
||||
chroma_fringe_pixels = chroma_fringe_count(
|
||||
cell,
|
||||
chroma_key=chroma_key,
|
||||
distance_threshold=args.chroma_fringe_threshold,
|
||||
edge_radius=args.chroma_fringe_edge_radius,
|
||||
alpha_minimum=args.chroma_fringe_alpha_minimum,
|
||||
)
|
||||
cell_info["chroma_fringe_pixels"] = chroma_fringe_pixels
|
||||
cells.append(cell_info)
|
||||
if used and nontransparent < args.min_used_pixels:
|
||||
errors.append(
|
||||
f"{state} row {row_index} column {column_index} is empty or too sparse ({nontransparent} pixels)"
|
||||
)
|
||||
if used and chroma_leak_pixels > args.max_chroma_leak_pixels:
|
||||
message = (
|
||||
f"{state} row {row_index} column {column_index} has {chroma_leak_pixels} "
|
||||
f"opaque pixels near chroma key {args.chroma_key}; this usually means "
|
||||
"the sprite background was not removed"
|
||||
)
|
||||
if args.allow_chroma_leak:
|
||||
warnings.append(message)
|
||||
else:
|
||||
errors.append(message)
|
||||
if used and chroma_fringe_pixels > args.max_chroma_fringe_pixels:
|
||||
message = (
|
||||
f"{state} row {row_index} column {column_index} has {chroma_fringe_pixels} "
|
||||
f"visible edge pixels contaminated by chroma key {args.chroma_key}"
|
||||
)
|
||||
if args.allow_chroma_fringe:
|
||||
warnings.append(message)
|
||||
else:
|
||||
errors.append(message)
|
||||
if used and nontransparent > CELL_WIDTH * CELL_HEIGHT * args.near_opaque_threshold:
|
||||
near_opaque_used_cells[f"{state} row {row_index}"].append(column_index)
|
||||
if not used and nontransparent != 0:
|
||||
errors.append(
|
||||
f"{state} row {row_index} unused column {column_index} is not transparent ({nontransparent} pixels)"
|
||||
)
|
||||
|
||||
for row_label, columns in near_opaque_used_cells.items():
|
||||
message = (
|
||||
f"{row_label} has {len(columns)} nearly opaque used cells; "
|
||||
"this usually means the sprite has a non-transparent background"
|
||||
)
|
||||
if args.allow_near_opaque_used_cells:
|
||||
warnings.append(message)
|
||||
else:
|
||||
errors.append(message)
|
||||
|
||||
alpha_count = alpha_nonzero_count(image)
|
||||
if alpha_count == ATLAS_WIDTH * ATLAS_HEIGHT:
|
||||
message = "atlas is fully opaque; custom pets require a transparent sprite background"
|
||||
if args.allow_opaque:
|
||||
warnings.append(message)
|
||||
else:
|
||||
errors.append(message)
|
||||
|
||||
transparent_rgb_residue = transparent_rgb_residue_count(image)
|
||||
if transparent_rgb_residue:
|
||||
errors.append(
|
||||
f"atlas has {transparent_rgb_residue} fully transparent pixels with non-zero RGB residue"
|
||||
)
|
||||
|
||||
result = {
|
||||
"ok": not errors,
|
||||
"file": str(atlas_path),
|
||||
"format": source_format,
|
||||
"mode": source_mode,
|
||||
"columns": COLUMNS,
|
||||
"rows": row_count,
|
||||
"sprite_version_number": 2 if is_extended_atlas else 1,
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"transparent_rgb_residue_pixels": transparent_rgb_residue,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"cells": cells,
|
||||
}
|
||||
|
||||
if args.json_out:
|
||||
Path(args.json_out).expanduser().resolve().write_text(
|
||||
json.dumps(result, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
print(json.dumps({k: v for k, v in result.items() if k != "cells"}, indent=2))
|
||||
raise SystemExit(0 if result["ok"] else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
98
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/validate_direction_blind_verdicts.py
Executable file
98
04_卡火(火)/火炬_全栈消息/卡若宠物制作/scripts/validate_direction_blind_verdicts.py
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare blind A/B direction classifications with the hidden answer key."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ALLOWED_DIRECTIONS = {"screen-left", "screen-right", "up", "down", "ambiguous"}
|
||||
|
||||
|
||||
def load_json(path: str) -> dict[str, object]:
|
||||
return json.loads(Path(path).expanduser().resolve().read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--answer-key", required=True)
|
||||
parser.add_argument("--verdicts", required=True)
|
||||
parser.add_argument("--json-out", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
answer_key = load_json(args.answer_key)
|
||||
verdicts = load_json(args.verdicts)
|
||||
expected_by_pair = {entry["pair"]: entry for entry in answer_key.get("pairs", [])}
|
||||
verdict_by_pair = {entry["pair"]: entry for entry in verdicts.get("pairs", [])}
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
unconfirmed: list[str] = []
|
||||
results: list[dict[str, object]] = []
|
||||
|
||||
for pair_id, expected in expected_by_pair.items():
|
||||
verdict = verdict_by_pair.get(pair_id)
|
||||
if verdict is None:
|
||||
errors.append(f"missing blind verdict for {pair_id}")
|
||||
continue
|
||||
axis = expected.get("axis", "horizontal")
|
||||
gate = expected.get("gate", "hard")
|
||||
if gate not in {"hard", "review"}:
|
||||
errors.append(f"{pair_id} has invalid gate: {gate!r}")
|
||||
gate = "hard"
|
||||
result: dict[str, object] = {"pair": pair_id, "axis": axis, "gate": gate}
|
||||
for slot in ("A", "B"):
|
||||
observed = verdict.get(slot)
|
||||
expected_direction = expected[slot].get(
|
||||
"expected_direction",
|
||||
expected[slot].get("expected_horizontal"),
|
||||
)
|
||||
if observed not in ALLOWED_DIRECTIONS:
|
||||
errors.append(f"{pair_id} {slot} has invalid classification: {observed!r}")
|
||||
elif observed == "ambiguous":
|
||||
message = f"{pair_id} {slot} {axis} axis is ambiguous"
|
||||
warnings.append(message)
|
||||
if gate == "hard":
|
||||
unconfirmed.append(message)
|
||||
elif observed != expected_direction:
|
||||
message = f"{pair_id} {slot} classified {observed}; expected {expected_direction}"
|
||||
if gate == "hard":
|
||||
errors.append(message)
|
||||
else:
|
||||
warnings.append(message)
|
||||
result[slot] = {
|
||||
"observed": observed,
|
||||
"expected": expected_direction,
|
||||
"source_direction": expected[slot]["source_direction"],
|
||||
"pass": observed == expected_direction,
|
||||
}
|
||||
if verdict.get("A") == verdict.get("B") and verdict.get("A") != "ambiguous":
|
||||
message = f"{pair_id} A and B were classified as the same {axis} direction"
|
||||
if gate == "hard":
|
||||
errors.append(message)
|
||||
else:
|
||||
warnings.append(message)
|
||||
results.append(result)
|
||||
|
||||
extra_pairs = sorted(set(verdict_by_pair) - set(expected_by_pair))
|
||||
if extra_pairs:
|
||||
errors.append(f"unexpected blind verdict pairs: {', '.join(extra_pairs)}")
|
||||
|
||||
output = {
|
||||
"ok": not errors and not unconfirmed,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"unconfirmed": unconfirmed,
|
||||
"reviewRequired": bool(warnings),
|
||||
"pairs": results,
|
||||
}
|
||||
output_path = Path(args.json_out).expanduser().resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps(output, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(output, indent=2))
|
||||
if errors or unconfirmed:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
238
04_卡火(火)/火炬_全栈消息/卡若宠物制作/tests/test_assemble_extended_atlas.py
Normal file
238
04_卡火(火)/火炬_全栈消息/卡若宠物制作/tests/test_assemble_extended_atlas.py
Normal file
@@ -0,0 +1,238 @@
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
MODULE_PATH = SKILL_DIR / "scripts" / "assemble_extended_atlas.py"
|
||||
sys.path.insert(0, str(MODULE_PATH.parent))
|
||||
SPEC = importlib.util.spec_from_file_location("assemble_extended_atlas", MODULE_PATH)
|
||||
if SPEC is None or SPEC.loader is None:
|
||||
raise RuntimeError(f"Unable to load {MODULE_PATH}")
|
||||
ASSEMBLER = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(ASSEMBLER)
|
||||
|
||||
|
||||
class AssembleExtendedAtlasTest(unittest.TestCase):
|
||||
def test_row_9_can_be_registered_before_row_10_is_generated(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
atlas_path = root / "base.png"
|
||||
neutral_path = root / "neutral.png"
|
||||
strip_path = root / "look-row-9.png"
|
||||
output_path = root / "registered-row-9.png"
|
||||
registration_path = root / "row-9-registration.json"
|
||||
|
||||
Image.new("RGBA", (1536, 1872), (0, 0, 0, 0)).save(atlas_path)
|
||||
neutral = Image.new("RGBA", (192, 208), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(neutral).rectangle((40, 18, 151, 197), fill="white")
|
||||
neutral.save(neutral_path)
|
||||
|
||||
strip = Image.new("RGB", (2176, 724), "#FF00FF")
|
||||
draw = ImageDraw.Draw(strip)
|
||||
slot_width = strip.width // 8
|
||||
for index in range(8):
|
||||
left = index * slot_width + 64
|
||||
draw.rectangle((left, 100, left + 140, 620), fill=(20 + index, 40, 80))
|
||||
strip.save(strip_path)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(MODULE_PATH),
|
||||
"--base-atlas",
|
||||
str(atlas_path),
|
||||
"--look-row-9",
|
||||
str(strip_path),
|
||||
"--neutral-cell",
|
||||
str(neutral_path),
|
||||
"--chroma-key",
|
||||
"#FF00FF",
|
||||
"--registered-row-output",
|
||||
str(output_path),
|
||||
"--registration-manifest-output",
|
||||
str(registration_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
with Image.open(output_path) as registered:
|
||||
self.assertEqual(registered.size, (1536, 208))
|
||||
self.assertTrue(
|
||||
all(
|
||||
registered.crop((column * 192, 0, (column + 1) * 192, 208)).getbbox()
|
||||
for column in range(8)
|
||||
)
|
||||
)
|
||||
self.assertTrue(registration_path.is_file())
|
||||
|
||||
def test_final_assembly_preserves_registered_row_9_geometry(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
atlas_path = root / "base.png"
|
||||
neutral_path = root / "neutral.png"
|
||||
row_9_path = root / "look-row-9.png"
|
||||
row_10_path = root / "look-row-10.png"
|
||||
registered_path = root / "registered-row-9.png"
|
||||
registration_path = root / "row-9-registration.json"
|
||||
output_path = root / "extended.png"
|
||||
|
||||
Image.new("RGBA", (1536, 1872), (0, 0, 0, 0)).save(atlas_path)
|
||||
neutral = Image.new("RGBA", (192, 208), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(neutral).rectangle((40, 18, 151, 197), fill="white")
|
||||
neutral.save(neutral_path)
|
||||
|
||||
for path, height in ((row_9_path, 500), (row_10_path, 520)):
|
||||
strip = Image.new("RGB", (2176, 724), "#FF00FF")
|
||||
draw = ImageDraw.Draw(strip)
|
||||
slot_width = strip.width // 8
|
||||
for index in range(8):
|
||||
left = index * slot_width + 64
|
||||
draw.rectangle(
|
||||
(left, 650 - height, left + 140, 649),
|
||||
fill=(20 + index, 40, 80),
|
||||
)
|
||||
strip.save(path)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(MODULE_PATH),
|
||||
"--base-atlas",
|
||||
str(atlas_path),
|
||||
"--look-row-9",
|
||||
str(row_9_path),
|
||||
"--neutral-cell",
|
||||
str(neutral_path),
|
||||
"--chroma-key",
|
||||
"#FF00FF",
|
||||
"--registered-row-output",
|
||||
str(registered_path),
|
||||
"--registration-manifest-output",
|
||||
str(registration_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(MODULE_PATH),
|
||||
"--base-atlas",
|
||||
str(atlas_path),
|
||||
"--registered-row-9",
|
||||
str(registered_path),
|
||||
"--row-9-registration",
|
||||
str(registration_path),
|
||||
"--look-row-10",
|
||||
str(row_10_path),
|
||||
"--neutral-cell",
|
||||
str(neutral_path),
|
||||
"--chroma-key",
|
||||
"#FF00FF",
|
||||
"--output",
|
||||
str(output_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
with Image.open(registered_path) as registered, Image.open(output_path) as atlas:
|
||||
self.assertEqual(
|
||||
registered.convert("RGBA").tobytes(),
|
||||
atlas.crop((0, 9 * 208, 1536, 10 * 208)).convert("RGBA").tobytes(),
|
||||
)
|
||||
|
||||
def test_look_rows_register_poses_before_one_shared_resize(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
strip_path = Path(temporary_directory) / "look-row-9.png"
|
||||
strip = Image.new("RGB", (2176, 724), "#FF00FF")
|
||||
draw = ImageDraw.Draw(strip)
|
||||
source_heights = []
|
||||
source_lefts = [220, 410, 650, 890, 1130, 1370, 1610, 1850]
|
||||
for index in range(8):
|
||||
height = 520 - index * 20
|
||||
source_heights.append(height)
|
||||
left = source_lefts[index]
|
||||
top = strip.height - height - 42
|
||||
draw.rectangle(
|
||||
(left, top, left + 163, top + height - 1),
|
||||
fill=(20 + index, 40, 80),
|
||||
)
|
||||
for y in range(top + 4, top + height - 4, 4):
|
||||
draw.line((left + 4, y, left + 159, y), fill=(240, 240, 240))
|
||||
strip.save(strip_path)
|
||||
|
||||
neutral = Image.new("RGBA", (192, 208), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(neutral).rectangle((40, 18, 151, 197), fill="white")
|
||||
|
||||
cells = ASSEMBLER.extract_row_strip_cells(
|
||||
strip_path,
|
||||
(255, 0, 255),
|
||||
16,
|
||||
)
|
||||
self.assertEqual(len(cells), 8)
|
||||
normalized = ASSEMBLER.normalize_cells_to_reference(cells, neutral)
|
||||
|
||||
ASSEMBLER.validate_normalized_look_cells(normalized, 0, 2, 24)
|
||||
|
||||
self.assertEqual({cell.size for cell in normalized}, {(192, 208)})
|
||||
|
||||
normalized_heights = [cell.getbbox()[3] - cell.getbbox()[1] for cell in normalized]
|
||||
self.assertEqual(normalized_heights[0], 180)
|
||||
for source_height, normalized_height in zip(
|
||||
source_heights,
|
||||
normalized_heights,
|
||||
):
|
||||
self.assertAlmostEqual(
|
||||
normalized_height / normalized_heights[0],
|
||||
source_height / source_heights[0],
|
||||
delta=0.01,
|
||||
)
|
||||
|
||||
def test_post_registration_edge_failure_requires_resynthesis(self) -> None:
|
||||
cell = Image.new("RGBA", (192, 208), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(cell).rectangle((0, 20, 80, 180), fill="white")
|
||||
|
||||
with self.assertRaisesRegex(SystemExit, "after deterministic registration"):
|
||||
ASSEMBLER.validate_normalized_look_cells([cell], 0, 2, 24)
|
||||
|
||||
def test_look_rows_do_not_upscale_small_source_poses(self) -> None:
|
||||
neutral = Image.new("RGBA", (192, 208), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(neutral).rectangle((40, 18, 151, 197), fill="white")
|
||||
cells = []
|
||||
for _ in range(8):
|
||||
cell = Image.new("RGBA", (192, 208), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(cell).rectangle((60, 80, 131, 179), fill="white")
|
||||
cells.append(cell)
|
||||
|
||||
normalized = ASSEMBLER.normalize_cells_to_reference(cells, neutral)
|
||||
|
||||
self.assertEqual(
|
||||
[cell.getbbox()[3] - cell.getbbox()[1] for cell in normalized],
|
||||
[100] * 8,
|
||||
)
|
||||
|
||||
def test_shared_scale_keeps_asymmetric_pose_inside_final_edges(self) -> None:
|
||||
neutral = Image.new("RGBA", (192, 208), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(neutral).rectangle((40, 18, 151, 197), fill="white")
|
||||
cell = Image.new("RGBA", (300, 300), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(cell)
|
||||
draw.rectangle((0, 20, 299, 50), fill="white")
|
||||
draw.rectangle((0, 20, 50, 279), fill="white")
|
||||
|
||||
normalized = ASSEMBLER.normalize_cells_to_reference([cell], neutral)
|
||||
|
||||
ASSEMBLER.validate_normalized_look_cells(normalized, 0, 2, 24)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,198 @@
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
MODULE_PATH = SKILL_DIR / "scripts" / "despill_chroma_edges.py"
|
||||
SPEC = importlib.util.spec_from_file_location("despill_chroma_edges", MODULE_PATH)
|
||||
if SPEC is None or SPEC.loader is None:
|
||||
raise RuntimeError(f"Unable to load {MODULE_PATH}")
|
||||
DESPILL = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(DESPILL)
|
||||
|
||||
|
||||
class ChromaMatteDecontaminationTest(unittest.TestCase):
|
||||
def test_recovers_foreground_from_soft_keyed_edge_in_linear_light(self) -> None:
|
||||
alpha = 0.5
|
||||
foreground = (0.18, 0.55, 0.82)
|
||||
key = (1.0, 0.0, 1.0)
|
||||
composite = tuple(
|
||||
DESPILL.linear_to_srgb(
|
||||
alpha * DESPILL.srgb_to_linear(channel)
|
||||
+ (1 - alpha) * DESPILL.srgb_to_linear(key_channel)
|
||||
)
|
||||
for channel, key_channel in zip(foreground, key)
|
||||
)
|
||||
image = Image.new("RGBA", (5, 5), (0, 0, 0, 0))
|
||||
edge = (*[round(channel * 255) for channel in composite], round(alpha * 255))
|
||||
for y in range(1, 4):
|
||||
for x in range(1, 4):
|
||||
image.putpixel((x, y), edge)
|
||||
image.putpixel((2, 2), (*[round(channel * 255) for channel in foreground], 255))
|
||||
|
||||
cleaned, report = DESPILL.decontaminate_image(
|
||||
image,
|
||||
chroma_key=(255, 0, 255),
|
||||
edge_radius=1,
|
||||
spill_tolerance=0.2,
|
||||
)
|
||||
|
||||
recovered = cleaned.getpixel((2, 1))
|
||||
for actual, expected in zip(recovered[:3], foreground):
|
||||
self.assertAlmostEqual(actual / 255, expected, delta=0.025)
|
||||
self.assertEqual(recovered[3], round(alpha * 255))
|
||||
self.assertEqual(report["algorithm"], "edge-local-chroma-spill-suppression")
|
||||
self.assertTrue(report["alpha_preserved"])
|
||||
|
||||
def test_does_not_change_soft_pixels_away_from_transparency_boundary(self) -> None:
|
||||
image = Image.new("RGBA", (7, 7), (40, 90, 140, 255))
|
||||
image.putpixel((3, 3), (150, 80, 180, 128))
|
||||
|
||||
cleaned, _ = DESPILL.decontaminate_image(
|
||||
image,
|
||||
chroma_key=(255, 0, 255),
|
||||
edge_radius=2,
|
||||
)
|
||||
|
||||
self.assertEqual(cleaned.getpixel((3, 3)), image.getpixel((3, 3)))
|
||||
|
||||
def test_does_not_reprocess_already_clean_edge(self) -> None:
|
||||
image = Image.new("RGBA", (3, 3), (0, 0, 0, 0))
|
||||
image.putpixel((1, 1), (235, 40, 220, 128))
|
||||
|
||||
cleaned, first_report = DESPILL.decontaminate_image(
|
||||
image,
|
||||
chroma_key=(255, 0, 255),
|
||||
)
|
||||
cleaned_again, second_report = DESPILL.decontaminate_image(
|
||||
cleaned,
|
||||
chroma_key=(255, 0, 255),
|
||||
)
|
||||
|
||||
self.assertEqual(cleaned_again.tobytes(), cleaned.tobytes())
|
||||
self.assertGreater(first_report["decontaminated_pixels"], 0)
|
||||
self.assertEqual(second_report["decontaminated_pixels"], 0)
|
||||
|
||||
def test_strength_zero_is_an_exact_before_preview(self) -> None:
|
||||
image = Image.new("RGBA", (3, 3), (0, 0, 0, 0))
|
||||
image.putpixel((1, 1), (170, 90, 210, 128))
|
||||
|
||||
cleaned, _ = DESPILL.decontaminate_image(
|
||||
image,
|
||||
chroma_key=(255, 0, 255),
|
||||
strength=0,
|
||||
)
|
||||
|
||||
self.assertEqual(cleaned.tobytes(), image.tobytes())
|
||||
|
||||
def test_removes_opaque_key_spill_from_the_silhouette_band(self) -> None:
|
||||
image = Image.new("RGBA", (9, 9), (0, 0, 0, 0))
|
||||
for y in range(2, 7):
|
||||
for x in range(2, 7):
|
||||
image.putpixel((x, y), (110, 0, 110, 255))
|
||||
for y in range(3, 6):
|
||||
for x in range(3, 6):
|
||||
image.putpixel((x, y), (8, 8, 8, 255))
|
||||
|
||||
cleaned, report = DESPILL.decontaminate_image(
|
||||
image,
|
||||
chroma_key=(255, 0, 255),
|
||||
edge_radius=2,
|
||||
spill_tolerance=0.04,
|
||||
)
|
||||
|
||||
self.assertEqual(cleaned.getpixel((2, 4)), (8, 8, 8, 255))
|
||||
self.assertGreater(report["spill_suppressed_pixels"], 0)
|
||||
|
||||
def test_preserves_non_key_opaque_boundary_color(self) -> None:
|
||||
image = Image.new("RGBA", (9, 9), (0, 0, 0, 0))
|
||||
for y in range(2, 7):
|
||||
for x in range(2, 7):
|
||||
image.putpixel((x, y), (20, 90, 210, 255))
|
||||
for y in range(3, 6):
|
||||
for x in range(3, 6):
|
||||
image.putpixel((x, y), (10, 60, 160, 255))
|
||||
|
||||
cleaned, report = DESPILL.decontaminate_image(
|
||||
image,
|
||||
chroma_key=(255, 0, 255),
|
||||
edge_radius=2,
|
||||
spill_tolerance=0.04,
|
||||
)
|
||||
|
||||
self.assertEqual(cleaned.getpixel((2, 4)), image.getpixel((2, 4)))
|
||||
self.assertEqual(report["spill_suppressed_pixels"], 0)
|
||||
|
||||
def test_preserves_nearly_neutral_opaque_boundary_color(self) -> None:
|
||||
image = Image.new("RGBA", (9, 9), (0, 0, 0, 0))
|
||||
for y in range(2, 7):
|
||||
for x in range(2, 7):
|
||||
image.putpixel((x, y), (101, 100, 101, 255))
|
||||
for y in range(3, 6):
|
||||
for x in range(3, 6):
|
||||
image.putpixel((x, y), (20, 20, 20, 255))
|
||||
|
||||
cleaned, report = DESPILL.decontaminate_image(
|
||||
image,
|
||||
chroma_key=(255, 0, 255),
|
||||
edge_radius=2,
|
||||
)
|
||||
|
||||
self.assertEqual(cleaned.getpixel((2, 4)), image.getpixel((2, 4)))
|
||||
self.assertEqual(report["spill_suppressed_pixels"], 0)
|
||||
|
||||
def test_removes_dark_saturated_key_spill(self) -> None:
|
||||
image = Image.new("RGBA", (9, 9), (0, 0, 0, 0))
|
||||
for y in range(2, 7):
|
||||
for x in range(2, 7):
|
||||
image.putpixel((x, y), (50, 0, 50, 255))
|
||||
for y in range(3, 6):
|
||||
for x in range(3, 6):
|
||||
image.putpixel((x, y), (8, 8, 8, 255))
|
||||
|
||||
cleaned, report = DESPILL.decontaminate_image(
|
||||
image,
|
||||
chroma_key=(255, 0, 255),
|
||||
edge_radius=2,
|
||||
)
|
||||
|
||||
self.assertEqual(cleaned.getpixel((2, 4)), (8, 8, 8, 255))
|
||||
self.assertGreater(report["spill_suppressed_pixels"], 0)
|
||||
|
||||
def test_extends_interior_color_through_non_key_translucent_edge(self) -> None:
|
||||
image = Image.new("RGBA", (7, 7), (0, 0, 0, 0))
|
||||
for y in range(1, 6):
|
||||
for x in range(1, 6):
|
||||
image.putpixel((x, y), (20, 180, 70, 80))
|
||||
for y in range(2, 5):
|
||||
for x in range(2, 5):
|
||||
image.putpixel((x, y), (30, 60, 90, 255))
|
||||
|
||||
cleaned, _ = DESPILL.decontaminate_image(
|
||||
image,
|
||||
chroma_key=(255, 0, 255),
|
||||
edge_radius=1,
|
||||
)
|
||||
|
||||
self.assertEqual(cleaned.getpixel((1, 3)), (30, 60, 90, 80))
|
||||
|
||||
def test_does_not_borrow_interior_color_across_atlas_cells(self) -> None:
|
||||
image = Image.new("RGBA", (384, 208), (0, 0, 0, 0))
|
||||
image.putpixel((191, 100), (220, 40, 180, 128))
|
||||
image.putpixel((192, 100), (20, 80, 220, 255))
|
||||
|
||||
cleaned, _ = DESPILL.decontaminate_image(
|
||||
image,
|
||||
chroma_key=(255, 0, 255),
|
||||
edge_radius=1,
|
||||
)
|
||||
|
||||
red, green, blue, alpha = cleaned.getpixel((191, 100))
|
||||
self.assertEqual((red, green, blue), (red, red, red))
|
||||
self.assertEqual(alpha, 128)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,215 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
VALIDATE_BLIND = SKILL_DIR / "scripts" / "validate_direction_blind_verdicts.py"
|
||||
MEASURE_CONTINUITY = SKILL_DIR / "scripts" / "measure_direction_continuity.py"
|
||||
MAKE_BLIND_SHEET = SKILL_DIR / "scripts" / "make_direction_blind_qa_sheet.py"
|
||||
|
||||
|
||||
class DirectionAcceptancePolicyTest(unittest.TestCase):
|
||||
def run_blind_validation(
|
||||
self,
|
||||
temporary_directory: str,
|
||||
*,
|
||||
gate: str | None,
|
||||
verdict_a: str,
|
||||
verdict_b: str,
|
||||
) -> tuple[subprocess.CompletedProcess[str], dict[str, object]]:
|
||||
root = Path(temporary_directory)
|
||||
answer_key = root / "answer-key.json"
|
||||
verdicts = root / "verdicts.json"
|
||||
output = root / "validation.json"
|
||||
pair = {
|
||||
"pair": "pair-1",
|
||||
"axis": "horizontal",
|
||||
"A": {
|
||||
"expected_direction": "screen-right",
|
||||
"source_direction": "022.5",
|
||||
},
|
||||
"B": {
|
||||
"expected_direction": "screen-left",
|
||||
"source_direction": "337.5",
|
||||
},
|
||||
}
|
||||
if gate is not None:
|
||||
pair["gate"] = gate
|
||||
answer_key.write_text(json.dumps({"pairs": [pair]}))
|
||||
verdicts.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"pairs": [
|
||||
{
|
||||
"pair": "pair-1",
|
||||
"A": verdict_a,
|
||||
"B": verdict_b,
|
||||
"reason": "test",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(VALIDATE_BLIND),
|
||||
"--answer-key",
|
||||
str(answer_key),
|
||||
"--verdicts",
|
||||
str(verdicts),
|
||||
"--json-out",
|
||||
str(output),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return completed, json.loads(output.read_text())
|
||||
|
||||
def test_review_pair_ambiguity_requests_review_without_failing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
completed, result = self.run_blind_validation(
|
||||
temporary_directory,
|
||||
gate="review",
|
||||
verdict_a="ambiguous",
|
||||
verdict_b="ambiguous",
|
||||
)
|
||||
|
||||
self.assertEqual(completed.returncode, 0)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertTrue(result["reviewRequired"])
|
||||
self.assertEqual(result["errors"], [])
|
||||
self.assertEqual(result["unconfirmed"], [])
|
||||
self.assertGreater(len(result["warnings"]), 0)
|
||||
|
||||
def test_clear_horizontal_ambiguity_is_also_unconfirmed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
completed, result = self.run_blind_validation(
|
||||
temporary_directory,
|
||||
gate="hard",
|
||||
verdict_a="ambiguous",
|
||||
verdict_b="ambiguous",
|
||||
)
|
||||
|
||||
self.assertEqual(completed.returncode, 1)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["errors"], [])
|
||||
self.assertGreater(len(result["unconfirmed"]), 0)
|
||||
self.assertGreater(len(result["warnings"]), 0)
|
||||
|
||||
def test_explicit_wrong_direction_remains_a_hard_failure(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
completed, result = self.run_blind_validation(
|
||||
temporary_directory,
|
||||
gate="hard",
|
||||
verdict_a="screen-left",
|
||||
verdict_b="screen-left",
|
||||
)
|
||||
|
||||
self.assertEqual(completed.returncode, 1)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertGreater(len(result["errors"]), 0)
|
||||
|
||||
def test_review_pair_wrong_direction_requests_review_without_failing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
completed, result = self.run_blind_validation(
|
||||
temporary_directory,
|
||||
gate="review",
|
||||
verdict_a="screen-left",
|
||||
verdict_b="screen-left",
|
||||
)
|
||||
|
||||
self.assertEqual(completed.returncode, 0)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["errors"], [])
|
||||
self.assertGreater(len(result["warnings"]), 0)
|
||||
|
||||
def test_missing_gate_preserves_strict_compatibility(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
completed, result = self.run_blind_validation(
|
||||
temporary_directory,
|
||||
gate=None,
|
||||
verdict_a="ambiguous",
|
||||
verdict_b="ambiguous",
|
||||
)
|
||||
|
||||
self.assertEqual(completed.returncode, 1)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertTrue(result["reviewRequired"])
|
||||
|
||||
def test_continuity_outlier_requests_review_without_failing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
atlas_path = root / "atlas.png"
|
||||
output = root / "continuity.json"
|
||||
atlas = Image.new("RGBA", (1536, 2288), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(atlas)
|
||||
for index in range(16):
|
||||
row = 9 + index // 8
|
||||
column = index % 8
|
||||
size = 120 if index == 4 else 50
|
||||
left = column * 192 + 20
|
||||
top = row * 208 + 20
|
||||
draw.rectangle((left, top, left + size, top + size), fill=(0, 0, 0, 255))
|
||||
atlas.save(atlas_path)
|
||||
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(MEASURE_CONTINUITY),
|
||||
str(atlas_path),
|
||||
"--json-out",
|
||||
str(output),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
result = json.loads(output.read_text())
|
||||
|
||||
self.assertEqual(completed.returncode, 0)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertTrue(result["reviewRequired"])
|
||||
self.assertGreater(len(result["warnings"]), 0)
|
||||
|
||||
def test_blind_sheet_uses_two_normal_size_cells_without_zoom_crops(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
atlas_path = root / "atlas.png"
|
||||
sheet_path = root / "blind.png"
|
||||
answer_key = root / "answer-key.json"
|
||||
Image.new("RGBA", (1536, 2288), (0, 0, 0, 0)).save(atlas_path)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(MAKE_BLIND_SHEET),
|
||||
str(atlas_path),
|
||||
"--output",
|
||||
str(sheet_path),
|
||||
"--answer-key",
|
||||
str(answer_key),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
with Image.open(sheet_path) as sheet:
|
||||
self.assertEqual(sheet.width, 2 * 192)
|
||||
|
||||
pairs = json.loads(answer_key.read_text())["pairs"]
|
||||
|
||||
self.assertEqual(len(pairs), 14)
|
||||
self.assertEqual([pair["axis"] for pair in pairs].count("horizontal"), 7)
|
||||
self.assertEqual([pair["axis"] for pair in pairs].count("vertical"), 7)
|
||||
self.assertEqual([pair["gate"] for pair in pairs].count("hard"), 2)
|
||||
self.assertEqual([pair["gate"] for pair in pairs].count("review"), 12)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,45 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
COMBINE = SKILL_DIR / "scripts" / "combine_direction_blind_verdicts.py"
|
||||
|
||||
|
||||
class DirectionBlindConsensusTest(unittest.TestCase):
|
||||
def test_strict_majority_wins_and_tie_becomes_ambiguous(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
reviews = [
|
||||
{"A": "screen-left", "B": "up"},
|
||||
{"A": "screen-left", "B": "down"},
|
||||
{"A": "screen-right", "B": "ambiguous"},
|
||||
]
|
||||
paths = []
|
||||
for index, review in enumerate(reviews):
|
||||
path = root / f"review-{index}.json"
|
||||
path.write_text(json.dumps({"pairs": [{"pair": "pair-1", **review}]}))
|
||||
paths.append(path)
|
||||
output = root / "combined.json"
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(COMBINE),
|
||||
*[argument for path in paths for argument in ("--verdicts", str(path))],
|
||||
"--json-out",
|
||||
str(output),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
pair = json.loads(output.read_text())["pairs"][0]
|
||||
|
||||
self.assertEqual(pair["A"], "screen-left")
|
||||
self.assertEqual(pair["B"], "ambiguous")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,92 @@
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
PREPARE = SKILL_DIR / "scripts" / "prepare_pet_run.py"
|
||||
|
||||
|
||||
class LookRowSafeBoxPromptTest(unittest.TestCase):
|
||||
def prepare_run(self, temporary_directory: str) -> Path:
|
||||
run_dir = Path(temporary_directory) / "run"
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(PREPARE),
|
||||
"--pet-name",
|
||||
"Safe Box Test",
|
||||
"--pet-notes",
|
||||
"a simple mascot",
|
||||
"--output-dir",
|
||||
str(run_dir),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return run_dir
|
||||
|
||||
def test_look_row_prompts_delegate_exact_slot_geometry_to_registration(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
run_dir = self.prepare_run(temporary_directory)
|
||||
|
||||
prompt_paths = [
|
||||
run_dir / "prompts" / "rows" / "look-row-9.md",
|
||||
run_dir / "prompts" / "rows" / "look-row-10.md",
|
||||
run_dir / "prompts" / "row-retries" / "look-row-9.md",
|
||||
run_dir / "prompts" / "row-retries" / "look-row-10.md",
|
||||
]
|
||||
for prompt_path in prompt_paths:
|
||||
prompt = prompt_path.read_text()
|
||||
self.assertIn("DETERMINISTIC REGISTRATION", prompt)
|
||||
self.assertIn("eight separated pose groups", prompt)
|
||||
self.assertIn("shared scale and baseline", prompt)
|
||||
self.assertNotIn(
|
||||
"reject this result if any foreground enters a blue safety band", prompt
|
||||
)
|
||||
|
||||
def test_look_row_prompts_lock_construction_and_boundaries(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
run_dir = self.prepare_run(temporary_directory)
|
||||
prompt_paths = [
|
||||
run_dir / "prompts" / "rows" / "look-row-9.md",
|
||||
run_dir / "prompts" / "rows" / "look-row-10.md",
|
||||
run_dir / "prompts" / "row-retries" / "look-row-9.md",
|
||||
run_dir / "prompts" / "row-retries" / "look-row-10.md",
|
||||
]
|
||||
for prompt_path in prompt_paths:
|
||||
prompt = prompt_path.read_text()
|
||||
self.assertIn("HARD LAYOUT AND CONTINUITY CONTRACT", prompt)
|
||||
self.assertIn("same body height, head size, baseline", prompt)
|
||||
self.assertIn("same coordinates across all eight frames", prompt)
|
||||
self.assertIn("reject this result", prompt)
|
||||
|
||||
row_9 = (run_dir / "prompts" / "rows" / "look-row-9.md").read_text()
|
||||
row_10 = (run_dir / "prompts" / "rows" / "look-row-10.md").read_text()
|
||||
self.assertIn("157.5 must be one even 22.5-degree step before 180", row_9)
|
||||
self.assertIn("180 must continue directly from row 9's 157.5", row_10)
|
||||
self.assertIn("337.5 must be one even 22.5-degree step before 000", row_10)
|
||||
|
||||
def test_look_row_prompts_keep_direction_targets_without_pixel_gates(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
run_dir = self.prepare_run(temporary_directory)
|
||||
|
||||
for prompt_kind in ("rows", "row-retries"):
|
||||
row_9 = (run_dir / "prompts" / prompt_kind / "look-row-9.md").read_text()
|
||||
row_10 = (run_dir / "prompts" / prompt_kind / "look-row-10.md").read_text()
|
||||
|
||||
self.assertIn("DIRECTION TARGETS", row_9)
|
||||
self.assertIn("`045`: horizontal SCREEN-RIGHT and vertical UP", row_9)
|
||||
self.assertIn("`135`: horizontal SCREEN-RIGHT and vertical DOWN", row_9)
|
||||
self.assertIn("`225`: horizontal SCREEN-LEFT and vertical DOWN", row_10)
|
||||
self.assertIn("`315`: horizontal SCREEN-LEFT and vertical UP", row_10)
|
||||
self.assertIn("Minor pupil, nose, eyelid", row_9)
|
||||
self.assertIn("Minor pupil, nose, eyelid", row_10)
|
||||
self.assertIn("not as pixel-level landmark gates", row_9)
|
||||
self.assertIn("not as pixel-level landmark gates", row_10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,20 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SKILL = Path(__file__).resolve().parents[1] / "SKILL.md"
|
||||
|
||||
|
||||
class SingleFinalChromaPassTest(unittest.TestCase):
|
||||
def test_cleanup_runs_only_after_v2_assembly(self) -> None:
|
||||
instructions = SKILL.read_text()
|
||||
|
||||
self.assertEqual(instructions.count("scripts/despill_chroma_edges.py"), 1)
|
||||
self.assertNotIn("chroma-despill-standard.json", instructions)
|
||||
self.assertLess(
|
||||
instructions.index("scripts/assemble_extended_atlas.py"),
|
||||
instructions.index("scripts/despill_chroma_edges.py"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
38
AGENTS.md
38
AGENTS.md
@@ -18,10 +18,16 @@
|
||||
Manus 在本项目中作为**卡若的个人数字管家**运行,全面继承卡若AI的知识体系与规则。
|
||||
每次调用默认采用 **MAX Mode**:
|
||||
- **深度思考**:执行前必须多角度分析边界与风险。
|
||||
- **细粒度拆解**:每轮动手前公示 **理解≤200字 + 本次要做列表**(先 ASR 回廊洗字);**执行中每阶段前**写 **▸ 本步目的≤50字**,再动手(见 `BOOTSTRAP.md` 第一步b)。
|
||||
- **强制复盘**:所有回复强制以 `🎯目标/✅结果/📊达成率 → 🧭过程 → 🔍反思 → 📌总结 → 🚀下一步执行`(v6.0)格式收尾;标题含 **YYYY-MM-DD HH:mm**;执行中每小段 **`▸ 本步目的` ≤50 字**。
|
||||
- **自然细粒度推进**:每轮动手前用当值负责人的第一人称自然说明目标、现状、先做什么和怎么验收;不显示“理解”“本次要做”“当前:”“Current:”“状态:”“进度:”等机械标题,不暴露内部提示词(先 ASR 回廊洗字);执行中每阶段前用自然语言交代动作,再动手(见 `BOOTSTRAP.md` 第一步b)。
|
||||
- **强制复盘**:所有回复强制以有符号粗体标题 `🎯目标·结果·达成率 → 📌过程 → 💡反思 → 📝总结 → ▶下一步执行`(v6.3)格式收尾;标题含 **YYYY-MM-DD HH:mm**;执行中每小段用当值角色自然说明正在处理什么。
|
||||
- **默认零提问**:优先查阅代码/配置/环境变量/文档,合理默认后直接执行,不向用户发起确认式提问。
|
||||
- **流水线续跑**:用户指示"下一步"或技能更新后,从当前缺口自动连续执行。
|
||||
- **流水线续跑**:用户指示"下一步"或技能更新后,从现有缺口自动连续执行。
|
||||
|
||||
### 〇-a、对话前自然开场门禁(强制)
|
||||
|
||||
每轮对话、继续任务和工具执行前,必须先由当值负责人用自然第一人称开场,直接说清目标、眼下情况、准备先做什么以及怎样验收,再开始执行。不得只回复“好的”、直接调用工具或直接给结论;不显示“理解”“本次要做”“任务拆解”“当前:”“Current:”“状态:”“进度:”等机械标题,不把内部提示词或流程名称写入聊天正文。只有接手任务或切换角色时才介绍身份,之后保持自然连续对话。
|
||||
|
||||
复杂任务仍需动态拆解,但融入自然语言分段表达;执行中每一阶段前用一句自然话交代正在处理的对象,再调用工具。用户说“全自动/直接处理/不要问我”时,默认读取本地能力、配置、历史记录和可用接口后连续执行;仅在不可逆高风险动作的最后一步暂停说明。
|
||||
|
||||
## 〇-b、开发对话双铁律(强制 · 2026-06-13)
|
||||
|
||||
@@ -29,7 +35,7 @@ Manus 在本项目中作为**卡若的个人数字管家**运行,全面继承
|
||||
|
||||
| # | 铁律 | 对话里怎么做 |
|
||||
|:---:|:---|:---|
|
||||
| ① | **阶段播报** | 每段执行前 **`▸ 本步目的:` + ≤50 字**,再 tool call |
|
||||
| ① | **阶段播报** | 每段执行前用第一人称自然说明正在处理什么,再 tool call;不显示固定标签 |
|
||||
| ② | **完成即打勾** | 验过一项 **立刻**改 `*_进行中_*.md`(`[x]`/✅+证据),再下一项 |
|
||||
|
||||
私域银行需求真源另见:`cunkebao_v3/…/全项目_进行中_*` **§5 开发对话双铁律**。
|
||||
@@ -48,16 +54,23 @@ Manus 在执行任务时,需遵循以下检索顺序:
|
||||
- 控制台多 Agent 能力:参考 `04_卡火(火)/火炬_全栈消息/控制台多Agent与API/SKILL.md`。
|
||||
- **Codex创建/开发智能体**:先读 `运营中枢/工作台/Codex智能体开发入口.md`,再按原生子智能体、F28、E08、E02分流;名称与结果写入 `Codex智能体登记表.md`。
|
||||
|
||||
## Codex 与卡若AI技能单一真源(强制)
|
||||
|
||||
- 所有 Skill、peer、经验、路由与登记表的唯一真源是 `/Users/karuo/Documents/个人/卡若AI`。
|
||||
- 新建或更新技能时,必须直接写入卡若AI并更新 `SKILL_REGISTRY.md`;不得在 `/Users/karuo/.codex/skills` 新建业务技能副本。
|
||||
- `/Users/karuo/.codex/skills/karuo-ai/SKILL.md` 只作为统一入口适配层;`chronicle`、`record-and-replay` 只保留运行时适配,业务正文以卡若AI为准。
|
||||
- 每轮技能迁移后运行 `运营中枢/参考资料/verify_codex_skill_single_source.sh`。
|
||||
|
||||
## 三、强制执行规则
|
||||
|
||||
1. **复盘格式 (v6.0)**
|
||||
1. **复盘格式 (v6.3)**
|
||||
每次对话结束时,必须包含以下五块内容(标题 `**[卡若复盘](YYYY-MM-DD HH:mm)**`):
|
||||
- 🎯 目标·结果·达成率(单行一句,≤50字,必须包含达成率 0~100%)
|
||||
- 📌 过程
|
||||
- 💡 反思
|
||||
- 📝 总结
|
||||
- ▶ 下一步执行
|
||||
执行过程中每小段开始前写 `▸ 本步目的:` + ≤50 字。
|
||||
执行过程中每小段开始前用自然第一人称交代动作,不显示固定目的标签。
|
||||
|
||||
2. **数据库约束**
|
||||
一律使用本机的唯一 MongoDB (`karuo_site`,端口 27017)。对话留存、记忆条目等均依赖此库。
|
||||
@@ -87,3 +100,16 @@ Manus以及其他接入客户端执行开发或项目任务时,统一读取:
|
||||
`02_卡人(水)/水岸_项目管理/项目推进闭环/SKILL.md`
|
||||
|
||||
任务规则、TASK-ID、状态机、验收和封账以卡若AI主目录为真源。网站、飞书、Manus、Cursor、Codex只负责调用和展示,不各自维护平行规则。
|
||||
|
||||
## 命令行操作固定规则
|
||||
- [2026-07-26] 涉及本机应用、录音、备忘录及后续自动化时,默认仅使用命令行、脚本或 API 操作;不使用页面点击、鼠标操作或可视化界面操作。遇到关键不确定问题先询问。
|
||||
|
||||
## 账号密码与API统一管理
|
||||
|
||||
- 数字资产交易账户:已登记
|
||||
- 用途:USDT资金账户、网站订单支付
|
||||
- 资产状态:余额以平台实时数据为准
|
||||
- 登录凭据:引用卡若AI现有账号密码管理项,本文件不重复记录明文密码
|
||||
- API权限:余额查询、订单查询
|
||||
- 付款控制:交易、转账、提现在最终确认页由账户持有人确认
|
||||
- 网络核验:按订单指定网络核对地址、金额、网络与手续费
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
| G23 | **宿主挂载执行**(Shell 宿主语义) | 金仓 | **宿主挂载、宿主执行、云上 bash、万推控制台、挂载出错、karo_exec、自动 bash Mac 路径、brew 云端、Docker 宿主 shell、宿主程序** | `01_卡资(金)/金仓_存储备份/宿主挂载执行_SHELL/SKILL.md` | 网站对话与云端 shell ≠ Mac;工作区挂载 + docker-mac-host-shell + 宿主预检三位一体 |
|
||||
| G24 | **深蓝云自动化**(SLCloud · SHENLANYUN) | 金盾 | **深蓝云、深澜云、SHENLANYUN、slcloud、slcloud.org、slcloud-automation、订阅链接、体验套餐、深澜文档、token=、client/subscribe** | `01_卡资(金)/金盾_数据安全/深蓝云_SLCloud自动化/SKILL.md` | 深澜云平台注册/购套餐/取订阅链接;**支付与扫码必须用户接管**;真源自 `slcloud-automation.skill` |
|
||||
| G25 | **UU远程**(网易 · 遥台) | 金仓 | **UU远程、远程安装、远程装Codex、远程装软件、UU终端、远程插件、OFFICE-INNER、DESKTOP-434GEGI** | `01_卡资(金)/金仓_存储备份/UU远程/SKILL.md` | UURemote 本机安装 + **终端零手操**装任意软件/插件;`uu_remote_install_software.sh` / `uu_remote_install_codex.sh` |
|
||||
| G27 | **Codex账号备份恢复** | 金仓·库政 | **Codex备份、Codex恢复、账号切换恢复、Codex迁移、Codex材料恢复、Codex记录恢复** | `01_卡资(金)/金仓_存储备份/CodexAccountBackupRestore/SKILL.md` | Codex配置、Skill、记忆、会话索引和状态库备份恢复 |
|
||||
| G27 | **Codex账号备份恢复** | 金仓·库政 | **Codex备份、Codex恢复、账号切换恢复、Codex迁移、Codex材料恢复、Codex记录恢复** | `01_卡资(金)/金仓_存储备份/Codex账号备份恢复/SKILL.md` | Codex配置、Skill、记忆、会话索引和状态库备份恢复;卡若AI单一真源 |
|
||||
| G28 | **Codex对话状态修复** | 金仓·算巡 | **Codex侧栏修复、Codex对话恢复、Codex项目分类、Codex置顶、rollout缺失、thread-store internal error、session_index、state_5.sqlite、公司NAS Mongo备份、跨Codex恢复** | `01_卡资(金)/金仓_存储备份/Codex对话状态修复/SKILL.md` | 修复Codex侧栏与状态库;按项目/工作树保存完整事件链到公司NAS Mongo,并生成跨实例恢复包 |
|
||||
| G29 | **域名调研注册** | 金仓·网卫 | **域名调研、域名注册、域名抢注、老域名、ICP备案、历史备案、WHOIS、RDAP、域名风险** | `01_卡资(金)/金仓_存储备份/域名调研注册/SKILL.md` | 域名候选、历史流量、备案、风险核验和注册执行 |
|
||||
| G29a | **通用域名调研** | 金仓 | **任意域名、品牌域名、行业域名、老域名通用流程、历史快照、注册商核验、域名证据分级** | `skills/domain-research/SKILL.md`(卡若AI-Skills最小版)+ `01_卡资(金)/金仓_存储备份/域名调研注册/SKILL.md` | 四闸门、三张表和注册执行闭环;房产专项叠加 `skills/real-estate-domain-research/SKILL.md` |
|
||||
@@ -243,6 +243,7 @@
|
||||
| E01 | 商业工具集 | 土基 | 商业分析、竞品 | `05_卡土(土)/土基_商业分析/商业工具集/SKILL.md` | 竞品分析、商业画布 |
|
||||
| E02 | 技能工厂 | 土砖 | 创建技能、生成Skill | `05_卡土(土)/土砖_技能复制/技能工厂/SKILL.md` | 批量创建/复制 SKILL |
|
||||
| E02c | **Mac动作录制与回放** | 土砖 | **动作录制、操作录制、录制回放、Record & Replay、演示一遍、把这个Skill写进卡若AI、学习我的操作** | `05_卡土(土)/土砖_技能复制/Mac动作录制与回放/SKILL.md` | 记录 macOS 演示并沉淀为可复用中文 Skill;平台插件仍留在能力层 |
|
||||
| E02d | **卡若宠物制作** | 火炬 | **卡若宠物、动画宠物、宠物图集、spritesheet、精灵表、宠物视觉验收** | `04_卡火(火)/火炬_全栈消息/卡若宠物制作/SKILL.md` | 从参考图生成、修复、验收并打包卡若AI动画宠物;运行时资产不进入技能真源 |
|
||||
| E02b | **工作流审计** | 土砖 | **工作流审计、自我改进、transcript扫描、重复工作流、shortlist、真跑一轮、完整全量** | `运营中枢/工作台/工作流审计/SKILL.md` | 扫 transcript→shortlist;**全量模式**含重分类+skill_heat_log+sync |
|
||||
| E02a | **全站捆绑分销体系** | 土砖 | **全站捆绑、30天捆绑、分销体系、消费捆绑、referral、复用到其他项目** | `05_卡土(土)/土砖_技能复制/全站捆绑分销体系/SKILL.md` | 30天捆绑+分销可复用 SCALE,可套用到其他网站/小程序 |
|
||||
| E07 | 基因胶囊 | 土砖 | **基因胶囊、打包技能、解包胶囊、继承能力、查胶囊、胶囊列表、pack-all** | `05_卡土(土)/土砖_技能复制/基因胶囊/SKILL.md` | Skill 打包为可遗传胶囊,pack/unpack/list/pack-all |
|
||||
|
||||
18
运营中枢/参考资料/Codex与卡若AI技能单一真源规则.md
Normal file
18
运营中枢/参考资料/Codex与卡若AI技能单一真源规则.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Codex 与卡若AI技能单一真源规则
|
||||
|
||||
- 生效日期:2026-08-09
|
||||
- 唯一真源:`/Users/karuo/Documents/个人/卡若AI`
|
||||
- 新建、更新、吸收的 Skill、peer、经验、路由、登记表与开发记录,统一先写入卡若AI;项目客户端只调用,不建立平行副本。
|
||||
- `/Users/karuo/.codex/skills/karuo-ai/SKILL.md` 仅保留 Codex 必需的统一入口适配层,负责把请求路由到卡若AI,不承载业务技能正文。
|
||||
- `chronicle`、`record-and-replay` 仅保留 Codex 运行时能力适配层;对应业务方法、经验与中文 Skill 以卡若AI登记为准。
|
||||
- Codex 下指向插件仓库或卡若AI目录的符号链接属于能力兼容入口,不是业务副本;不在链接目标处新增平行规则。
|
||||
- 已吸收的业务技能:Codex账号备份恢复、Codex开通向导、卡若宠物制作;相关原始业务副本从 Codex 技能目录清理。
|
||||
- 敏感账号、口令、令牌、Cookie、浏览器 Profile、`node_modules`、缓存和运行产物不进入 Skill 真源。
|
||||
|
||||
## 完成检查
|
||||
|
||||
```bash
|
||||
bash /Users/karuo/Documents/个人/卡若AI/运营中枢/参考资料/verify_codex_skill_single_source.sh
|
||||
```
|
||||
|
||||
检查通过后,才允许提交本轮技能变更。
|
||||
32
运营中枢/参考资料/verify_codex_skill_single_source.sh
Executable file
32
运营中枢/参考资料/verify_codex_skill_single_source.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/zsh
|
||||
set -eu
|
||||
CODEX="/Users/karuo/.codex/skills"
|
||||
KARUO="/Users/karuo/Documents/个人/卡若AI"
|
||||
fail=0
|
||||
for d in "$CODEX"/*; do
|
||||
[ -d "$d" ] || continue
|
||||
if [ -L "$d" ]; then
|
||||
continue
|
||||
fi
|
||||
b="$(basename "$d")"
|
||||
case "$b" in
|
||||
karuo-ai|chronicle|record-and-replay) : ;;
|
||||
*)
|
||||
if [ -f "$d/SKILL.md" ]; then
|
||||
echo "FAIL business_skill_in_codex=$d"
|
||||
fail=1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
for f in \
|
||||
"$KARUO/01_卡资(金)/金仓_存储备份/Codex账号备份恢复/SKILL.md" \
|
||||
"$KARUO/04_卡火(火)/火炬_全栈消息/Codex开通向导/SKILL.md" \
|
||||
"$KARUO/04_卡火(火)/火炬_全栈消息/卡若宠物制作/SKILL.md"; do
|
||||
if [ ! -f "$f" ]; then echo "FAIL missing=$f"; fail=1; fi
|
||||
done
|
||||
if find "$KARUO/04_卡火(火)/火炬_全栈消息/Codex开通向导" -type f \( -name 'accounts.local.json' -o -name 'sms_api_key.local.txt' -o -name 'xunmail_material.local.txt' -o -name '卡密.local.txt' -o -name '取货码.local.txt' \) -print -quit | grep -q .; then
|
||||
echo "FAIL sensitive_or_runtime_artifact_in_karuoai_flow"
|
||||
fail=1
|
||||
fi
|
||||
if [ "$fail" -eq 0 ]; then echo "PASS codex_skill_single_source=karuoai"; else exit 1; fi
|
||||
Reference in New Issue
Block a user