Author SHA1 Message Date
xsl 215663177a feat: 新增mask区域重绘功能 + 羽化贴回优化 + 发丝增强
新增功能:
- inpaint_mask.py: mask区域重绘服务(enhance/pure_inpaint两种模式)
  严格只在mask区做SD inpainting,发丝自然化
- /inpaint 页面 + /api/inpaint 接口:画板手绘mask + 提示词编辑 + denoising可调
- enhance_hair.webui_img2img: 新增denoising_strength参数(原硬编码0.35)

羽化贴回优化(hair_swap_manual.py 步骤⑥):
- feather_px(羽化范围)+ feather_alpha(羽化强度)拆分为两个独立参数
- 改为只羽化边缘(mask内部保持硬切,仅边缘带渐变)
- 新增黑底羽化对比图(纯黑背景凸显边缘过渡)

其他改动:
- OSS/COS密钥脱敏:改为可选import,未设环境变量时不崩溃
- manual页面:发型列表改为本次训练的8个(含hair_flow)
- enhance二次增强:重绘区改为「原图头发∪手绘mask」并外扩10%+减发际线带3cm
- 发型清单文档更新:179→180个可用发型(+hair_flow)

涉及文件:
- 新增: inpaint_mask.py, inpaint.html
- 修改: app.py, manual.html, hair_swap_manual.py, enhance_hair.py
- 修改: oss_module.py, upload_oss.py, cos_module.py(密钥脱敏)
- 文档: HAIRSTYLES_AVAILABLE.md, hairstyles_available.csv
2026-07-08 20:38:31 +08:00
10 changed files with 751 additions and 229 deletions
+9 -7
View File
@@ -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
View File
@@ -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
1 hair_id gender 预览图 LoRA大小MB
2 1905785164224868354 girl 144
3 1907641420518580226 boy 144
4 1907641442341543937 boy 144
5 1907641528173780994 boy 144
6 1907650883958923266 boy 144
7 1907650924350070785 boy 144
8 1907651105032298498 boy 144
9 1907651244006367233 boy 144
10 1907651308460236802 girl 144
11 1907651348528422914 girl 144
12 1907651413477220354 girl 144
13 1907651464794529794 girl 144
14 1907651525024735234 girl 144
15 1907651578753769473 girl 144
16 1907651632629604354 girl 144
17 1907651680352395265 girl 144
18 1907651747310264321 girl 144
19 1907805226322419713 boy 144
20 1929905737381048322 boy 144
21 1929905770088230914 boy 144
22 1929906202869100546 boy 144
23 1929906230224351234 boy 144
24 1929906315553271809 boy 144
25 1929906349401305089 boy 144
26 1929906429839667202 boy 144
27 1935354779623858177 boy 144
28 1935354812175851522 boy 144
29 1935354869738479617 boy 144
30 1935354938051108865 boy 144
31 1935355030892027905 boy 144
32 1935355149217538050 girl 144
33 1935355188652384257 girl 144
34 1935355258143612929 girl 144
35 1935355292960530433 girl 144
36 1935355361726144514 girl 144
37 1935355403207811074 girl 144
38 1935355449991077889 girl 144
39 1935355504156319745 girl 144
40 1935355551300296705 girl 144
41 1935355607394918402 girl 144
42 1935355698319040514 girl 144
43 1935355740559876097 girl 144
44 1935355778526715905 girl 144
45 1935355868347736065 girl 144
46 1935355934743568386 girl 144
47 1935355969141055490 girl 144
48 1935356042201636865 girl 144
49 1935356092684279809 girl 144
50 1935356131338985473 girl 144
51 1935356178227109890 girl 144
52 1938489620162781185 boy 144
53 1938489674713899010 boy 144
54 1938489739088076801 boy 144
55 1938489840707674114 boy 144
56 1938489915521474561 boy 144
57 1938489994072399874 boy 144
58 1938490058547240961 boy 144
59 1938492790033195009 girl 144
60 1938492830059438081 girl 144
61 1938841049238970370 girl 144
62 1938848709308948482 girl 144
63 1938848730150445058 girl 144
64 1938848782956732417 girl 144
65 1939330299344560130 girl 144
66 1939339435616608258 girl 144
67 1939351219396255745 boy 144
68 1939351238526476289 boy 144
69 1939351319849836545 boy 144
70 1939351392453238785 girl 144
71 1939351512506802177 girl 144
72 1940007538260287490 boy 144
73 1941163289636872193 boy 144
74 1941163324332154882 boy 144
75 1941163373069967362 boy 144
76 1941163615962112002 boy 144
77 1941163645464846338 boy 144
78 1941163754357366785 boy 144
79 1941163816533729282 boy 144
80 1941163884078800898 boy 144
81 1941163952840220673 boy 144
82 1941164012827156482 boy 144
83 1941352458711572482 boy 144
84 1941352541028982786 boy 144
85 1941352599665352705 boy 144
86 1943143930804936705 boy 144
87 1943144007388733441 boy 144
88 1945789428732895233 boy 144
89 1953267161121464322 boy 144
90 1953267288284372994 boy 144
91 1953270137953230849 girl 144
92 1953270523594317826 girl 144
93 1953270724765720577 boy 144
94 1953271922151432194 boy 144
95 1953272913265467394 boy 144
96 1953288800932503554 boy 144
97 1954536242109784066 boy 144
98 1954536297776586754 boy 144
99 1954536402516746241 boy 144
100 1954536893304840194 boy 144
101 1954536956919848962 boy 144
102 1954537022875279362 boy 144
103 1954537085882114050 boy 144
104 1954537191729569793 boy 144
105 1954537709436706817 girl 144
106 1954537743532204034 girl 144
107 1954537926731014146 girl 144
108 1954537961069780994 girl 144
109 1954538119216013313 girl 144
110 1954538156499181569 girl 144
111 1954538259330932738 girl 144
112 1954538335306555393 girl 144
113 1980312945301954561 boy 144
114 1980313052302843905 girl 144
115 1980313166576656385 girl 144
116 1980313210109337601 girl 144
117 1980313433502162946 boy 144
118 1980313576506957825 girl 144
119 1980313740403580930 girl 144
120 1980313819915001858 girl 144
121 1980314032369082369 girl 144
122 1980314146483511297 girl 144
123 1980314209322573825 girl 144
124 1980314290419441665 girl 144
125 1980314381058351106 girl 144
126 1980314477720281090 girl 144
127 1980314710101499906 boy 144
128 1980314764421931009 boy 144
129 1980314868713299969 boy 144
130 1980315054516772866 boy 144
131 1980315179934851074 girl 144
132 1980315238260842497 girl 144
133 1980361636742209537 girl 144
134 1980367742952579074 boy 144
135 1980367781510815746 boy 144
136 1980367800787836929 boy 144
137 1980367852847538177 boy 144
138 1980367968627105793 boy 144
139 1980368109866098690 boy 144
140 1980368205752082434 boy 144
141 1980368368847593473 boy 144
142 1980368465631158274 boy 144
143 1980368588293578754 boy 144
144 1980368787732733953 boy 144
145 1980368837162606594 boy 144
146 1980368937435832321 boy 144
147 1980413320612847618 girl 144
148 1980415346071609345 girl 144
149 1980415897056354306 girl 144
150 1980422351658196994 boy 144
151 1980422373388886018 boy 144
152 1981177324700549122 girl 144
153 1981177378714796034 boy 144
154 1981177477423546370 boy 144
155 1981177594750812162 boy 144
156 1981177742742634497 boy 144
157 1981177931859607554 boy 144
158 3325266 girl 144
159 huaban1 hair_flow girl 144
160 huaban2 huaban1 girl 144
161 new_test_001 huaban2 boy girl 144
162 newf_1214 new_test_001 girl boy 144
163 newf_12141 newf_1214 girl 144
164 newf_121445553 newf_12141 boy girl 144
165 newf_12314 newf_121445553 girl boy 144
166 newf_1235314 newf_12314 girl 144
167 newf_204 newf_1235314 girl 144
168 newf_216 newf_204 girl 144
169 test001 newf_216 boy girl 144
170 test002 test001 girl boy 144
171 test003 test002 boy girl 144
172 test0036 test003 boy 144
173 test888 test0036 boy 144
174 圆-心形 test888 girl boy 144
175 圆-椭圆 圆-心形 girl 144
176 圆-波浪 圆-椭圆 girl 144
177 圆-直线 圆-波浪 girl 144
178 圆-花瓣 圆-直线 girl 144
179 心形-心形 圆-花瓣 girl 144
180 心形-椭圆 心形-心形 girl 144
181 心形-椭圆 girl
+74 -3
View File
@@ -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}")
+323
View File
@@ -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>
+13 -11
View File
@@ -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),
+5 -2
View File
@@ -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)
+16 -5
View File
@@ -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):
+46 -12
View File
@@ -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),
+64
View File
@@ -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=1mask外保留原图)
"""
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(底层是 inpaintinginpainting_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
+16 -5
View File
@@ -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):