基于 CLIP 模型特征搭建简易的个人图像搜索引擎

Update: 已经更新 Github 代码: https://github.com/atarss/clip-image-search

上周花了一周时间实现了一个简易的个人图像搜索引擎,可以实现以图搜图和用文字搜图的功能,效果如下:

其中尝试图搜图的效果尤其好,远超出了我的预期。我之前用的 iPhone 相册和 Google Photos 自带的搜索已经被这个结果吊打了。看到这个效果我把我之前 QQ 群聊天记录的接近百万量级的图片都跑了一遍 CLIP Feature,简单搭了一个个人图片搜索引擎。这里把过程记录下来分享给大家。

背景

最开始是年初被同事安利了一个 iOS 的 app 叫 Queryable(寻隐),可以用文字搜图好像效果还不错。想到自己确实之前也遇到过有几张图我想给别人看,但自带的搜索功能完全没法快速找到,最后找半天也找不着只能作罢。之前听说这个正是用 CLIP Feature 实现的。上周正好有时间,想试下现在这个 CLIP 到底有多好用,就做了个简单的实验:

两张都是我自己拍到的汽车的照片,两次都遇到了“我想给人看我之前在路边见过很有趣的车”,之前用 iPhone 自带的搜索只能用 ‘Car’ 关键词,搜出来一大堆干扰项,想强调汽车的颜色和品牌是完全不现实的,而这里用更加详细的描述(蓝色的 Subaru / 粉色的保时捷)直接就可以快速搜索到了。

为什么 CLIP 这么好用,因为 CLIP 在训练的时候会同时监督图像与文本标签的内容,使得不管文字和图像都会被编码到同一个空间上,这样就可以在同一个空间上度量图像特征和文本特征。当然这个只是所谓理论,具体还得看实际效果,后面会有我自己图库上的搜索展示。

设计思路与实现

  • 图像来源:iPhone 可以直接用 iTunes 导入手机相册的原始图片到电脑里面。Google Photo 可以用 Takeout 导出数据然后下载下来,包含原始文件和用 json 形式存储的 meta 信息。QQ 和 Dingtalk 之类的需要去翻软件的数据存储目录,Wechat 我想搞但还没找到合适的导出数据方法……
  • 图片存储:由于 QQ 群聊图片数量巨大,因此找了块垃圾 SSD 用来存储,直接存到文件系统上面,考虑去重图像会计算一个 MD5 Hash。
  • 特征提取:使用的是 Github 上 OpenAI 官方的 CLIP 库。计算特征前图片会 Center Crop 到 224×224 尺寸。使用了 "ViT-L/14" 模型,输出 768 维的 Feature。
  • 特征存储:用了 MongoDB(对着 ChatGPT 现学的),特征实际上是 FP16 类型所以一张图的特征是 1536 字节,如果使用小一点的模型特征也可以更小。
  • 搜索前端:现学了 Gradio,做这类 Demo 还挺好用。

特征提取与入库

注意在 CUDA 上跑的时候默认输出是 FP16,而 CPU 上的模型调用就只能输出 FP32 了,如果后面要在 CPU 上继续处理的话需要对齐一下类型

CLIP_MODEL = "ViT-L/14"
IMAGE_DIR = "/mnt/e/temp/iphone-import"
CLIP_MODEL_DOWNLOAD_ROOT = "/home/andy/dev/CLIP-dev/model"

def main():
    IMAGE_PATH_LIST = sorted(glob(os.path.join(IMAGE_DIR, "*.*")))
    IMAGE_PATH_LIST = [i for i in IMAGE_PATH_LIST if not (i.endswith(".GIF") or i.endswith(".MP4") or i.endswith("AAE"))]
    print("Found {} images".format(len(IMAGE_PATH_LIST)))

    device = "cuda"

    model, preprocess = clip.load(CLIP_MODEL, device=device, download_root=CLIP_MODEL_DOWNLOAD_ROOT)
    feature_dict = {}

    for image_path in tqdm(IMAGE_PATH_LIST):
        image_basename = os.path.basename(image_path)

        image = preprocess(Image.open(image_path))
        image = image.unsqueeze(0).to(device)

        with torch.no_grad():
            image_feat = model.encode_image(image)
            image_feat = image_feat.detach().cpu().numpy()  # It is still FP16 on CPU Here

        feature_dict[image_basename] = image_feat

入库时会同时包含图片的创建日期、尺寸、扩展名等,方便后面检索用

