
文章目录图像转字符画从朴素映射到对比度拉伸一、v1 朴素版二、v2 优化版三、v3 前景主体用字符渲染四、一句话总结参考图像转字符画从朴素映射到对比度拉伸两个版本的核心差异只有两处——字符梯度和灰度映射策略但输出的视觉层次感完全不同。一、v1 朴素版像素灰度 → 线性映射 → 字符字符梯度18 级M 3 N B 6 Q # O C ? 7 ! : – ; . [空格] 暗 ←──────────────────────────────→ 亮映射公式grey(2126*R7152*G722*B)/10000# Rec.709, 范围 0~255char_idxint((grey/257.0)*18)# 除以 alpha1 (257)空字符触发条件条件场景alpha 0RGBA 图片的完全透明像素grey 242.7极亮像素映射到char[17] 问题中间灰如 grey128落在:或–附近前景和背景的字符视觉重量拉不开人物轮廓容易糊成一片。原图代码fromPILimportImage# # 字符梯度: 按视觉密度从高(暗)到低(亮)排列# # 索引0 M → 最密字符 (最暗的像素)# 索引17 → 空字符 (最亮的像素)# 共18个字符最后一个 是空格——纯白/极亮区域在字符画中呈现为空白charlist(M3NB6Q#OC?7!:–;. )defget_char(r,g,b,alpha256): 像素 → 字符映射 返回空字符 (索引17) 的两种情况: ┌─────────────────────────────────────────────────────┐ │ 1. alpha 0 │ │ PNG 图像的完全透明像素 → 直接返回空格 │ │ 适用场景: 带透明通道的 RGBA 图片 │ │ │ │ 2. 灰度值 grey ~243 (接近纯白) │ │ char_idx int((grey / 257.0) * 18) │ │ 当 grey 17 * 257 / 18 ≈ 242.7 时, │ │ char_idx 17 → 返回 char[17] │ │ 即: 越亮的像素越稀疏, 纯白区域完全留空 │ └─────────────────────────────────────────────────────┘ 灰度计算: Rec.709 标准相对亮度公式 Y 0.2126*R 0.7152*G 0.0722*B 系数放大 10000 倍做整数运算, 结果范围 0~255 # 情况1: 透明像素 → 空字符ifalpha0:return # Rec.709 亮度: Y 0.2126*R 0.7152*G 0.0722*Bgrey(2126*r7152*g722*b)/10000# 灰度 → 字符索引映射# grey 范围 0~255, alpha 默认 256# char_idx int((grey / 257) * 18)# 暗像素 (grey→0): char_idx → 0 → char[0] M (最密)# 亮像素 (grey→255): char_idx → int(17.86) 17 → char[17] (空)# 阈值: grey 242.7 时, char_idx 17, 对应空格char_idxint((grey/(alpha1.0))*len(char))returnchar[char_idx]defwrite_file(out_file_name,content):withopen(out_file_name,w)asf:f.write(content)defmain(file_nameinput.jpg,width96,height64,out_file_name./demo/output2.txt): 图像 → 字符画 对于 RGB 图像 (无 alpha 通道): - 纯黑像素 (grey≈0) → M 最密, 视觉上最重 - 纯白像素 (grey≈255) → 空字符, 视觉上完全留白 - 中间调按密度梯度分布 对于 RGBA 图像 (带透明通道): - 完全透明像素 (alpha0) → 空字符, 相当于挖空 textimImage.open(file_name)imim.resize((width,height),Image.NEAREST)foriinrange(height):forjinrange(width):textget_char(*im.getpixel((j,i)))text\nprint(text)write_file(out_file_name,text)if__name____main__:main(./demo/2.png)结果二、v2 优化版像素灰度 → 对比度拉伸 → 字符映射字符梯度54 级密度差距更大 # % M 8 B Q 0 O g N W X D R Z $ O K w p q d b a o x n u c z r f t / | ( ) 1 { } [ ] ? - _ ~ i ! l I ; : , ^ . [空格] 暗 ←════════════════════════════════════════════════════════════════════════→ 亮新增对比度拉伸defapply_contrast_stretch(grey_array,black_point15,white_point235):stretched(grey-black_point)/(white_point-black_point)returnnp.clip(stretched,0,1)*255这就是拉开前景/背景差距的关键。原理很简单原始灰度 拉伸后灰度 字符效果 ───────── ───────── ────────── 0~15 → 0 # (极密前景实心) 16~127 → 8~130 %M8BQ (偏密前景过渡) 128~234 → 131~254 /|() (偏疏背景过渡) 235~255 → 255 . [空格] (极疏背景留白)black_point15把暗部压到纯黑white_point235把亮部提纯白。128 附近的中间灰不再含糊而是被推到两极。灰度转换也从手动 Rec.709 公式改为 Pillow 内置Image.convert(L)输出等价但代码更简洁。代码fromPILimportImageimportnumpyasnp# # 字符梯度: 按视觉密度从高到低排列# # 从最密(暗/前景)到最疏(亮/背景)拉大了两端的视觉重量差距# # 代表极致暗部. 代表极致亮部charlist(#%M8BQ0OgNWXDRZ$OKwpqdbaoxnuczrft/|()1{}[]?-_~i!lI;:,^.\ )defapply_contrast_stretch(grey_array,black_point10,white_point240): 对比度拉伸: 将灰度直方图在 [black_point, white_point] 区间内拉伸到 [0, 255] 效果: - 原图中偏暗的区域 (靠近 black_point) 被推向 0 → 用更密的字符 - 原图中偏亮的区域 (靠近 white_point) 被推向 255 → 用更疏的字符 - [black_point, white_point] 区间外的值直接被钳制到端点 - 拉大前景/背景的字符视觉差距 :param grey_array: 灰度值 numpy 数组 (0-255) :param black_point: 黑点阈值低于此值被钳制为纯黑 :param white_point: 白点阈值高于此值被钳制为纯白 :return: 拉伸后的灰度数组 stretched(grey_array.astype(np.float32)-black_point)/(white_point-black_point)stretchednp.clip(stretched,0,1)# 钳制到 [0, 1]return(stretched*255).astype(np.uint8)defgrey_to_char(grey): 灰度值 → 字符映射 使用均匀映射将 0-255 的灰度值对应到字符梯队。 经过 contrast_stretch 处理后中间调已被推向两端 前景(暗)自然得到高密度字符背景(亮)得到低密度字符。 ifgrey0:returnchar[0]idxint(grey/255*(len(char)-1))returnchar[idx]defmain(file_name./demo/1.png,width96,height64,out_file_name./demo/output.txt,black_point15,white_point235): 图像 → 字符画转换 :param file_name: 输入图片路径 :param width: 输出字符宽度 (列数) :param height: 输出字符高度 (行数) :param out_file_name: 输出文本文件路径 :param black_point: 对比度拉伸黑点 (0-255), 低于此值→纯黑 :param white_point: 对比度拉伸白点 (0-255), 高于此值→纯白 imImage.open(file_name).convert(RGB)# 缩放: NEAREST 保留边缘锐度对字符画效果优于 LANCZOSimim.resize((width,height),Image.NEAREST)# 转灰度: 标准 Rec.709 亮度公式# Y 0.2126*R 0.7152*G 0.0722*Bgreyim.convert(L)grey_arraynp.array(grey)# 对比度拉伸: 关键步骤——将中间灰推向两端的黑/白grey_arrayapply_contrast_stretch(grey_array,black_point,white_point)# 逐像素映射为字符textforiinrange(height):forjinrange(width):textgrey_to_char(grey_array[i,j])text\nprint(text)withopen(out_file_name,w)asf:f.write(text)if__name____main__:main(./demo/2.png,black_point15,white_point235)三、v3 前景主体用字符渲染缺陷面对灰度分布比较复杂的图片效果很乱。eg输入输出可以更智能一点来个前景分割把背景都置为空字符importcv2importnumpyasnpfromPILimportImage# # 字符梯度: 按视觉密度从高到低排列 (同 v2)# charlist(#%M8BQ0OgNWXDRZ$OKwpqdbaoxnuczrft/|()1{}[]?-_~i!lI;:,^.\ )defget_foreground_mask(grey,methodotsu,edge_refineTrue): OpenCV 前景/背景分割 → 生成二值 mask 三种分割方法: ┌──────────────────────────────────────────────────────┐ │ otsu 大津二值化: 适用于直方图双峰的图像 │ │ 自动寻找最佳阈值前景暗/背景亮时效果好 │ │ │ │ adaptive 自适应阈值: 局部光照不均匀时更鲁棒 │ │ 每个像素用其邻域的加权均值做阈值 │ │ │ │ canny 边缘包围区域填充: 先 Canny 找边缘 │ │ 再对边缘图做形态学闭操作 轮廓填充 │ └──────────────────────────────────────────────────────┘ :param grey: 灰度图 (H, W), uint8, 0~255 :param method: 分割方法 otsu | adaptive | canny :param edge_refine: 是否用 Canny 边缘细化 mask 边界 :return: 二值 mask (H, W), 1前景, 0背景 ifmethodotsu:# 大津法: 自动阈值 → 二值化# THRESH_BINARY_INV: 暗部(前景)白色(255), 亮部(背景)黑色(0)# 最后反转: 前景1, 背景0_,maskcv2.threshold(grey,0,255,cv2.THRESH_BINARY_INVcv2.THRESH_OTSU)mask(mask0).astype(np.uint8)elifmethodadaptive:# 自适应阈值: 每个像素用 21×21 邻域的高斯加权均值 - 5 作为阈值# 局部光照不均时比 Otsu 更鲁棒maskcv2.adaptiveThreshold(grey,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,cv2.THRESH_BINARY_INV,21,5)mask(mask0).astype(np.uint8)elifmethodcanny:# Canny 边缘 → 形态学闭操作闭合边缘 → 轮廓内填充edgescv2.Canny(grey,50,150)# 闭操作: 先膨胀后腐蚀闭合断开的边缘曲线kernelcv2.getStructuringElement(cv2.MORPH_ELLIPSE,(5,5))closedcv2.morphologyEx(edges,cv2.MORPH_CLOSE,kernel,iterations2)# floodFill 从边缘向内填充masknp.zeros((grey.shape[0]2,grey.shape[1]2),np.uint8)# 从图像四角做 flood fill → 标记外部区域cv2.floodFill(closed.copy(),mask,(0,0),255)# 反转: flood fill 标记的是外部 → 内部就是前景mask(mask[1:-1,1:-1]0).astype(np.uint8)# 膨胀恢复边缘区域maskcv2.dilate(mask,kernel,iterations1)# --- 形态学后处理 (所有方法共用) ---kernel_smallcv2.getStructuringElement(cv2.MORPH_ELLIPSE,(3,3))# 去掉孤立的噪声点 (开操作: 先腐蚀去掉小白点再膨胀恢复)maskcv2.morphologyEx(mask,cv2.MORPH_OPEN,kernel_small,iterations1)# --- Canny 边缘细化 (可选) ---ifedge_refine:# 用 Canny 边缘作为边界向内侵蚀 mask# 让前景/背景边界更贴合物体真实轮廓edge_kernelcv2.getStructuringElement(cv2.MORPH_ELLIPSE,(3,3))maskcv2.dilate(mask,kernel_small,iterations1)# 先膨胀maskcv2.erode(mask,kernel_small,iterations2)# 再收缩: 沿边缘收紧returnmaskdefapply_contrast_stretch(grey_array,black_point10,white_point240):对比度拉伸 (同 v2) — 仅作用于前景区域stretched(grey_array.astype(np.float32)-black_point)/(white_point-black_point)stretchednp.clip(stretched,0,1)return(stretched*255).astype(np.uint8)defgrey_to_char(grey):灰度值 → 字符 (同 v2)ifgrey0:returnchar[0]idxint(grey/255*(len(char)-1))returnchar[idx]defmain(file_name./demo/1.png,width128,height80,out_file_name./demo/output_v3.txt,methodotsu,black_point15,white_point235,edge_refineTrue): 图像 → 字符画 v3: 背景置空 核心改进 (对比 v2): ┌───────────────────────────────────────────────────────┐ │ v2: 所有像素都映射为字符亮背景区域用稀疏字符 (如 .) │ │ v3: 前/背景分割后背景直接用空格 彻底留白 │ │ 只有前景物体才被字符渲染视觉主体更突出 │ └───────────────────────────────────────────────────────┘ :param file_name: 输入图片路径 :param width: 输出字符宽度 (列数) :param height: 输出字符高度 (行数) :param out_file_name: 输出文本文件路径 :param method: 分割方法 otsu | adaptive | canny :param black_point: 对比度拉伸黑点 (前景内部) :param white_point: 对比度拉伸白点 (前景内部) :param edge_refine: 是否用 Canny 细化边界 # 1. 读取 缩放imcv2.imread(file_name)imcv2.resize(im,(width,height),interpolationcv2.INTER_NEAREST)greycv2.cvtColor(im,cv2.COLOR_BGR2GRAY)# 2. 前/背景分割 → 二值 maskmaskget_foreground_mask(grey,methodmethod,edge_refineedge_refine)# 3. 对灰度图做对比度拉伸 (仅影响前景区域的字符映射)grey_stretchedapply_contrast_stretch(grey,black_point,white_point)# 4. 逐像素生成字符# mask0 (背景) → 空格# mask1 (前景) → 按拉伸后的灰度映射字符textforiinrange(height):forjinrange(width):ifmask[i,j]0:text # 背景: 完全留空else:textgrey_to_char(grey_stretched[i,j])# 前景: 字符渲染text\nprint(text)withopen(out_file_name,w)asf:f.write(text)if__name____main__:main(./demo/1.png,methodotsu,width128,height64,black_point15,white_point235,edge_refineTrue)哈哈优化过头了四、一句话总结v1 是灰度对应字符v2 是先拉开灰度差距再对应字符。多的那一步对比度拉伸就是糊和不糊的分界线v3 只有前景主体用字符渲染背景完全留空。调节black_point和white_point可以控制风格两值越靠近如 50/200对比越强烈画面越像木刻版画两值越远离如 0/255越接近 v1 的朴素效果。缺陷char 的长宽分配也不确定属于超参因为字符的宽度也不一样想要和输入的原图保持同比例需要调试参考https://mp.weixin.qq.com/s/PvZW6SCQTfNRmAQX2bwUUg