Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
215663177a |
@@ -1,6 +1,6 @@
|
||||
# 可用发型清单
|
||||
|
||||
> 当前共 **179 个发型**可用于换发型功能(hair_flow 训练完成后为 180 个)。
|
||||
> 当前共 **180 个发型**可用于换发型功能。
|
||||
> 数据来源:`/api/hairstyles` 接口实时返回,要求发型同时具备「LoRA权重 + ref材质 + config.json」三件齐全。
|
||||
> 全量清单见同目录 `hairstyles_available.csv`。
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
|
||||
| 类别 | 数量 | 说明 |
|
||||
|------|------|------|
|
||||
| **总可用发型** | **179** | 三件齐全,可直接换发型 |
|
||||
| **总可用发型** | **180** | 三件齐全,可直接换发型 |
|
||||
| ├ 男款 (boy) | 93 | 适合男性脸型 |
|
||||
| ├ 女款 (girl) | 86 | 适合女性脸型 |
|
||||
| ├ 中文名命名 | 7 | 本次按脸型训练的新发型 |
|
||||
| ├ 英文名命名 | 15 | 历史测试发型 |
|
||||
| ├ 女款 (girl) | 87 | 适合女性脸型(+hair_flow) |
|
||||
| ├ 中文名命名 | 7 | 按脸型训练的新发型 |
|
||||
| ├ 英文名命名 | 16 | 历史测试发型 + hair_flow |
|
||||
| └ 数字ID命名 | 157 | 生产环境发型(雪花算法ID) |
|
||||
|
||||
**性别说明**:gender 字段决定发型被分到"男款/女款",但换发型时**男女发型都能用**(只是预览图用对应性别的标准脸)。
|
||||
@@ -55,6 +55,7 @@
|
||||
|
||||
| hair_id | gender | 备注 |
|
||||
|---------|--------|------|
|
||||
| **hair_flow** | **girl** | **波浪卷发,最新训练(2026-07-07)** |
|
||||
| huaban1 | girl | 画板1 |
|
||||
| huaban2 | girl | 画板2 |
|
||||
| new_test_001 | boy | 测试 |
|
||||
@@ -104,8 +105,9 @@
|
||||
|
||||
### 2. 调试页(带可视化)
|
||||
- `http://localhost:8888/debug` — 全参数调试台
|
||||
- `http://localhost:8888/manual` — 手绘 mask 重绘
|
||||
- `http://localhost:8888/hairline` — 发际线 mask 实验
|
||||
- `http://localhost:8888/manual` — 手绘 mask 换发型(含 enhance 二次增强 + 羽化贴回)
|
||||
- `http://localhost:8888/inpaint` — **mask区域重绘**(发丝自然化,enhance/pure_inpaint两种模式)
|
||||
- `http://localhost:8888/hairline` — 发际线 mask 实验(4种方案对比)
|
||||
|
||||
### 3. API 调用
|
||||
```bash
|
||||
|
||||
+181
-180
@@ -1,180 +1,181 @@
|
||||
hair_id,gender,预览图,LoRA大小MB
|
||||
1905785164224868354,girl,有,144
|
||||
1907641420518580226,boy,有,144
|
||||
1907641442341543937,boy,有,144
|
||||
1907641528173780994,boy,有,144
|
||||
1907650883958923266,boy,有,144
|
||||
1907650924350070785,boy,有,144
|
||||
1907651105032298498,boy,有,144
|
||||
1907651244006367233,boy,有,144
|
||||
1907651308460236802,girl,有,144
|
||||
1907651348528422914,girl,有,144
|
||||
1907651413477220354,girl,有,144
|
||||
1907651464794529794,girl,有,144
|
||||
1907651525024735234,girl,有,144
|
||||
1907651578753769473,girl,有,144
|
||||
1907651632629604354,girl,有,144
|
||||
1907651680352395265,girl,有,144
|
||||
1907651747310264321,girl,有,144
|
||||
1907805226322419713,boy,有,144
|
||||
1929905737381048322,boy,有,144
|
||||
1929905770088230914,boy,有,144
|
||||
1929906202869100546,boy,有,144
|
||||
1929906230224351234,boy,有,144
|
||||
1929906315553271809,boy,有,144
|
||||
1929906349401305089,boy,有,144
|
||||
1929906429839667202,boy,有,144
|
||||
1935354779623858177,boy,有,144
|
||||
1935354812175851522,boy,有,144
|
||||
1935354869738479617,boy,有,144
|
||||
1935354938051108865,boy,有,144
|
||||
1935355030892027905,boy,有,144
|
||||
1935355149217538050,girl,有,144
|
||||
1935355188652384257,girl,有,144
|
||||
1935355258143612929,girl,有,144
|
||||
1935355292960530433,girl,有,144
|
||||
1935355361726144514,girl,有,144
|
||||
1935355403207811074,girl,有,144
|
||||
1935355449991077889,girl,有,144
|
||||
1935355504156319745,girl,有,144
|
||||
1935355551300296705,girl,有,144
|
||||
1935355607394918402,girl,有,144
|
||||
1935355698319040514,girl,有,144
|
||||
1935355740559876097,girl,有,144
|
||||
1935355778526715905,girl,有,144
|
||||
1935355868347736065,girl,有,144
|
||||
1935355934743568386,girl,有,144
|
||||
1935355969141055490,girl,有,144
|
||||
1935356042201636865,girl,有,144
|
||||
1935356092684279809,girl,有,144
|
||||
1935356131338985473,girl,有,144
|
||||
1935356178227109890,girl,有,144
|
||||
1938489620162781185,boy,有,144
|
||||
1938489674713899010,boy,有,144
|
||||
1938489739088076801,boy,有,144
|
||||
1938489840707674114,boy,有,144
|
||||
1938489915521474561,boy,有,144
|
||||
1938489994072399874,boy,有,144
|
||||
1938490058547240961,boy,有,144
|
||||
1938492790033195009,girl,有,144
|
||||
1938492830059438081,girl,有,144
|
||||
1938841049238970370,girl,有,144
|
||||
1938848709308948482,girl,有,144
|
||||
1938848730150445058,girl,有,144
|
||||
1938848782956732417,girl,有,144
|
||||
1939330299344560130,girl,有,144
|
||||
1939339435616608258,girl,有,144
|
||||
1939351219396255745,boy,有,144
|
||||
1939351238526476289,boy,有,144
|
||||
1939351319849836545,boy,有,144
|
||||
1939351392453238785,girl,有,144
|
||||
1939351512506802177,girl,有,144
|
||||
1940007538260287490,boy,有,144
|
||||
1941163289636872193,boy,有,144
|
||||
1941163324332154882,boy,有,144
|
||||
1941163373069967362,boy,有,144
|
||||
1941163615962112002,boy,有,144
|
||||
1941163645464846338,boy,有,144
|
||||
1941163754357366785,boy,有,144
|
||||
1941163816533729282,boy,有,144
|
||||
1941163884078800898,boy,有,144
|
||||
1941163952840220673,boy,有,144
|
||||
1941164012827156482,boy,有,144
|
||||
1941352458711572482,boy,有,144
|
||||
1941352541028982786,boy,有,144
|
||||
1941352599665352705,boy,有,144
|
||||
1943143930804936705,boy,有,144
|
||||
1943144007388733441,boy,有,144
|
||||
1945789428732895233,boy,有,144
|
||||
1953267161121464322,boy,有,144
|
||||
1953267288284372994,boy,有,144
|
||||
1953270137953230849,girl,有,144
|
||||
1953270523594317826,girl,有,144
|
||||
1953270724765720577,boy,有,144
|
||||
1953271922151432194,boy,有,144
|
||||
1953272913265467394,boy,有,144
|
||||
1953288800932503554,boy,有,144
|
||||
1954536242109784066,boy,有,144
|
||||
1954536297776586754,boy,有,144
|
||||
1954536402516746241,boy,有,144
|
||||
1954536893304840194,boy,有,144
|
||||
1954536956919848962,boy,有,144
|
||||
1954537022875279362,boy,有,144
|
||||
1954537085882114050,boy,有,144
|
||||
1954537191729569793,boy,有,144
|
||||
1954537709436706817,girl,有,144
|
||||
1954537743532204034,girl,有,144
|
||||
1954537926731014146,girl,有,144
|
||||
1954537961069780994,girl,有,144
|
||||
1954538119216013313,girl,有,144
|
||||
1954538156499181569,girl,有,144
|
||||
1954538259330932738,girl,有,144
|
||||
1954538335306555393,girl,有,144
|
||||
1980312945301954561,boy,有,144
|
||||
1980313052302843905,girl,有,144
|
||||
1980313166576656385,girl,有,144
|
||||
1980313210109337601,girl,有,144
|
||||
1980313433502162946,boy,有,144
|
||||
1980313576506957825,girl,有,144
|
||||
1980313740403580930,girl,有,144
|
||||
1980313819915001858,girl,有,144
|
||||
1980314032369082369,girl,有,144
|
||||
1980314146483511297,girl,有,144
|
||||
1980314209322573825,girl,有,144
|
||||
1980314290419441665,girl,有,144
|
||||
1980314381058351106,girl,有,144
|
||||
1980314477720281090,girl,有,144
|
||||
1980314710101499906,boy,有,144
|
||||
1980314764421931009,boy,有,144
|
||||
1980314868713299969,boy,有,144
|
||||
1980315054516772866,boy,有,144
|
||||
1980315179934851074,girl,有,144
|
||||
1980315238260842497,girl,有,144
|
||||
1980361636742209537,girl,有,144
|
||||
1980367742952579074,boy,有,144
|
||||
1980367781510815746,boy,有,144
|
||||
1980367800787836929,boy,有,144
|
||||
1980367852847538177,boy,有,144
|
||||
1980367968627105793,boy,有,144
|
||||
1980368109866098690,boy,有,144
|
||||
1980368205752082434,boy,有,144
|
||||
1980368368847593473,boy,有,144
|
||||
1980368465631158274,boy,有,144
|
||||
1980368588293578754,boy,有,144
|
||||
1980368787732733953,boy,有,144
|
||||
1980368837162606594,boy,有,144
|
||||
1980368937435832321,boy,有,144
|
||||
1980413320612847618,girl,有,144
|
||||
1980415346071609345,girl,有,144
|
||||
1980415897056354306,girl,有,144
|
||||
1980422351658196994,boy,有,144
|
||||
1980422373388886018,boy,有,144
|
||||
1981177324700549122,girl,有,144
|
||||
1981177378714796034,boy,有,144
|
||||
1981177477423546370,boy,有,144
|
||||
1981177594750812162,boy,有,144
|
||||
1981177742742634497,boy,有,144
|
||||
1981177931859607554,boy,有,144
|
||||
3325266,girl,有,144
|
||||
huaban1,girl,有,144
|
||||
huaban2,girl,有,144
|
||||
new_test_001,boy,有,144
|
||||
newf_1214,girl,有,144
|
||||
newf_12141,girl,有,144
|
||||
newf_121445553,boy,有,144
|
||||
newf_12314,girl,有,144
|
||||
newf_1235314,girl,有,144
|
||||
newf_204,girl,有,144
|
||||
newf_216,girl,有,144
|
||||
test001,boy,有,144
|
||||
test002,girl,有,144
|
||||
test003,boy,有,144
|
||||
test0036,boy,有,144
|
||||
test888,boy,有,144
|
||||
圆-心形,girl,有,144
|
||||
圆-椭圆,girl,有,144
|
||||
圆-波浪,girl,有,144
|
||||
圆-直线,girl,有,144
|
||||
圆-花瓣,girl,有,144
|
||||
心形-心形,girl,有,144
|
||||
心形-椭圆,girl,有,144
|
||||
hair_id,gender
|
||||
1905785164224868354,girl
|
||||
1907641420518580226,boy
|
||||
1907641442341543937,boy
|
||||
1907641528173780994,boy
|
||||
1907650883958923266,boy
|
||||
1907650924350070785,boy
|
||||
1907651105032298498,boy
|
||||
1907651244006367233,boy
|
||||
1907651308460236802,girl
|
||||
1907651348528422914,girl
|
||||
1907651413477220354,girl
|
||||
1907651464794529794,girl
|
||||
1907651525024735234,girl
|
||||
1907651578753769473,girl
|
||||
1907651632629604354,girl
|
||||
1907651680352395265,girl
|
||||
1907651747310264321,girl
|
||||
1907805226322419713,boy
|
||||
1929905737381048322,boy
|
||||
1929905770088230914,boy
|
||||
1929906202869100546,boy
|
||||
1929906230224351234,boy
|
||||
1929906315553271809,boy
|
||||
1929906349401305089,boy
|
||||
1929906429839667202,boy
|
||||
1935354779623858177,boy
|
||||
1935354812175851522,boy
|
||||
1935354869738479617,boy
|
||||
1935354938051108865,boy
|
||||
1935355030892027905,boy
|
||||
1935355149217538050,girl
|
||||
1935355188652384257,girl
|
||||
1935355258143612929,girl
|
||||
1935355292960530433,girl
|
||||
1935355361726144514,girl
|
||||
1935355403207811074,girl
|
||||
1935355449991077889,girl
|
||||
1935355504156319745,girl
|
||||
1935355551300296705,girl
|
||||
1935355607394918402,girl
|
||||
1935355698319040514,girl
|
||||
1935355740559876097,girl
|
||||
1935355778526715905,girl
|
||||
1935355868347736065,girl
|
||||
1935355934743568386,girl
|
||||
1935355969141055490,girl
|
||||
1935356042201636865,girl
|
||||
1935356092684279809,girl
|
||||
1935356131338985473,girl
|
||||
1935356178227109890,girl
|
||||
1938489620162781185,boy
|
||||
1938489674713899010,boy
|
||||
1938489739088076801,boy
|
||||
1938489840707674114,boy
|
||||
1938489915521474561,boy
|
||||
1938489994072399874,boy
|
||||
1938490058547240961,boy
|
||||
1938492790033195009,girl
|
||||
1938492830059438081,girl
|
||||
1938841049238970370,girl
|
||||
1938848709308948482,girl
|
||||
1938848730150445058,girl
|
||||
1938848782956732417,girl
|
||||
1939330299344560130,girl
|
||||
1939339435616608258,girl
|
||||
1939351219396255745,boy
|
||||
1939351238526476289,boy
|
||||
1939351319849836545,boy
|
||||
1939351392453238785,girl
|
||||
1939351512506802177,girl
|
||||
1940007538260287490,boy
|
||||
1941163289636872193,boy
|
||||
1941163324332154882,boy
|
||||
1941163373069967362,boy
|
||||
1941163615962112002,boy
|
||||
1941163645464846338,boy
|
||||
1941163754357366785,boy
|
||||
1941163816533729282,boy
|
||||
1941163884078800898,boy
|
||||
1941163952840220673,boy
|
||||
1941164012827156482,boy
|
||||
1941352458711572482,boy
|
||||
1941352541028982786,boy
|
||||
1941352599665352705,boy
|
||||
1943143930804936705,boy
|
||||
1943144007388733441,boy
|
||||
1945789428732895233,boy
|
||||
1953267161121464322,boy
|
||||
1953267288284372994,boy
|
||||
1953270137953230849,girl
|
||||
1953270523594317826,girl
|
||||
1953270724765720577,boy
|
||||
1953271922151432194,boy
|
||||
1953272913265467394,boy
|
||||
1953288800932503554,boy
|
||||
1954536242109784066,boy
|
||||
1954536297776586754,boy
|
||||
1954536402516746241,boy
|
||||
1954536893304840194,boy
|
||||
1954536956919848962,boy
|
||||
1954537022875279362,boy
|
||||
1954537085882114050,boy
|
||||
1954537191729569793,boy
|
||||
1954537709436706817,girl
|
||||
1954537743532204034,girl
|
||||
1954537926731014146,girl
|
||||
1954537961069780994,girl
|
||||
1954538119216013313,girl
|
||||
1954538156499181569,girl
|
||||
1954538259330932738,girl
|
||||
1954538335306555393,girl
|
||||
1980312945301954561,boy
|
||||
1980313052302843905,girl
|
||||
1980313166576656385,girl
|
||||
1980313210109337601,girl
|
||||
1980313433502162946,boy
|
||||
1980313576506957825,girl
|
||||
1980313740403580930,girl
|
||||
1980313819915001858,girl
|
||||
1980314032369082369,girl
|
||||
1980314146483511297,girl
|
||||
1980314209322573825,girl
|
||||
1980314290419441665,girl
|
||||
1980314381058351106,girl
|
||||
1980314477720281090,girl
|
||||
1980314710101499906,boy
|
||||
1980314764421931009,boy
|
||||
1980314868713299969,boy
|
||||
1980315054516772866,boy
|
||||
1980315179934851074,girl
|
||||
1980315238260842497,girl
|
||||
1980361636742209537,girl
|
||||
1980367742952579074,boy
|
||||
1980367781510815746,boy
|
||||
1980367800787836929,boy
|
||||
1980367852847538177,boy
|
||||
1980367968627105793,boy
|
||||
1980368109866098690,boy
|
||||
1980368205752082434,boy
|
||||
1980368368847593473,boy
|
||||
1980368465631158274,boy
|
||||
1980368588293578754,boy
|
||||
1980368787732733953,boy
|
||||
1980368837162606594,boy
|
||||
1980368937435832321,boy
|
||||
1980413320612847618,girl
|
||||
1980415346071609345,girl
|
||||
1980415897056354306,girl
|
||||
1980422351658196994,boy
|
||||
1980422373388886018,boy
|
||||
1981177324700549122,girl
|
||||
1981177378714796034,boy
|
||||
1981177477423546370,boy
|
||||
1981177594750812162,boy
|
||||
1981177742742634497,boy
|
||||
1981177931859607554,boy
|
||||
3325266,girl
|
||||
hair_flow,girl
|
||||
huaban1,girl
|
||||
huaban2,girl
|
||||
new_test_001,boy
|
||||
newf_1214,girl
|
||||
newf_12141,girl
|
||||
newf_121445553,boy
|
||||
newf_12314,girl
|
||||
newf_1235314,girl
|
||||
newf_204,girl
|
||||
newf_216,girl
|
||||
test001,boy
|
||||
test002,girl
|
||||
test003,boy
|
||||
test0036,boy
|
||||
test888,boy
|
||||
圆-心形,girl
|
||||
圆-椭圆,girl
|
||||
圆-波浪,girl
|
||||
圆-直线,girl
|
||||
圆-花瓣,girl
|
||||
心形-心形,girl
|
||||
心形-椭圆,girl
|
||||
|
||||
|
@@ -87,6 +87,12 @@ def manual_page():
|
||||
return send_from_directory(os.path.join(BASE_DIR, "static"), "manual.html")
|
||||
|
||||
|
||||
@app.route("/inpaint")
|
||||
def inpaint_page():
|
||||
"""mask区域重绘测试页(纯发丝增强/自然重绘)"""
|
||||
return send_from_directory(os.path.join(BASE_DIR, "static"), "inpaint.html")
|
||||
|
||||
|
||||
@app.route("/api/swap", methods=["POST"])
|
||||
def api_swap_proxy():
|
||||
"""代理换发型请求到 8801(避免前端跨域问题)"""
|
||||
@@ -340,6 +346,7 @@ def api_swap_manual():
|
||||
blend_dilate=(int(bd[0]), int(bd[1])),
|
||||
seamless_dilate=(int(sd[0]), int(sd[1])),
|
||||
feather_px=int(g("feather_px", 0)),
|
||||
feather_alpha=float(g("feather_alpha", 1.0)),
|
||||
enhance=bool(g("enhance", False)),
|
||||
enhance_denoising=float(g("enhance_denoising", 0.35)),
|
||||
)
|
||||
@@ -402,14 +409,25 @@ def preview_img(hair_id):
|
||||
|
||||
@app.route("/train_src/<hair_id>")
|
||||
def train_src_img(hair_id):
|
||||
"""返回发型的训练原图(hair_type_images/<hair_id>.jpg/.png)。
|
||||
用于测试页展示发型真实样子,而非套在标准脸上的效果图。
|
||||
"""返回发型的训练原图,用于测试页展示发型真实样子。
|
||||
依次查找:hair_type_images/ → images/ → train_material/<id>/images 输入 → 预览图(兜底)。
|
||||
"""
|
||||
src_dir = "/home/xsl/change_hair/hair_type_images"
|
||||
# 候选源目录(按优先级)
|
||||
candidate_dirs = [
|
||||
"/home/xsl/change_hair/hair_type_images",
|
||||
"/home/xsl/change_hair/images",
|
||||
"/home/xsl/change_hair/data/batch_train_inputs/" + hair_id,
|
||||
]
|
||||
for src_dir in candidate_dirs:
|
||||
for ext in (".jpg", ".jpeg", ".png"):
|
||||
path = os.path.join(src_dir, hair_id + ext)
|
||||
if os.path.exists(path):
|
||||
return send_from_directory(src_dir, hair_id + ext)
|
||||
# 兜底:返回套脸预览图(previews/<hair_id>.jpg)
|
||||
preview_dir = os.path.join(BASE_DIR, "static", "previews")
|
||||
preview_path = os.path.join(preview_dir, hair_id + ".jpg")
|
||||
if os.path.exists(preview_path):
|
||||
return send_from_directory(preview_dir, hair_id + ".jpg")
|
||||
return ("", 404)
|
||||
|
||||
|
||||
@@ -507,6 +525,59 @@ def api_grow():
|
||||
return jsonify({"state": -1, "msg": f"生发失败: {e}"}), 500
|
||||
|
||||
|
||||
@app.route("/api/inpaint", methods=["POST"])
|
||||
def api_inpaint():
|
||||
"""mask区域重绘接口(严格只在mask区做SD inpainting)。
|
||||
|
||||
入参 JSON:
|
||||
img: 原图 base64
|
||||
mask: 手绘mask base64(白色=重绘区)
|
||||
prompt: 提示词(空则用模式默认值)
|
||||
denoising_strength: 重绘强度 0~1(默认0.5)
|
||||
mode: "enhance" 或 "pure_inpaint"(默认enhance)
|
||||
返回: {state, result, mask_used, info}
|
||||
"""
|
||||
try:
|
||||
d = request.json
|
||||
img_b64 = d.get("img", "")
|
||||
mask_b64 = d.get("mask", "")
|
||||
if not img_b64 or not mask_b64:
|
||||
return jsonify({"state": -1, "msg": "img 和 mask 不能为空(请先手绘重绘区域)"}), 400
|
||||
|
||||
img = _b64_to_ndarray(img_b64, color=True)
|
||||
mask = _b64_to_ndarray(mask_b64, color=False)
|
||||
if img is None or mask is None:
|
||||
return jsonify({"state": -1, "msg": "img 或 mask 解析失败"}), 400
|
||||
|
||||
prompt = d.get("prompt", "")
|
||||
denoising = float(d.get("denoising_strength", 0.5))
|
||||
mode = d.get("mode", "enhance")
|
||||
if mode not in ("enhance", "pure_inpaint"):
|
||||
mode = "enhance"
|
||||
|
||||
print(f"[inpaint] mode={mode}, denoising={denoising}, prompt={prompt[:40]}, img={img.shape}")
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/home/xsl/change_hair/project/hair_service_sd")
|
||||
from inpaint_mask import inpaint_mask as do_inpaint
|
||||
result, mask_used, info = do_inpaint(
|
||||
img, mask, prompt=prompt,
|
||||
denoising_strength=denoising, mode=mode
|
||||
)
|
||||
|
||||
_, buf = cv2.imencode(".jpg", result, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||
result_b64 = base64.b64encode(buf).decode("utf-8")
|
||||
_, mbuf = cv2.imencode(".png", mask_used)
|
||||
mask_used_b64 = "data:image/png;base64," + base64.b64encode(mbuf).decode("utf-8")
|
||||
|
||||
return jsonify({"state": 0, "result": result_b64,
|
||||
"mask_used": mask_used_b64, "info": info})
|
||||
except Exception as e:
|
||||
print(f"[inpaint] 失败: {e}")
|
||||
traceback.print_exc()
|
||||
return jsonify({"state": -1, "msg": f"mask重绘失败: {e}"}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"生发服务启动,端口 {PORT}")
|
||||
print(f"测试页面: http://0.0.0.0:{PORT}")
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>mask区域重绘</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; font-size: 14px; }
|
||||
.header { background: #16213e; padding: 16px 28px; border-bottom: 1px solid #0f3460; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 19px; font-weight: 600; }
|
||||
.header p { font-size: 12px; color: #888; margin-top: 3px; }
|
||||
.header .links a { color: #4ecca3; font-size: 12px; text-decoration: none; margin-left: 12px; }
|
||||
.container { display: flex; gap: 18px; padding: 18px; max-width: 1800px; margin: 0 auto; }
|
||||
|
||||
/* 左侧工具+参数 */
|
||||
.panel { width: 300px; flex-shrink: 0; background: #16213e; border-radius: 10px; padding: 16px; height: fit-content; }
|
||||
.panel h3 { font-size: 13px; color: #4ecca3; margin: 14px 0 8px; text-transform: uppercase; letter-spacing: 1px; padding-bottom: 5px; border-bottom: 1px solid #0f3460; }
|
||||
.panel h3:first-child { margin-top: 0; }
|
||||
.hint { font-size: 11px; color: #888; line-height: 1.6; margin: 6px 0 10px; }
|
||||
|
||||
.tool-row { display: flex; gap: 6px; margin-bottom: 10px; }
|
||||
.btn-tool { flex: 1; padding: 8px; background: #0f3460; color: #ccc; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; }
|
||||
.btn-tool.active { background: #4ecca3; color: #16213e; font-weight: 600; }
|
||||
.btn-tool:hover { background: #1a4a7a; }
|
||||
.btn-tool.active:hover { background: #6ee0bd; }
|
||||
.btn-danger { width: 100%; padding: 7px; background: #c0392b; color: #fff; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; margin-bottom: 10px; }
|
||||
.btn-danger:hover { background: #e74c3c; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.field-label { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #bbb; margin-bottom: 5px; }
|
||||
.field-label .val { color: #4ecca3; font-family: monospace; font-size: 11px; }
|
||||
.field input[type="range"] { width: 100%; height: 4px; -webkit-appearance: none; background: #0f3460; border-radius: 2px; outline: none; }
|
||||
.field input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #4ecca3; cursor: pointer; }
|
||||
.field input[type="text"], .field textarea { width: 100%; padding: 6px 8px; background: #0d1b3e; border: 1px solid #0f3460; border-radius: 4px; color: #eee; font-size: 12px; font-family: monospace; }
|
||||
.field textarea { min-height: 60px; resize: vertical; }
|
||||
|
||||
.mode-tabs { display: flex; gap: 6px; margin-bottom: 10px; }
|
||||
.mode-tab { flex: 1; padding: 8px; background: #0f3460; border: none; border-radius: 5px; color: #aaa; cursor: pointer; font-size: 12px; text-align: center; }
|
||||
.mode-tab.active { background: #4ecca3; color: #16213e; font-weight: 600; }
|
||||
|
||||
.btn { display: block; width: 100%; padding: 12px; border: none; border-radius: 6px; font-size: 15px; cursor: pointer; transition: .15s; }
|
||||
.btn-primary { background: #4ecca3; color: #16213e; font-weight: 600; margin-top: 8px; }
|
||||
.btn-primary:hover { background: #6ee0bd; }
|
||||
.btn-primary:disabled { background: #555; color: #999; cursor: not-allowed; }
|
||||
|
||||
/* 右侧画板+结果 */
|
||||
.workspace { flex: 1; min-width: 0; }
|
||||
.canvas-wrap { background: #0d0d1a; border-radius: 10px; padding: 18px; text-align: center; min-height: 280px; display: flex; align-items: center; justify-content: center; }
|
||||
.upload-zone { width: 100%; max-width: 600px; border: 2px dashed #0f3460; border-radius: 10px; padding: 40px 20px; text-align: center; cursor: pointer; transition: .2s; }
|
||||
.upload-zone:hover { border-color: #4ecca3; background: rgba(78,204,163,.05); }
|
||||
.upload-zone svg { width: 42px; height: 42px; fill: #4ecca3; margin-bottom: 10px; }
|
||||
.upload-zone p { color: #888; font-size: 13px; }
|
||||
.upload-zone p.highlight { color: #4ecca3; }
|
||||
#canvas-container { position: relative; display: inline-block; max-width: 100%; }
|
||||
#img-canvas, #mask-canvas { display: block; max-width: 100%; border-radius: 6px; }
|
||||
#mask-canvas { position: absolute; top: 0; left: 0; cursor: crosshair; touch-action: none; }
|
||||
#mask-canvas.eraser { cursor: cell; }
|
||||
.canvas-tip { font-size: 11px; color: #666; margin-top: 8px; }
|
||||
#status { text-align: center; padding: 24px; color: #4ecca3; display: none; }
|
||||
.spinner { display: inline-block; width: 18px; height: 18px; border: 3px solid #0f3460; border-top-color: #4ecca3; border-radius: 50%; animation: spin .8s linear infinite; margin-right: 8px; vertical-align: middle; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#error-msg { color: #e74c3c; padding: 16px; display: none; font-size: 13px; background: rgba(231,76,60,.1); border-radius: 6px; margin-top: 12px; }
|
||||
|
||||
.result-section { margin-top: 16px; background: #16213e; border-radius: 10px; padding: 16px; display: none; }
|
||||
.result-section.show { display: block; }
|
||||
.result-section h3 { font-size: 14px; color: #4ecca3; margin-bottom: 12px; }
|
||||
.result-grid { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.result-card { flex: 1; min-width: 240px; }
|
||||
.result-card h4 { font-size: 12px; color: #aaa; margin-bottom: 6px; text-align: center; }
|
||||
.result-card img { width: 100%; border-radius: 6px; display: block; cursor: zoom-in; }
|
||||
.info-box { margin-top: 12px; padding: 10px 12px; background: #0d1b3e; border-radius: 6px; font-size: 11px; color: #888; line-height: 1.7; }
|
||||
#lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.92); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; padding: 20px; }
|
||||
#lightbox img { max-width: 95%; max-height: 95%; border-radius: 8px; }
|
||||
#lightbox.show { display: flex; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>🖌️ mask区域重绘(发丝自然化)</h1>
|
||||
<p>上传图片 → 涂抹要重绘的区域 → 调提示词和强度 → 生成。严格只在涂抹区重绘,其余保留原图</p>
|
||||
</div>
|
||||
<div class="links">
|
||||
<a href="/manual">→ 手绘mask换发型</a>
|
||||
<a href="/debug">→ 换发型调试</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- 左侧 -->
|
||||
<div class="panel">
|
||||
<h3>① 画笔工具</h3>
|
||||
<div class="hint">在右侧图片上涂抹需要重绘的区域(白色=重绘,黑色=保留原图)</div>
|
||||
<div class="tool-row">
|
||||
<button class="btn-tool active" id="tool-brush">🖌️ 画笔</button>
|
||||
<button class="btn-tool" id="tool-eraser">🧹 橡皮</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">画笔大小 <span class="val" id="size-val">25px</span></div>
|
||||
<input type="range" id="brush-size" min="5" max="80" value="25">
|
||||
</div>
|
||||
<button class="btn-danger" id="btn-clear">✕ 清除全部涂抹</button>
|
||||
|
||||
<h3>② 重绘模式</h3>
|
||||
<div class="mode-tabs">
|
||||
<button class="mode-tab active" id="mode-enhance" onclick="setMode('enhance')">发丝增强<br><span style="font-size:10px;opacity:.7">enhance</span></button>
|
||||
<button class="mode-tab" id="mode-pure" onclick="setMode('pure_inpaint')">自然重绘<br><span style="font-size:10px;opacity:.7">pure_inpaint</span></button>
|
||||
</div>
|
||||
|
||||
<h3>③ 提示词</h3>
|
||||
<div class="field">
|
||||
<textarea id="prompt" placeholder="留空用模式默认提示词">high quality, detailed natural hair strands, realistic hair texture, sharp focus, photorealistic</textarea>
|
||||
</div>
|
||||
|
||||
<h3>④ 重绘强度</h3>
|
||||
<div class="field">
|
||||
<div class="field-label">denoising_strength <span class="val" id="v-den">0.50</span><span style="color:#666;font-size:10px;margin-left:6px">越低越保留原图</span></div>
|
||||
<input type="range" id="den" min="0.1" max="1.0" step="0.05" value="0.5" oninput="$('v-den').textContent=parseFloat(this.value).toFixed(2)">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="btn-run" disabled>▶ 生成(mask区域重绘)</button>
|
||||
<div class="hint" style="margin-top:10px">
|
||||
<b>说明:</b><br>
|
||||
• 严格只在涂抹区域重绘,mask外完全保留原图<br>
|
||||
• enhance模式:发丝细节增强(默认0.35强度)<br>
|
||||
• pure_inpaint模式:自然重绘(默认0.5强度)<br>
|
||||
• 改提示词可控制重绘方向(如"detailed curly hair")
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧画板+结果 -->
|
||||
<div class="workspace">
|
||||
<div class="canvas-wrap">
|
||||
<div class="upload-zone" id="upload-zone">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 13v6H5v-6H3v8h18v-8zM6 9l1.41 1.41L11 6.83V18h2V6.83l3.59 3.58L18 9l-6-6z"/></svg>
|
||||
<p class="highlight">点击上传图片</p>
|
||||
<p>支持 jpg/png,建议人脸清晰照</p>
|
||||
</div>
|
||||
<div id="canvas-container" style="display:none">
|
||||
<canvas id="img-canvas"></canvas>
|
||||
<canvas id="mask-canvas"></canvas>
|
||||
</div>
|
||||
<input type="file" id="file-input" accept="image/*" style="display:none">
|
||||
</div>
|
||||
<div class="canvas-tip" id="canvas-tip" style="display:none;text-align:center">用左侧画笔在图片上涂抹要重绘的区域</div>
|
||||
|
||||
<div id="status"><span class="spinner"></span><span id="status-text">重绘中,约15-30秒...</span></div>
|
||||
<div id="error-msg"></div>
|
||||
|
||||
<div class="result-section" id="result-section">
|
||||
<h3>对比结果</h3>
|
||||
<div class="result-grid">
|
||||
<div class="result-card"><h4>原图</h4><img id="r-orig"></div>
|
||||
<div class="result-card"><h4>使用的mask</h4><img id="r-mask"></div>
|
||||
<div class="result-card"><h4>重绘结果</h4><img id="r-new"></div>
|
||||
</div>
|
||||
<div class="info-box" id="info-box"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lightbox" onclick="this.classList.remove('show')"><img id="lightbox-img"></div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const $=id=>document.getElementById(id);
|
||||
let mode="enhance", imgB64Orig=null;
|
||||
const imgCanvas=$("img-canvas"),maskCanvas=$("mask-canvas");
|
||||
const imgCtx=imgCanvas.getContext("2d"),maskCtx=maskCanvas.getContext("2d",{willReadFrequently:true});
|
||||
let brushSize=25,drawing=false,lastX=0,lastY=0;
|
||||
|
||||
// 模式切换
|
||||
window.setMode=(m)=>{
|
||||
mode=m;
|
||||
$("mode-enhance").classList.toggle("active",m==="enhance");
|
||||
$("mode-pure").classList.toggle("active",m==="pure_inpaint");
|
||||
// 切换模式时更新默认提示词(如果当前是空的或等于另一模式的默认值)
|
||||
const defaults={
|
||||
enhance:"high quality, detailed natural hair strands, realistic hair texture, sharp focus, photorealistic",
|
||||
pure_inpaint:"natural realistic hair, soft hair strands, photorealistic, high quality, detailed"
|
||||
};
|
||||
const cur=$("prompt").value.trim();
|
||||
if(!cur || Object.values(defaults).includes(cur)){
|
||||
$("prompt").value=defaults[m];
|
||||
}
|
||||
// 切换模式时调整默认denoising
|
||||
if(m==="enhance" && parseFloat($("den").value)===0.5){ $("den").value=0.35; $("v-den").textContent="0.35"; }
|
||||
if(m==="pure_inpaint" && parseFloat($("den").value)===0.35){ $("den").value=0.5; $("v-den").textContent="0.50"; }
|
||||
};
|
||||
|
||||
// 上传
|
||||
$("upload-zone").onclick=()=>$("file-input").click();
|
||||
$("file-input").onchange=e=>{ if(e.target.files[0]) loadImage(e.target.files[0]); };
|
||||
$("upload-zone").ondragover=e=>{e.preventDefault();$("upload-zone").style.borderColor="#4ecca3";};
|
||||
$("upload-zone").ondragleave=()=>$("upload-zone").style.borderColor="#0f3460";
|
||||
$("upload-zone").ondrop=e=>{e.preventDefault();if(e.dataTransfer.files[0])loadImage(e.dataTransfer.files[0]);};
|
||||
function loadImage(file){
|
||||
if(!file.type.startsWith("image/")){alert("请上传图片");return;}
|
||||
const rd=new FileReader();
|
||||
rd.onload=e=>{
|
||||
const im=new Image();
|
||||
im.onload=()=>{
|
||||
let w=im.width,h=im.height;
|
||||
const MAX=1024;
|
||||
if(Math.max(w,h)>MAX){const sc=MAX/Math.max(w,h);w=Math.round(w*sc);h=Math.round(h*sc);}
|
||||
imgCanvas.width=w;imgCanvas.height=h;
|
||||
maskCanvas.width=w;maskCanvas.height=h;
|
||||
imgCtx.drawImage(im,0,0,w,h);
|
||||
maskCtx.clearRect(0,0,w,h);
|
||||
imgB64Orig=imgCanvas.toDataURL("image/jpeg",0.95);
|
||||
$("canvas-container").style.display="inline-block";
|
||||
$("upload-zone").style.display="none";
|
||||
$("canvas-tip").style.display="block";
|
||||
$("btn-run").disabled=false;
|
||||
};
|
||||
im.src=e.target.result;
|
||||
};
|
||||
rd.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// 画笔
|
||||
let drawMode="brush";
|
||||
$("tool-brush").onclick=()=>{drawMode="brush";$("tool-brush").classList.add("active");$("tool-eraser").classList.remove("active");maskCanvas.classList.remove("eraser");};
|
||||
$("tool-eraser").onclick=()=>{drawMode="eraser";$("tool-eraser").classList.add("active");$("tool-brush").classList.remove("active");maskCanvas.classList.add("eraser");};
|
||||
$("brush-size").oninput=e=>{brushSize=+e.target.value;$("size-val").textContent=brushSize+"px";};
|
||||
$("btn-clear").onclick=()=>{maskCtx.globalCompositeOperation="source-over";maskCtx.clearRect(0,0,maskCanvas.width,maskCanvas.height);};
|
||||
|
||||
function getPos(e){
|
||||
const r=maskCanvas.getBoundingClientRect();
|
||||
const sc=maskCanvas.width/r.width;
|
||||
const cx=(e.touches?e.touches[0].clientX:e.clientX)-r.left;
|
||||
const cy=(e.touches?e.touches[0].clientY:e.clientY)-r.top;
|
||||
return [cx*sc,cy*sc];
|
||||
}
|
||||
function startDraw(e){e.preventDefault();drawing=true;const[x,y]=getPos(e);lastX=x;lastY=y;drawDot(x,y);}
|
||||
function moveDraw(e){if(!drawing)return;e.preventDefault();const[x,y]=getPos(e);drawLine(lastX,lastY,x,y);lastX=x;lastY=y;}
|
||||
function endDraw(){drawing=false;}
|
||||
function drawDot(x,y){
|
||||
if(drawMode==="brush"){maskCtx.globalCompositeOperation="source-over";maskCtx.fillStyle="rgba(255,60,80,0.5)";}
|
||||
else{maskCtx.globalCompositeOperation="destination-out";maskCtx.fillStyle="rgba(0,0,0,1)";}
|
||||
maskCtx.beginPath();maskCtx.arc(x,y,brushSize/2,0,Math.PI*2);maskCtx.fill();
|
||||
}
|
||||
function drawLine(x1,y1,x2,y2){
|
||||
if(drawMode==="brush"){maskCtx.globalCompositeOperation="source-over";maskCtx.strokeStyle="rgba(255,60,80,0.5)";}
|
||||
else{maskCtx.globalCompositeOperation="destination-out";maskCtx.strokeStyle="rgba(0,0,0,1)";}
|
||||
maskCtx.beginPath();maskCtx.moveTo(x1,y1);maskCtx.lineTo(x2,y2);
|
||||
maskCtx.lineWidth=brushSize;maskCtx.lineCap="round";maskCtx.lineJoin="round";maskCtx.stroke();
|
||||
}
|
||||
maskCanvas.addEventListener("mousedown",startDraw);
|
||||
maskCanvas.addEventListener("mousemove",moveDraw);
|
||||
window.addEventListener("mouseup",endDraw);
|
||||
maskCanvas.addEventListener("touchstart",startDraw,{passive:false});
|
||||
maskCanvas.addEventListener("touchmove",moveDraw,{passive:false});
|
||||
maskCanvas.addEventListener("touchend",endDraw);
|
||||
|
||||
// 提取mask为黑白二值PNG
|
||||
function extractMaskB64(){
|
||||
const tmp=document.createElement("canvas");
|
||||
tmp.width=maskCanvas.width;tmp.height=maskCanvas.height;
|
||||
const tc=tmp.getContext("2d");
|
||||
const md=maskCtx.getImageData(0,0,maskCanvas.width,maskCanvas.height);
|
||||
const out=tc.createImageData(tmp.width,tmp.height);
|
||||
for(let i=0;i<md.data.length;i+=4){
|
||||
const v=md.data[i+3]>30?255:0;
|
||||
out.data[i]=v;out.data[i+1]=v;out.data[i+2]=v;out.data[i+3]=255;
|
||||
}
|
||||
tc.putImageData(out,0,0);
|
||||
return tmp.toDataURL("image/png");
|
||||
}
|
||||
|
||||
// 执行
|
||||
$("btn-run").onclick=async()=>{
|
||||
if(!imgB64Orig) return;
|
||||
// 检查mask
|
||||
const md=maskCtx.getImageData(0,0,maskCanvas.width,maskCanvas.height);
|
||||
let hasMask=false;
|
||||
for(let i=3;i<md.data.length;i+=4){if(md.data[i]>30){hasMask=true;break;}}
|
||||
if(!hasMask){alert("请先用画笔涂抹需要重绘的区域");return;}
|
||||
|
||||
const maskB64=extractMaskB64();
|
||||
$("btn-run").disabled=true;
|
||||
$("status").style.display="block";
|
||||
$("status-text").textContent="mask区域重绘中,约15-30秒...";
|
||||
$("error-msg").style.display="none";
|
||||
$("result-section").classList.remove("show");
|
||||
try{
|
||||
const resp=await fetch("/api/inpaint",{
|
||||
method:"POST",headers:{"Content-Type":"application/json"},
|
||||
body:JSON.stringify({
|
||||
img:imgB64Orig, mask:maskB64,
|
||||
prompt:$("prompt").value.trim(),
|
||||
denoising_strength:parseFloat($("den").value),
|
||||
mode:mode
|
||||
})
|
||||
});
|
||||
const data=await resp.json();
|
||||
$("status").style.display="none";
|
||||
if(data.state===0){
|
||||
$("r-orig").src=imgB64Orig;
|
||||
$("r-mask").src=data.mask_used;
|
||||
$("r-new").src="data:image/jpeg;base64,"+data.result;
|
||||
$("info-box").textContent=data.info;
|
||||
// 绑定点击放大
|
||||
["r-orig","r-mask","r-new"].forEach(id=>{
|
||||
$(id).onclick=(e)=>{ $("lightbox-img").src=e.target.src; $("lightbox").classList.add("show"); };
|
||||
});
|
||||
$("result-section").classList.add("show");
|
||||
$("result-section").scrollIntoView({behavior:"smooth"});
|
||||
}else{
|
||||
$("error-msg").textContent="❌ "+(data.msg||"失败");
|
||||
$("error-msg").style.display="block";
|
||||
}
|
||||
}catch(err){
|
||||
$("status").style.display="none";
|
||||
$("error-msg").textContent="❌ 请求失败: "+err.message;
|
||||
$("error-msg").style.display="block";
|
||||
}
|
||||
$("btn-run").disabled=false;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -159,9 +159,13 @@ body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; ba
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">feather_px <span class="val" id="v-fp">0</span><span class="tip" title="严格mask贴回的边缘羽化像素。0=硬边缘,>0=高斯模糊边缘平滑过渡(消除贴回接缝)">ⓘ</span></div>
|
||||
<div class="field-label">feather_px <span class="val" id="v-fp">0</span><span class="tip" title="羽化范围(像素),边缘带宽度。0=无羽化,越大羽化范围越宽">ⓘ</span></div>
|
||||
<input type="range" id="fp" min="0" max="60" step="1" value="0" oninput="syncInt('fp')">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field-label">feather_alpha <span class="val" id="v-fa">1.00</span><span class="tip" title="羽化强度(0~1)。0=硬切不羽化,1=最大羽化。控制边缘过渡柔和程度">ⓘ</span></div>
|
||||
<input type="range" id="fa" min="0" max="1.0" step="0.05" value="1.0" oninput="syncFloat('fa')">
|
||||
</div>
|
||||
<div class="readonly-info">
|
||||
<b style="color:#888">webui端固定:</b><br>
|
||||
<code>cfg=7</code> <code>steps=20</code> <code>sampler=DPM++ 2M Karras</code>
|
||||
@@ -182,7 +186,7 @@ body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; ba
|
||||
|
||||
<!-- 中间发型选择 -->
|
||||
<div class="mid-panel">
|
||||
<h3>③ 选发型</h3>
|
||||
<h3>③ 选发型 <span id="count-info" style="font-size:10px;color:#888;font-weight:normal"></span></h3>
|
||||
<div class="hairstyle-grid" id="hairstyle-grid"><div style="color:#666;font-size:11px">加载中...</div></div>
|
||||
<div style="font-size:11px;color:#4ecca3;margin-top:8px;text-align:center" id="sel-info">未选择</div>
|
||||
<button class="btn-tool" style="margin-top:8px;width:100%" onclick="loadStatus()">↻ 刷新</button>
|
||||
@@ -225,14 +229,6 @@ body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; ba
|
||||
<script>
|
||||
(function(){
|
||||
const $=id=>document.getElementById(id);
|
||||
const HAIRSTYLES = [
|
||||
{face:"圆",name:"圆-心形"},{face:"圆",name:"圆-椭圆"},{face:"圆",name:"圆-波浪"},{face:"圆",name:"圆-直线"},{face:"圆",name:"圆-花瓣"},
|
||||
{face:"心形",name:"心形-心形"},{face:"心形",name:"心形-椭圆"},{face:"心形",name:"心形-波浪"},{face:"心形",name:"心形-直线"},{face:"心形",name:"心形-花瓣"},
|
||||
{face:"方脸",name:"方脸-心形"},{face:"方脸",name:"方脸-椭圆"},{face:"方脸",name:"方脸-波浪"},{face:"方脸",name:"方脸-直线"},{face:"方脸",name:"方脸-花瓣"},
|
||||
{face:"椭圆",name:"椭圆-心形"},{face:"椭圆",name:"椭圆-椭圆"},{face:"椭圆",name:"椭圆-波浪"},{face:"椭圆",name:"椭圆-直线"},{face:"椭圆",name:"椭圆-花瓣"},
|
||||
{face:"菱形",name:"菱形-心"},{face:"菱形",name:"菱形-椭圆"},{face:"菱形",name:"菱形-波浪"},{face:"菱形",name:"菱形-直线"},{face:"菱形",name:"菱形-花瓣"},
|
||||
{face:"长",name:"长-心形"},{face:"长",name:"长 -椭圆"},{face:"长",name:"长 -波浪"},{face:"长",name:"长 -直线"},{face:"长",name:"长 -花瓣"}
|
||||
];
|
||||
|
||||
let readySet=new Set(), selectedHair=null, imgB64Orig=null;
|
||||
// 画板状态
|
||||
@@ -240,6 +236,11 @@ body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; ba
|
||||
const imgCtx=imgCanvas.getContext("2d"),maskCtx=maskCanvas.getContext("2d",{willReadFrequently:true});
|
||||
let mode="brush",brushSize=25,drawing=false,lastX=0,lastY=0;
|
||||
|
||||
// 本次训练的发型(昨天6个 + 今天hair_flow)
|
||||
const HAIRSTYLES = [
|
||||
{name:"hair_flow"},{name:"圆-心形"},{name:"圆-椭圆"},{name:"圆-波浪"},
|
||||
{name:"圆-直线"},{name:"圆-花瓣"},{name:"心形-心形"},{name:"心形-椭圆"}
|
||||
];
|
||||
async function loadStatus(){
|
||||
try{
|
||||
const r=await fetch("/api/hairstyles");const d=await r.json();
|
||||
@@ -254,7 +255,7 @@ body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; ba
|
||||
const ready=readySet.has(h.name);
|
||||
const it=document.createElement("div");
|
||||
it.className="hairstyle-item"+(ready?" ready":" pending")+(h.name===selectedHair?" selected":"");
|
||||
it.innerHTML=`<img loading="lazy" src="/train_src/${encodeURIComponent(h.name)}" onerror="this.style.objectFit='contain';this.src='data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%22100%22><text x=%2250%22 y=%2255%22 text-anchor=%22middle%22 fill=%22%23666%22 font-size=%2210%22>训练中</text></svg>'"><div class="name">${h.name}</div>`;
|
||||
it.innerHTML=`<img loading="lazy" src="/train_src/${encodeURIComponent(h.name)}" onerror="this.style.objectFit='contain';this.src='data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%22100%22><text x=%2250%22 y=%2255%22 text-anchor=%22middle%22 fill=%22%23666%22 font-size=%2210%22>无图</text></svg>'"><div class="name">${h.name}</div>`;
|
||||
if(ready) it.onclick=()=>{
|
||||
selectedHair=h.name;
|
||||
$("sel-info").textContent="已选: "+h.name;
|
||||
@@ -366,6 +367,7 @@ body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; ba
|
||||
is_hr:$("sw-is_hr").classList.contains("on"),
|
||||
dilate_kernel:[parseInt($("dk0").value),parseInt($("dk1").value)],
|
||||
feather_px:parseInt($("fp").value),
|
||||
feather_alpha:parseFloat($("fa").value),
|
||||
denoising_strength:parseFloat($("den").value),
|
||||
enhance:$("sw-enhance").classList.contains("on"),
|
||||
enhance_denoising:parseFloat($("ed").value),
|
||||
|
||||
@@ -20,8 +20,11 @@ class COS_object():
|
||||
token = None # 如果使用永久密钥不需要填入 token,如果使用临时密钥需要填入,临时密钥生成和使用指引参见 https://cloud.tencent.com/document/product/436/14048
|
||||
self.scheme = 'https' # 指定使用 http/https 协议来访问 COS,默认为 https,可不填
|
||||
self.BucketName= os.getenv('COS_BUCKET', '<your-cos-bucket>')
|
||||
for param in (secret_id, secret_key, self.region, self.BucketName):
|
||||
assert '<' not in param, '请设置环境变量 COS_SECRET_ID / COS_SECRET_KEY / COS_REGION / COS_BUCKET'
|
||||
# 密钥未设置时标记不可用(不抛异常)
|
||||
if '<' in secret_id or '<' in secret_key:
|
||||
print('[COS] ⚠️ 未设置 COS 密钥环境变量,COS 上传功能不可用')
|
||||
self.client = None
|
||||
return
|
||||
config = CosConfig(Region=self.region, SecretId=secret_id, SecretKey=secret_key, Token=token, Scheme=self.scheme)
|
||||
self.client = CosS3Client(config)
|
||||
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import time
|
||||
|
||||
import oss2
|
||||
import os
|
||||
|
||||
try:
|
||||
import oss2
|
||||
_OSS2_AVAILABLE = True
|
||||
except Exception as _e:
|
||||
print(f'[OSS] ⚠️ oss2 导入失败({_e}),OSS 上传功能不可用')
|
||||
_OSS2_AVAILABLE = False
|
||||
|
||||
class OSS_object():
|
||||
def __init__(self):
|
||||
if not _OSS2_AVAILABLE:
|
||||
print('[OSS] oss2 不可用,OSS 功能关闭')
|
||||
self.bucket = None
|
||||
return
|
||||
access_key_id = os.getenv('OSS_TEST_ACCESS_KEY_ID', '<your-access-key-id>')
|
||||
access_key_secret = os.getenv('OSS_TEST_ACCESS_KEY_SECRET', '<your-access-key-secret>')
|
||||
bucket_name = os.getenv('OSS_TEST_BUCKET', '<your-bucket-name>')
|
||||
endpoint = os.getenv('OSS_TEST_ENDPOINT', '<your-endpoint>')
|
||||
for param in (access_key_id, access_key_secret, bucket_name, endpoint):
|
||||
assert '<' not in param, '请设置环境变量 OSS_TEST_ACCESS_KEY_ID / OSS_TEST_ACCESS_KEY_SECRET / OSS_TEST_BUCKET / OSS_TEST_ENDPOINT'
|
||||
|
||||
# 密钥未设置时标记不可用(不抛异常,避免服务启动/换发型时崩溃)
|
||||
if '<' in access_key_id or '<' in access_key_secret:
|
||||
print('[OSS] ⚠️ 未设置 OSS 密钥环境变量,OSS 上传功能不可用(本地测试用 base64 返回即可)')
|
||||
self.bucket = None
|
||||
return
|
||||
self.bucket = oss2.Bucket(oss2.Auth(access_key_id, access_key_secret), endpoint, bucket_name)
|
||||
|
||||
def upload_file(self, file, target_name):
|
||||
|
||||
@@ -36,7 +36,8 @@ def hair_swap_manual(origin_img, hand_mask, hair_id, hairstyle_process, landmark
|
||||
denoising_strength=0.6, # 重绘强度(唯一能透传到webui的SD参数)
|
||||
# ===== 贴回/融合(真正生效)=====
|
||||
blend_dilate=(5, 5), # 步骤⑥ strict mask贴回时mask膨胀核
|
||||
feather_px=0, # ★ strict贴回边缘羽化像素(0=无羽化硬边缘,>0=高斯模糊边缘)
|
||||
feather_px=0, # ★ strict贴回边缘羽化范围(像素,边缘带宽度,0=无羽化)
|
||||
feather_alpha=1.0, # ★ 羽化强度(0~1,0=硬切,1=最大羽化,控制边缘过渡柔和度)
|
||||
seamless_dilate=(9, 9), # 步骤⑦ 泊松融合mask膨胀核
|
||||
# ===== enhance 二次增强重绘 =====
|
||||
enhance=False, # 步骤⑧ 是否对结果再做一次低强度SD重绘(让发丝更清晰)
|
||||
@@ -230,10 +231,20 @@ def hair_swap_manual(origin_img, hand_mask, hair_id, hairstyle_process, landmark
|
||||
result_strict_hard[mask_back == 0] = origin_img[mask_back == 0]
|
||||
|
||||
if feather_px and feather_px > 0:
|
||||
# ★ 只羽化边缘:mask 内部保持硬切(255),只有边缘带做渐变
|
||||
# 1. 对 mask_back 做高斯模糊(得到边缘的渐变)
|
||||
k = max(1, int(feather_px)) * 2 + 1 # 高斯核必须是奇数
|
||||
mask_back_blur = cv2.GaussianBlur(mask_back, (k, k), 0)
|
||||
mask_blend = (mask_back_blur.astype(np.float32) / 255)[..., None]
|
||||
feather_info = f",边缘羽化{feather_px}px"
|
||||
mask_blur = cv2.GaussianBlur(mask_back, (k, k), 0)
|
||||
# 2. 取 mask 内部区域(离边缘 > feather_px 的部分),强制为255(不羽化)
|
||||
erode_kernel = np.ones((k, k), np.uint8)
|
||||
mask_inner = cv2.erode(mask_back, erode_kernel)
|
||||
# 3. 合成:内部硬切(255) + 边缘用模糊值
|
||||
mask_blend_mask = np.maximum(mask_inner, mask_blur).astype(np.float32)
|
||||
# 4. ★ feather_alpha 控制羽化强度:在「全硬切(原mask)」和「边缘渐变(mask_blend_mask)」之间插值
|
||||
# alpha=0 → 完全硬切(无羽化),alpha=1 → 最大羽化
|
||||
mask_blend_mask = mask_back.astype(np.float32) * (1 - feather_alpha) + mask_blend_mask * feather_alpha
|
||||
mask_blend = (mask_blend_mask / 255)[..., None]
|
||||
feather_info = f",边缘羽化{feather_px}px 强度{feather_alpha:.2f}"
|
||||
else:
|
||||
mask_blend = hard_blend
|
||||
result_strict = (sd_result_back.astype(np.float32) * mask_blend +
|
||||
@@ -241,17 +252,38 @@ def hair_swap_manual(origin_img, hand_mask, hair_id, hairstyle_process, landmark
|
||||
result_strict = np.clip(result_strict, 0, 255).astype(np.uint8)
|
||||
result_strict[mask_back == 0] = origin_img[mask_back == 0]
|
||||
|
||||
# ★ 羽化对比图:左=硬边缘(feather=0) 右=当前羽化,裁剪mask边界附近放大
|
||||
# ★ 羽化对比图(黑底版):纯黑背景 + 贴回图,凸显羽化边缘过渡
|
||||
# 用黑色背景而非原图,避免原图干扰,纯粹对比羽化效果
|
||||
feather_compare = None
|
||||
feather_compare_full = None
|
||||
if feather_px and feather_px > 0:
|
||||
# 计算 mask_blend(用于把贴回图叠加到黑底上)
|
||||
ys, xs = np.where(mask_back > 10)
|
||||
if len(ys) > 0:
|
||||
x1 = max(0, xs.min()-20); x2 = min(origin_img.shape[1], xs.max()+20)
|
||||
y1 = max(0, ys.min()-20); y2 = min(origin_img.shape[0], ys.max()+20)
|
||||
hard_crop = result_strict_hard[y1:y2, x1:x2]
|
||||
blur_crop = result_strict[y1:y2, x1:x2]
|
||||
sep = np.full((hard_crop.shape[0], 3, 3), 128, dtype=np.uint8)
|
||||
# 黑底版:贴回图 * mask_blend + 黑色(0) * (1-mask_blend)
|
||||
# result_strict_hard 已是「贴回图+原图」混合,这里重新算黑底版
|
||||
black_bg = np.zeros_like(origin_img)
|
||||
# 无羽化版(黑底)
|
||||
hard_on_black = (sd_result_back.astype(np.float32) * hard_blend +
|
||||
black_bg.astype(np.float32) * (1 - hard_blend))
|
||||
hard_on_black = np.clip(hard_on_black, 0, 255).astype(np.uint8)
|
||||
hard_on_black[mask_back == 0] = 0 # mask外纯黑
|
||||
# 有羽化版(黑底)
|
||||
blur_on_black = (sd_result_back.astype(np.float32) * mask_blend +
|
||||
black_bg.astype(np.float32) * (1 - mask_blend))
|
||||
blur_on_black = np.clip(blur_on_black, 0, 255).astype(np.uint8)
|
||||
blur_on_black[mask_back == 0] = 0 # mask外纯黑
|
||||
|
||||
# 局部放大版(裁剪mask边界附近)
|
||||
x1 = max(0, xs.min()-30); x2 = min(origin_img.shape[1], xs.max()+30)
|
||||
y1 = max(0, ys.min()-30); y2 = min(origin_img.shape[0], ys.max()+30)
|
||||
hard_crop = hard_on_black[y1:y2, x1:x2]
|
||||
blur_crop = blur_on_black[y1:y2, x1:x2]
|
||||
sep = np.full((hard_crop.shape[0], 3, 3), 60, dtype=np.uint8) # 深灰分隔线
|
||||
feather_compare = np.hstack([hard_crop, sep, blur_crop])
|
||||
# 完整图版(不裁剪)
|
||||
sep_full = np.full((origin_img.shape[0], 3, 3), 60, dtype=np.uint8)
|
||||
feather_compare_full = np.hstack([hard_on_black, sep_full, blur_on_black])
|
||||
|
||||
result = result_strict if strict_mask else result_full
|
||||
|
||||
@@ -262,7 +294,9 @@ def hair_swap_manual(origin_img, hand_mask, hair_id, hairstyle_process, landmark
|
||||
{"label": f"当前选用({'严格mask' if strict_mask else '整框覆盖'})", "b64": _enc(result)},
|
||||
]
|
||||
if feather_compare is not None:
|
||||
step6_images.append({"label": f"★ 羽化对比(左=硬边 右=羽化{feather_px}px)", "b64": _enc(feather_compare)})
|
||||
step6_images.append({"label": f"★ 黑底羽化对比·边缘(左=无羽化 右=羽化{feather_px}pxα{feather_alpha:.1f})", "b64": _enc(feather_compare)})
|
||||
if feather_compare_full is not None:
|
||||
step6_images.append({"label": f"★ 黑底羽化对比·完整(左=无羽化 右=羽化{feather_px}pxα{feather_alpha:.1f})", "b64": _enc(feather_compare_full)})
|
||||
steps.append({
|
||||
"title": "⑥ 贴回原图",
|
||||
"desc": f"把SD结果逆warpAffine贴回用户原图。可调:strict_mask={strict_mask}, blend_dilate={bd}{feather_info}。整框覆盖=整个裁剪框覆盖原图;严格mask=只在mask区域融合,mask外保留原图",
|
||||
@@ -390,7 +424,7 @@ def hair_swap_manual(origin_img, hand_mask, hair_id, hairstyle_process, landmark
|
||||
"strict_mask": strict_mask, "seamless_blend": seamless_blend,
|
||||
"is_hr": is_hr, "dilate_kernel": list(dilate_kernel),
|
||||
"denoising_strength": denoising_strength,
|
||||
"blend_dilate": list(blend_dilate), "feather_px": feather_px,
|
||||
"blend_dilate": list(blend_dilate), "feather_px": feather_px, "feather_alpha": feather_alpha,
|
||||
"seamless_dilate": list(seamless_dilate),
|
||||
"enhance": enhance, "enhance_denoising": enhance_denoising,
|
||||
"mask_area_ratio_pct": round(mask_area_ratio, 1),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""mask 区域重绘(inpainting)服务
|
||||
|
||||
两种模式(底层都是 webui img2img inpainting,严格只在mask区重绘):
|
||||
1. enhance: 用 enhance_hair.webui_img2img(默认提示词=发丝增强,denoising默认0.35)
|
||||
2. pure_inpaint: 纯SD inpainting(默认提示词=自然头发,denoising默认0.5)
|
||||
|
||||
严格只重绘 mask 区域:inpainting_fill=1(mask外保留原图)
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
from utils import enhance_hair
|
||||
|
||||
|
||||
def inpaint_mask(img, mask, prompt="", denoising_strength=0.5, mode="enhance"):
|
||||
"""在 mask 区域做 SD inpainting 重绘。
|
||||
|
||||
参数:
|
||||
img: 输入图 BGR
|
||||
mask: 灰度mask,白色(255)=重绘区,与img同尺寸
|
||||
prompt: 提示词(空则用模式默认值)
|
||||
denoising_strength: 重绘强度 0~1
|
||||
mode: "enhance" 或 "pure_inpaint"
|
||||
|
||||
返回:
|
||||
result: 重绘后的图(mask区已重绘,其余保留原图)
|
||||
info: 本次参数说明
|
||||
"""
|
||||
h, w = img.shape[:2]
|
||||
|
||||
# 归一化 mask(保证 0/255,尺寸一致)
|
||||
if mask.shape[:2] != (h, w):
|
||||
mask = cv2.resize(mask, (w, h), interpolation=cv2.INTER_NEAREST)
|
||||
if mask.ndim == 3:
|
||||
mask = cv2.cvtColor(mask, cv2.COLOR_BGR2GRAY)
|
||||
_, mask_bin = cv2.threshold(mask, 30, 255, cv2.THRESH_BINARY)
|
||||
|
||||
# 模式默认值
|
||||
if not prompt:
|
||||
if mode == "enhance":
|
||||
prompt = "high quality, detailed natural hair strands, realistic hair texture, sharp focus, photorealistic"
|
||||
else:
|
||||
prompt = "natural realistic hair, soft hair strands, photorealistic, high quality, detailed"
|
||||
|
||||
# 统计 mask 区域占比
|
||||
mask_ratio = float((mask_bin > 0).sum()) / mask_bin.size * 100
|
||||
|
||||
# 调用 enhance_hair.webui_img2img(底层是 inpainting,inpainting_fill=1 严格只在mask区重绘)
|
||||
result = enhance_hair.webui_img2img(
|
||||
img, mask_bin, prompt=prompt,
|
||||
denoising_strength=denoising_strength
|
||||
)
|
||||
|
||||
# webui 可能调整尺寸(调整到8的倍数),resize 回原图尺寸
|
||||
if result.shape[:2] != (h, w):
|
||||
result = cv2.resize(result, (w, h), interpolation=cv2.INTER_LANCZOS4)
|
||||
|
||||
# 双重保险:强制 mask 外区域用原图(虽然 inpainting_fill=1 已保证,再加一层确保严格)
|
||||
mask_3c = (mask_bin > 0)[..., None]
|
||||
result = np.where(mask_3c, result, img).astype(np.uint8)
|
||||
|
||||
info = (f"模式={mode},提示词=\"{prompt[:50]}...\",denoising={denoising_strength},"
|
||||
f"mask区域={mask_ratio:.1f}%。严格只重绘mask区,其余保留原图。")
|
||||
return result, mask_bin, info
|
||||
@@ -1,17 +1,28 @@
|
||||
import time
|
||||
|
||||
import oss2
|
||||
import os
|
||||
|
||||
try:
|
||||
import oss2
|
||||
_OSS2_AVAILABLE = True
|
||||
except Exception as _e:
|
||||
print(f'[OSS upload_oss] ⚠️ oss2 导入失败({_e}),OSS 上传功能不可用')
|
||||
_OSS2_AVAILABLE = False
|
||||
|
||||
class OSS_object():
|
||||
def __init__(self):
|
||||
if not _OSS2_AVAILABLE:
|
||||
print('[OSS upload_oss] oss2 不可用,OSS 功能关闭')
|
||||
self.bucket = None
|
||||
return
|
||||
access_key_id = os.getenv('OSS_TEST_ACCESS_KEY_ID', '<your-access-key-id>')
|
||||
access_key_secret = os.getenv('OSS_TEST_ACCESS_KEY_SECRET', '<your-access-key-secret>')
|
||||
bucket_name = os.getenv('OSS_TEST_BUCKET', '<your-bucket-name>')
|
||||
endpoint = os.getenv('OSS_TEST_ENDPOINT', '<your-endpoint>')
|
||||
for param in (access_key_id, access_key_secret, bucket_name, endpoint):
|
||||
assert '<' not in param, '请设置环境变量 OSS_TEST_ACCESS_KEY_ID / OSS_TEST_ACCESS_KEY_SECRET / OSS_TEST_BUCKET / OSS_TEST_ENDPOINT'
|
||||
|
||||
# 密钥未设置时标记不可用(不抛异常)
|
||||
if '<' in access_key_id or '<' in access_key_secret:
|
||||
print('[OSS upload_oss] ⚠️ 未设置 OSS 密钥环境变量,OSS 上传功能不可用')
|
||||
self.bucket = None
|
||||
return
|
||||
self.bucket = oss2.Bucket(oss2.Auth(access_key_id, access_key_secret), endpoint, bucket_name)
|
||||
|
||||
def upload_file(self, file, target_name):
|
||||
|
||||
Reference in New Issue
Block a user