image_feat_bytes = image_feat.tobytes()
stat = os.stat(image_path)
image_filesize = stat.st_size
image_mtime = datetime.fromtimestamp(stat.st_mtime)
image_datestr = image_mtime.strftime("%Y-%m-%dT%H:%M:%S.%fZ")

image_width, image_height = image_shape

document = {
    'filename': os.path.basename(image_path),
    'extension': image_path.split(".")[-1],
    'importance': TARGET_IMPORTANCE,
    'height': image_height,
    'width': image_width,
    'filesize': image_filesize,
    'date': image_datestr,
    'feature': image_feat_bytes,
}

x = collection.insert_one(document)

检索与前端设计

目前检索是从 MongoDB 上一口气把全部特征都 fetch 下来然后直接算 cosine distance。时间基本会花在 MongoDB 的检索上面。检索这里基本思路就是按照距离排序,算是任何检索系统都会用到的核心代码了。

这里检索引入了上面我们考虑到的“根据图像 meta 信息做预筛选”的条件。

cos_similarity = torch.nn.CosineSimilarity(dim=1, eps=1e-6)

def calc_cpu_compare(query_feature, feature_list):
    time_start = time.time()
    feature_list = torch.from_numpy(feature_list.astype('float32'))
    sim_score = cos_similarity(query_feature, feature_list).detach().numpy()
    time_end = time.time()
    # print("[DEBUG] calculate cos distance {} for {:.6f}s".format(feature_list.shape, (time_end - time_start)))
    return sim_score

def search_nearest_image_feature(
        query_feature,
        topn=20,
        minimum_width=0, minimum_height=0,
        minimum_importance=0, extension_choice=[],
        mongo_collection=None):
    assert isinstance(minimum_height, int) and isinstance(minimum_width, int)
    assert mongo_collection is not None
    # find all image filename and features
    mongo_query_dict = {}
    if minimum_width > 0:
        mongo_query_dict["width"] = {"$gte": minimum_width}
    if minimum_height > 0:
        mongo_query_dict["height"] = {"$gte": minimum_height}
    if minimum_importance > 0:
        mongo_query_dict["importance"] = {"$gte": minimum_importance}
    if len(extension_choice) > 0:
        mongo_query_dict["extension"] = {"$in": extension_choice}
    
    cursor = mongo_collection.find(mongo_query_dict, {"_id": 0, "filename": 1, "feature": 1})

    with torch.no_grad():
        if DEVICE == "cpu":
            feature_list = []
            filename_list = []
            sim_score_list = []
            query_feature = query_feature.astype('float32').reshape(1, 768)
            query_feature = torch.from_numpy(query_feature)

            for doc in cursor:
                feature_list.append(np.frombuffer(doc["feature"], "float16"))
                filename_list.append(doc["filename"])

                if len(feature_list) >= MAX_SPLIT_SIZE:
                    feature_list = np.array(feature_list)
                    sim_score_list.append(calc_cpu_compare(query_feature, feature_list))
                    feature_list = []
                    # filename_list = []
            if len(feature_list) > 0:
                feature_list = np.array(feature_list)
                sim_score_list.append(calc_cpu_compare(query_feature, feature_list))

            if len(sim_score_list) == 0:
                return [], []

            sim_score = np.concatenate(sim_score_list, axis=0)
            print("[DEBUG] len(sim_score) = {}".format(len(sim_score)))

    top_n_idx = np.argsort(sim_score)[::-1][:topn]
    top_n_filename = [filename_list[idx] for idx in top_n_idx]
    top_n_score = [sim_score[idx] for idx in top_n_idx]

    return top_n_filename, top_n_score

前端这里使用了 Gradio,感觉非常适合个人做 Demo 用,自带的 Image 控件支持拖拽导入也非常适合我们的图搜图场景:

