简介:面向具备一定Python与图像处理基础、希望动手实践人像美化算法的开发者与学习者,这套基于Python的人工智能美颜系统可用于理解从人脸关键点检测到肤色增强、面部特征优化的实现路径。压缩包共10个文件、约68.95MB,包含Python主程序与界面脚本、启动批处理、编译缓存、人脸68点特征模型数据、界面描述文件及说明文档等,覆盖程序运行、桌面交互与模型数据几类要素。已有4942人学习下载。读者可结合关键点数据与图像处理逻辑,研究预处理、模型推理和后处理的衔接方式,并借助界面文件了解桌面端交互如何组织;说明文档与配置脚本也有助于梳理环境搭建和二次开发思路,适合作为课程设计、毕业项目或算法入门的实践参考。
1. 一张自拍的三秒钟:Python 人工智能美颜系统到底在算什么
上个月有个做证件照工具的朋友找我,说他的批量脚本被用户吐槽「磨得跟塑料模特一样」。他原来用固定半径高斯模糊,再叠一层锐化,肤色深一点的照片就直接糊成一片。后来我们把流程换成一套 Python 写的人工智能美颜系统:先用轻量检测模型定位人脸和 68 个关键点,再分区域做磨皮、美白、瘦脸、大眼,每个环节独立给强度。同一张原图,参数从「自然」拉到「精修」,出来完全是两个东西。
这套东西适合两类人。一类是想搞清楚美颜滤镜到底在像素上做了什么的学生和初级开发,核心代码不到一千行,每个算子都能单独跑出来看对比图;另一类是要把美颜能力塞进自己后台服务、又不愿意按调用量付费的团队,算法层和接口层是分开的,换个壳就能用。下面按我实际拆解的顺序走:先看架构和目录,再逐个算子看代码,然后是跑起来的命令和接口,最后讲几个让成品率掉一半的坑。
2. 架构与目录:一次请求在检测、关键点、像素处理之间怎么流转
2.1 从上传到出图的六段流水线
整条链路是串行的六段,任何一段出错都会让后面全废,所以每段都留了可单独调用的入口,方便定位问题。
第一段是解码与方向修正,把上传的字节流变成 BGR 的 ndarray,同时按 EXIF 把手机竖拍的照片转正。第二段是人脸检测,拿到一个或多个人脸框。第三段是关键点回归,在每个框里回归 68 个坐标点。第四段是几何形变,瘦脸和大眼都在这一步,因为它们会做像素重采样。第五段是皮肤处理,磨皮和美白按掩膜生效。第六段是编码输出,压成 JPEG 或 PNG 返回。
顺序不能随便换。我一开始把磨皮放在形变前面,结果瘦脸做完,被拉伸区域的磨皮效果被重采样打散了,出现一条明暗不均的过渡带。正确顺序是先形变后磨皮:形变产生的插值痕迹,正好被后面的磨皮顺手压掉;反过来做,磨皮区域的一致性会被破坏。这条血泪经验写在pipeline.py的注释里。
# core/pipeline.py import numpy as np from core.detector import FaceLandmarkDetector from core.smoother import skin_mask, smooth_skin from core.whitener import whiten_skin from core.warp import slim_face, enlarge_eyes class BeautyPipeline: def __init__(self, detector: FaceLandmarkDetector, cfg: dict): self.detector = detector self.cfg = cfg def run(self, bgr: np.ndarray) -> np.ndarray: faces = self.detector.detect(bgr) if not faces: return bgr # 没检到脸就原样返回,不硬处理 out = bgr.copy() p = self.cfg for f in faces: pts = f["landmarks"] # 1) 几何形变:重采样会留下插值痕迹,交给后面的磨皮收尾 out = slim_face(out, pts, p["slim_strength"]) out = enlarge_eyes(out, pts, p["eye_scale"]) # 2) 皮肤处理:掩膜必须用「形变后」的关键点重算,否则位置对不上 mask = skin_mask(out, pts) out = smooth_skin(out, mask, p["smooth_strength"]) out = whiten_skin(out, mask, p["whiten_strength"]) return outcfg是一个普通字典,从 YAML 读进来。slim_strength取 0 到 1,eye_scale取 1.0 到 1.25,超过这个范围出来的就是外星人。注意skin_mask用的是形变后重新算的关键点,这是个容易忽略的细节:如果复用形变前的点,瘦脸做完掩膜会偏出脸颊边缘,磨皮就会在脸外面糊一块。
2.2 目录结构与模块职责
项目不大,但模块边界划得很清楚,算法层完全不依赖 Web 框架,这样命令行和接口能复用同一套代码。
ai-beauty/ ├── app/ │ ├── api.py # FastAPI 路由层,只做参数校验和编解码 │ └── schemas.py # 请求/响应模型 ├── core/ │ ├── detector.py # YuNet 检测 + 68 点回归 │ ├── smoother.py # 皮肤掩膜 + 高频分离磨皮 │ ├── whitener.py # LAB 空间提亮 │ ├── warp.py # 局部平移 / 局部缩放 │ ├── pipeline.py # 六段流水线的串联 │ └── io_utils.py # 带 EXIF 修正的读写 ├── configs/ │ └── default.yaml # 三档预设:自然 / 精修 / 证件照 ├── models/ │ ├── face_detection_yunet.onnx │ └── shape_predictor_68_face_landmarks.dat ├── tests/ │ ├── assets/ # 十张覆盖不同肤色、光照、侧脸的回归图 │ └── test_operators.py ├── cli.py ├── requirements.txt └── README.mdmodels/目录里的权重不进版本库,只留一个说明文件写清文件名和放置路径。tests/assets那十张图是整个项目最值钱的部分,后面讲二次开发还会用到。core/里任何模块都不许import fastapi,这条约束保证了算法能被单独拿到脚本里跑。
2.3 检测器选型:YuNet 加 68 点的组合,以及依赖怎么装
检测这块有两条常见路线。一条是 dlib 的 HOG 检测器加 68 点预测器,另一条是 OpenCV DNN 里的 YuNet 加 dlib 的 68 点。我选后者,原因是 dlib 自带的 HOG 检测器在侧脸和小脸上召回率一般,而 YuNet 是 ONNX 模型,cv2.FaceDetectorYN直接加载,CPU 上单张 1080P 图大概 10 毫秒,还带 5 个五官关键点,能顺手做个人脸对齐。
关键点为什么用 68 点而不是 468 点的网格模型?因为形变控制点不是越多越好。瘦脸只需要下颌轮廓的第 0 到 16 号点,大眼只需要眼睛的 36 到 45 号点,68 点刚好覆盖这些语义明确的部位。468 点虽然密,但控制点一多,参数就没法直观调,改一个强度值要重算上百个约束,调试成本反而高。
依赖装起来主要是一个坑:dlib 需要本地编译。
python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate # opencv-contrib 才有 ximgproc,后面导向滤波要用 pip install opencv-contrib-python numpy pillow pyyaml pip install "fastapi[all]" uvicorn python-multipart pip install dlib # 需要 cmake 和 C++ 工具链,装不上看第 5 章opencv-contrib-python和opencv-python不能同时装,两个包都提供cv2模块,混装会出现导入随机指向某一个的玄学问题,卸载干净再装一个就行。模型文件按 README 里的文件名放到models/下,YuNet 是 ONNX 格式,dlib 的 68 点是 dat 格式,两个加起来不到 100MB。
3. 核心算法拆解:磨皮、美白、瘦脸、大眼四个算子的代码实现
3.1 皮肤掩膜:颜色阈值和关键点凸包为什么必须一起用
所有皮肤处理都必须先有掩膜,否则头发、眉毛、背景墙会一起被磨掉。掩膜有两种取法,单独用都不行。
只用颜色阈值,用 YCrCb 空间卡一个肤色范围,问题是很吃光照:暖色调的墙纸、木色桌面、偏黄的灯光都会漏进来,实测一张室内照片能有 30% 的背景被误判成皮肤。只用关键点凸包,问题是在凸包内部的非皮肤区域也会被算进去,眉毛、嘴唇、鼻孔、眼镜框全在里面,磨完眉毛会糊掉一半。
所以我的做法是两者取交集,再用形态学补一下边缘:
# core/smoother.py import cv2 import numpy as np def skin_mask(bgr: np.ndarray, pts: np.ndarray) -> np.ndarray: """返回 uint8 掩膜,255 表示可处理区域""" h, w = bgr.shape[:2] # 1) 颜色通道:YCrCb 对光照变化的鲁棒性比 HSV 好 ycrcb = cv2.cvtColor(bgr, cv2.COLOR_BGR2YCrCb) lower = np.array([0, 133, 77], np.uint8) upper = np.array([255, 173, 127], np.uint8) color = cv2.inRange(ycrcb, lower, upper) # 2) 几何通道:68 点里取脸部轮廓 + 额头,做凸包 hull_pts = np.vstack([pts[0:17], pts[17:27]]).astype(np.int32) geo = np.zeros((h, w), np.uint8) cv2.fillConvexPoly(geo, cv2.convexHull(hull_pts), 255) # 3) 交集后再开运算,去掉零碎噪点 mask = cv2.bitwise_and(color, geo) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))) # 4) 羽化,避免处理区边缘出现硬接缝 return cv2.GaussianBlur(mask, (0, 0), 7)lower和upper的 Cr、Cb 范围是常用经验值,Cr 管红绿倾向,Cb 管蓝黄倾向,卡太窄会漏掉偏黑或偏白的肤色,卡太宽会把橙色衣服拉进来。第 0 到 16 号是脸部外轮廓,17 到 26 号是眉毛,把眉毛一起圈进凸包是为了让额头也覆盖到,不然额头会明显比脸颊暗一档。最后那步高斯核大小传(0, 0)表示让 OpenCV 按 sigma 自动算核尺寸,7是 sigma,值越大过渡越软。
提示:掩膜算完可以先存成图看一眼,直接把 mask 叠到原图上,三五秒就能判断阈值卡得对不对,比反复调磨皮参数高效得多。
3.2 磨皮:高频分离保住纹理
直接上高斯模糊或者双边滤波,最大的问题是「磨过了」,毛孔没了,皮肤纹理也没了,只剩一片均匀的色块。解决办法是高频分离:把图像拆成低频和高频两层,低频压噪,高频保留纹理,最后按强度回掺。
def smooth_skin(bgr: np.ndarray, mask: np.ndarray, strength: float = 0.6, d: int = 15, sigma_color: float = 75, sigma_space: float = 75) -> np.ndarray: # 1) 低频层:双边滤波在压掉毛孔的同时保住五官边缘 low = cv2.bilateralFilter(bgr, d, sigma_color, sigma_space) # 2) 高频层 = 原图 - 低频,就是纹理和瑕疵 high = cv2.subtract(bgr.astype(np.int16), low.astype(np.int16)) # 3) 按强度回掺高频:strength=1 不磨,strength=0 全糊 merged = low.astype(np.int16) + (high * (1.0 - strength)).astype(np.int16) merged = np.clip(merged, 0, 255).astype(np.uint8) # 4) 只在掩膜内生效,掩膜已经羽化过,直接线性混合 m = (mask.astype(np.float32) / 255.0)[..., None] return np.clip(bgr * (1 - m) + merged * m, 0, 255).astype(np.uint8)d是双边滤波的邻域直径,15 对应 1080P 图上大约 15 像素的作用范围,图像越大这个值要跟着调,我一般取长边的 1.5%。sigma_color控制颜色差异多大的像素才参与加权,取大了会把眼睛和皮肤混在一起;sigma_space控制空间衰减,取大了边缘会糊。这两个值 75 是个稳妥的起点。
strength是唯一暴露给用户的参数,取值 0 到 1。0.6 大概是「看得出皮肤变好但不像修图」的程度,0.85 以上适合证件照,1.0 表示完全不磨,用来做 A/B 对比。第 3 步那个回掺是整套磨皮的核心,减掉高频就是磨皮,加回一部分就是保留质感,多少由 strength 决定,逻辑上非常干净。
如果觉得双边滤波速度不够,可以换cv2.ximgproc.guidedFilter,导向滤波是 O(N) 的,在大图上快三到五倍。代价是要装 contrib 包,而且参数语义不一样,radius和eps得重新调。
3.3 美白:LAB 空间只提 L 通道
美白最容易翻车的地方是在 BGR 或 RGB 里直接加常数,加完整张图偏色,脸发灰或者发红。正确做法是转到 LAB 空间,只动亮度通道 L,保留 a、b 两个色度通道。
# core/whitener.py def whiten_skin(bgr: np.ndarray, mask: np.ndarray, strength: float = 0.3, gamma: float = 1.25) -> np.ndarray: lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB) l, a, b = cv2.split(lab) # 1) 构造 gamma 查找表,gamma>1 表示提亮 lut = np.array([((i / 255.0) ** (1.0 / gamma)) * 255 for i in range(256)], dtype=np.uint8) l_new = cv2.LUT(l, lut) # 2) 按 strength 把提亮后的 L 和原始 L 混合 l_blend = cv2.addWeighted(l, 1.0 - strength, l_new, strength, 0) # 3) a/b 通道原样保留,避免整体偏色 out = cv2.cvtColor(cv2.merge([l_blend, a, b]), cv2.COLOR_LAB2BGR) m = (mask.astype(np.float32) / 255.0)[..., None] return np.clip(bgr * (1 - m) + out * m, 0, 255).astype(np.uint8)gamma取 1.25 大约相当于提亮半档,1.5 会明显发白。strength控制混合比例,0.3 是比较克制的值。用 LUT 而不是逐像素算幂函数,是因为cv2.LUT是查表,256 个值算一次就行,比 numpy 的**快一个数量级,这在批量处理时差距很明显。
只提 L 通道有个副作用:人脸会显得有点灰,缺血色。想再自然一点,可以在 a 通道加一点点正值(偏红),或者在 b 通道加一点点正值(偏黄),幅度控制在 1 到 3 之间,再多就假了。这个微调在whiten_skin里留了个可选参数,默认不开。
3.4 瘦脸与大眼:remap 局部变形的正确方向
瘦脸和大眼本质上是同一类操作:在局部做像素位移。OpenCV 里用cv2.remap实现,但它有个反直觉的地方——map_x和map_y记录的是「目标图的每个像素去源图的哪个位置取色」,不是「源图的像素搬到目标图的哪里」。方向搞反了,脸会往两边鼓,而不是往中间收。
先看局部平移,这是瘦脸的基础:
# core/warp.py def local_translate(img: np.ndarray, center: tuple, radius: float, delta: np.ndarray) -> np.ndarray: """把 center 周围 radius 内的内容,沿 delta 方向整体推移""" h, w = img.shape[:2] yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) dist = np.sqrt((xx - center[0]) ** 2 + (yy - center[1]) ** 2) # 权重从圆心 1 衰减到半径处 0,平方衰减让边缘过渡更自然 weight = np.clip(1.0 - dist / radius, 0, 1) ** 2 # 目标像素取源图「反方向偏移」的位置,这才是内容朝 delta 移动 map_x = xx - weight * delta[0] map_y = yy - weight * delta[1] return cv2.remap(img, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)center是要推动的部位坐标,radius是影响范围,delta是位移向量(像素)。weight那行的平方是关键:线性衰减在边界处一阶导数不连续,肉眼能看到一圈折痕,平方衰减把切线拉平了,过渡就看不出来。
基于这个基础函数,瘦脸就是沿着下颌线取六个点,每个点朝面部中轴方向推一小段:
def slim_face(img: np.ndarray, pts: np.ndarray, strength: float = 0.5) -> np.ndarray: if strength <= 0: return img out = img.copy() face_w = float(pts[16][0] - pts[0][0]) # 左右脸轮廓的水平距离 if face_w <= 1: return out axis_x = float((pts[0][0] + pts[16][0]) / 2.0) # 面部中轴近似 max_shift = face_w * 0.025 * strength # 位移上限约为脸宽的 2.5% for idx in (3, 5, 7, 9, 11, 13): px, py = float(pts[idx][0]), float(pts[idx][1]) dist = abs(axis_x - px) if dist < face_w * 0.15: # 太靠中轴的点不参与,避免拉扯嘴部 continue direction = 1.0 if px < axis_x else -1.0 delta = np.array([direction * max_shift, 0.0], np.float32) out = local_translate(out, (px, py), dist * 1.2, delta) return out选第 3、5、7 和 9、11、13 号点,是左脸和右脸各三个下颌点,跳过第 8 号下巴尖。radius取dist * 1.2的意思是影响范围略大于该点到中轴的距离,这样中轴附近权重已经接近 0,不会把鼻子和嘴带歪。max_shift用脸宽做归一化,不管输入是 300 像素的小图还是 4000 像素的高清原图,效果一致,这是个很重要的尺度不变性设计。
大眼用的是局部缩放,数学上是把采样坐标朝中心收拢:
def local_scale(img: np.ndarray, center: tuple, radius: float, scale: float) -> np.ndarray: """radius 内按 scale 放大,scale>1 表示放大""" h, w = img.shape[:2] yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) dx, dy = xx - center[0], yy - center[1] dist = np.sqrt(dx * dx + dy * dy) t = np.clip(dist / radius, 0, 1) # 中心 factor=scale,边缘 factor=1,二次过渡 factor = 1.0 + (scale - 1.0) * (1.0 - t) ** 2 map_x = center[0] + dx / factor map_y = center[1] + dy / factor return cv2.remap(img, map_x, map_y, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT) def enlarge_eyes(img: np.ndarray, pts: np.ndarray, scale: float = 1.15) -> np.ndarray: if scale <= 1.0: return img out = img.copy() for a, b in ((36, 39), (42, 45)): # 左右眼的外眼角和内眼角 p1, p2 = pts[a], pts[b] center = ((p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0) eye_w = float(np.linalg.norm(p2 - p1)) out = local_scale(out, center, eye_w * 1.1, scale) return outfactor的构造要注意方向:map_x = cx + dx / factor,当scale = 1.15时中心区域factor是 1.15,dx被除以 1.15 变小,采样点向中心靠拢,源图内容就被拉伸放大。我第一次写成了乘法,结果眼睛越调越小,对着公式推了半小时才发现。scale上限给到 1.25,再大眼白和瞳孔的比例就失真了,出来是鱼眼效果。半径取眼宽的 1.1 倍,刚好把眼皮和眼袋包进去,不至于带动眉毛。
4. 跑起来与服务化:命令行、YAML 预设和 FastAPI 接口
4.1 三条命令跑通第一次
环境装好、模型放好之后,先用命令行验证算法链路,别一上来就起服务,出了问题分不清是算法错还是接口错。
# 单张图,用默认的「自然」预设 python cli.py --input demo.jpg --output out.jpg # 手动指定四个强度参数,做参数扫描 python cli.py --input demo.jpg --output out_strict.jpg \ --smooth 0.85 --whiten 0.4 --slim 0.8 --eye 1.2 # 整个目录批量跑 python cli.py --input ./raw/ --output ./done/ --preset id_photo --workers 4--smooth控制磨皮强度,0 到 1;--whiten控制美白,0 到 1;--slim控制瘦脸,0 到 1;--eye是放大倍数,1.0 表示不动。--preset会覆盖单参数,指向configs/default.yaml里的某个预设。--workers是进程数,因为 OpenCV 的大部分算子会释放 GIL,但 Python 层的循环不会,用多进程比多线程实在。
cli.py本身很短,二十来行,负责解析参数、调用BeautyPipeline、写出文件。真正的逻辑都在core/里,这也意味着你可以直接把cli.py删掉换成别的入口,算法层不用动。
# cli.py 的核心部分 import argparse, yaml from core.io_utils import read_image, write_image from core.detector import FaceLandmarkDetector from core.pipeline import BeautyPipeline def main(): ap = argparse.ArgumentParser() ap.add_argument("--input", required=True) ap.add_argument("--output", required=True) ap.add_argument("--preset", default="natural") ap.add_argument("--smooth", type=float, default=None) ap.add_argument("--whiten", type=float, default=None) ap.add_argument("--slim", type=float, default=None) ap.add_argument("--eye", type=float, default=None) args = ap.parse_args() with open("configs/default.yaml", "r", encoding="utf-8") as f: cfg = yaml.safe_load(f)[args.preset] # 命令行参数优先级高于预设,None 表示没传,保留预设值 for k in ("smooth", "whiten", "slim", "eye"): v = getattr(args, k) if v is not None: cfg[f"{k}_strength" if k != "eye" else "eye_scale"] = v det = FaceLandmarkDetector("models/face_detection_yunet.onnx", "models/shape_predictor_68_face_landmarks.dat") pipe = BeautyPipeline(det, cfg) write_image(args.output, pipe.run(read_image(args.input))) if __name__ == "__main__": main()参数合并这块的逻辑是「命令行覆盖预设」,用None作为「没传」的标记。如果把default=0.6直接写在add_argument里,就无法区分「用户显式传了 0.6」和「用户没传用默认值」,预设功能就废了,这是个很常见的设计失误。
4.2 配置项:强度参数怎么调才不假
预设用 YAML 存,三个档位的差别不在算法,只在参数:
# configs/default.yaml natural: # 日常社交图,看不出修过 smooth_strength: 0.55 whiten_strength: 0.25 slim_strength: 0.45 eye_scale: 1.10 retouch: # 精修,效果明显但要求五官还认得出 smooth_strength: 0.85 whiten_strength: 0.45 slim_strength: 0.75 eye_scale: 1.18 id_photo: # 证件照,只磨皮美白,不动几何形状 smooth_strength: 0.90 whiten_strength: 0.35 slim_strength: 0.0 eye_scale: 1.0证件照那一档把slim_strength和eye_scale归零,因为证件照改了脸型会被人脸比对系统判定为不一致,这个坑我踩过一次,客户投诉说上传后认证失败。真实场景里,形变类参数永远是最需要谨慎的,磨皮美白只是观感问题,瘦脸大眼可能影响业务合规。
调参有个经验顺序:先定smooth_strength,它决定了整体质感;再调whiten_strength,它影响肤色但不影响结构;最后才碰slim_strength和eye_scale,因为形变会改变人脸的几何特征,改完得重新看磨皮效果是否还合适。反过来调,很容易在形变做完之后发现磨皮过度,又得全部推倒重来。
4.3 封装 HTTP 接口与并发处理
接口层只做三件事:收图、调 pipeline、返回图。算法对象是进程级的,不能每次请求都重新加载模型,一个 YuNet 加一个 dlib 预测器启动要一两秒,每次都加载服务直接废掉。
# app/api.py from functools import lru_cache from fastapi import FastAPI, File, UploadFile, Form from fastapi.responses import Response import cv2, numpy as np from core.detector import FaceLandmarkDetector from core.pipeline import BeautyPipeline from core.io_utils import decode_with_exif app = FastAPI() @lru_cache(maxsize=1) def get_pipeline() -> BeautyPipeline: det = FaceLandmarkDetector("models/face_detection_yunet.onnx", "models/shape_predictor_68_face_landmarks.dat") return BeautyPipeline(det, { "smooth_strength": 0.55, "whiten_strength": 0.25, "slim_strength": 0.45, "eye_scale": 1.10, }) @app.post("/beauty") async def beauty(file: UploadFile = File(...), smooth: float = Form(0.55), whiten: float = Form(0.25)): raw = await file.read() bgr = decode_with_exif(raw) if bgr is None: return Response(status_code=400, content=b"decode failed") h, w = bgr.shape[:2] if max(h, w) > 1600: # 长边限到 1600,避免大图拖垮并发 s = 1600.0 / max(h, w) bgr = cv2.resize(bgr, (int(w * s), int(h * s)), interpolation=cv2.INTER_AREA) pipe = get_pipeline() pipe.cfg["smooth_strength"] = max(0.0, min(1.0, smooth)) pipe.cfg["whiten_strength"] = max(0.0, min(1.0, whiten)) out = pipe.run(bgr) ok, buf = cv2.imencode(".jpg", out, [int(cv2.IMWRITE_JPEG_ <p> <a href="https://download.csdn.net/download/m0_38106923/11253733" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>