From 2962154d6932be4e977923e7672f1e6ae980a582 Mon Sep 17 00:00:00 2001 From: hua zhuang <30758919@qq.com> Date: Thu, 21 May 2026 10:49:06 +0800 Subject: [PATCH] Initial commit: ComfyUI-AgeShift - age shift node pack --- .gitignore | 26 + CHANGELOG.md | 62 +++ CONTRIBUTING.md | 92 ++++ GITHUB_PUBLISHING.md | 134 +++++ INSTALL_NOTES.md | 263 ++++++++++ LICENSE | 21 + PROMPTS.md | 235 +++++++++ README.md | 393 ++++++++++++++ __init__.py | 22 + cli/README.md | 82 +++ cli/age_shift_cli.py | 543 ++++++++++++++++++++ example_workflows/age_detect_only.json | 111 ++++ example_workflows/age_shift_pulid_flux.json | 316 ++++++++++++ nodes/__init__.py | 0 nodes/_types.py | 141 +++++ nodes/detect.py | 82 +++ nodes/detector_loader.py | 69 +++ nodes/prompt_builder.py | 167 ++++++ pyproject.toml | 36 ++ requirements.txt | 3 + 20 files changed, 2798 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 GITHUB_PUBLISHING.md create mode 100644 INSTALL_NOTES.md create mode 100644 LICENSE create mode 100644 PROMPTS.md create mode 100644 README.md create mode 100644 __init__.py create mode 100644 cli/README.md create mode 100644 cli/age_shift_cli.py create mode 100644 example_workflows/age_detect_only.json create mode 100644 example_workflows/age_shift_pulid_flux.json create mode 100644 nodes/__init__.py create mode 100644 nodes/_types.py create mode 100644 nodes/detect.py create mode 100644 nodes/detector_loader.py create mode 100644 nodes/prompt_builder.py create mode 100644 pyproject.toml create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d4824e --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +.env +.idea/ +.vscode/ +.DS_Store + +# build / dist +build/ +dist/ +*.egg-info/ +*.tar.gz + +# CLI runtime +out/ +output/ +*.dump.json +*.detect.json +*.shift_age*.json + +# models (downloaded to HF cache, not committed) +models/ +hf_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fa7094b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to this project will be documented in this file. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.1] - 2026-05-21 + +### Documentation +- New `PROMPTS.md`: complete reference of all 20 age-range feature mappings, + full positive/negative prompt examples at ages 5/15/25/45/65/80, and + documentation of the anti-drift negative-prompt logic. +- Rewrote `README.md` with badges, pipeline diagram, identity-preservation + tuning guide, and a real-world troubleshooting section. +- Expanded `INSTALL_NOTES.md` with the InsightFace `antelopev2` manual + placement requirement (the double-`models` directory quirk) and a + self-check shell script. + +### Notes +No code changes in this release — just documentation. Existing installations +need no action. + +## [0.1.0] - 2026-05-21 + +### Added +- Initial release. +- `AgeDetectorLoader` — loads HF ViT age-classification models with an + in-process `_MODEL_CACHE` keyed by `model_id|device|dtype`. +- `AgeDetect` — runs age estimation on a ComfyUI IMAGE tensor; computes + continuous age via weighted mean over bin midpoints (or argmax of bin + centers if `method=argmax_midpoint`). Returns `(age:INT, band:STRING, + confidence:FLOAT, summary:STRING)`. +- `AgePromptBuilder` — emits paired positive/negative prompts with concrete + age-feature descriptors (e.g. "deep nasolabial folds, salt-and-pepper hair") + rather than just a numeric age. Optional anti-drift negative prompts auto- + inject contextual repulsion (e.g. for target=65, adds "baby face, teenage + features, youthful smooth skin" to the negative). +- `AgeRangeBatch` — emits JSONL prompts for a comma-separated list of target + ages, consumed by the CLI for batch sequence generation. +- `cli/age_shift_cli.py` — end-to-end CLI: upload → detect → PuLID-Flux gen. + Supports `--detect-only`, single `--target-age`, batched `--target-ages`, + full per-knob control over PuLID weight / Flux steps / guidance / seed, + and `--dump-workflow` for debugging. + Uses PEP 723 inline-script metadata so `uv run --script` handles deps + with no manual `pip install`. +- Example workflows: `age_detect_only.json` (UI, 6 nodes) and + `age_shift_pulid_flux.json` (UI, 17 nodes with grouped layout). +- `INSTALL_NOTES.md` documenting server-side install on Ubuntu 22.04 + + dual RTX 3090 + CUDA 12.6 + Python 3.13. + +### Known limitations +- `AgeDetect` runs whole-image classification — input should be face-cropped. +- Image generation requires separately installing ComfyUI-PuLID-Flux and + Flux 1 dev / schnell weights. +- Aging-down (adult → child) is harder than aging-up because cranial + proportions change substantially. For child direction, manually lower + `--pulid-weight` to 0.75–0.80. Consider face-landmark ControlNet for + future versions. +- The `subject` widget is a single value across all ages in the batch CLI, + which produces awkward phrasing like "5 year old male man". Workaround: + call AgePromptBuilder once per age from a higher-level orchestrator with + the appropriate subject noun. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..aeb6dc7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,92 @@ +# Contributing + +Thanks for your interest in ComfyUI-AgeShift! This project follows the same +patterns as the rest of the `aadebuger` ComfyUI node packages. + +## Dev setup + +```bash +git clone https://github.com/aadebuger/ComfyUI-AgeShift.git +cd ComfyUI-AgeShift + +# install into your ComfyUI venv +source /path/to/ComfyUI/.venv/bin/activate +uv pip install -e . +``` + +Restart ComfyUI; the four nodes should appear under category `🎂 AgeShift`. + +## Repo layout + +``` +ComfyUI-AgeShift/ +├── __init__.py # NODE_CLASS_MAPPINGS / DISPLAY_NAME_MAPPINGS +├── nodes/ +│ ├── __init__.py # empty package marker +│ ├── _types.py # shared constants + age-feature mapping +│ ├── detector_loader.py # _MODEL_CACHE pattern +│ ├── detect.py +│ └── prompt_builder.py +├── cli/ +│ ├── README.md +│ └── age_shift_cli.py # ComfyClient + build_workflow + main +└── example_workflows/ + ├── age_detect_only.json + └── age_shift_pulid_flux.json +``` + +## Adding a new node + +1. Drop a new file in `nodes/yourname.py`. Use this skeleton: + + ```python + class YourNode: + @classmethod + def INPUT_TYPES(cls): + return {"required": {...}, "optional": {...}} + + RETURN_TYPES = (...) + RETURN_NAMES = (...) + FUNCTION = "run" + CATEGORY = "🎂 AgeShift" + + def run(self, ...): + return (...) + ``` + +2. Register it in the top-level `__init__.py`: + + ```python + from .nodes.yourname import YourNode + NODE_CLASS_MAPPINGS["YourNode"] = YourNode + NODE_DISPLAY_NAME_MAPPINGS["YourNode"] = "🎂 Your Node" + ``` + +3. Update `cli/age_shift_cli.py` if the new node should be CLI-driven; add a + `build_*_workflow()` function. + +4. Add an example workflow under `example_workflows/`. + +5. Bump `CHANGELOG.md`. + +## Coding conventions + +- **Lazy-import heavy deps** (torch, transformers, PIL) inside the node method, + not at module top-level. Keeps ComfyUI startup fast. +- **Use `_MODEL_CACHE` for loaders**, keyed by all input args. Implement + `IS_CHANGED` to reflect that. +- **Strings, not enums for HF model IDs** — gives users a way to swap in + alternate models without code edits. +- Category prefix is always `🎂 AgeShift`. +- Display names always start with `🎂` for searchability. + +## Pull requests + +Small focused PRs preferred. Please include: +- A short description of what changed and why. +- A `CHANGELOG.md` entry under `[Unreleased]`. +- If a new node: an example workflow. + +## License + +By contributing, you agree your contributions are licensed under MIT. diff --git a/GITHUB_PUBLISHING.md b/GITHUB_PUBLISHING.md new file mode 100644 index 0000000..5ff6cd9 --- /dev/null +++ b/GITHUB_PUBLISHING.md @@ -0,0 +1,134 @@ +# GitHub Publishing Cheat Sheet + +发布到 `github.com/aadebuger/ComfyUI-AgeShift` 时的素材模板。 + +--- + +## Repository About (一行描述) + +``` +Face age detection and identity-preserved age-shift portraits for ComfyUI — pairs with PuLID-Flux +``` + +(或更短:`ComfyUI nodes for face age detection and age-shifted portraits via PuLID-Flux`) + +## Topics(逗号分隔) + +``` +comfyui, comfyui-nodes, age-detection, age-progression, face-aging, pulid, flux, vit, huggingface, image-generation, ai +``` + +--- + +## Release v0.1.1 notes(复制到 GitHub Release 描述) + +```markdown +## ComfyUI-AgeShift v0.1.1 — Documentation release + +Documentation polish + complete prompt reference. No code changes; existing installs need no action. + +### What's new +- 📖 **New PROMPTS.md** — every auto-generated prompt across all 20 age ranges, with full positive/negative examples at ages 5/15/25/45/65/80 and an explanation of the anti-drift negative logic. +- 🩹 **Troubleshooting section** added to README covering the four bugs commonly hit during first install: missing PuLID-Flux deps, InsightFace `antelopev2` path quirks, `websocket` vs `websocket-client` naming, and identity-drift tuning. +- 📝 **INSTALL_NOTES.md expanded** with the `antelopev2` manual placement step — the InsightFace lib requires a double `models/` directory (`models/insightface/models/antelopev2/`) which is not obvious. +- 🎯 **Identity preservation tuning guide** in README: when to bump `--pulid-weight`, when to drop guidance, and how to handle aging-down to child direction. + +### Background + +This release captures the lessons from the first end-to-end deployment on Ubuntu 22.04 + dual RTX 3090. See INSTALL_NOTES.md for the full record. +``` + +--- + +## Release v0.1.0 notes(初始发布,可选) + +```markdown +## ComfyUI-AgeShift v0.1.0 — Initial release + +Four ComfyUI nodes plus a CLI for face age detection and age-shifted portrait generation. Designed to pair with ComfyUI-PuLID-Flux for identity preservation. + +### Nodes +- 🎂 **AgeDetectorLoader** — caches a HuggingFace ViT age classifier (default: `nateraw/vit-age-classifier`) +- 🎂 **AgeDetect** — continuous age estimation via weighted mean over bin midpoints +- 🎂 **AgePromptBuilder** — translates target age into concrete feature prompts ("deep nasolabial folds, salt-and-pepper hair") plus anti-drift negatives +- 🎂 **AgeRangeBatch** — JSONL output for batch age-progression sequences + +### CLI +- End-to-end `age_shift_cli.py`: upload → detect → PuLID-Flux generation +- Supports `--detect-only`, single `--target-age`, or batched `--target-ages 5,15,25,45,65` +- PEP 723 inline metadata — no manual `pip install` needed when invoked with `uv run --script` + +### Requirements +- ComfyUI +- (Optional, for image generation) ComfyUI-PuLID-Flux + Flux 1 dev/schnell weights + +See README.md for full installation and quick-start. +``` + +--- + +## 发布步骤(沿用 aadebuger 节奏) + +```bash +cd /path/to/local/ComfyUI-AgeShift + +git init +git add . +git commit -m "v0.1.1: Documentation release with complete PROMPTS.md + +- Add PROMPTS.md listing every auto-generated prompt across 20 age ranges +- Add troubleshooting section to README (PuLID deps, antelopev2 paths, + websocket-client naming, identity drift tuning) +- Expand INSTALL_NOTES.md with InsightFace antelopev2 manual placement +- Add identity preservation tuning guide" + +# 在 github.com/new 创建 aadebuger/ComfyUI-AgeShift +git remote add origin git@github.com:aadebuger/ComfyUI-AgeShift.git +git branch -M main +git push -u origin main + +git tag v0.1.1 +git push origin v0.1.1 +``` + +发布后: +- 仓库 About 粘上面那行 +- Topics 粘上面那串 +- Release 用 v0.1.1 那段 markdown +- 第一个 star 后考虑提 PR 到 + https://github.com/ltdrdata/ComfyUI-Manager 的 `custom-node-list.json` + +## ComfyUI-Manager 注册条目模板 + +提 PR 时往 `custom-node-list.json` 加这个对象: + +```json +{ + "author": "aadebuger", + "title": "ComfyUI-AgeShift", + "id": "comfyui-ageshift", + "reference": "https://github.com/aadebuger/ComfyUI-AgeShift", + "files": [ + "https://github.com/aadebuger/ComfyUI-AgeShift" + ], + "install_type": "git-clone", + "description": "Face age detection (ViT) and age-conditioned prompt building. Pairs with PuLID-Flux for identity-preserving age-shift portraits — from infant to 90+." +} +``` + +--- + +## Screenshots TODO + +`README.md` 现在没有截图。发布时强烈建议放上: +- `docs/example_progression.png` — 一张源图 + 5/15/25/45/65 五张生成的拼图 +- `docs/node_palette.png` — ComfyUI 节点 palette 截图,显示 4 个 🎂 节点 +- `docs/workflow_screenshot.png` — `age_shift_pulid_flux.json` 加载到 UI 的样子 + +可以放 `docs/` 目录,然后在 README 的 Features 章节后面 / Quick start 章节前面插入: + +```markdown +![Age progression example](docs/example_progression.png) +``` + +Hua 跑出第一张满意的样图后,扔进 `docs/` 直接 push,这页就完整了。 diff --git a/INSTALL_NOTES.md b/INSTALL_NOTES.md new file mode 100644 index 0000000..2e3e017 --- /dev/null +++ b/INSTALL_NOTES.md @@ -0,0 +1,263 @@ +# INSTALL_NOTES + +实战安装记录(脱敏)。环境:Ubuntu 22.04 + 双 RTX 3090 + CUDA 12.6 + +Python 3.13 + torch 2.11.0+cu126,ComfyUI 装在 `/mnt/modelall/ComfyUI/`, +uv 管理 venv。 + +> 这份文档按"从零到第一张图"的实际顺序写,包含每一步可能踩到的坑。如果在 +> 别的机器上装,把 `/mnt/modelall` 替换成你的路径即可。 + +--- + +## 1. 安装 AgeShift 节点包 + +```bash +cd /mnt/modelall/ComfyUI/custom_nodes +git clone https://github.com/aadebuger/ComfyUI-AgeShift.git +cd ComfyUI-AgeShift + +source /mnt/modelall/ComfyUI/.venv/bin/activate +export HF_HOME=/mnt/modelall/hf_cache # 关键 — 主盘只有 10GB +uv pip install -r requirements.txt +``` + +依赖很轻(`transformers / pillow / numpy`),几乎一定已经在 ComfyUI venv 里了。 + +--- + +## 2. 安装 ComfyUI-PuLID-Flux + +```bash +cd /mnt/modelall/ComfyUI/custom_nodes +git clone https://github.com/balazik/ComfyUI-PuLID-Flux.git +cd ComfyUI-PuLID-Flux +uv pip install -r requirements.txt --no-cache-dir +``` + +主要拉:`facexlib / onnxruntime-gpu / insightface / timm / ftfy`。 + +### ⚠️ 容易踩的坑 + +**坑 1:`onnxruntime-gpu` 版本** + +CUDA 12.6 需要 `onnxruntime-gpu >= 1.18`。装完确认: + +```bash +uv pip show onnxruntime-gpu | grep -i version +``` + +**坑 2:`insightface` 编译失败** + +```bash +# 如果报 Cython 缺失 +uv pip install cython +uv pip install insightface --no-build-isolation +``` + +--- + +## 3. 下载模型权重 + +### Flux 主模型(选一个) + +| 选项 | 文件 | 大小 | 备注 | +|---|---|---|---| +| **dev fp8(推荐)** | `flux1-dev-fp8.safetensors` | ~12 GB | 无 license,质量好 | +| dev 全精度 | `flux1-dev.safetensors` | ~23 GB | gated,需 HF token | +| schnell | `flux1-schnell.safetensors` | ~24 GB | 开放,4 步快,PuLID 效果一般 | + +```bash +# 方案 A: dev fp8(最推荐) +cd /mnt/modelall/ComfyUI/models/unet +wget "https://huggingface.co/Kijai/flux-fp8/resolve/main/flux1-dev-fp8.safetensors" + +# 方案 B: dev 全精度 +huggingface-cli login # 粘贴 HF token,需要先在网页同意 license +huggingface-cli download black-forest-labs/FLUX.1-dev flux1-dev.safetensors \ + --local-dir /mnt/modelall/ComfyUI/models/unet/ +``` + +### Flux 配套(CLIP + VAE) + +```bash +# 如果还没装 Flux 配套权重 +cd /mnt/modelall/ComfyUI/models +huggingface-cli download MaxedOut/ComfyUI-Starter-Packs Flux1/clip/clip_l.safetensors \ + --local-dir hf_cache/manual/ +# 类似下载 t5xxl_fp8_scaled.safetensors, ae.safetensors,然后 symlink 进 models/clip/ models/vae/ + +# 实际项目里 Hua 的环境已经有,通过 symlink 指向 MaxedOut starter pack: +ls -lh /mnt/modelall/ComfyUI/models/clip/ +# clip_l.safetensors -> /mnt/modelall/hf_cache/.../Flux1/clip/clip_l.safetensors +# t5xxl_fp8_scaled.safetensors -> ... +``` + +### PuLID-Flux 权重(~1.1 GB) + +```bash +mkdir -p /mnt/modelall/ComfyUI/models/pulid +cd /mnt/modelall/ComfyUI/models/pulid +wget "https://huggingface.co/guozinan/PuLID/resolve/main/pulid_flux_v0.9.1.safetensors" +``` + +### ⚠️ InsightFace antelopev2(关键步骤,容易漏) + +PuLID-Flux 用 `antelopev2` 不是默认的 `buffalo_l`,**不会自动下载**。 +必须手动放,而且目录结构要正确(双层 `models`,反直觉但 InsightFace 库 +就是这么要求的): + +```bash +cd /mnt/modelall/ComfyUI/models/insightface +mkdir -p models && cd models + +# 从 GitHub releases +wget "https://github.com/deepinsight/insightface/releases/download/v0.7/antelopev2.zip" +unzip antelopev2.zip && rm antelopev2.zip + +# 验证目录结构 +ls -lh antelopev2/ +# 必须有 5 个文件: +# 1k3d68.onnx 2d106det.onnx genderage.onnx glintr100.onnx scrfd_10g_bnkps.onnx +``` + +**最终路径必须是**:`/mnt/modelall/ComfyUI/models/insightface/models/antelopev2/` + +(注意两层 `models`。少一层会触发 `AssertionError: assert 'detection' in self.models`。) + +如果 GitHub releases 拉不动: + +```bash +# 方案 1: HF 镜像 +huggingface-cli download MonsterMMORPG/tools antelopev2.zip --local-dir /tmp/ +unzip /tmp/antelopev2.zip -d /mnt/modelall/ComfyUI/models/insightface/models/ + +# 方案 2: 走 GitHub 代理 +wget "https://ghproxy.com/https://github.com/deepinsight/insightface/releases/download/v0.7/antelopev2.zip" +``` + +### EVA02-CLIP-L(自动下载,不用管) + +PuLID-Flux 节点首次运行时自动从 HF 拉 `EVA02_CLIP_L_336_psz14_s6B.pt`(~430 MB) +到 `$HF_HOME`。第一张图会卡 1-2 分钟,之后从 cache 走。 + +### Age 检测器(自动下载,不用管) + +`nateraw/vit-age-classifier`(~330 MB)首次运行从 HF 拉到 `$HF_HOME`。 + +--- + +## 4. 启动 ComfyUI(模板) + +```bash +pkill -9 -f main.py; sleep 3 +cd /mnt/modelall/ComfyUI && source .venv/bin/activate +export HF_HOME=/mnt/modelall/hf_cache # 关键!别忘 +nohup python main.py --listen 0.0.0.0 --port 8188 > ~/comfy_start.log 2>&1 & +sleep 35 +grep -iE "IMPORT FAILED|pulid|AgeShift|error" ~/comfy_start.log | head -20 +``` + +预期:看到 PuLID-Flux 和 AgeShift 都被加载,没有 `IMPORT FAILED`。 + +--- + +## 5. 验证所有节点都注册了 + +```bash +curl -s http://127.0.0.1:8188/object_info | python3 -c " +import json,sys +d = json.load(sys.stdin) +need = ['AgeDetectorLoader','AgeDetect','AgePromptBuilder','AgeRangeBatch', + 'PulidFluxModelLoader','PulidFluxEvaClipLoader', + 'PulidFluxInsightFaceLoader','ApplyPulidFlux'] +for n in need: + print(f' {n}:', '✓' if n in d else '✗') +print(f' total: {len(d)}')" +``` + +8 个都打 ✓ 才能跑完整 pipeline。 + +--- + +## 6. 客户端 CLI + +CLI 自带 PEP 723 inline metadata,uv 会自动管依赖。**不用 pip install 任何东西**: + +```bash +# Mac 本地 +cd /path/to/ComfyUI-AgeShift + +# 单图检测(~2 秒) +uv run --script cli/age_shift_cli.py face.jpg --detect-only --ip 10.8.0.12 + +# 完整流程 +uv run --script cli/age_shift_cli.py face.jpg \ + --target-ages 5,15,25,45,65 \ + --gender male --subject man \ + --flux-unet flux1-dev-fp8.safetensors \ + --flux-t5 t5xxl_fp8_scaled.safetensors \ + --pulid-weight 0.95 --guidance 3.0 --steps 28 --seed 42 \ + --ip 10.8.0.12 --out ./out +``` + +`--flux-t5 t5xxl_fp8_scaled.safetensors` 是因为 MaxedOut Starter Pack 给的是 +scaled 版,默认 CLI 里写的是 `e4m3fn`。其他 Flux 文件名也类似,以你服务器 +`ls models/unet/` 实际看到的为准。 + +--- + +## 7. 历史踩过的坑汇总 + +| 坑 | 表现 | 解决 | +|---|---|---| +| 主盘 HF cache 撑满 | 模型下到一半失败 | **必须** `export HF_HOME=/mnt/modelall/hf_cache` | +| `processor` 类名不一致 | `ImportError: cannot import name 'ViTImageProcessor'` | 节点里改用 `AutoImageProcessor`(已修) | +| CPU 上跑 fp16 | NaN 或 warn | loader 强制 CPU=fp32(已修) | +| `image` tensor 是 fp16 | `(... * 255).astype(uint8)` 越界 | detect 里 `.float()` 兜底(已修) | +| `websocket` 包名错装 | `ModuleNotFoundError: websocket` | 装的是 `websocket-client`,不是 `websocket` | +| PuLID 节点目录在但不注册 | curl object_info 看不到 | 多半是 facexlib/onnxruntime-gpu 缺,装 requirements | +| `assert 'detection' in self.models` | InsightFace 启动失败 | antelopev2 路径错(必须双层 `models`)或文件不全(5 个 onnx 缺一不可) | +| 生成的脸不像源图 | 身份漂移 | 见 README 的 identity preservation tuning 章节 | +| 5 岁生成出"小型成人" | child 方向用了 adult 默认参数 | child 方向降 `--pulid-weight 0.75` | + +--- + +## 8. CLI 自检 checklist(给新机器用) + +```bash +SERVER=10.8.0.12:8188 + +# 0. 服务器在跑 +curl -s -o /dev/null -w "HTTP %{http_code}\n" http://$SERVER/ + +# 1. 节点都注册了 +curl -s http://$SERVER/object_info | python3 -c " +import json, sys +d = json.load(sys.stdin) +for n in ['AgeDetectorLoader','AgeDetect','AgePromptBuilder','AgeRangeBatch', + 'PulidFluxModelLoader','ApplyPulidFlux']: + print(f' {n}: {\"✓\" if n in d else \"✗\"}')" + +# 2. 模型文件齐 +for f in models/unet/flux1-dev-fp8.safetensors \ + models/clip/clip_l.safetensors \ + models/clip/t5xxl_fp8_scaled.safetensors \ + models/vae/ae.safetensors \ + models/pulid/pulid_flux_v0.9.1.safetensors \ + models/insightface/models/antelopev2/scrfd_10g_bnkps.onnx; do + if [ -e "/mnt/modelall/ComfyUI/$f" ]; then echo " ✓ $f"; else echo " ✗ $f MISSING"; fi +done + +# 3. 端到端检测 +uv run --script cli/age_shift_cli.py test.jpg --detect-only --ip $SERVER +``` + +预期最终输出: +``` +[1/3] Uploading test.jpg → 10.8.0.12:8188 ... + uploaded as: test.jpg +[2/3] Detecting age ... + prompt_id=... + → Estimated age: 34 (adult) | top bin: 30-39 (0.62) | top-3: 30-39=0.62, ... +Done (detect only). +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be1c276 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 aadebuger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PROMPTS.md b/PROMPTS.md new file mode 100644 index 0000000..bf2bbaa --- /dev/null +++ b/PROMPTS.md @@ -0,0 +1,235 @@ +# Prompt Reference + +This document lists **every prompt** the `AgePromptBuilder` and `AgeRangeBatch` +nodes produce, so you can see what's going into Flux / SDXL and customize +where needed. + +The core insight: **Flux responds far better to concrete physical descriptors +than to age numbers.** Compare: + +| Naive | What AgeShift does | +|---|---| +| `a 70 year old man` | `a 70 year old man, late sixties, deep facial wrinkles, gray or white hair often thinning, sagging jowls, age spots, looser skin` | +| `a 5 year old child` | `a 5 year old male boy, young toddler, round face, big bright eyes, smooth soft skin, baby teeth, innocent expression` | + +The numeric age is kept (for cases where the model has been trained on +"X year old" phrasing) but the concrete features carry most of the signal. + +--- + +## How the prompt is assembled + +`AgePromptBuilder` joins these parts in order: + +``` +positive = subject + features(age) + style + extra_positive +negative = negative_base + anti_age_drift(age) +``` + +Where: +- **subject**: `a {target_age} year old {gender }{subject_noun}` — e.g. `a 25 year old male man` +- **features(age)**: looked up from the table below +- **style**: user-controllable, defaults to `photorealistic portrait, sharp focus, natural studio lighting, neutral background` +- **extra_positive**: user-controllable, defaults to `same identity, same facial structure, same eye shape` +- **negative_base**: anti-cartoon/blur boilerplate +- **anti_age_drift**: contextual anti-prompts depending on the target band (see below) + +--- + +## Age → feature mapping (full) + +The internal `age_features(age: int) -> str` function picks one of 20 ranges: + +| Age range | Band | Features | +|---|---|---| +| **0–2** | infant | infant, round soft chubby face, large innocent eyes, very smooth skin, tiny features, fine wispy hair | +| **3–5** | child | young toddler, round face, big bright eyes, smooth soft skin, baby teeth, innocent expression | +| **6–9** | child | young child, round youthful face, bright clear eyes, perfectly smooth skin, no facial hair, soft fine hair | +| **10–12** | child | pre-teen child, slightly more defined features, clear smooth skin, bright eyes, youthful softness, full thick hair | +| **13–15** | teenager | early teenager, transitional features, fresh smooth skin, occasional minor blemishes, youthful face shape, thick hair | +| **16–19** | teenager | late teenager, defined youthful features, smooth clear skin, full thick hair, sharp jawline beginning to form, no wrinkles | +| **20–24** | young adult | young adult in early twenties, smooth taut skin, fully developed features, full thick hair, vibrant complexion, no visible wrinkles | +| **25–29** | young adult | young adult in late twenties, smooth healthy skin, mature defined features, full hair, very faint laugh lines only when smiling | +| **30–34** | adult | adult in early thirties, slightly more mature skin, very subtle fine lines around eyes, full hair, healthy clear complexion | +| **35–39** | adult | adult in late thirties, mature features, subtle fine lines around eyes and forehead, slight nasolabial folds beginning, full hair perhaps thinning slightly | +| **40–44** | adult | early middle-aged adult, fine wrinkles around eyes and forehead, defined nasolabial folds, possibly slight graying at temples | +| **45–49** | middle-aged | middle-aged adult, visible fine wrinkles, deeper nasolabial folds, gray strands appearing in hair, slight skin texture changes | +| **50–54** | middle-aged | early fifties, clear wrinkles around eyes and mouth, salt-and-pepper hair, slight jowl formation, mature skin texture | +| **55–59** | middle-aged | late fifties, pronounced wrinkles, mostly graying hair, jowls forming, mature skin with some age spots beginning | +| **60–64** | senior | early sixties, deep wrinkles, predominantly gray hair possibly receding, sagging skin around jaw, visible age spots | +| **65–69** | senior | late sixties, deep facial wrinkles, gray or white hair often thinning, sagging jowls, age spots, looser skin | +| **70–74** | senior | early seventies, deeply wrinkled face, white or thin gray hair, pronounced sagging skin, age spots, hollowing around eyes | +| **75–79** | elderly | late seventies, very deep wrinkles all over face, thin white hair, significantly sagging skin, prominent age spots, frail features | +| **80–84** | elderly | early eighties, profoundly weathered face, sparse white hair, very loose sagging skin, hollow cheeks, frail elderly features | +| **85+** | elderly | very elderly, deeply weathered and wrinkled face, sparse thin white hair, extremely loose papery skin, hollow features, fragile appearance | + +--- + +## Anti-drift negative prompts + +When `include_negative_age_drift=True` (default), the builder appends contextual +anti-prompts based on the target band: + +| Target band | Appended anti-prompts | +|---|---| +| young adult, adult, middle-aged, senior, elderly | `baby face, infant features, childish proportions` | +| middle-aged, senior, elderly | `teenage features, youthful smooth skin` | +| infant, child, teenager, young adult | `deep wrinkles, gray hair, age spots, sagging skin` | +| infant, child | `facial hair, beard, mustache, adult features` | + +This stops Flux from regressing toward whatever age the source photo shows. +Without it, a source photo of a 40-year-old tends to leak adult features into +the "age 5" generation. + +--- + +## Full example prompts at key ages + +All examples below use `subject="man"`, `gender="male"`, and the default style. + +### age = 5 + +**Positive**: +``` +a 5 year old male man, young toddler, round face, big bright eyes, smooth soft skin, baby teeth, innocent expression, photorealistic portrait, sharp focus, natural studio lighting, neutral background, same identity, same facial structure, same eye shape +``` + +**Negative**: +``` +cartoon, illustration, painting, anime, 3d render, deformed, distorted, blurry, low quality, plastic skin, extra fingers, watermark, text, deep wrinkles, gray hair, age spots, sagging skin, facial hair, beard, mustache, adult features +``` + +> 💡 For child generation, replace `--subject man` with `--subject boy` to avoid the awkward "male man" phrasing. + +### age = 15 + +**Positive**: +``` +a 15 year old male man, early teenager, transitional features, fresh smooth skin, occasional minor blemishes, youthful face shape, thick hair, photorealistic portrait, sharp focus, natural studio lighting, neutral background, same identity, same facial structure, same eye shape +``` + +**Negative**: +``` +cartoon, illustration, painting, anime, 3d render, deformed, distorted, blurry, low quality, plastic skin, extra fingers, watermark, text, deep wrinkles, gray hair, age spots, sagging skin +``` + +### age = 25 + +**Positive**: +``` +a 25 year old male man, young adult in late twenties, smooth healthy skin, mature defined features, full hair, very faint laugh lines only when smiling, photorealistic portrait, sharp focus, natural studio lighting, neutral background, same identity, same facial structure, same eye shape +``` + +**Negative**: +``` +cartoon, illustration, painting, anime, 3d render, deformed, distorted, blurry, low quality, plastic skin, extra fingers, watermark, text, baby face, infant features, childish proportions, deep wrinkles, gray hair, age spots, sagging skin +``` + +### age = 45 + +**Positive**: +``` +a 45 year old male man, middle-aged adult, visible fine wrinkles, deeper nasolabial folds, gray strands appearing in hair, slight skin texture changes, photorealistic portrait, sharp focus, natural studio lighting, neutral background, same identity, same facial structure, same eye shape +``` + +**Negative**: +``` +cartoon, illustration, painting, anime, 3d render, deformed, distorted, blurry, low quality, plastic skin, extra fingers, watermark, text, baby face, infant features, childish proportions, teenage features, youthful smooth skin +``` + +### age = 65 + +**Positive**: +``` +a 65 year old male man, late sixties, deep facial wrinkles, gray or white hair often thinning, sagging jowls, age spots, looser skin, photorealistic portrait, sharp focus, natural studio lighting, neutral background, same identity, same facial structure, same eye shape +``` + +**Negative**: +``` +cartoon, illustration, painting, anime, 3d render, deformed, distorted, blurry, low quality, plastic skin, extra fingers, watermark, text, baby face, infant features, childish proportions, teenage features, youthful smooth skin +``` + +### age = 80 + +**Positive**: +``` +a 80 year old male man, early eighties, profoundly weathered face, sparse white hair, very loose sagging skin, hollow cheeks, frail elderly features, photorealistic portrait, sharp focus, natural studio lighting, neutral background, same identity, same facial structure, same eye shape +``` + +**Negative**: +``` +cartoon, illustration, painting, anime, 3d render, deformed, distorted, blurry, low quality, plastic skin, extra fingers, watermark, text, baby face, infant features, childish proportions, teenage features, youthful smooth skin +``` + +--- + +## AgeRangeBatch output (JSONL) + +Calling `AgeRangeBatch` with `ages_csv="5,15,25,45,65"`, +`subject="man"`, `gender="male"` produces: + +```jsonl +{"age": 5, "band": "child", "prompt": "a 5 year old male man, young toddler, round face, big bright eyes, smooth soft skin, baby teeth, innocent expression, photorealistic portrait, sharp focus, natural lighting, same identity"} +{"age": 15, "band": "teenager", "prompt": "a 15 year old male man, early teenager, transitional features, fresh smooth skin, occasional minor blemishes, youthful face shape, thick hair, photorealistic portrait, sharp focus, natural lighting, same identity"} +{"age": 25, "band": "young adult", "prompt": "a 25 year old male man, young adult in late twenties, smooth healthy skin, mature defined features, full hair, very faint laugh lines only when smiling, photorealistic portrait, sharp focus, natural lighting, same identity"} +{"age": 45, "band": "adult", "prompt": "a 45 year old male man, middle-aged adult, visible fine wrinkles, deeper nasolabial folds, gray strands appearing in hair, slight skin texture changes, photorealistic portrait, sharp focus, natural lighting, same identity"} +{"age": 65, "band": "senior", "prompt": "a 65 year old male man, late sixties, deep facial wrinkles, gray or white hair often thinning, sagging jowls, age spots, looser skin, photorealistic portrait, sharp focus, natural lighting, same identity"} +``` + +Consumed by the CLI (`age_shift_cli.py`) to loop one Flux generation per age. + +--- + +## Customizing the prompts + +### Per-call override (no code change) + +The `AgePromptBuilder` node exposes these widgets, all overridable from the UI: + +- `subject` — the noun. Default `person`. Set to `man` / `woman` / `boy` / `girl` for natural phrasing. +- `gender` — adds `male` / `female` / `non-binary` before the subject. Set to `unspecified` to skip. +- `style` — appended after features. Customize for cinematic / vintage / etc. +- `extra_positive` — identity-preserving anchors. +- `negative_base` — boilerplate negatives. +- `include_negative_age_drift` — toggle the contextual anti-prompts. + +### Permanent override (edit code) + +Edit `nodes/_types.py`, the `age_features()` function. Each `if`-branch +returns the feature string for that age window. Add new branches or rewrite +existing ones — no other code touches these strings. + +### Custom subject nouns + +When generating across a wide age range, no single subject noun fits all +ages (`man` is wrong for age 5, `boy` is wrong for age 50). The CLI keeps it +as one knob for simplicity; for production runs, call the node per-age with +the appropriate noun: + +| Age | Suggested subject | Suggested gender | +|---|---|---| +| 0–12 | `boy` / `girl` | `male` / `female` (or `unspecified`) | +| 13–19 | `teenager` | `male` / `female` | +| 20–60 | `man` / `woman` | `male` / `female` | +| 60+ | `elderly man` / `elderly woman` | `male` / `female` | + +--- + +## Why these specific features? + +The features were chosen based on what Flux 1 dev actually responds to in +testing — not a generic dermatology textbook. Notably: + +- **Nasolabial folds** are mentioned in the 35+ ranges because Flux reliably + draws them when prompted, and they're a strong age signal. +- **"Gray strands appearing in hair"** at 45–49 vs **"salt-and-pepper hair"** at + 50–54 vs **"mostly graying hair"** at 55–59 — Flux responds to these + specific phrasings differently and produces visibly distinct outputs. +- **"Sparse white hair"** vs **"thin white hair"** — the former produces more + receded hairlines, the latter more even thinning. +- The **anti-drift negatives** are critical for child generation when the + source is an adult; without them, the model produces small adults with + child-sized bodies but adult facial proportions. + +If you find a phrasing that consistently produces better results for a +specific age range, open a PR with before/after comparisons. diff --git a/README.md b/README.md new file mode 100644 index 0000000..76bbb1a --- /dev/null +++ b/README.md @@ -0,0 +1,393 @@ +# ComfyUI-AgeShift + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) +[![ComfyUI](https://img.shields.io/badge/ComfyUI-custom%20nodes-7e5cad.svg)](https://github.com/comfyanonymous/ComfyUI) + +> **Detect a face's age, then generate identity-preserved portraits at any target age — from infant to 90+.** + +ComfyUI nodes for face age detection (ViT classifier) and age-conditioned +prompt building. Pairs with [ComfyUI-PuLID-Flux](https://github.com/balazik/ComfyUI-PuLID-Flux) +(or [InstantID](https://github.com/cubiq/ComfyUI_InstantID)) to produce +identity-preserving age-shifted portraits. + +```text +source face ──► AgeDetect ──► current age = 40 + │ +target age (e.g. 65) ──► AgePromptBuilder ──► positive + negative prompt + │ + ▼ + PuLID-Flux + Flux 1 dev + │ + ▼ + aged portrait (65 y/o) +``` + +--- + +## ✨ Features + +- 🎂 **Continuous age estimation** — ViT classifier with weighted-mean + decoding over bin midpoints; produces smooth integer ages (`32`, `47`) + rather than snapping to bin centers. +- 🎂 **Concrete-feature prompts** — translates `target_age=65` into + `"deep facial wrinkles, gray or white hair often thinning, sagging jowls, age spots"`, + which Flux follows much more reliably than numeric age alone. +- 🎂 **Anti-drift negatives** — auto-injects contextual negatives so a 40-year-old + source doesn't leak adult features into the "age 5" generation. +- 🎂 **Identity-agnostic** — works with PuLID-Flux, InstantID, IPAdapter FaceID, + or any face-conditioning backbone. We don't reimplement ID injection; we just + drive the prompt side. +- 🎂 **Batch CLI** — generate an age progression sequence (5 → 15 → 25 → 45 → 65) + in one command from your laptop, talking to a remote ComfyUI server. + +--- + +## 📦 Installation + +### 1. Clone the node package + +```bash +cd /path/to/ComfyUI/custom_nodes +git clone https://github.com/aadebuger/ComfyUI-AgeShift.git +cd ComfyUI-AgeShift + +# in the ComfyUI venv: +source /path/to/ComfyUI/.venv/bin/activate +uv pip install -r requirements.txt +``` + +Dependencies are minimal (`transformers`, `pillow`, `numpy`) — almost +always already in a ComfyUI venv. + +### 2. (Optional but recommended) Companion packages + +The age-detect side works standalone. For identity-preserving generation +you also need PuLID-Flux + Flux: + +```bash +cd /path/to/ComfyUI/custom_nodes +git clone https://github.com/balazik/ComfyUI-PuLID-Flux.git +cd ComfyUI-PuLID-Flux +uv pip install -r requirements.txt +``` + +| Asset | Where | Size | Notes | +|---|---|---|---| +| Flux 1 dev fp8 | `models/unet/flux1-dev-fp8.safetensors` | ~12 GB | from `Kijai/flux-fp8` (no license required) | +| CLIP-L | `models/clip/clip_l.safetensors` | ~250 MB | standard Flux | +| T5-XXL | `models/clip/t5xxl_fp8_*.safetensors` | ~5 GB | scaled or e4m3fn both work | +| VAE | `models/vae/ae.safetensors` | ~330 MB | standard Flux | +| PuLID-Flux weights | `models/pulid/pulid_flux_v0.9.1.safetensors` | ~1.1 GB | from `guozinan/PuLID` | +| InsightFace antelopev2 | `models/insightface/models/antelopev2/*.onnx` | ~360 MB | **manual placement required** — see below | +| EVA02-CLIP-L | auto-downloaded | ~430 MB | downloads on first run | + +> ⚠️ **InsightFace `antelopev2` does NOT auto-download** (unlike the default +> `buffalo_l`). You must place the 5 ONNX files manually at +> `models/insightface/models/antelopev2/` (note the double `models` — +> that's how the InsightFace lib expects it). See +> [INSTALL_NOTES.md](INSTALL_NOTES.md) for one-liner downloads. + +### 3. Restart ComfyUI + +Search the node palette for `🎂` — four nodes should appear. + +Quick verification from the CLI: + +```bash +curl -s http://127.0.0.1:8188/object_info | python3 -c " +import json,sys; d = json.load(sys.stdin) +for n in ['AgeDetectorLoader','AgeDetect','AgePromptBuilder','AgeRangeBatch', + 'PulidFluxModelLoader','ApplyPulidFlux']: + print(f' {n}:', '✓' if n in d else '✗')" +``` + +--- + +## 🚀 Quick start + +### Detect only (UI) + +Open `example_workflows/age_detect_only.json` in ComfyUI, drop a portrait +into the LoadImage node, queue. The summary `PreviewAny` shows: + +```text +Estimated age: 40 (adult) | top bin: 30-39 (46.8%) | top-3: 30-39=0.47, 40-49=0.43, 50-59=0.05 +``` + +### Full pipeline (UI) + +Open `example_workflows/age_shift_pulid_flux.json`, set the `target_age` +on the AgePromptBuilder node, queue. + +### CLI + +```bash +# Detect only — runs in ~2 seconds on CPU +uv run --script cli/age_shift_cli.py face.jpg --detect-only --ip + +# Single target age +uv run --script cli/age_shift_cli.py face.jpg --target-age 65 --ip + +# Age progression sequence +uv run --script cli/age_shift_cli.py face.jpg \ + --target-ages 5,15,25,45,65 \ + --gender male --subject man \ + --pulid-weight 0.95 --guidance 3.0 --steps 28 --seed 42 \ + --flux-unet flux1-dev-fp8.safetensors \ + --flux-t5 t5xxl_fp8_scaled.safetensors \ + --ip --out ./out +``` + +Output: `./out/age_005_*.png`, `./out/age_015_*.png`, ... + +See [cli/README.md](cli/README.md) for the full argument list. + +--- + +## 🧠 Node reference + +### 🎂 Age Detector Loader + +Loads a HF ViT age-classification model, with in-process cache. + +| Input | Type | Default | +|---|---|---| +| `model_id` | STRING | `nateraw/vit-age-classifier` | +| `device` | enum | `auto` | +| `dtype` | enum | `fp32` | +| `force_reload` | BOOL | False | + +Output: `AGE_DETECTOR`. + +### 🎂 Age Detect + +| Input | Type | Default | +|---|---|---| +| `detector` | AGE_DETECTOR | — | +| `image` | IMAGE | — | +| `method` | enum | `weighted_mean` | + +Outputs: `age:INT`, `age_band:STRING`, `confidence:FLOAT`, `summary:STRING`. + +> Note: this is whole-image classification. For non-portrait input, run a +> face-crop step upstream (e.g. via [ComfyUI-Crystools](https://github.com/crystian/ComfyUI-Crystools) +> or InsightFace). + +### 🎂 Age Prompt Builder + +| Input | Type | Default | +|---|---|---| +| `target_age` | INT | 30 | +| `subject` | STRING | `person` | +| `gender` | enum | `unspecified` | +| `style` | STRING | photorealistic portrait... | +| `extra_positive` | STRING | same identity... | +| `negative_base` | STRING | cartoon, illustration... | +| `include_negative_age_drift` | BOOL | True | + +Outputs: `positive:STRING`, `negative:STRING`, `summary:STRING`, `target_age:INT`. + +📖 **See [PROMPTS.md](PROMPTS.md) for the complete list of generated prompts +across all 20 age ranges + examples at age 5/15/25/45/65/80.** + +### 🎂 Age Range Batch + +CLI-oriented. Emits JSONL `{age, band, prompt}` for a comma-separated list +of target ages. + +--- + +## 🎯 Identity preservation tuning + +The default settings work for most cases. If the generated faces look like +*different people*, walk this checklist: + +### 1. Lock the seed across ages + +Different seeds = different sub-identities. Pass `--seed 42` (or any fixed +value) so all ages share the same noise schedule. + +### 2. Bump PuLID weight + +```bash +--pulid-weight 0.95 # default 0.9; up to 1.0 +``` + +### 3. Lower the prompt guidance + +```bash +--guidance 3.0 # default 3.5 +``` + +The age prompt is a strong signal — lowering guidance leaves more room for +the identity to assert itself. + +### 4. More steps + +```bash +--steps 28 # default 20 +``` + +PuLID continues injecting identity throughout sampling, so more steps = +more chances for ID to converge. + +### 5. Crop the source to a clean portrait + +PuLID extracts identity via InsightFace, which works best on a centered +frontal face. If the input has background / side angle / multiple faces, +crop first. + +### 6. Strengthen identity anchors in the prompt + +```bash +--extra-positive "exact same person, identical facial bone structure, same eye distance, same nose shape, same lip shape" +``` + +### Direction-specific tips + +| Direction | Tip | +|---|---| +| **Aging up** (40 → 70) | Default settings work well. Optionally `--style "photorealistic portrait, soft natural lighting"` (avoid sharp focus, which over-emphasizes wrinkles) | +| **Aging down** (40 → 20) | Default settings work well. | +| **Going to child** (adult → 5–12) | Drop `--pulid-weight 0.75–0.80` — full strength preserves adult bone structure and produces unsettling "small adult" results. | +| **Going to infant** (adult → 0–3) | Hard. Consider face-landmark ControlNet weighted on upper face only, or accept that the result is "what this person's son/daughter might look like" rather than a literal regression. | + +--- + +## 🐞 Troubleshooting + +### `PulidFluxModelLoader not found` + +PuLID-Flux either isn't installed or failed to import. Check: + +```bash +grep -B2 -A8 -iE "pulid|IMPORT FAILED" ~/comfy_start.log | tail -40 +``` + +Most often `facexlib` / `onnxruntime-gpu` / `insightface` is missing: + +```bash +cd /path/to/ComfyUI/custom_nodes/ComfyUI-PuLID-Flux +uv pip install -r requirements.txt --no-cache-dir +``` + +Then restart ComfyUI. + +### `assert 'detection' in self.models` (AssertionError from InsightFace) + +The `antelopev2` model files aren't at the expected path. Required layout +(note the **double `models`** directory): + +```text +/path/to/ComfyUI/models/insightface/models/antelopev2/ +├── 1k3d68.onnx +├── 2d106det.onnx +├── genderage.onnx +├── glintr100.onnx +└── scrfd_10g_bnkps.onnx +``` + +Fix: + +```bash +cd /path/to/ComfyUI/models/insightface +mkdir -p models && cd models +wget "https://github.com/deepinsight/insightface/releases/download/v0.7/antelopev2.zip" +unzip antelopev2.zip && rm antelopev2.zip +``` + +### `No module named 'websocket'` + +The CLI needs `websocket-client` (NOT `websocket`, which is a separate +abandoned package): + +```bash +uv pip install websocket-client +``` + +Or just use `uv run --script cli/age_shift_cli.py ...` — the script's +PEP 723 header declares deps, uv handles them automatically. + +### Faces all look like different people + +See the [identity preservation tuning](#-identity-preservation-tuning) +section. Most often: missing `--seed`, or `--pulid-weight` too low. + +### "child" generation produces creepy mini-adults + +PuLID weight is too high for child direction. Try `--pulid-weight 0.75` +for child-direction runs; keep it at 0.9–0.95 for aging-up runs. + +### Hangs on first generation + +PuLID's first run downloads EVA02-CLIP-L (~430 MB) and (if you skipped the +manual antelopev2 step) tries to set up InsightFace. Watch the ComfyUI server +log — once it prints `apply_pulid done`, generations become fast. + +--- + +## 🧰 Project layout + +```text +ComfyUI-AgeShift/ +├── __init__.py # NODE_CLASS_MAPPINGS +├── nodes/ +│ ├── _types.py # age → features mapping (the brain) +│ ├── detector_loader.py # _MODEL_CACHE pattern +│ ├── detect.py # ViT classifier execution +│ └── prompt_builder.py # AgePromptBuilder + AgeRangeBatch +├── cli/ +│ ├── README.md +│ └── age_shift_cli.py # PEP 723 inline-deps script +├── example_workflows/ +│ ├── age_detect_only.json # minimal UI workflow +│ └── age_shift_pulid_flux.json # full pipeline UI workflow +├── PROMPTS.md # complete prompt reference +├── INSTALL_NOTES.md # server-side install record +├── CHANGELOG.md +├── CONTRIBUTING.md +├── pyproject.toml +└── requirements.txt +``` + +--- + +## 🛣️ Roadmap + +- [ ] Built-in face-crop node (so users don't need a separate package) +- [ ] CLI flag `--pulid-fusion {mean,concat}` and `--pulid-train-step` for advanced ID tuning +- [ ] InstantID + SDXL alternative workflow (for users without Flux) +- [ ] ControlNet face-landmark integration for stronger child-direction results +- [ ] Side-by-side grid output (concatenated PNG) for sequence runs + +--- + +## 🤝 Contributing + +PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). The contribution most +likely to help right now: testing different age-feature phrasings on Flux +and submitting improvements to `_types.py` with before/after comparison +images. + +--- + +## 📜 License + +MIT — see [LICENSE](LICENSE). + +The age classifier (`nateraw/vit-age-classifier`) is MIT-licensed. +PuLID, Flux, and InsightFace weights have their own licenses — see those +projects for details. + +--- + +## 🙏 Acknowledgments + +- [PuLID](https://github.com/ToTheBeginning/PuLID) — Liu et al., the actual + identity-preservation magic. +- [ComfyUI-PuLID-Flux](https://github.com/balazik/ComfyUI-PuLID-Flux) — balazik, + the ComfyUI port we depend on. +- [nateraw/vit-age-classifier](https://huggingface.co/nateraw/vit-age-classifier) — + the age classifier we ship as default. +- [Flux](https://github.com/black-forest-labs/flux) — Black Forest Labs. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..47bf377 --- /dev/null +++ b/__init__.py @@ -0,0 +1,22 @@ +"""ComfyUI-AgeShift: face age detection + age-conditioned prompt building.""" + +from .nodes.detector_loader import AgeDetectorLoader +from .nodes.detect import AgeDetect +from .nodes.prompt_builder import AgePromptBuilder, AgeRangeBatch + +NODE_CLASS_MAPPINGS = { + "AgeDetectorLoader": AgeDetectorLoader, + "AgeDetect": AgeDetect, + "AgePromptBuilder": AgePromptBuilder, + "AgeRangeBatch": AgeRangeBatch, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "AgeDetectorLoader": "🎂 Age Detector Loader", + "AgeDetect": "🎂 Age Detect", + "AgePromptBuilder": "🎂 Age Prompt Builder", + "AgeRangeBatch": "🎂 Age Range Batch", +} + +WEB_DIRECTORY = None +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..10bb701 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,82 @@ +# age_shift_cli.py + +End-to-end CLI for ComfyUI-AgeShift. Talks to a running ComfyUI server, +uploads a face image, runs detection, and (optionally) drives PuLID-Flux to +generate age-shifted portraits. + +## Quick start + +```bash +# Detect age only +uv run python age_shift_cli.py face.jpg --detect-only --ip 10.8.0.12 + +# Single target age +uv run python age_shift_cli.py face.jpg --target-age 65 --ip 10.8.0.12 + +# Sequence +uv run python age_shift_cli.py face.jpg \ + --target-ages 5,15,25,45,65 \ + --gender male --out ./out --ip 10.8.0.12 + +# Debug — save generated workflow JSON(s) for inspection in UI +uv run python age_shift_cli.py face.jpg --target-age 65 \ + --dump-workflow /tmp/agewf.json --ip 10.8.0.12 +``` + +## Argparse reference + +| Group | Flag | Default | Notes | +|---|---|---|---| +| **server** | `--ip` | `127.0.0.1` | ComfyUI host | +| | `--port` | `8188` | ComfyUI port | +| | `--server` | — | overrides `--ip:--port` | +| **mode** | `--detect-only` | False | skip generation | +| | `--target-age` | — | single target | +| | `--target-ages` | — | csv of targets, e.g. `5,15,25,45,65` | +| **subject** | `--subject` | `person` | injected into prompt | +| | `--gender` | `unspecified` | `male` / `female` / `non-binary` | +| | `--style` | `photorealistic ...` | base style prompt | +| | `--extra-positive` | `same identity, ...` | identity-preserving anchor | +| **gen** | `--width / --height` | 1024 | Flux native is 1024 | +| | `--steps` | 20 | Flux dev: 20–28 is fine | +| | `--cfg` | 1.0 | Flux uses CFG=1 + `--guidance` | +| | `--guidance` | 3.5 | Flux distillation guidance | +| | `--sampler / --scheduler` | `euler / simple` | | +| | `--seed` | 0 (random per age) | | +| **pulid** | `--pulid-weight` | 0.9 | identity strength | +| | `--pulid-start / --pulid-end` | 0.0 / 1.0 | active range | +| **models** | `--flux-unet` | `flux1-dev.safetensors` | filename on server | +| | `--flux-clip-l` | `clip_l.safetensors` | | +| | `--flux-t5` | `t5xxl_fp8_e4m3fn.safetensors` | | +| | `--flux-vae` | `ae.safetensors` | | +| | `--pulid-weights` | `pulid_flux_v0.9.1.safetensors` | | +| | `--insightface-provider` | `CUDA` | `CPU` / `ROCM` also valid | +| **detector** | `--detector-model` | `nateraw/vit-age-classifier` | HF ID or local path | +| | `--detector-device` | `auto` | | +| | `--detector-dtype` | `fp32` | | +| **debug** | `--out` | `./out` | output dir | +| | `--dump-workflow` | — | writes `*.detect.json` / `*.shift_ageN.json` | +| | `--timeout` | 600 | per-workflow seconds | + +## Troubleshooting + +**`/prompt 400 - node 'AgeDetectorLoader' not found`** +The node package isn't loaded on the server. Check +`http://server:8188/object_info | grep AgeDetect`. + +**`/prompt 400 - node 'PulidFluxModelLoader' not found`** +Install `ComfyUI-PuLID-Flux` on the server. + +**`Estimated age: 0`** +Detector hit a corrupt or all-black input. Confirm the source image renders +correctly: `curl http://server:8188/view?filename=face.jpg`. + +**Hangs after `executing node sampler_node`** +PuLID weight too aggressive, or InsightFace download in progress. First run +of PuLID downloads ~600 MB of weights — watch the server log. + +**Output looks like the source age, not the target** +- Bump `--guidance 4.5` (Flux follows prompt harder). +- Drop `--pulid-weight 0.75` (less identity dominance). +- Steps to 24–28. +- Make sure your style prompt isn't pulling toward "young model". diff --git a/cli/age_shift_cli.py b/cli/age_shift_cli.py new file mode 100644 index 0000000..06f9fb3 --- /dev/null +++ b/cli/age_shift_cli.py @@ -0,0 +1,543 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "websocket-client>=1.6", +# "requests>=2.31", +# "pillow>=10.0", +# ] +# /// +""" +age_shift_cli.py — detect current age and generate target-age portraits +via ComfyUI + ComfyUI-AgeShift + ComfyUI-PuLID-Flux. + +Usage: + # Detect only + uv run python age_shift_cli.py face.jpg --detect-only --ip 10.8.0.12 + + # Single target age (e.g. age this person up to 65) + uv run python age_shift_cli.py face.jpg --target-age 65 --ip 10.8.0.12 + + # Age sequence (5/15/25/45/65) + uv run python age_shift_cli.py face.jpg \\ + --target-ages 5,15,25,45,65 \\ + --gender male --out ./out --ip 10.8.0.12 + + # Dump workflow JSONs for debugging + uv run python age_shift_cli.py face.jpg --target-age 65 \\ + --dump-workflow /tmp/wf.json --ip 10.8.0.12 +""" +import argparse +import json +import sys +import time +import uuid +from pathlib import Path +from typing import Any, Dict, List + +import requests +from websocket import create_connection + + +# ============================================================================= +# ComfyClient +# ============================================================================= +class ComfyClient: + def __init__(self, server: str = "127.0.0.1:8188"): + self.server = server + self.client_id = str(uuid.uuid4()) + self.http = f"http://{server}" + self.ws = f"ws://{server}/ws?clientId={self.client_id}" + + def upload_image(self, file_path: Path, overwrite: bool = True) -> str: + with open(file_path, "rb") as f: + files = {"image": (file_path.name, f, "application/octet-stream")} + data = {"overwrite": "true" if overwrite else "false", "type": "input"} + r = requests.post(f"{self.http}/upload/image", files=files, data=data, timeout=60) + r.raise_for_status() + return r.json().get("name", file_path.name) + + def submit(self, workflow: dict) -> str: + payload = {"prompt": workflow, "client_id": self.client_id} + r = requests.post(f"{self.http}/prompt", json=payload, timeout=30) + if r.status_code != 200: + raise RuntimeError(f"/prompt {r.status_code}: {r.text[:1000]}") + return r.json()["prompt_id"] + + def history(self, prompt_id: str) -> dict: + r = requests.get(f"{self.http}/history/{prompt_id}", timeout=30) + r.raise_for_status() + data = r.json() + return data.get(prompt_id, {}) + + def view(self, filename: str, subfolder: str = "", type_: str = "output") -> bytes: + params = {"filename": filename, "subfolder": subfolder, "type": type_} + r = requests.get(f"{self.http}/view", params=params, timeout=120) + r.raise_for_status() + return r.content + + def run(self, workflow: dict, timeout_s: float = 600) -> dict: + ws = create_connection(self.ws, timeout=timeout_s) + try: + pid = self.submit(workflow) + print(f" prompt_id={pid}") + t0 = time.time() + last_node = None + while True: + if time.time() - t0 > timeout_s: + raise TimeoutError(f"Workflow timed out after {timeout_s}s") + msg = ws.recv() + if isinstance(msg, bytes): + continue + evt = json.loads(msg) + t = evt.get("type") + d = evt.get("data", {}) + if d.get("prompt_id") and d["prompt_id"] != pid: + continue + if t == "progress": + cur, tot = d.get("value", 0), d.get("max", 1) + print(f"\r ▸ {cur:>4}/{tot} ", end="", flush=True) + elif t == "executing": + n = d.get("node") + if n is None: + print() # finalize line + break + if n != last_node: + print(f"\r ▸ node {str(n):<32}", end="", flush=True) + last_node = n + elif t == "execution_error": + raise RuntimeError( + f"Error in node {d.get('node_id')}: {d.get('exception_message')}" + ) + return self.history(pid) + finally: + ws.close() + + +# ============================================================================= +# Workflow builders (API format — node ids are strings) +# ============================================================================= +def build_detect_workflow( + image_name: str, + *, + model_id: str = "nateraw/vit-age-classifier", + device: str = "auto", + dtype: str = "fp32", +) -> Dict[str, Any]: + return { + "load": { + "class_type": "LoadImage", + "inputs": {"image": image_name, "upload": "image"}, + }, + "loader": { + "class_type": "AgeDetectorLoader", + "inputs": {"model_id": model_id, "device": device, "dtype": dtype}, + }, + "detect": { + "class_type": "AgeDetect", + "inputs": { + "detector": ["loader", 0], + "image": ["load", 0], + "method": "weighted_mean", + }, + }, + "show_age": { + "class_type": "PreviewAny", + "inputs": {"source": ["detect", 0]}, + }, + "show_band": { + "class_type": "PreviewAny", + "inputs": {"source": ["detect", 1]}, + }, + "show_conf": { + "class_type": "PreviewAny", + "inputs": {"source": ["detect", 2]}, + }, + "show_summary": { + "class_type": "PreviewAny", + "inputs": {"source": ["detect", 3]}, + }, + } + + +def build_shift_workflow( + image_name: str, + target_age: int, + *, + # flux model file names (must be present in ComfyUI/models/*) + flux_unet: str, + flux_clip_l: str, + flux_t5: str, + flux_vae: str, + pulid_weights: str, + insightface_provider: str = "CUDA", + # generation knobs + width: int = 1024, + height: int = 1024, + steps: int = 20, + cfg: float = 1.0, + guidance: float = 3.5, + sampler: str = "euler", + scheduler: str = "simple", + seed: int = 0, + # pulid knobs + pulid_weight: float = 0.9, + pulid_start: float = 0.0, + pulid_end: float = 1.0, + # prompt knobs + subject: str = "person", + gender: str = "unspecified", + style: str = "photorealistic portrait, sharp focus, natural studio lighting", + extra_positive: str = "same identity, same facial structure, same eye shape", + negative_base: str = ( + "cartoon, illustration, painting, anime, 3d render, " + "deformed, distorted, blurry, low quality, plastic skin, watermark, text" + ), +) -> Dict[str, Any]: + """Build a full PuLID-Flux + AgeShift workflow (API format). + + Requires ComfyUI-PuLID-Flux installed alongside this package. + """ + return { + # Inputs + "load_face": { + "class_type": "LoadImage", + "inputs": {"image": image_name, "upload": "image"}, + }, + # Age-conditioned prompt + "prompt": { + "class_type": "AgePromptBuilder", + "inputs": { + "target_age": int(target_age), + "subject": subject, + "gender": gender, + "style": style, + "extra_positive": extra_positive, + "negative_base": negative_base, + "include_negative_age_drift": True, + }, + }, + # Flux backbone + "unet": { + "class_type": "UNETLoader", + "inputs": {"unet_name": flux_unet, "weight_dtype": "default"}, + }, + "dual_clip": { + "class_type": "DualCLIPLoader", + "inputs": { + "clip_name1": flux_clip_l, + "clip_name2": flux_t5, + "type": "flux", + }, + }, + "vae": {"class_type": "VAELoader", "inputs": {"vae_name": flux_vae}}, + # PuLID-Flux identity injection + "pulid_model": { + "class_type": "PulidFluxModelLoader", + "inputs": {"pulid_file": pulid_weights}, + }, + "eva_clip": {"class_type": "PulidFluxEvaClipLoader", "inputs": {}}, + "insightface": { + "class_type": "PulidFluxInsightFaceLoader", + "inputs": {"provider": insightface_provider}, + }, + "apply_pulid": { + "class_type": "ApplyPulidFlux", + "inputs": { + "model": ["unet", 0], + "pulid_flux": ["pulid_model", 0], + "eva_clip": ["eva_clip", 0], + "face_analysis": ["insightface", 0], + "image": ["load_face", 0], + "weight": pulid_weight, + "start_at": pulid_start, + "end_at": pulid_end, + "fusion": "mean", + "fusion_weight_max": 1.0, + "fusion_weight_min": 0.0, + "train_step": 1000, + "use_gray": True, + }, + }, + # Text → conditioning + "pos_encode": { + "class_type": "CLIPTextEncode", + "inputs": {"clip": ["dual_clip", 0], "text": ["prompt", 0]}, + }, + "neg_encode": { + "class_type": "CLIPTextEncode", + "inputs": {"clip": ["dual_clip", 0], "text": ["prompt", 1]}, + }, + "guidance_node": { + "class_type": "FluxGuidance", + "inputs": {"conditioning": ["pos_encode", 0], "guidance": guidance}, + }, + # Latent + sample + "latent": { + "class_type": "EmptySD3LatentImage", + "inputs": {"width": width, "height": height, "batch_size": 1}, + }, + "sampler_node": { + "class_type": "KSampler", + "inputs": { + "model": ["apply_pulid", 0], + "positive": ["guidance_node", 0], + "negative": ["neg_encode", 0], + "latent_image": ["latent", 0], + "seed": int(seed), + "steps": int(steps), + "cfg": float(cfg), + "sampler_name": sampler, + "scheduler": scheduler, + "denoise": 1.0, + }, + }, + "decode": { + "class_type": "VAEDecode", + "inputs": {"samples": ["sampler_node", 0], "vae": ["vae", 0]}, + }, + "save": { + "class_type": "SaveImage", + "inputs": { + "images": ["decode", 0], + "filename_prefix": f"ageshift_age{int(target_age):03d}", + }, + }, + "show_prompt_summary": { + "class_type": "PreviewAny", + "inputs": {"source": ["prompt", 2]}, + }, + } + + +# ============================================================================= +# History helpers +# ============================================================================= +def extract_text(history: dict, node_id: str) -> str: + outs = history.get("outputs", {}) + if node_id not in outs: + return "" + no = outs[node_id] + if "text" in no: + v = no["text"] + return "\n".join(str(x) for x in v) if isinstance(v, list) else str(v) + parts = [] + for v in no.values(): + if isinstance(v, list): + parts.extend(str(x) for x in v) + return "\n".join(parts) + + +def extract_images(history: dict, node_id: str) -> List[Dict[str, str]]: + outs = history.get("outputs", {}) + if node_id not in outs: + return [] + return outs[node_id].get("images", []) + + +# ============================================================================= +# Main +# ============================================================================= +def main(): + ap = argparse.ArgumentParser( + description="AgeShift: detect current age and generate target-age portraits " + "via ComfyUI + PuLID-Flux", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + ap.add_argument("image", type=Path, help="Source face image (jpg/png)") + + # server + ap.add_argument("--ip", default="127.0.0.1") + ap.add_argument("--port", type=int, default=8188) + ap.add_argument("--server", default=None, help="full host:port (overrides --ip --port)") + + # mode + ap.add_argument("--detect-only", action="store_true", help="only run age detection") + ap.add_argument("--target-age", type=int, default=None, help="single target age to generate") + ap.add_argument( + "--target-ages", + default=None, + help="csv list of target ages, e.g. 5,15,25,45,65", + ) + + # generation knobs + ap.add_argument("--subject", default="person") + ap.add_argument( + "--gender", + default="unspecified", + choices=["unspecified", "male", "female", "non-binary"], + ) + ap.add_argument( + "--style", + default="photorealistic portrait, sharp focus, natural studio lighting, neutral background", + ) + ap.add_argument( + "--extra-positive", + default="same identity, same facial structure, same eye shape", + ) + ap.add_argument("--width", type=int, default=1024) + ap.add_argument("--height", type=int, default=1024) + ap.add_argument("--steps", type=int, default=20) + ap.add_argument("--cfg", type=float, default=1.0) + ap.add_argument("--guidance", type=float, default=3.5) + ap.add_argument("--sampler", default="euler") + ap.add_argument("--scheduler", default="simple") + ap.add_argument("--seed", type=int, default=0, help="0 = random per age") + ap.add_argument("--pulid-weight", type=float, default=0.9) + ap.add_argument("--pulid-start", type=float, default=0.0) + ap.add_argument("--pulid-end", type=float, default=1.0) + + # model files (must already exist on the ComfyUI server) + ap.add_argument("--flux-unet", default="flux1-dev.safetensors") + ap.add_argument("--flux-clip-l", default="clip_l.safetensors") + ap.add_argument("--flux-t5", default="t5xxl_fp8_e4m3fn.safetensors") + ap.add_argument("--flux-vae", default="ae.safetensors") + ap.add_argument("--pulid-weights", default="pulid_flux_v0.9.1.safetensors") + ap.add_argument( + "--insightface-provider", + default="CUDA", + choices=["CUDA", "CPU", "ROCM"], + ) + + # detector + ap.add_argument("--detector-model", default="nateraw/vit-age-classifier") + ap.add_argument( + "--detector-device", + default="auto", + choices=["auto", "cuda", "cuda:0", "cuda:1", "cpu"], + ) + ap.add_argument("--detector-dtype", default="fp32", choices=["fp32", "fp16", "bf16"]) + + # output / debug + ap.add_argument("--out", type=Path, default=Path("./out")) + ap.add_argument( + "--dump-workflow", + type=Path, + default=None, + help="dump generated workflow JSON(s) for debugging", + ) + ap.add_argument("--timeout", type=float, default=600.0) + + args = ap.parse_args() + server = args.server or f"{args.ip}:{args.port}" + args.out.mkdir(parents=True, exist_ok=True) + + if not args.image.exists(): + print(f"ERROR: image not found: {args.image}", file=sys.stderr) + sys.exit(2) + + client = ComfyClient(server) + + # ---- upload ---- + print(f"[1/3] Uploading {args.image.name} → {server} ...") + server_name = client.upload_image(args.image) + print(f" uploaded as: {server_name}") + + # ---- detect ---- + print(f"[2/3] Detecting age ...") + detect_wf = build_detect_workflow( + server_name, + model_id=args.detector_model, + device=args.detector_device, + dtype=args.detector_dtype, + ) + if args.dump_workflow: + dump = args.dump_workflow.with_suffix(".detect.json") + dump.parent.mkdir(parents=True, exist_ok=True) + dump.write_text(json.dumps(detect_wf, indent=2, ensure_ascii=False)) + print(f" wrote {dump}") + hist = client.run(detect_wf, timeout_s=args.timeout) + summary = extract_text(hist, "show_summary") or "(no summary)" + age_str = extract_text(hist, "show_age").strip() + try: + detected_age = int(float(age_str)) + except (ValueError, TypeError): + detected_age = None + print(f" → {summary}") + + if args.detect_only: + print("Done (detect only).") + return + + # ---- shift ---- + targets: List[int] = [] + if args.target_age is not None: + targets.append(args.target_age) + if args.target_ages: + for s in args.target_ages.split(","): + s = s.strip() + if s: + try: + targets.append(int(s)) + except ValueError: + print(f" ! skip invalid age: {s!r}", file=sys.stderr) + if not targets: + print( + "No --target-age / --target-ages given; nothing to generate.", + file=sys.stderr, + ) + sys.exit(0) + # dedup, keep order + seen = set() + targets = [a for a in targets if not (a in seen or seen.add(a))] + + print(f"[3/3] Generating {len(targets)} age-shifted image(s) ...") + for i, age in enumerate(targets, 1): + print(f" ({i}/{len(targets)}) target age = {age}") + seed = args.seed if args.seed else int(time.time() * 1000) % (2**31 - 1) + i + wf = build_shift_workflow( + server_name, + age, + flux_unet=args.flux_unet, + flux_clip_l=args.flux_clip_l, + flux_t5=args.flux_t5, + flux_vae=args.flux_vae, + pulid_weights=args.pulid_weights, + insightface_provider=args.insightface_provider, + width=args.width, + height=args.height, + steps=args.steps, + cfg=args.cfg, + guidance=args.guidance, + sampler=args.sampler, + scheduler=args.scheduler, + seed=seed, + pulid_weight=args.pulid_weight, + pulid_start=args.pulid_start, + pulid_end=args.pulid_end, + subject=args.subject, + gender=args.gender, + style=args.style, + extra_positive=args.extra_positive, + ) + if args.dump_workflow: + dump = args.dump_workflow.with_suffix(f".shift_age{age}.json") + dump.write_text(json.dumps(wf, indent=2, ensure_ascii=False)) + print(f" wrote {dump}") + try: + hist = client.run(wf, timeout_s=args.timeout) + except Exception as e: + print(f" ! failed: {e}", file=sys.stderr) + continue + prompt_summary = extract_text(hist, "show_prompt_summary").strip() + if prompt_summary: + print(f" prompt: {prompt_summary}") + imgs = extract_images(hist, "save") + if not imgs: + print(f" ! no output images for age {age}", file=sys.stderr) + continue + for img in imgs: + data = client.view( + img["filename"], + img.get("subfolder", ""), + img.get("type", "output"), + ) + out_path = args.out / f"age_{age:03d}_{img['filename']}" + out_path.write_bytes(data) + print(f" → {out_path} ({len(data)//1024} KB)") + + print("All done.") + + +if __name__ == "__main__": + main() diff --git a/example_workflows/age_detect_only.json b/example_workflows/age_detect_only.json new file mode 100644 index 0000000..0b36d05 --- /dev/null +++ b/example_workflows/age_detect_only.json @@ -0,0 +1,111 @@ +{ + "last_node_id": 5, + "last_link_id": 5, + "nodes": [ + { + "id": 1, + "type": "LoadImage", + "pos": [40, 200], + "size": {"0": 315, "1": 314}, + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + {"name": "IMAGE", "type": "IMAGE", "links": [1], "slot_index": 0}, + {"name": "MASK", "type": "MASK", "links": null} + ], + "properties": {"Node name for S&R": "LoadImage"}, + "widgets_values": ["face.jpg", "image"] + }, + { + "id": 2, + "type": "AgeDetectorLoader", + "pos": [40, 560], + "size": {"0": 315, "1": 130}, + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + {"name": "detector", "type": "AGE_DETECTOR", "links": [2], "slot_index": 0} + ], + "properties": {"Node name for S&R": "AgeDetectorLoader"}, + "widgets_values": ["nateraw/vit-age-classifier", "auto", "fp32", false] + }, + { + "id": 3, + "type": "AgeDetect", + "pos": [400, 200], + "size": {"0": 315, "1": 130}, + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + {"name": "detector", "type": "AGE_DETECTOR", "link": 2}, + {"name": "image", "type": "IMAGE", "link": 1} + ], + "outputs": [ + {"name": "age", "type": "INT", "links": [3], "slot_index": 0}, + {"name": "age_band", "type": "STRING", "links": [4], "slot_index": 1}, + {"name": "confidence", "type": "FLOAT", "links": null, "slot_index": 2}, + {"name": "summary", "type": "STRING", "links": [5], "slot_index": 3} + ], + "properties": {"Node name for S&R": "AgeDetect"}, + "widgets_values": ["weighted_mean"] + }, + { + "id": 4, + "type": "PreviewAny", + "pos": [760, 200], + "size": {"0": 320, "1": 76}, + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [{"name": "source", "type": "*", "link": 5}], + "outputs": [], + "properties": {"Node name for S&R": "PreviewAny"}, + "title": "Summary", + "widgets_values": [] + }, + { + "id": 5, + "type": "PreviewAny", + "pos": [760, 320], + "size": {"0": 320, "1": 76}, + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [{"name": "source", "type": "*", "link": 3}], + "outputs": [], + "properties": {"Node name for S&R": "PreviewAny"}, + "title": "Age (int)", + "widgets_values": [] + }, + { + "id": 6, + "type": "PreviewAny", + "pos": [760, 440], + "size": {"0": 320, "1": 76}, + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [{"name": "source", "type": "*", "link": 4}], + "outputs": [], + "properties": {"Node name for S&R": "PreviewAny"}, + "title": "Age band", + "widgets_values": [] + } + ], + "links": [ + [1, 1, 0, 3, 1, "IMAGE"], + [2, 2, 0, 3, 0, "AGE_DETECTOR"], + [3, 3, 0, 5, 0, "INT"], + [4, 3, 1, 6, 0, "STRING"], + [5, 3, 3, 4, 0, "STRING"] + ], + "groups": [], + "config": {}, + "extra": {}, + "version": 0.4 +} diff --git a/example_workflows/age_shift_pulid_flux.json b/example_workflows/age_shift_pulid_flux.json new file mode 100644 index 0000000..9aad3eb --- /dev/null +++ b/example_workflows/age_shift_pulid_flux.json @@ -0,0 +1,316 @@ +{ + "last_node_id": 20, + "last_link_id": 30, + "nodes": [ + { + "id": 1, + "type": "LoadImage", + "pos": [40, 40], + "size": {"0": 315, "1": 314}, + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + {"name": "IMAGE", "type": "IMAGE", "links": [1], "slot_index": 0}, + {"name": "MASK", "type": "MASK", "links": null} + ], + "properties": {"Node name for S&R": "LoadImage"}, + "title": "Source face", + "widgets_values": ["face.jpg", "image"] + }, + { + "id": 2, + "type": "AgePromptBuilder", + "pos": [40, 400], + "size": {"0": 360, "1": 320}, + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + {"name": "positive", "type": "STRING", "links": [2], "slot_index": 0}, + {"name": "negative", "type": "STRING", "links": [3], "slot_index": 1}, + {"name": "summary", "type": "STRING", "links": [4], "slot_index": 2}, + {"name": "target_age", "type": "INT", "links": null, "slot_index": 3} + ], + "properties": {"Node name for S&R": "AgePromptBuilder"}, + "widgets_values": [ + 65, + "person", + "unspecified", + "photorealistic portrait, sharp focus, natural studio lighting, neutral background", + "same identity, same facial structure, same eye shape", + "cartoon, illustration, painting, anime, 3d render, deformed, distorted, blurry, low quality, plastic skin, watermark, text", + true + ] + }, + { + "id": 3, + "type": "UNETLoader", + "pos": [440, 40], + "size": {"0": 315, "1": 82}, + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [{"name": "MODEL", "type": "MODEL", "links": [5], "slot_index": 0}], + "properties": {"Node name for S&R": "UNETLoader"}, + "widgets_values": ["flux1-dev.safetensors", "default"] + }, + { + "id": 4, + "type": "DualCLIPLoader", + "pos": [440, 160], + "size": {"0": 315, "1": 106}, + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [{"name": "CLIP", "type": "CLIP", "links": [6, 7], "slot_index": 0}], + "properties": {"Node name for S&R": "DualCLIPLoader"}, + "widgets_values": ["clip_l.safetensors", "t5xxl_fp8_e4m3fn.safetensors", "flux"] + }, + { + "id": 5, + "type": "VAELoader", + "pos": [440, 300], + "size": {"0": 315, "1": 58}, + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [{"name": "VAE", "type": "VAE", "links": [8], "slot_index": 0}], + "properties": {"Node name for S&R": "VAELoader"}, + "widgets_values": ["ae.safetensors"] + }, + { + "id": 6, + "type": "PulidFluxModelLoader", + "pos": [440, 400], + "size": {"0": 315, "1": 58}, + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [], + "outputs": [{"name": "PULIDFLUX", "type": "PULIDFLUX", "links": [9], "slot_index": 0}], + "properties": {"Node name for S&R": "PulidFluxModelLoader"}, + "widgets_values": ["pulid_flux_v0.9.1.safetensors"] + }, + { + "id": 7, + "type": "PulidFluxEvaClipLoader", + "pos": [440, 500], + "size": {"0": 315, "1": 26}, + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [], + "outputs": [{"name": "EVA_CLIP", "type": "EVA_CLIP", "links": [10], "slot_index": 0}], + "properties": {"Node name for S&R": "PulidFluxEvaClipLoader"}, + "widgets_values": [] + }, + { + "id": 8, + "type": "PulidFluxInsightFaceLoader", + "pos": [440, 560], + "size": {"0": 315, "1": 58}, + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [], + "outputs": [{"name": "FACEANALYSIS", "type": "FACEANALYSIS", "links": [11], "slot_index": 0}], + "properties": {"Node name for S&R": "PulidFluxInsightFaceLoader"}, + "widgets_values": ["CUDA"] + }, + { + "id": 9, + "type": "ApplyPulidFlux", + "pos": [800, 40], + "size": {"0": 315, "1": 290}, + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + {"name": "model", "type": "MODEL", "link": 5}, + {"name": "pulid_flux", "type": "PULIDFLUX", "link": 9}, + {"name": "eva_clip", "type": "EVA_CLIP", "link": 10}, + {"name": "face_analysis", "type": "FACEANALYSIS", "link": 11}, + {"name": "image", "type": "IMAGE", "link": 1} + ], + "outputs": [{"name": "MODEL", "type": "MODEL", "links": [12], "slot_index": 0}], + "properties": {"Node name for S&R": "ApplyPulidFlux"}, + "widgets_values": [0.9, 0.0, 1.0, "mean", 1.0, 0.0, 1000, true] + }, + { + "id": 10, + "type": "CLIPTextEncode", + "pos": [800, 360], + "size": {"0": 315, "1": 96}, + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + {"name": "clip", "type": "CLIP", "link": 6}, + {"name": "text", "type": "STRING", "link": 2, "widget": {"name": "text"}} + ], + "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [13], "slot_index": 0}], + "properties": {"Node name for S&R": "CLIPTextEncode"}, + "title": "Positive (age-conditioned)", + "widgets_values": [""] + }, + { + "id": 11, + "type": "CLIPTextEncode", + "pos": [800, 500], + "size": {"0": 315, "1": 96}, + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + {"name": "clip", "type": "CLIP", "link": 7}, + {"name": "text", "type": "STRING", "link": 3, "widget": {"name": "text"}} + ], + "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [14], "slot_index": 0}], + "properties": {"Node name for S&R": "CLIPTextEncode"}, + "title": "Negative", + "widgets_values": [""] + }, + { + "id": 12, + "type": "FluxGuidance", + "pos": [1160, 360], + "size": {"0": 315, "1": 58}, + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [{"name": "conditioning", "type": "CONDITIONING", "link": 13}], + "outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [15], "slot_index": 0}], + "properties": {"Node name for S&R": "FluxGuidance"}, + "widgets_values": [3.5] + }, + { + "id": 13, + "type": "EmptySD3LatentImage", + "pos": [1160, 460], + "size": {"0": 315, "1": 106}, + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [], + "outputs": [{"name": "LATENT", "type": "LATENT", "links": [16], "slot_index": 0}], + "properties": {"Node name for S&R": "EmptySD3LatentImage"}, + "widgets_values": [1024, 1024, 1] + }, + { + "id": 14, + "type": "KSampler", + "pos": [1520, 40], + "size": {"0": 315, "1": 262}, + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + {"name": "model", "type": "MODEL", "link": 12}, + {"name": "positive", "type": "CONDITIONING", "link": 15}, + {"name": "negative", "type": "CONDITIONING", "link": 14}, + {"name": "latent_image", "type": "LATENT", "link": 16} + ], + "outputs": [{"name": "LATENT", "type": "LATENT", "links": [17], "slot_index": 0}], + "properties": {"Node name for S&R": "KSampler"}, + "widgets_values": [0, "randomize", 20, 1.0, "euler", "simple", 1.0] + }, + { + "id": 15, + "type": "VAEDecode", + "pos": [1520, 340], + "size": {"0": 315, "1": 46}, + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + {"name": "samples", "type": "LATENT", "link": 17}, + {"name": "vae", "type": "VAE", "link": 8} + ], + "outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [18], "slot_index": 0}], + "properties": {"Node name for S&R": "VAEDecode"}, + "widgets_values": [] + }, + { + "id": 16, + "type": "SaveImage", + "pos": [1520, 420], + "size": {"0": 315, "1": 270}, + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [{"name": "images", "type": "IMAGE", "link": 18}], + "outputs": [], + "properties": {}, + "widgets_values": ["ageshift_age065"] + }, + { + "id": 17, + "type": "PreviewAny", + "pos": [40, 760], + "size": {"0": 360, "1": 76}, + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [{"name": "source", "type": "*", "link": 4}], + "outputs": [], + "properties": {"Node name for S&R": "PreviewAny"}, + "title": "Prompt summary", + "widgets_values": [] + } + ], + "links": [ + [1, 1, 0, 9, 4, "IMAGE"], + [2, 2, 0, 10, 1, "STRING"], + [3, 2, 1, 11, 1, "STRING"], + [4, 2, 2, 17, 0, "STRING"], + [5, 3, 0, 9, 0, "MODEL"], + [6, 4, 0, 10, 0, "CLIP"], + [7, 4, 0, 11, 0, "CLIP"], + [8, 5, 0, 15, 1, "VAE"], + [9, 6, 0, 9, 1, "PULIDFLUX"], + [10, 7, 0, 9, 2, "EVA_CLIP"], + [11, 8, 0, 9, 3, "FACEANALYSIS"], + [12, 9, 0, 14, 0, "MODEL"], + [13, 10, 0, 12, 0, "CONDITIONING"], + [14, 11, 0, 14, 2, "CONDITIONING"], + [15, 12, 0, 14, 1, "CONDITIONING"], + [16, 13, 0, 14, 3, "LATENT"], + [17, 14, 0, 15, 0, "LATENT"], + [18, 15, 0, 16, 0, "IMAGE"] + ], + "groups": [ + { + "title": "Inputs", + "bounding": [30, 0, 370, 740], + "color": "#3f789e", + "font_size": 24 + }, + { + "title": "Flux + PuLID loaders", + "bounding": [430, 0, 335, 640], + "color": "#a1309b", + "font_size": 24 + }, + { + "title": "Apply + condition", + "bounding": [790, 0, 335, 620], + "color": "#629930", + "font_size": 24 + }, + { + "title": "Sample → decode → save", + "bounding": [1510, 0, 335, 700], + "color": "#b58b2a", + "font_size": 24 + } + ], + "config": {}, + "extra": {}, + "version": 0.4 +} diff --git a/nodes/__init__.py b/nodes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nodes/_types.py b/nodes/_types.py new file mode 100644 index 0000000..09540c1 --- /dev/null +++ b/nodes/_types.py @@ -0,0 +1,141 @@ +"""Shared constants and helpers used by the AgeShift nodes.""" + +# nateraw/vit-age-classifier 标签 → 该区间的中位数(用于加权平均年龄) +AGE_BIN_MIDPOINTS = { + "0-2": 1.0, + "3-9": 6.0, + "10-19": 14.5, + "20-29": 24.5, + "30-39": 34.5, + "40-49": 44.5, + "50-59": 54.5, + "60-69": 64.5, + "more than 70": 75.0, +} + + +def age_band(age: int) -> str: + """粗分年龄段标签。""" + if age < 3: + return "infant" + if age < 13: + return "child" + if age < 20: + return "teenager" + if age < 30: + return "young adult" + if age < 45: + return "adult" + if age < 60: + return "middle-aged" + if age < 75: + return "senior" + return "elderly" + + +def age_features(age: int) -> str: + """Return a comma-separated string of physical features for a given age. + + Concrete descriptors work much better than just numbers for Flux/SDXL — + e.g. 'deep nasolabial folds, crow's feet' beats 'a 60 year old'. + """ + if age < 3: + return ( + "infant, round soft chubby face, large innocent eyes, very smooth skin, " + "tiny features, fine wispy hair" + ) + elif age < 6: + return ( + "young toddler, round face, big bright eyes, smooth soft skin, " + "baby teeth, innocent expression" + ) + elif age < 10: + return ( + "young child, round youthful face, bright clear eyes, perfectly smooth skin, " + "no facial hair, soft fine hair" + ) + elif age < 13: + return ( + "pre-teen child, slightly more defined features, clear smooth skin, " + "bright eyes, youthful softness, full thick hair" + ) + elif age < 16: + return ( + "early teenager, transitional features, fresh smooth skin, occasional minor blemishes, " + "youthful face shape, thick hair" + ) + elif age < 20: + return ( + "late teenager, defined youthful features, smooth clear skin, " + "full thick hair, sharp jawline beginning to form, no wrinkles" + ) + elif age < 25: + return ( + "young adult in early twenties, smooth taut skin, fully developed features, " + "full thick hair, vibrant complexion, no visible wrinkles" + ) + elif age < 30: + return ( + "young adult in late twenties, smooth healthy skin, mature defined features, " + "full hair, very faint laugh lines only when smiling" + ) + elif age < 35: + return ( + "adult in early thirties, slightly more mature skin, very subtle fine lines around eyes, " + "full hair, healthy clear complexion" + ) + elif age < 40: + return ( + "adult in late thirties, mature features, subtle fine lines around eyes and forehead, " + "slight nasolabial folds beginning, full hair perhaps thinning slightly" + ) + elif age < 45: + return ( + "early middle-aged adult, fine wrinkles around eyes and forehead, " + "defined nasolabial folds, possibly slight graying at temples" + ) + elif age < 50: + return ( + "middle-aged adult, visible fine wrinkles, deeper nasolabial folds, " + "gray strands appearing in hair, slight skin texture changes" + ) + elif age < 55: + return ( + "early fifties, clear wrinkles around eyes and mouth, salt-and-pepper hair, " + "slight jowl formation, mature skin texture" + ) + elif age < 60: + return ( + "late fifties, pronounced wrinkles, mostly graying hair, jowls forming, " + "mature skin with some age spots beginning" + ) + elif age < 65: + return ( + "early sixties, deep wrinkles, predominantly gray hair possibly receding, " + "sagging skin around jaw, visible age spots" + ) + elif age < 70: + return ( + "late sixties, deep facial wrinkles, gray or white hair often thinning, " + "sagging jowls, age spots, looser skin" + ) + elif age < 75: + return ( + "early seventies, deeply wrinkled face, white or thin gray hair, " + "pronounced sagging skin, age spots, hollowing around eyes" + ) + elif age < 80: + return ( + "late seventies, very deep wrinkles all over face, thin white hair, " + "significantly sagging skin, prominent age spots, frail features" + ) + elif age < 85: + return ( + "early eighties, profoundly weathered face, sparse white hair, " + "very loose sagging skin, hollow cheeks, frail elderly features" + ) + else: + return ( + "very elderly, deeply weathered and wrinkled face, sparse thin white hair, " + "extremely loose papery skin, hollow features, fragile appearance" + ) diff --git a/nodes/detect.py b/nodes/detect.py new file mode 100644 index 0000000..ba49ae9 --- /dev/null +++ b/nodes/detect.py @@ -0,0 +1,82 @@ +"""Run age classification on a ComfyUI IMAGE tensor.""" + +import numpy as np + +from ._types import AGE_BIN_MIDPOINTS, age_band + + +class AgeDetect: + """Estimate the age of the largest/dominant face in an image. + + Note: this is whole-image classification — the input should be a portrait + cropped to the face. For arbitrary photos, run a face-detect crop step first + (e.g. via FaceCrop nodes or InsightFace). + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "detector": ("AGE_DETECTOR",), + "image": ("IMAGE",), + }, + "optional": { + "method": (["weighted_mean", "argmax_midpoint"], {"default": "weighted_mean"}), + }, + } + + RETURN_TYPES = ("INT", "STRING", "FLOAT", "STRING") + RETURN_NAMES = ("age", "age_band", "confidence", "summary") + FUNCTION = "detect" + CATEGORY = "🎂 AgeShift" + + def detect(self, detector, image, method="weighted_mean"): + import torch + from PIL import Image + + model = detector["model"] + processor = detector["processor"] + device = detector["device"] + id2label = detector["id2label"] + + # ComfyUI IMAGE: torch tensor [B, H, W, 3] in 0..1 + img = image[0] + if hasattr(img, "cpu"): + arr = (img.cpu().float().numpy() * 255.0).clip(0, 255).astype(np.uint8) + else: + arr = (np.asarray(img) * 255.0).clip(0, 255).astype(np.uint8) + pil = Image.fromarray(arr) + + inputs = processor(images=pil, return_tensors="pt") + inputs = {k: v.to(device) for k, v in inputs.items()} + + with torch.no_grad(): + logits = model(**inputs).logits + probs = torch.softmax(logits.float(), dim=-1)[0].cpu().numpy() + + if method == "weighted_mean": + total = 0.0 + for idx, p in enumerate(probs): + label = id2label[idx] + mid = AGE_BIN_MIDPOINTS.get(label, 30.0) + total += float(p) * mid + estimated_age = int(round(total)) + top_idx = int(np.argmax(probs)) + confidence = float(probs[top_idx]) + else: # argmax_midpoint + top_idx = int(np.argmax(probs)) + label = id2label[top_idx] + estimated_age = int(round(AGE_BIN_MIDPOINTS.get(label, 30.0))) + confidence = float(probs[top_idx]) + + band = age_band(estimated_age) + top_label = id2label[int(np.argmax(probs))] + top3_idx = np.argsort(probs)[::-1][:3] + top3 = ", ".join(f"{id2label[i]}={probs[i]:.2f}" for i in top3_idx) + + summary = ( + f"Estimated age: {estimated_age} ({band}) | " + f"top bin: {top_label} ({confidence:.1%}) | " + f"top-3: {top3}" + ) + return (estimated_age, band, confidence, summary) diff --git a/nodes/detector_loader.py b/nodes/detector_loader.py new file mode 100644 index 0000000..27f33d0 --- /dev/null +++ b/nodes/detector_loader.py @@ -0,0 +1,69 @@ +"""Loader for the age-classification model (ViT).""" + +_MODEL_CACHE = {} + + +class AgeDetectorLoader: + """Load a HuggingFace age-classification model and processor. + + Default model: nateraw/vit-age-classifier (~330MB, 9 age bins). + Other compatible HF image-classification models can be specified by ID. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model_id": ("STRING", {"default": "nateraw/vit-age-classifier"}), + "device": (["auto", "cuda", "cuda:0", "cuda:1", "cpu"], {"default": "auto"}), + "dtype": (["fp32", "fp16", "bf16"], {"default": "fp32"}), + }, + "optional": { + "force_reload": ("BOOLEAN", {"default": False}), + }, + } + + RETURN_TYPES = ("AGE_DETECTOR",) + RETURN_NAMES = ("detector",) + FUNCTION = "load" + CATEGORY = "🎂 AgeShift" + + def load(self, model_id, device, dtype, force_reload=False): + cache_key = f"{model_id}|{device}|{dtype}" + if not force_reload and cache_key in _MODEL_CACHE: + return (_MODEL_CACHE[cache_key],) + + # lazy imports — keep top-level light + import torch + from transformers import AutoImageProcessor, AutoModelForImageClassification + + if device == "auto": + actual_device = "cuda" if torch.cuda.is_available() else "cpu" + else: + actual_device = device + + dtype_map = {"fp32": torch.float32, "fp16": torch.float16, "bf16": torch.bfloat16} + torch_dtype = dtype_map[dtype] + if actual_device == "cpu": + # CPU 上 fp16/bf16 容易出问题,强制 fp32 + torch_dtype = torch.float32 + + processor = AutoImageProcessor.from_pretrained(model_id) + model = AutoModelForImageClassification.from_pretrained(model_id, torch_dtype=torch_dtype) + model.to(actual_device) + model.eval() + + bundle = { + "model": model, + "processor": processor, + "device": actual_device, + "dtype": dtype, + "id2label": model.config.id2label, + "model_id": model_id, + } + _MODEL_CACHE[cache_key] = bundle + return (bundle,) + + @classmethod + def IS_CHANGED(cls, **kwargs): + return "|".join(f"{k}={v}" for k, v in sorted(kwargs.items())) diff --git a/nodes/prompt_builder.py b/nodes/prompt_builder.py new file mode 100644 index 0000000..43a75c0 --- /dev/null +++ b/nodes/prompt_builder.py @@ -0,0 +1,167 @@ +"""Build age-conditioned positive/negative prompts.""" + +import json + +from ._types import age_band, age_features + + +class AgePromptBuilder: + """Construct a (positive, negative) prompt pair for a target age. + + Designed to feed CLIPTextEncode → KSampler in a Flux / SDXL workflow + combined with PuLID / InstantID for identity preservation. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "target_age": ("INT", {"default": 30, "min": 0, "max": 120, "step": 1}), + }, + "optional": { + "subject": ("STRING", {"default": "person", "multiline": False}), + "gender": ( + ["unspecified", "male", "female", "non-binary"], + {"default": "unspecified"}, + ), + "style": ( + "STRING", + { + "default": "photorealistic portrait, sharp focus, natural studio lighting, neutral background", + "multiline": True, + }, + ), + "extra_positive": ( + "STRING", + {"default": "same identity, same facial structure, same eye shape", "multiline": True}, + ), + "negative_base": ( + "STRING", + { + "default": ( + "cartoon, illustration, painting, anime, 3d render, " + "deformed, distorted, blurry, low quality, plastic skin, " + "extra fingers, watermark, text" + ), + "multiline": True, + }, + ), + "include_negative_age_drift": ("BOOLEAN", {"default": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "INT") + RETURN_NAMES = ("positive", "negative", "summary", "target_age") + FUNCTION = "build" + CATEGORY = "🎂 AgeShift" + + def build( + self, + target_age, + subject="person", + gender="unspecified", + style="photorealistic portrait", + extra_positive="", + negative_base="", + include_negative_age_drift=True, + ): + band = age_band(target_age) + features = age_features(target_age) + gender_part = "" if gender == "unspecified" else f"{gender} " + subject_str = f"a {target_age} year old {gender_part}{subject}" + + positive_parts = [subject_str, features] + if style.strip(): + positive_parts.append(style.strip()) + if extra_positive.strip(): + positive_parts.append(extra_positive.strip()) + positive = ", ".join(positive_parts) + + neg_parts = [] + if negative_base.strip(): + neg_parts.append(negative_base.strip()) + if include_negative_age_drift: + anti = [] + if band in ("young adult", "adult", "middle-aged", "senior", "elderly"): + anti.append("baby face, infant features, childish proportions") + if band in ("middle-aged", "senior", "elderly"): + anti.append("teenage features, youthful smooth skin") + if band in ("infant", "child", "teenager", "young adult"): + anti.append("deep wrinkles, gray hair, age spots, sagging skin") + if band in ("infant", "child"): + anti.append("facial hair, beard, mustache, adult features") + if anti: + neg_parts.append(", ".join(anti)) + negative = ", ".join(neg_parts) + + summary = f"target={target_age} ({band}) | pos_chars={len(positive)} | neg_chars={len(negative)}" + return (positive, negative, summary, int(target_age)) + + +class AgeRangeBatch: + """Emit a JSONL list of (age, band, prompt) tuples for a batch CLI workflow. + + This node is mostly useful for the CLI / batch tooling — it doesn't drive a + KSampler directly, since ComfyUI evaluates one prompt per submission. The + CLI consumes this output to loop over ages. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "ages_csv": ("STRING", {"default": "5,15,25,45,65", "multiline": False}), + }, + "optional": { + "subject": ("STRING", {"default": "person"}), + "gender": ( + ["unspecified", "male", "female", "non-binary"], + {"default": "unspecified"}, + ), + "style": ( + "STRING", + {"default": "photorealistic portrait, sharp focus, natural lighting", "multiline": True}, + ), + "extra_positive": ("STRING", {"default": "same identity", "multiline": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "INT") + RETURN_NAMES = ("prompts_jsonl", "ages_csv", "count") + FUNCTION = "build" + CATEGORY = "🎂 AgeShift" + + def build( + self, + ages_csv, + subject="person", + gender="unspecified", + style="photorealistic portrait", + extra_positive="same identity", + ): + ages = [] + for s in ages_csv.split(","): + s = s.strip() + if not s: + continue + try: + ages.append(int(s)) + except ValueError: + continue + + gender_part = "" if gender == "unspecified" else f"{gender} " + lines = [] + for a in ages: + features = age_features(a) + band = age_band(a) + prompt = ( + f"a {a} year old {gender_part}{subject}, {features}, " + f"{style}, {extra_positive}" + ) + lines.append( + json.dumps( + {"age": a, "band": band, "prompt": prompt}, + ensure_ascii=False, + ) + ) + return ("\n".join(lines), ",".join(str(a) for a in ages), len(ages)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f6bb344 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "comfyui-ageshift" +version = "0.1.1" +description = "ComfyUI nodes for face age detection and age-conditioned prompt building" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "aadebuger" }] +keywords = ["comfyui", "age-detection", "age-progression", "face", "pulid", "flux"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "transformers>=4.40.0", + "pillow>=10.0.0", + "numpy>=1.24", +] + +[project.urls] +Homepage = "https://github.com/aadebuger/ComfyUI-AgeShift" +Repository = "https://github.com/aadebuger/ComfyUI-AgeShift" +Issues = "https://github.com/aadebuger/ComfyUI-AgeShift/issues" + +[tool.setuptools] +packages = ["nodes"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..399902d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +transformers>=4.40.0 +pillow>=10.0.0 +numpy>=1.24