with gr.Blocks() as demo:
    heading = gr.Markdown("# CLIP Image Search Demo")
    
    # Use tabs
    with gr.Tab("Using prompt and CLIP feature"):
        prompt_textbox = gr.Textbox(lines=4, label="Prompt")
        button_prompt = gr.Button("Search").style(size="lg")
    with gr.Tab("Using image and CLIP feature"):
        input_image = gr.Image(label="Image", type="pil")
        button_image = gr.Button("Search").style(size="lg")

    with gr.Accordion("Search options", open=False):
        extension_choice = gr.CheckboxGroup(["jpg", "png", "gif"], label="extension", info="choose extension for search")
        with gr.Row():
            topn = gr.Number(value=64, label="topn")
            minimum_width = gr.Number(value=0, label="minimum_width")
            minimun_height = gr.Number(value=0, label="minimum_height")
            minimun_importance = gr.Number(value=0, label="minimum_importance")

    gr_gallery = gr.Gallery(label="results").style(grid=4, height=6)

    button_prompt.click(submit, inputs=[prompt_textbox, topn, minimum_width, minimun_height, minimun_importance, extension_choice], outputs=[gr_gallery])
    button_image.click(submit, inputs=[input_image, topn, minimum_width, minimun_height, minimun_importance, extension_choice], outputs=[gr_gallery])

demo.launch()

代码性能分析

这里分成两部分,一个是整个数据库初始化的操作,另一个是数据库建好之后的检索操作。

建库其实比较花时间,我在 Windows 10 的 WSL 下跑的 CUDA,具体数字不太记得,但大概是一小时跑 100k 数量图片的量级。我整个数据库全跑完大概要半天的时间。

检索的时间很大一部分来自于 MongoDB 查找(把百万量级的 feature 拿出来)。而算特征距离的速度,我实测每 8192 张图需要 20ms 左右,百万量级图片全算一遍距离需要 2~3s,估计还有不少优化空间。最后完整实测,一次全量搜索计算百万特征距然后找出 top N 在我 NAS 的 i3-10105T CPU 下需要 6~7s 的时间。

跑服务的话,因为 CLIP 模型要常驻内存,我用的 “ViT-L/14” 比较大,大概要占用 2~3GB 的内存,加上 mongodb 也要占用 1~2GB,不少 NAS 可能有些吃力,但 PC 一般问题不大。

效果展示与吐槽

首先是我发现 CLIP 模型是有一定 OCR 能力的。我先尝试搜索了一个 “anime screenshot with captions” 想搜一些动画截图。发现第一张图里面的英文文本是 “Your code’s buggy. We can’t sell this product.”,神奇的是竟然可以用这串文字也搜到同样的图。感觉 CLIP 模型也不算特别大,竟然已经训练出来还不错的 OCR 能力了。可惜就是 CLIP 对中文理解能力几乎为零完全没法用。

另外我还发现,有了百万量级的 QQ 群聊记录之后,这个搜索引擎非常适合搜 meme 表情包还有动画角色。比如下面这些表情包:

另外我也尝试给它一些没见过的动画角色(远晚于模型出现时间的角色),它也似乎能“认识”这些角色并且结果上搜索出相同角色的图片。

不管模型是不是真正知道角色的定义,但至少模型能通过语义上找出新的图片同样包含旧图片上的元素。这些组合其实正是人类对一个新角色的定义,能实现这样程度的检索已经非常令人惊喜了。而以前我看到的“图搜图”更多还是在传统图像特征的空间上找近邻,现在模型相比之下更像是“理解了图片的内容了”。

最后还有一个值得注意的现象:在我尝试用文字搜图的时候,相似度基本只有 0.2~0.3,0.3 就已经是非常高的相似度了。而用图片搜图的时候 0.8~0.9 的相似度都十分常见。这里面有几个解释:

  • 某同学强调图像的 Space 与文本的 Space 还是有 bias,跨模态需要对齐才能达到更好的效果。同模态对比相似度高而跨模态相似度低是很正常的。
  • 而我的观点是,图像包含的信息显然不是几个单词就能描述清楚的,比如正常拍一个人像照片,可能简单的描述就是这个人是谁长什么样子;但实际上还有很多不在这个描述里的信息也会被计算进 Feature 里面,比如背景有什么内容,图像的清晰度如何,有没有大光圈带来的虚化,人穿了哪些衣服哪些手势,有什么表情姿势等等。因此文字到图片的对比实际上是【最有代表性的描述】去比对【对图片尽可能完全的全量描述】,自然这种情况下文字对图片的距离更远,而图与图之间的距离更近了。

