看到“day20”这个标题,你可能觉得奇怪:不就一个天数吗?但如果你做过任何以“连续记录”为驱动的个人项目,就会懂第20天是个非常微妙的节点——新鲜感已经耗得差不多,离终点又还有一截,要么靠惯性滑过去,要么就此烂尾。这篇要聊的,就是我给自己定的一个20天小项目:用连续20天做一个图像批量处理的小工具,并在第20天完整复盘整个过程。内容包括为什么选20天、前10天怎么熬过迷茫期、后半程靠什么提速、收尾时踩过的三个典型坑,以及最后实际产出的数据。想尝试“用天数逼自己完成一件小事”的读者,尤其是第一次做长期个人项目的朋友,这篇文章应该能给你一套可以直接照搬的节奏和方法。
1. 为什么是“day20”:连续20天项目的起心动念
1.1 以天为单位做项目,治好了我的拖延症
事情的起因其实很俗:我电脑里攒了上千张图片,有的是截图,有的是相机导出来的原片,文件名乱得像一锅粥,想找一张图得翻半天。我一直想写个脚本批量改名、顺便压缩一下体积,但“有时间再做”这句话说了大半年,愣是没动过一行代码。
后来我意识到,问题不在于“没时间”,而在于任务太模糊。批量处理图片——听起来简单,但具体先做什么?用什么库?要不要做界面?这些都没想清楚,潜意识里就一直拖着。于是我想换个思路:不给自己规定“做完”,而是规定“做20天”。每天不要求有大进展,但必须打开编辑器、写点东西、留一条记录。第20天不管做成什么样,都要总结一次。这种做法不新鲜,很多人给自己定过“30天挑战”“100天打卡”,但我特意选20天,原因是这个长度刚刚好:三周左右,足够走完“立flag—踩坑—修正—收尾”的完整循环,又不会因为周期太长而在中途彻底放弃。
规则也很简单:每天至少投入30分钟;当天做了什么、卡在哪里、明天准备做什么,三行字写在日志里;不追求每天都有大版本更新,但第20天必须有一个能跑通的成品。听起来很宽松对吧?但真正执行起来,前三天我就差点破功。
1.2 从“day20”这个节点反推:目标拆解与里程碑
定完规则之后,我先做了另一件事:从第20天倒推,把这个模糊的“图片批量处理工具”拆成可以每天推进的里程碑。这个动作看起来平平无奇,但它是我觉得整个项目里最值的一步。
我的拆法是这样:第1到5天,先搞清楚需求边界和环境。说白了就是明确工具到底要解决什么问题——我的核心需求就两个:批量重命名(加前缀、按序号排列)和批量压缩(在画质可接受的前提下减小体积)。再确定技术路线,因为我是用Python顺手,图像处理就选了Pillow库,不碰OpenCV那些重型依赖。第6到10天把主路径跑通,先做一版只有一个入口和几个参数的脚本,宁可丑,但要能跑。第11到15天逐步加功能,比如递归遍历子目录、输出预览、跳过已处理文件。第16到19天集中测试和修bug,把真机上的上千张图分批跑,记录失败场景。第20天收尾复盘,整理文档、统计数据和写这篇东西。
这样拆完,每个阶段的任务都变得非常具体,而“day20”就不再只是一个天数,而是一个验收节点。我甚至给自己写了一句“验收定义”:第20天结束时,把任一文件夹丢进脚本,能自动递归找到所有图片、按规则改名、压缩输出,并且不会因为个别文件名异常而中途崩溃。
2. 前10天的真实节奏:从“不知道做什么”到最小原型跑通
2.1 头两天根本学不进去,后来是怎么爬出来的
说实话,前两天的记录拿出来看有点丢人。day01我只做了一件事:装环境、建文件夹、读了一小时Pillow的官方文档,没有写一行业务代码。更糟的是day02,我打开编辑器坐了一个小时,满脑子都是“这个工具到底要支持多少种情况”“要不要顺便做重命名格式校验”“图片旋转要不要考虑”,最后只写了个读取文件夹路径的骨架,然后默默关掉电脑。
那两天给我最大的教训是,小项目千万别在开头想架构,想多了唯一的结果就是什么都不做。我当时的解决办法很笨但很有效:把“做工具”暂时抛到脑后,改成“先写一个能跑的、只能处理单张图片的脚本”。不管丑不丑,不管功能全不全,只要我把一张JPG读进来、压缩、重新命名、写出去,就是一个最小闭环。于是我删掉了原计划里那些花里胡哨的念头,在day03写下了这段代码:
from PIL import Image import os src = "test.jpg" dst = "processed_test.jpg" img = Image.open(src) img.save(dst, quality=80, optimize=True) print("done:", dst)就这么一小段,运行成功的瞬间,我的心态完全变了。因为“能不能做”这个问题已经不再是问题,剩下的全是“怎么做得更好”。所以如果你是第一次做这类项目,我强烈建议把“最小原型跑通”放在整个计划的最前面,哪怕原型蠢到只能处理一个文件,它给你带来的推进感也远比十页规划文档有用。
2.2 第3到第10天:选型与最小原型的实现
从day03开始,节奏明显就稳了。我先把脚本从单文件扩展成支持一个文件夹,再慢慢加上参数。技术选型在这个时候体现出它的价值:选Pillow而不是别的库,主要是因为它开箱即用,API简单,对JPEG、PNG、WebP这些常用格式都支持,而且用quality和optimize两个参数就能完成大部分压缩需求。我可以把精力全放在流程上,不用去碰像素级的图像编码逻辑。
到了day06,脚本已经长这样了:
from PIL import Image import os import sys def process_image(src_path, dst_path, prefix="", quality=85): img = Image.open(src_path) img = img.convert("RGB") filename = os.path.basename(src_path) name, ext = os.path.splitext(filename) new_name = f"{prefix}{name}{ext}" out_path = os.path.join(dst_path, new_name) img.save(out_path, quality=quality, optimize=True) print(f"processed: {src_path} -> {out_path}") if __name__ == "__main__": src_dir = sys.argv[1] dst_dir = sys.argv[2] prefix = sys.argv[3] if len(sys.argv) > 3 else "IMG_" os.makedirs(dst_dir, exist_ok=True) for item in os.listdir(src_dir): if item.lower().endswith((".jpg", ".jpeg", ".png")): process_image(os.path.join(src_dir, item), dst_dir, prefix)这段代码现在看来当然很初级,但它在day06的时候已经解决了我80%的问题:能遍历单层目录、能改名、能压缩。第7到第10天我一直在做细节补充,比如对PNG图片的透明通道做特殊处理,压缩参数从写死改成可配置,还有把处理结果打印得更清楚。
说实话,前10天里最难的其实不是代码,而是“每天都要碰一下”的习惯养成。我给自己定的规矩是:哪怕当天很累,也要至少打开项目跑一次脚本,然后写三行日志。这个习惯本身比那几天写的代码珍贵得多,它能保证day11的时候你不会因为隔了两三天而完全接不上状态。
3. 后半程的提速方法:让第11到第19天不再原地打转
3.1 把“每天写代码”改成“每天完成一个微目标”
如果说前10天靠的是新鲜感和执行力,那后半段拼的就是节奏设计。day10结束后我回看自己的日志,发现一个规律:只要当天的任务是“想给脚本加个功能”,效率往往很一般,甚至会在若干个功能点之间来回横跳;但凡是当天的任务能被一句话讲清楚的,比如“让脚本能递归遍历子文件夹”“给压缩参数加上取值范围校验”,完成率几乎百分之百。
于是从day11开始,我调整了策略,不再用“今天写代码”这种空泛的说法当目标,而是每天晚上定好第二天的微目标,且保证这个目标能在30到60分钟内完成。后面几天的微目标长这样:
第11天:支持递归遍历子目录,遇到非图片文件自动跳过。第12天:输出处理前后的文件大小对比,让我直观看到压缩效果。第13天:加一个预览模式,先打印将发生的改名规则,不真正写入。第14天:把quality参数从命令行传进去,并限制在35到95之间,避免误传0导致报错。第15天:做一个“已处理文件列表”,重复运行同一个文件夹时跳过已完成的部分。第16天:针对文件名包含空格、中文、多个点的场景做兼容测试。第17天:处理PNG透明通道在压缩时变黑的问题。第18天:全量跑一次自己的图库,记录报错和异常样本。第19天:修复第18天发现的所有问题,跑第二遍完整测试。
这9天里真正写在代码里的东西,就一句话:把目标变具体。大脑是很吃“确定性”的,你说“今天增加递归遍历”,它就知道要做什么;你说“今天看点相关的资料”,它就不知道该往哪儿使劲。后者在长期项目里特别容易导致原地打转——你感觉自己忙了一晚上,实际什么都没产出。
3.2 验收清单和“日终回顾”的实操写法
另外一个在后半程帮了大忙的东西,是一张很简陋的验收清单。我把它直接写在项目目录下的一个文本文件里,每次改完代码就逐条勾:
- 脚本是否能在命令行里用参数指定输入和输出路径?
- 是否会自动创建不存在的输出目录?
- 是否支持递归读取子目录?
- 是否会在压缩前备份原图?
- 是否能把“处理失败”的文件单独列出来而不是直接中断?
- 重复运行同一目录,是否会重复处理?
这些问题的价值不在于答案本身,而在于它们逼着我把“感觉差不多了”变成“可验证的标准”。比如“支持递归读取子目录”看着简单,实际写的时候就得处理递归函数里的路径拼接,处理完还得用一个嵌套文件夹试一遍才知道有没有写对。
再说“日终回顾”。我自己的格式是每天固定三问:今天交付了什么?卡在哪儿?明天第一个动作是什么?前两问用来把当天的信息压缩成记忆点,第三问是关键——它让第二天的“开始成本”降到最低。比如我day17写着“明天第一个动作:跑一下带透明通道的PNG样本”,第二天我打开电脑根本不用想,直接找样本执行就行。如果没有这一句,第二天光“从哪儿开始”就能消耗十几分钟。
4. 第20天收尾踩过的三个典型坑
4.1 坑一:误以为 quality=95 就是“高清无损”
第18天全量测试时,我拿着一批高质量的相机原图做压缩实验,发现一个反直觉的现象:把quality设成95,输出文件居然比原图还大。我当时第一反应是脚本写错了,反复查了半天,确认save的参数没写反,optimize也没问题。后来又用同一张图分别用95、90、85、80的quality跑了一遍,才彻底明白原因。
quality在JPEG编码里的作用,是控制量化表的缩放系数,它和最终文件大小不是线性关系。很多相机原图本身的压缩率已经很高,把quality设为95甚至会推高输出体积。更关键的是,我已经在前面做了img.convert("RGB"),这张图如果本身没有额外的元数据负担,压缩收益就非常有限。真正影响体积的往往不是单一的quality值,而是原图的分辨率。比如一张4000x3000的照片,位深度和量化表才是体积大头,简单调quality根本压不动。
后来我给自己补了一组直观数据,就拿一张典型照片做测试,方便以后参考:
| 参数 | 输出大小 | 相对原图 |
|---|---|---|
| 原图 | 2.41 MB | 100% |
| quality=95 | 2.52 MB | 104% |
| quality=85 | 1.86 MB | 77% |
| quality=75 | 1.41 MB | 58% |
| quality=85 + 尺寸缩到50% | 412 KB | 17% |
结论很简单:想明显减小体积,要么接受一定画质损失,要么直接缩分辨率。只会调quality的小工具,面对“原图体积已经不小”的场景是失效的。我在day19给脚本加了一个可选的尺寸缩放比例参数,比如传入0.5就把图片宽高变成原来的50%,这是这次踩坑留下的最大变化。
4.2 坑二:文件名解析在批量场景下随机出错
第二个坑更隐蔽,发生在day16的兼容性测试阶段。我准备了一批带空格的、带中文的、带多个点的文件,比如“我的 截图 (1).final.jpg”,跑第一遍的时候部分文件处理成功,部分直接报错,而且报错的文件毫无规律。我第一反应是编码问题,因为用的是中文目录,于是又检查了路径编码,还是不对。
后来我给脚本加了一行异常打印,把出错的完整文件名打出来,才发现问题出在文件后缀解析上。最初的代码里我用了filename.split(".")[-1]去取后缀,这个写法在普通文件名上没问题,但遇到“我的 截图 (1).final.jpg”这种含多个点的名字,取到的后缀还是jpg,看起来正常,实际上问题出在拼接输出路径时我用的是name + ext,而name取的是split(".")[0]——也就是“我的 截图 (1)”,根本没把.final当名字的一部分。这就导致输出文件名和预期不一致,而且如果多个类似文件存在,还会生成重复的目标路径,触发覆盖冲突。
正确的做法是用os.path.splitext,它按最后一个点拆分:
name, ext = os.path.splitext("我的 截图 (1).final.jpg") # name = "我的 截图 (1).final" # ext = ".jpg"这次排查给我留了个很深的印象:单文件测试能跑通,不代表批量场景能跑通。很多文件名问题在单张图上不会暴露,只有数据量大了之后才会随机出现。所以批量处理工具在开发阶段,一定要准备一组“脏数据”做测试,包括带空格、带中文、带多个点、后缀大小写混合的情况,不要只拿一份干净样本跑一遍就当完了。
4.3 坑三:进程中断后只能用零散样本重新调试
第18天的全量测试跑到一半,脚本身上的一个意外直接崩了——有一张损坏的图片让Pillow抛出异常,而当时脚本用的是单进程顺序处理,前面已经处理完的30多张图虽然写进了磁盘,但整个脚本直接退出,后续所有文件都没了着落。最烦的是,我找不到“哪些文件已经处理完”的记录,因为脚本根本没有写日志文件,我唯一的信息是运行窗口里刷屏的打印输出。
这个问题本质上是“小工具最容易被忽略的可靠性问题”:功能看着都正常,但一旦中断,就得从头再来。我在day19做了两个改动来解决它。第一,把所有处理结果追加写入一个processed.txt,每次成功处理完一个文件就记录一行。第二,下次启动时先读取processed.txt,把已经处理过的文件名放进一个集合,循环里遇到集合里的文件就跳过。核心代码大概长这样:
processed = set() if os.path.exists("processed.txt"): with open("processed.txt", "r", encoding="utf-8") as f: processed = set(line.strip() for line in f if line.strip()) for item in file_list: if item in processed: continue try: process_one_file(item) with open("processed.txt", "a", encoding="utf-8") as f: f.write(item + "\n") except Exception as e: print(f"failed: {item}, reason: {e}")这段代码很朴素,但它把“跑全量”从高风险操作变成了可以随时中断、随时续跑的低风险操作。后来我把同样的思路用在了其他批处理脚本里,几乎成了我的默认模板。至于当时找不到已处理列表的教训,也让我养成了一个习惯:任何批量任务,第一步先考虑断点续跑,再考虑功能丰富度。
5. 20天结束时拿到的数据,和如果重来会调整的三件事
5.1 每日输出的复盘口径,以及计划与实际的差距
第20天做收尾统计时,我重新翻了一遍每天的日志,把数据整理成了下面这张表:
| 项目 | 数据 |
|---|---|
| 实际写代码天数 | 19天(其中1天只写了日志没碰代码) |
| 累计有效编码时间 | 约18小时 |
| 核心功能数量 | 6项(递归处理、改名、压缩、预览、跳过已处理、日志断点) |
| 全部测试图片数 | 约1200张 |
| 处理失败并修复的问题 | 4类(损坏图片、名称解析、透明通道变黑、体积不减反增) |
| 第20天成品状态 | 命令行工具可用,但无GUI、无打包程序、部分文档缺失 |
对比一下我最初在day01拟的“完美收尾”计划:计划里的第20天应该有一个像模像样的图形界面,所有参数都能可视化调整,还附带一份完善的配置文件。实际做出来的,只是一个个颇“寒酸”的命令行脚本。但如果你问我是否觉得这个项目失败了,我会说没有。因为这20天的价值不在于那个脚本本身有多完美,而在于我彻底跑通了一件从0到1的事。功能不够,下次迭代就行;文档缺失,后续补上就行。而“能不能把一个模糊想法变成一个能跑的东西”这种能力,恰好只能靠这种小项目一点点练出来。
我也统计了一下自己使用工具处理那1200张图片的实际效果:总占用空间从最初的1.8GB左右降到了约900MB,同时保留了相对可接受的画质。对于一个20天业余时间做出来的小工具,这个结果我认为完全达到了当初设定“批量改名和压缩”的目标。
5.2 下一步延伸:从脚本到工具,从“day20”到长期项目
第20天的日志里,我给自己列了三个后续方向。第一个是把脚本包装成真正的命令行工具,用参数解析库规范参数格式,让其他环境的人也能直接安装使用。第二个是给它加一个简单的图形界面,或者做成右键菜单集成到文件管理器里,这样不懂命令行的朋友也能用。第三个是把“已处理列表”升级成更完整的任务日志,记录每次处理的参数、时间、失败原因,方便日后追溯。
如果让我再做一轮类似的“day20”项目,有三件事我会在一开始就调整。第一,从第1天就写README,哪怕只有两行字,也要记录“这个项目要干什么、主要文件在哪”;第20天再补文档的痛苦远大于每天顺手写一句。第二,每三天做一次完整测试,而不是等到第16天才集中测,因为问题越早暴露,修复成本越低。第三,在计划里预留两天buffer,不把每个功能排得太满,给意外留出空间。
最后想分享一个我直到第15天才悟到的小技巧:把“计划中的当天任务”和“实际完成的当天内容”写在同一个文档里,不要分开记。一开始觉得分开没问题,后来发现“计划”和“实际”放在一起才能看到差距。翻日志的时候,计划写着“今天实现GUI”,实际写着“纠结了一天转码问题,只改了两行代码”,这种对比看着有点打脸,但恰恰是这些打脸记录让我逐渐摸清了自己的真实执行速度。你以为的自己和实际的自己差多远,只有这种连续记录能回答。如果你也想给自己定一个“day20”计划,不管做什么方向,这点是我最想让你提前知道的事。