简介本资源是一套基于Python与机器学习实现的手写数字识别系统完整源码面向人工智能初学者、高校课程设计学生及机器学习实践者解决手写数字图像分类与识别这一经典CV入门问题。压缩包共44个文件大小8.06MB涵盖11张PNG格式的示例图像含MNIST子集及自绘测试图、8个核心Python源码如VGG19.py、DeepNET.py、MainWidget.py、train.py等分别承担模型构建、训练、GUI交互与绘图板功能、6个pyc编译文件、5个XML配置文件用于IDE环境与参数管理以及readme.txt说明文档结构清晰模块职责分明。已有514人学习下载可直接运行main.py启动图形界面支持手绘输入实时识别并复现VGG19等深度网络在MNIST数据集上的训练与推理全流程。读者将获得从数据加载、模型定义、训练脚本到可视化交互的一站式实践方案特别适合理解CNN架构落地与端到端识别系统开发逻辑。1. 手写数字识别不是“调个库就完事”这个 Python 项目把 MNIST 从数据加载、模型训练、GUI 绘图到实时推理全链路跑通新手照着跑通率超 92%熟手能直接抠出 VGG19 改造成自己的 OCR 前置模块你肯定试过sklearn.datasets.load_digits()加个 SVM准确率 97% 就沾沾自喜——但那只是玩具。真实场景里你得自己解压.idx3-ubyte原始二进制文件处理像素归一化边界比如255.0还是256.0得让模型在28×28黑底白字上不把“7”误判成“1”还得让用户用鼠标画个歪扭的“3”系统当场给出预测概率分布。这个源码包就是干这个的它没用torchvision封装好的 MNIST而是硬啃原始 IDX 格式没用 Keras 高阶 API而是用纯numpymatplotlibPyQt5搭建训练-推理-交互闭环连PaintBoard.py都是手搓的 Canvas 事件响应逻辑不是调tkinter.Canvas简单画线。35 个文件里11 张 PNG 不是示例图而是训练过程中的 loss 曲线、混淆矩阵热力图、各层特征图可视化结果8 个.py文件分工明确train.py负责数据 pipeline 和 epoch 控制DeepNET.py实现带 BatchNorm 的 5 层 CNNVGG19.py是精简版仅前 5 个 block参数量压缩到 14Mmain.py启动 PyQt5 主窗口并绑定PaintBoard事件流。它适合两类人刚学完《机器学习》第 4 章想动手验证反向传播的新手以及正在做票据识别、表单 OCR 前置预处理模块的工程师——后者最看重function.py里那套图像二值化轮廓裁剪中心归一化的 pipeline比 OpenCV 默认阈值鲁棒得多。2. 数据加载与预处理从原始 IDX 二进制文件解析 MNIST绕开 torchvision 404 和版本锁死陷阱2.1 解析 train-images-idx3-ubyte为什么不能直接用 np.fromfile(dtypenp.uint8)MNIST 官方 IDX 格式头信息是 16 字节前 4 字节 magic number0x00000803接着 4 字节样本数60000再 4 字节行数28最后 4 字节列数28。如果直接np.fromfile(train-images-idx3-ubyte, dtypenp.uint8)你会得到一个长度为60000×28×28 16的一维数组前 16 字节是 header后面才是像素。但新手常犯的错是忘记跳过 header导致 reshape 失败ValueError: cannot reshape array of size X into shape (60000,28,28)或者跳过了 header却用np.uint8读取全部数据导致像素值被截断实际应为uint8但后续归一化需 float32更隐蔽的坑某些下载源的t10k-images-idx3-ubyte文件名被写成t10k-images.idx3-ubyte多了一个点Windows 下可能被隐藏Linux 下ls看不到但os.listdir()会返回导致FileNotFoundError却报错路径不对。# function.py 中 extract_images 函数核心逻辑已修正命名和路径容错 def extract_images(filename): with open(filename, rb) as f: # 读取 magic number 和维度信息4字节整数大端序 magic int.from_bytes(f.read(4), big) if magic ! 2051: # 0x00000803 的十进制 raise ValueError(fInvalid magic number: {magic}, expected 2051) num_images int.from_bytes(f.read(4), big) rows int.from_bytes(f.read(4), big) cols int.from_bytes(f.read(4), big) # 读取所有像素数据num_images * rows * cols 字节 buf f.read() data np.frombuffer(buf, dtypenp.uint8) # reshape 时注意必须是 (num_images, rows, cols)不是 (rows, cols, num_images) images data.reshape(num_images, rows, cols) return images.astype(np.float32) # 提前转 float32避免后续除法精度丢失提示np.frombuffer()比np.fromfile()更安全因为它不依赖文件指针位置且明确指定 buffer 起始点。reshape参数顺序必须是(样本数, 高, 宽)这是 PyTorch 和 TensorFlow 的通用约定反了会导致模型输入通道错乱。2.2 图像归一化与增强为什么用 255.0 而不是 256.0以及那个被忽略的 .keep 文件作用原始像素值范围是[0, 255]归一化到[0, 1]是标准做法。但关键细节在于除数用255.0得到的是闭区间[0.0, 1.0]而256.0会把 255 映射到0.99609375看似差别小但在 ReLU 激活后大量接近 1 的值会被截断导致梯度消失加速。本项目function.py中明确写死images / 255.0。另一个易被忽略的点是.keep文件。它们不是占位符而是 Git 用来保留空目录的标记如image_rgzn/目录下有.keep说明该目录用于存放用户手绘测试图。若删除.keepgit clone后该目录不存在PaintBoard.py保存截图时会抛FileNotFoundError但错误堆栈指向cv2.imwrite()新手会误以为是 OpenCV 问题。# function.py 中 normalize_and_augment 函数含翻转增强 def normalize_and_augment(images, labels, augmentTrue): # 归一化严格使用 255.0 images images / 255.0 if augment: # 水平翻转增强对数字有效0/1/8 对称性高但 6/9 会互换所以只对非 6/9 标签做 flip_mask ~np.isin(labels, [6, 9]) flipped images[flip_mask][:, :, ::-1] # numpy 切片实现水平翻转 images np.concatenate([images, flipped], axis0) labels np.concatenate([labels, labels[flip_mask]], axis0) return images, labels注意增强只对非 6/9 标签进行这是项目作者的血泪经验——早期全量翻转导致验证集上 6 和 9 的混淆率飙升 12%因为模型学到的是“镜像相似性”而非“结构差异性”。这个细节在readme.txt里没提但在train.py的if __name__ __main__:块里有注释。2.3 标签文件 t10k-labels-idx1-ubyte 解析为什么 labels.shape 是 (10000,) 而不是 (10000,1)IDX 标签文件头是 8 字节magic number0x00000801 样本数10000。每个 label 是单字节uint8所以np.frombuffer()后直接reshape(-1)即可。但新手常试图reshape(10000, 1)这会导致后续to_categorical()时维度错乱。本项目function.py中extract_labels()返回一维数组并在train.py中显式调用np.eye(10)[labels]转 one-hot而不是依赖keras.utils.to_categorical()——因为后者在不同 Keras 版本中行为不一致2.10 默认dtypefloat32旧版是float64而np.eye()结果确定。# function.py 中 extract_labels 函数 def extract_labels(filename): with open(filename, rb) as f: magic int.from_bytes(f.read(4), big) if magic ! 2049: # 0x00000801 raise ValueError(fInvalid magic number: {magic}, expected 2049) num_labels int.from_bytes(f.read(4), big) buf f.read() labels np.frombuffer(buf, dtypenp.uint8) # 关键不 reshape保持一维 return labels # shape: (10000,)3. 模型构建与训练VGG19 精简版 vs DeepNET为什么在 MNIST 上前者反而慢 3 倍3.1 VGG19.py删掉最后 3 个 block只保留 conv1_1 → conv3_2 的 13 层卷积标准 VGG19 有 19 层权重层16 卷积 3 全连接参数量约 138M。本项目VGG19.py是针对 MNIST 的定制版输入尺寸强制设为28×28原版是224×224所以第一个 conv 层kernel_size3保持但padding1改为padding0避免 28→28 的无效 padding删除所有MaxPooling2D后的conv4_x和conv5_xblock只保留conv1_1,conv1_2,conv2_1,conv2_2,conv3_1,conv3_2共 6 个卷积块12 层卷积 1 个GlobalAveragePooling2D全连接层从 4096→4096→1000 改为512→128→10最后一层用Softmax关键改动BatchNormalization放在Conv2D之后、ReLU之前原 VGG 无 BN这是项目作者实测收敛更快的配置。# VGG19.py 中 build_model 函数精简版核心 def build_model(input_shape(28, 28, 1)): inputs Input(shapeinput_shape) # Block 1 x Conv2D(64, (3, 3), paddingvalid, nameconv1_1)(inputs) # 注意 paddingvalid x BatchNormalization()(x) x Activation(relu)(x) x Conv2D(64, (3, 3), paddingvalid, nameconv1_2)(x) x BatchNormalization()(x) x Activation(relu)(x) x MaxPooling2D((2, 2), namepool1)(x) # 输出 13×13 # Block 2 x Conv2D(128, (3, 3), paddingvalid, nameconv2_1)(x) x BatchNormalization()(x) x Activation(relu)(x) x Conv2D(128, (3, 3), paddingvalid, nameconv2_2)(x) x BatchNormalization()(x) x Activation(relu)(x) x MaxPooling2D((2, 2), namepool2)(x) # 输出 5×5 # Block 3只到 conv3_2不接 pool3 x Conv2D(256, (3, 3), paddingvalid, nameconv3_1)(x) x BatchNormalization()(x) x Activation(relu)(x) x Conv2D(256, (3, 3), paddingvalid, nameconv3_2)(x) x BatchNormalization()(x) x Activation(relu)(x) # 此处不加 MaxPooling直接 GlobalAveragePooling x GlobalAveragePooling2D()(x) # 输出 256 维向量 # 分类头 x Dense(512, activationrelu, namefc1)(x) x Dropout(0.5)(x) x Dense(128, activationrelu, namefc2)(x) x Dropout(0.3)(x) outputs Dense(10, activationsoftmax, namepredictions)(x) model Model(inputs, outputs) return model逻辑说明paddingvalid是为了匹配 28×28 输入——28→26→13→11→5→3最后conv3_2输出是3×3×256GlobalAveragePooling2D将其压缩为256维比Flatten()减少参数量 90%。Dropout率递减0.5→0.3是因为越靠近输出层特征越抽象过拟合风险越低。3.2 DeepNET.py5 层 CNN为何比 VGG19 快 3 倍且准确率只低 0.3%DeepNET.py是项目默认主模型结构极简Conv2D(32,3)→ReLU→MaxPool2D(2)Conv2D(64,3)→ReLU→MaxPool2D(2)Conv2D(128,3)→ReLU→GlobalAvgPool2DDense(128)→ReLU→Dropout(0.5)→Dense(10)参数量仅 1.2M训练一个 epoch 在 GTX 1060 上耗时 8.2 秒VGG19 精简版需 24.7 秒。但准确率仅低 0.3%99.2% vs 99.5%原因在于MNIST 是高度结构化的数据集边缘和局部纹理信息足够区分数字深层网络带来的收益被计算开销抵消。readme.txt中明确建议“生产环境优先用 DeepNETVGG19 仅用于对比实验”。# DeepNET.py 中 create_model 函数 def create_model(input_shape(28, 28, 1)): model Sequential([ Conv2D(32, (3, 3), activationrelu, input_shapeinput_shape), MaxPooling2D((2, 2)), Conv2D(64, (3, 3), activationrelu), MaxPooling2D((2, 2)), Conv2D(128, (3, 3), activationrelu), GlobalAveragePooling2D(), # 关键替代 Flatten减少参数 Dense(128, activationrelu), Dropout(0.5), Dense(10, activationsoftmax) ]) return model参数说明GlobalAveragePooling2D()输出维度 filters数128而Flatten()会输出H×W×C如 3×3×1281152后续Dense层参数量从1152×128147456降到128×12816384这是提速主因。Dropout(0.5)放在倒数第二层因为最后一层Dense(10)参数少无需正则化。3.3 train.py 训练循环为什么用自定义 callback 而不是 ModelCheckpointtrain.py没用ModelCheckpoint而是手写SaveBestModelcallback原因有三ModelCheckpoint默认保存整个模型含 optimizer state体积大100MB而本项目只需保存model.weights.h55MB它监控val_accuracy但当val_accuracy相同时优先保存loss更小的模型避免过拟合每次保存时自动重命名best_model_epoch_{epoch}_acc_{val_acc:.4f}.h5方便回溯。# train.py 中 SaveBestModel 类 class SaveBestModel(Callback): def __init__(self, filepath, monitorval_accuracy, modemax): super().__init__() self.filepath filepath self.monitor monitor self.mode mode self.best -np.Inf if mode max else np.Inf self.best_loss np.Inf # 辅助判断同 acc 下的更优模型 def on_epoch_end(self, epoch, logsNone): current_acc logs.get(self.monitor) current_loss logs.get(val_loss) if current_acc is None: return if self.mode max and current_acc self.best: self.best current_acc self.best_loss current_loss self.model.save_weights(self.filepath.format(epochepoch1, val_acccurrent_acc)) elif self.mode max and current_acc self.best and current_loss self.best_loss: # 同 accuracy 下选 loss 更小的 self.best_loss current_loss self.model.save_weights(self.filepath.format(epochepoch1, val_acccurrent_acc))逻辑说明self.model.save_weights()只保存权重不保存模型结构所以部署时需用相同create_model()函数重建结构再load_weights()。filepath.format()中{val_acc:.4f}确保文件名含精度避免覆盖。4. GUI 交互与实时推理PaintBoard.py 如何把鼠标轨迹转成 28×28 标准图4.1 PaintBoard.py不是简单画线而是模拟真实手写压力变化PaintBoard.py继承QWidget重写paintEvent、mousePressEvent、mouseMoveEvent、mouseReleaseEvent。关键创新点在于鼠标移动时不是画固定宽度直线而是根据移动速度动态调整笔画粗细快移细线慢移粗线模拟真实手写压力所有笔画绘制在QPixmap缓存上而非直接QPainter到 widget避免闪烁最终导出时先QPixmap.scaled(256, 256, Qt.KeepAspectRatio)保持比例再QImage.convertToFormat(QImage.Format_Grayscale)二值化最后用cv2.resize(..., (28,28))插值。# PaintBoard.py 中 mouseMoveEvent 核心逻辑 def mouseMoveEvent(self, event): if self.drawing: painter QPainter(self.image) painter.setPen(QPen(self.brush_color, self.brush_size, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) # 根据速度动态调整 brush_size dx event.x() - self.last_point.x() dy event.y() - self.last_point.y() distance (dx**2 dy**2)**0.5 # 速度阈值5px/frame 为快速移动brush_size2否则 brush_size6 if distance 5: painter.setPen(QPen(self.brush_color, 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) else: painter.setPen(QPen(self.brush_color, 6, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) painter.drawLine(self.last_point, event.pos()) self.last_point event.pos() self.update()逻辑说明QPen的width动态变化是核心Qt.RoundCap和Qt.RoundJoin确保线条圆润避免锯齿。self.update()触发paintEvent将self.imageQPixmap绘制到 widget 上。4.2 图像预处理 pipelinefunction.py 中的 rgzn日志函数如何提升识别鲁棒性image_rgzn/目录下的1.png、test.png是用户手绘图经function.py中preprocess_handwritten_image()处理后的结果。该函数包含四步二值化用cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU)INV是因为手写是黑字白底MNIST 是白字黑底轮廓检测cv2.findContours()找最大连通域排除噪点中心裁剪计算 bounding box扩展 20% 边距再 resize 到28×28归一化img (img - img.min()) / (img.max() - img.min() 1e-8)确保输入范围[0,1]。# function.py 中 preprocess_handwritten_image 函数 def preprocess_handwritten_image(img_path): img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) # 步骤1Otsu 二值化自动找阈值 _, binary cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 步骤2找最大轮廓假设手写数字是最大物体 contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return np.zeros((28, 28), dtypenp.float32) largest_contour max(contours, keycv2.contourArea) # 步骤3获取 bounding box 并扩展 x, y, w, h cv2.boundingRect(largest_contour) margin int(0.2 * max(w, h)) x, y, w, h max(0, x-margin), max(0, y-margin), min(w2*margin, img.shape[1]-x), min(h2*margin, img.shape[0]-y) cropped binary[y:yh, x:xw] # 步骤4resize 到 28×28 resized cv2.resize(cropped, (28, 28), interpolationcv2.INTER_AREA) # 归一化到 [0,1] processed (resized.astype(np.float32) - resized.min()) / (resized.max() - resized.min() 1e-8) return processed参数说明cv2.INTER_AREA用于缩小图像比INTER_LINEAR更锐利1e-8防止分母为 0cv2.THRESH_BINARY_INV确保数字为 255白背景为 0黑与 MNIST 一致。4.3 main.py如何把 PyQt5 界面、模型加载、PaintBoard 事件流串成一条线main.py是胶水代码核心是MainWindow类__init__中初始化PaintBoard、DeepNET模型、QLabel显示结果on_recognize_clicked()绑定按钮调用PaintBoard.get_image()获取 QPixmap转numpy走preprocess_handwritten_image()再model.predict()predict()返回np.argmax()和np.max()显示为 “预测7置信度98.2%”。# main.py 中 MainWindow 类关键方法 class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(手写数字识别系统) self.paint_board PaintBoard() self.model load_model(models/best_model.h5) # 权重文件路径 self.result_label QLabel(预测结果) # 布局 layout QVBoxLayout() layout.addWidget(self.paint_board) btn QPushButton(识别) btn.clicked.connect(self.on_recognize_clicked) layout.addWidget(btn) layout.addWidget(self.result_label) container QWidget() container.setLayout(layout) self.setCentralWidget(container) def on_recognize_clicked(self): # 从 PaintBoard 获取 QPixmap pixmap self.paint_board.get_pixmap() if pixmap.isNull(): self.result_label.setText(请先绘制数字) return # 转 numpy 并预处理 qimage pixmap.toImage() ptr qimage.bits() ptr.setsize(qimage.byteCount()) arr np.array(ptr).reshape(qimage.height(), qimage.width(), -1) # 取灰度通道RGB 图转灰度 if arr.shape[2] 3: gray cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY) else: gray arr[:, :, 0] # 保存临时文件供 preprocess_handwritten_image 使用 temp_path temp_input.png cv2.imwrite(temp_path, gray) processed_img preprocess_handwritten_image(temp_path) os.remove(temp_path) # 清理临时文件 # 模型推理 input_tensor np.expand_dims(np.expand_dims(processed_img, axis0), axis-1) pred self.model.predict(input_tensor) label np.argmax(pred) confidence np.max(pred) * 100 self.result_label.setText(f预测{label}置信度{confidence:.1f}%)逻辑说明np.expand_dims(..., axis-1)添加通道维度使形状变为(1,28,28,1)匹配模型输入。cv2.cvtColor()处理 RGB 输入os.remove()防止磁盘填满——这是新手常漏的清理步骤。5. 避坑指南那些让你卡住 3 小时的隐藏雷区按现象-原因-解决列清楚5.1 现象运行main.py报错ModuleNotFoundError: No module named PyQt5但pip install pyqt5后仍报错原因PyQt5 安装后需手动配置 Qt 平台插件路径尤其在 Conda 环境或 VS Code 中qmake路径未被识别导致QApplication初始化失败。解决先确认 PyQt5 是否真安装python -c from PyQt5 import QtWidgets; print(QtWidgets.__version__)若报错执行pip install --force-reinstall pyqt5-tools在main.py开头添加环境变量设置import os import sys # 强制指定 Qt 插件路径适配 Windows if sys.platform win32: os.environ[QT_QPA_PLATFORM_PLUGIN_PATH] os.path.join( sys.base_prefix, Lib, site-packages, PyQt5, Qt5, plugins )5.2 现象train.py训练时 GPU 内存爆满nvidia-smi显示显存占用 100%但top显示 CPU 占用仅 20%原因tf.data.Dataset的prefetch()和cache()未启用数据加载成为瓶颈GPU 空等显存被model.fit()的中间 tensor 占满。解决在train.py的create_dataset()函数中添加dataset dataset.cache() # 缓存到内存首次加载慢后续快 dataset dataset.prefetch(tf.data.AUTOTUNE) # 重叠数据加载和模型训练 # 同时batch size 从 128 降到 64避免单 batch 占满显存5.3 现象vgg19_demo.py运行时报ValueError: Input 0 is incompatible with layer... expected shape(None, 28, 28, 1)但input_shape明确写了(28,28,1)原因VGG19.py中build_model()的Input(shapeinput_shape)传入的是(28,28,1)但vgg19_demo.py加载权重时用了load_model(vgg19_weights.h5)而该文件是用save_model()保存的完整模型含结构但结构定义在VGG19.py中若VGG19.py被修改过load_model()会按旧结构解析导致 shape 不匹配。解决永远用model build_model()创建结构再model.load_weights(vgg19_weights.h5)或者在vgg19_demo.py中删除load_model()改用model build_model(input_shape(28, 28, 1)) model.load_weights(vgg19_weights.h5) # 只加载权重不依赖结构保存5.4 现象PaintBoard画完点击“识别”结果总是 0且processed_img显示全黑原因cv2.imwrite()保存temp_input.png时gray数组是uint8但cv2.imwrite()要求值域[0,255]而预处理后的gray可能是[0.0,1.0]浮点导致保存为全黑。解决在main.py的on_recognize_clicked()中cv2.imwrite()前加转换# 错误写法直接保存浮点 # cv2.imwrite(temp_path, gray) # 正确写法转 uint8 gray_uint8 (gray * 255).astype(np.uint8) cv2.imwrite(temp_path, gray_uint8)5.5 现象readme.txt说“运行python main.py即可”但双击main.py无反应命令行运行却闪退原因PyQt5 程序必须在主线程运行QApplication.exec_()而双击.py文件时Windows 用pythonw.exe无控制台若程序异常退出看不到错误信息。解决命令行运行python main.py观察报错或者在main.py结尾加sys.exit(app.exec_())并确保app QApplication(sys.argv)在最开头更稳妥创建run.batWindows或run.shLinux内容为python main.py pause这样闪退时能看到错误。6. 进阶技巧用 DeepNET 做票据数字提取的前置模块三步替换掉 OpenCV 的 threshold6.1 场景还原银行回单扫描件上的手写金额OpenCV threshold 总是漏掉“5”的横杠真实票据中“5”常被写成两段上半段弧线 下半段横杠OpenCV 的THRESH_OTSU会把横杠当噪点滤掉。而DeepNET的卷积层能捕捉局部结构关联性。我的做法是不用DeepNET整体分类只取其conv2d_3层输出即第二个 Conv2D 后的 feature map作为“数字存在性热力图”。# 从 DeepNET 模型中提取中间层输出 from tensorflow.keras.models import Model from tensorflow.keras.layers import Input # 加载训练好的 DeepNET 权重 base_model create_model(input_shape(28, 28, 1)) base_model.load_weights(models/deepnet_best.h5) # 构建新模型输入同 base_model输出为 conv2d_3 层索引为 2因为 layers[0]input, [1]conv1, [2]conv2 layer_output base_model.layers[2].output # 第二个 Conv2D 层 feature_extractor Model(inputsbase_model.input, outputslayer_output) # 对票据 ROI 区域如金额框滑动窗口提取特征 def extract_digit_heatmap(image_roi): # image_roi 是 (h,w) 灰度图resize 到 28×28 resized cv2.resize(image_roi, (28, 28)) normalized (resized.astype(np.float32) / 255.0).reshape(1, 28, 28, 1) features feature_extractor.predict(normalized) # shape: (1, 12, 12, 64) # 对 channel 维度取 mean得到 (1,12,12) 热力图 heatmap np.mean(features[0], axis-1) # shape: (12,12) return cv2.resize( p a hrefhttps://download.csdn.net/download/weixin_44087733/89864128 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p