Limitations and TODO

  • CLIP 模型完全不能理解英语之外的语言,prompt 只能写英语,中文日文都抓瞎(但是有 Chinese-CLIP 或许也能用?)。
  • CLIP 模型还是有些限制的。224×224 的 Center Crop 导致它其实不会处理被 Crop 掉的边缘信息。搜图的时候会因此丢结果。
  • 现在我们发现 CLIP 模型有一定的 OCR 能力了,但似乎还不太够(也包括它只能理解英语)。如果想准确查询图片里的文字(比如找个名字、手机号、身份证号之类的),我希望能引入 OCR + 文字检索功能,更好地实现我个人“找图”的需求。这个已经在做了而且可以再水一篇 Blog(?)【Update:已更新
  • 如果能让我看过的图(不只 QQ 群),包括微信、Twitter 等图片来源都能自动入库的话,我后面找图的图库会大很多。需要一个自动导入的模块。
  • 真正画饼的话,我希望 ChatGPT 级别的智能可以有更强的处理私人信息的能力,能够总结和检索跨模态的信息(图像文字声音视频)。最近看 plugin 出来 ChatGPT Plugins 里已经有了一个可以吃到外部信息的 API 实现: ChatGPT Retrieval Plugin,如果这个能力能集成到开源模型里面,相信以后的工作生活方式会发生很大的变化。

参考

购买机械键盘轴体时的个人选择分享

背景

2022 年底突发奇想买一个新的机械键盘,猛然发现客制化键盘和国产轴已经如此发达了;经过双十一双十二试着买了好多轴,终于把自己服役了快六年的 FC660M 替换成了新键盘。遂将中间过程得到的经验记录下来。

本文重点包括:

  • 更加科学的轴体分类
  • 个人选择段有声落轴和类静电容轴体过程的参考

轴体分类

在国内讨论轴体的分类的时候,大家都喜欢用 Cherry 的名字给出轴体分类。大家讨论时都会用下面的分类:

  • (类)红轴:线性手感轴
  • (类)青轴:有声段落轴
  • (类)茶轴:弱段落轴
  • 第四类手感(大段落 / 提前段落 / 类 HP)

Cherry 茶轴算是一个微妙的存在,不可否认很多人喜欢它的手感,过去很多人会这样描述它:声音和段落感介于红轴和青轴之间,没有很响的声音,只有很弱的段落感。因为很多人喜欢大家会把它单独拿出来作为一个分类叫“弱段落”。

另外也有人会强调所谓的“类 HP 轴”,强调它所谓“独特”的手感,有更大的段落行程等等。很多淘宝店介绍这类轴会叫“第四类手感”。但个人看来类 HP 核心就是“类静电容”,甚至可以说是“类薄膜”手感。相对青轴“较硬”的段落感,这类轴体会有更加顺滑渐变的力度曲线。

淘宝上看到的“第四类复古手感”描述令人迷惑

然而类茶和类 HP 在我看来并没有很明确的分界,都是一种更为柔和且不发声的段落轴,可以总结为“无声段落轴”。纠结之时看到国外网站(比如这个)对轴体的分类:Linear / Clicky / Tactile,豁然开朗,感觉这个分类更加准确地描述了手感。基于个人翻译,后面我会用这样的分类去描述我选择的轴体:

重新定义的分类

  • 线性手感 (Linear):类红 / 黑轴
  • 清脆手感 (Clicky):相当于有声段落,类青轴
  • 弹性手感 (Tactile):类茶轴 / Topre 静电容

之所以这样是遇到了一些 Tactile 的轴很难当做“大段落 / 类HP”,但与 Cherry 茶轴手感还有很大的差距。原因是有几个关键概念【触发位置高度 / 行程 / 段落力度差距 / 声音】其实是相互独立的,但早年只有茶轴一个选项的时候,用户并没有说清“我想要类茶轴因为我喜欢茶轴的哪种特性”。这些关键因素的排列组合导致一些产品难以判断与茶轴或者 HP 哪个更接近,因此把前面的类茶和大段落整体组合放在一个分类下面,叫做“弹性手感”更容易避免歧义,也可以把之前的静电容 Topre 轴体一起包含进来。

后面我会针对我选择轴体过程中遇到的实例与他们的力度曲线一起分析影响手感体验的变量,以及如何选出自己喜欢的段落轴。我对线性轴还没有充分的认识,我对线性轴的手感也没有特别的喜好,这里不会讲解线性轴。

Clicky – 清脆手感类型轴体的选择

下面我整理了一个表格,放了一些我购置新键盘的时候试用过的 Clicky 类型轴体。// 里面除了 Cherry 就是凯华:

轴体名称样图力度曲线吐槽
Cherry 青轴大家最熟悉的段落手感
Cherry 绿轴相对青轴重一些
凯华 BOX 白轴凯华经典的段落轴,物美价廉
凯华 BOX V2 白轴白轴升级版,看曲线段落感更强一些
凯华极速粉相对下面更加顺滑
凯华极速金相对下面段落感更强
凯华国风尊耀黄国风轴个人感觉有点黏,回弹不太跟手
凯华国风釉彩绿两个基本只有触发力度区别
凯华知夏可以理解为BOX 白升级版;声音更清脆,可能有点吵
凯华极地狐与知夏手感接近,许多人认为是知夏进一步提升的设计,声音要更吵一点
T1 级别段落轴
凯华水母段落手感特别轻盈,许多同事在表格里最喜欢的轴
T1 级别段落轴
凯华重力蓝
凯华 Jade
凯华 Navy
凯华 白枭基本是最重的段落轴
官方提供的力度曲线是一个很好地理解轴体特性的工具。尤其大家在手上有一些 Cherry 轴的时候可以考虑对照力度曲线,缓慢地按下再缓慢地抬起,这样能够更加清楚地知道曲线力度与手感的对应关系。

段落轴可以说是国产轴(凯华)吊打 Cherry 的象征了;白轴零售价一块出头体验公认要比 Cherry 青轴好很多,上极地狐一个轴三块钱基本就是极致体验了。个人最后考虑性价比选择了 BOX 白 V2,但后面很可能换成知夏。

  • 针对上面的轴补充一些吐槽:
    • 追求性价比的话白轴或者白轴 V2 都是很好的选择。我自己家里的键盘在用白 V2。
    • 追求体验(不差钱)的话推荐水母段落或者极地狐,水母段落对我个人来说有点过轻了,但周围很多人特别喜欢,表示敲着特别“顺滑”,手感很轻不累。网上看网友友推荐极地狐的多一些
    • 从曲线上看很多轴其实是偏线性的,这些轴除了在触发时有声音外,整体手感其实段落感不强,本质上是靠声音提示出来的段落感。不信可以带降噪耳机试试。
    • 凯华的极速粉和极速金没有抬起来时的 Clicky 声音,除此之外的段落轴都有。而 Cherry 青 / 绿都是没有的
    • 关于大键用的加重手感的轴(表格里最后四个),手上重量的感受差不多是 Jade < 重力蓝 < Navy < 白枭;我觉得 Jade 其实就足够了。但个人觉得这个大键力度比正常键位要沉,使用上这种手感的差距很难接受,所以我没有在大键上使用这种加重的轴。

最后说一下段落轴的声音问题。声音是整个机械键盘组好之后多个因素决定的,轴体、键帽、机械键盘底座的结构、消音措施等等,键盘下面垫个毛巾都能很大程度影响声音。很多人会追求调整到自己最喜欢的声音,但建议不要过早优化,因为自己在试轴器上体验到的声音很可能跟最后装到键盘上的声音有巨大的差距。先选好手感的方向,再在候选里面寻找优化声音的方向,这是选择有声段落轴体更好的顺序。

Tactile – 弹性手感类型轴体的选择

因为公司里用青轴实在太吵 自己一直想搞一个静电容的键盘,但发现现在客制化配合一些轴体也能达到类似静电容的效果。所以我也买了十几种 Tactile 的轴体尝试效果。

这种无声段落轴体的多样性要比有声段落轴体大许多,我个人还是从力度曲线出发试图总结出这些轴体的共性和差异。另外我个人的目标是找到一个类 Topre 静电容手感的轴体,而且是类似大克数Q弹手感的静电容而不是较轻类似线性的静电容,因此我对类 Cherry 茶轴的调研要少许多,只买了少数用来做对比。

轴体名称样图力度曲线吐槽
Cherry 茶轴大多数人都很熟悉的 baseline 手感
凯华 BOX 茶轴
凯华 BOX V2 茶轴V2 与 V1 差距非常大,从曲线就可以看出来。
拉长了
凯华 重力橙这个更接近凯华 V1 茶的手感,加重了一下
凯华 HAKO TrueHAKO 系列看介绍说是希望培养用户打字不触到底的习惯。这个跟我自己习惯差太多。
整体手感是加重的 V1 茶,按到底比重力橙还要累
凯华 HAKO Clear
凯华 知更鸟
凯华 金丝雀
凯华 杜若
TTC V2 静音茶
(简称“茶静”)
感觉 TTC 的类茶轴就要比凯华好很多,凯华还是更适合做有声段落
TTC 静音月白
(简称“白静”)
相比上面的 TTC 茶静要更“Q弹”,我个人非常喜欢,周围也有朋友在用
这类“Q弹”手感里面 TTC 可以保证不同阶段手感衔接很好,不会显得生硬
T1 级别类静电容
佳达隆 小袋鼠T1 级别类静电容
但有观点说小袋鼠轴的一致性差一些
水月雨 段落 季华

Tactile 类型的手感整体可以分成类 Cherry 茶轴的手感和类静电容(类薄膜?)手感两大类吧。类 Cherry 茶轴这部分我自己实在是没什么发言权,因为实在不太懂这种手感好在哪里。对我这种喜欢打字按到底,按压过程中希望尽可能得到反馈的人来说,Cherry 茶只是一个段落感太弱甚至接近线性轴的一个不会进入选择范围的选项。但看网上很多人评价 TTC 茶静是一个相当不错的替代选项。

而类静电容(类薄膜)是我这次组键盘的核心目标,尝试的选项也要多了一些。从力度曲线上就能看出这类手感与茶轴的最大区别是:增加了段落行程(横轴上触发位置与导通位置差距加大),增加了段落手感的差距(纵轴上触发时的力度与导通时的力度差距增加)。总体来说我个人非常推荐下面几个:TTC 静音月白,佳达隆小袋鼠,以及水月雨季华。

TTC 静音月白从买来就意识到这是个完成度极高的优质轴体,在触底的时候增加了一个缓冲因此用力往下按不会有键帽击打下面的声音,起到了静音的效果,整体手感也因此显得更加“圆润”,不生硬。佳达隆小袋鼠手感与静音月白非常接近,只是声音和触底手感导致体验上会感觉段落感更强一些。水月雨的段落轴设计思路也非常接近,手感个人觉得介于 TTC 和佳达隆之间,但没有触底的静音设计所以会有些声音。我最终选择了水月雨的轴体作为最终工作上机械键盘使用的轴体,但静音月白同样非常值得推荐。

另外需要吐槽一下凯华的几个类静电容轴体的设计。我尝试了他们的知更鸟、金丝雀和杜若,对我个人来说最大的问题是手感显得非常硬,按下触发时会有一个非常“脆”的力度下降过程,知更鸟相对好一点但是它的导通位置太高显得高低段落有点小。虽然我没有把任何一款凯华轴体列入自己的最终候选名单,但如果自己根据力度曲线找到自己的喜好的话,也可以试一下看看,本身他们设计上并没有特别的硬伤,只是大家手感喜好可能有差距。

小结

Clicky 类型的轴体个人推荐:凯华水母段落,凯华极地狐;预算有限可以考虑凯华 BOX 白(V2)

Tactile 轴体中类静电容轴体个人推荐:TTC 静音月白(白静),佳达隆小袋鼠,水月雨段落季华

补充:一些回忆

我的第一把机械键盘应该是 2015 年 BYR 论坛上买的校友的二手青轴 G80-3000。那个年代机械键盘没什么太多的选项。手感就只有 Cherry 的【红 / 青 / 茶】和静电容(基本是 HHKB)这几大类。机械轴的话牌子除了 Cherry 基本只有大F (Filco) 和大L (Leopold) 可以选。配列稍微有趣的除了 HHKB 之外就是 Minila 和 660 配列了。当时 2016 年去日本的时候先买了个 Minila,实习用了一段时间感觉一些常用光标相关的功能键 (Ins / Del / Home / End) 的位置不太舒服,之后换成了青轴的 FC660 一直用到现在。很长一段时间我都想搞一个静电容玩一玩但一直没机会,看着好几个朋友用着 FC660C 特别眼馋。

今天不得不感慨国内键盘客制化圈子的壮大,Cherry 青轴已经不再是不可超越的神话,国产凯华 BOX 已经有更好更多的选项;客制化自定义也有更多廉价的配件可以选择,几百块钱的成本就可以简单组合出自己想要的手感和配列。进一步想折腾可以尝试考虑改善声音和大键手感也有成熟决方案,基本都可以在专门的淘宝店里找到适合自己键盘的改装配件(切好的棉花之类的)。

最令我震惊的是发现罗技的量产键盘已经选择国产轴体作为低端游戏键盘的轴体选项(K835/845 用 TTC),说明实际上国产轴已经被一些国际大厂认可,短期来看 Cherry 的下坡路已经是不可避免的了。