[{"content":"内存/显存管理的教训 数据加载 今天换到5090 Laptop (24G VRAM) 把GDINO开到400张图的时候即使有batch，VRAM还是炸了，结果2分钟的推理变成了15分钟，之后才发现这个代码完全没有操心内存和显存占用的问题。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 SAMPLE_SIZE = 9 GDINO_BATCH_SIZE = 4 #采样略 paths = [os.path.join(PROJECT_ROOT, \u0026#34;data\u0026#34;, p) for p in sample_images[\u0026#34;downloaded_path\u0026#34;]] # p aths = [\u0026#39;/Users/yukun/projects/wakareeru/data/img/E233系/1f5c3f_Toyota Vehicle Center.jpg\u0026#39;, \u0026#34;/Users/yukun/projects/wakareeru/data/img/EF510形/ca6c48_UetsuhonsenKomachiKoshuFreight.jpg\u0026#34;, # \u0026#39;/Users/yukun/projects/wakareeru/data/img/E231系/c5ea96_Ochanomizu Crossing 2020-03-17.jpg\u0026#39;] images = [load_img_with_orientation(path) for path in paths] text_labels = [[\u0026#34;a train\u0026#34;]] * (len(images)//2) + [[\u0026#34;a single locomotive\u0026#34;]] * (len(images) - len(images)//2) # 针对动车组和机车用不同的标签，分别侦测整列车和机车自己。 inputs = processor( images=images, text=text_labels, return_tensors=\u0026#34;pt\u0026#34;, padding=True, # batch 内图片尺寸不同时需要 padding ).to(device) # (width, height) -\u0026gt; (height, width)，每张图片单独从归一化坐标转换回原始尺寸 target_sizes = [img.size[::-1] for img in images] # 缓存每个 batch 的模型输出；之后只改阈值或重新打印时，不需要再跑模型。 gdino_batch_outputs = [] gdino_batch_input_ids = [] gdino_batch_target_sizes = [] with torch.no_grad(): for start in range(0, len(images), GDINO_BATCH_SIZE): end = start + GDINO_BATCH_SIZE inputs_batch = {k: v[start:end] for k, v in inputs.items()} gdino_batch_outputs.append(model(**inputs_batch)) gdino_batch_input_ids.append(inputs_batch[\u0026#34;input_ids\u0026#34;]) gdino_batch_target_sizes.append(target_sizes[start:end]) 能注意到两个糟糕的点，images = [load_img_with_orientation(path) for path in paths]直接把全量图片object一口气塞进内存，结果一点开就吃走了40G RAM，幸好这台Workstation是64G内存的配置，不过如果真的到了全量阶段也只会变成一坨大的。其实如果用Hugging Face和Torch带的workflow的话，他们的内存管理会帮我解决这个问题，例如可以在Dataloader(batch=32)这里只需通过参数就解决这个问题，但是因为想要快速实验所以还是自己跳了个坑。通过yield的迭代器作为Generator可以解决这个问题，吗？\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 def iter_batches(db_path, batch_size=32): con = sqlite3.connect(db_path) cur = con.cursor() cur.arraysize = batch_size # 影响 fetchmany 的默认值 cur.execute(\u0026#34;\u0026#34;\u0026#34; SELECT file_title, cropped_path, series FROM images WHERE embed_status IS NULL AND crop_status = \u0026#39;done\u0026#39; ORDER BY file_title -- 保证幂等，每次顺序一致 \u0026#34;\u0026#34;\u0026#34;) while True: batch = cur.fetchmany(batch_size) # 每次只从 SQLite 拉 batch_size 行 if not batch: break yield batch # 挂起，把控制权交给调用方 con.close() 现在的问题变成了，如果一次只加载一个Batch，那GPU岂不是很闲，还要等CPU加载？，其实说对了，所以最后还是需要PyTorch的DataLoader来帮我们解决预加载和缓存的问题，自己的随手轮子哪有别人的优化好呢。不过需要一个class实现几个接口，可以在后面的正式pipeline中完成。\n1 2 3 4 5 6 7 loader = DataLoader( dataset, batch_size=32, num_workers=4, # 4 个子进程并行加载图像 prefetch_factor=2, # 每个 worker 超前准备 2 个 batch pin_memory=True, # 锁页内存，加速 CPU→GPU 传输 ) 这样就只用改参数就万事大吉了。\n推理结果detach 好久没用pytorch，都忘了tensor是要送进device的。下面的代码可以及时把结果推到RAM里去。\n1 2 3 4 5 6 7 with torch.no_grad(): outputs = model(pixel_values) # 在 GPU embeddings = outputs.last_hidden_state[:, 0] embeddings = embeddings.detach().cpu().numpy() # 立刻卸载 # outputs 对象此时没有引用，下次 GC 或 cuda 分配时会自动释放 # 但主动 del 更保险： del outputs, pixel_values 之前打开结果打印出来一看结果全是tensor([...], device=cuda)，啊哈哈。\n","date":"2026-05-06T13:44:23+08:00","image":"/p/wakareeru-data-3/cover.jpeg","permalink":"/p/wakareeru-data-3/","title":"わかれーる：细粒度的日本铁路分类模型：内存/显存资源管理（1）"},{"content":"内饰，局部细节特写图片的清洗 内饰图片，虽然具有火车特征但是和外部照片差距过大，试图用一个模型去 handle 泛化似乎不太可能。加之许多车辆内饰都采用高度相似的风格设计，例如 JR 东日本的 E217-E231-E233-E235 系列（尽管有微小差别），甚至蔓延到了新潟地区用车 E129。虽然不在目前的 scope，但是阪急电铁的车内内饰也高度统一，如此试图区分车辆种类不太划算。加之，DINO 的车辆判断下会把车辆内饰作为特征判定为一整个火车，所以必须滤除这个部分。\n之后试图用 Grounding DINO 寻找 boundary box 进行 Zero-shot 的多列车识别，发现 Grounding DINO 在未微调的情况下语义上无法细微区分机车，动车组，车辆内部与外部，在训练中可能 train 标签也泛化到了内饰，导致如果内饰图片进入就完全无法检测数量。所以有必要严格去掉所有的内饰图。\n不过实践最后采用了一个非常取巧的设计。在前一个笔记本中我已经根据能想到的关键词大概写了一个粗略过滤：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 FILE_EXCLUDE_PATTERNS = ( \u0026#34;interior\u0026#34;, \u0026#34;inside\u0026#34;, \u0026#34;seat\u0026#34;, \u0026#34;seats\u0026#34;, \u0026#34;seating\u0026#34;, \u0026#34;reclining\u0026#34;, \u0026#34;free-space\u0026#34;, \u0026#34;cab\u0026#34;, \u0026#34;cockpit\u0026#34;, \u0026#34;toilet\u0026#34;, \u0026#34;wc\u0026#34;, \u0026#34;route map\u0026#34;, \u0026#34;counter\u0026#34;, \u0026#34;merchandising counter\u0026#34;, \u0026#34;display\u0026#34;, \u0026#34;lcd\u0026#34;, \u0026#34;vvvf\u0026#34;, \u0026#34;logo\u0026#34;, \u0026#34;air cleaner\u0026#34;, \u0026#34;antenna\u0026#34;, \u0026#34;pantograph\u0026#34;, \u0026#34;camera\u0026#34;, \u0026#34;accident\u0026#34;, \u0026#34;syanai\u0026#34;, \u0026#34;車内\u0026#34;, \u0026#34;運転台\u0026#34;, \u0026#34;運転室\u0026#34;, \u0026#34;トイレ\u0026#34;, \u0026#34;便所\u0026#34;,\u0026#34;カメラ\u0026#34;, \u0026#34;事故\u0026#34;, \u0026#34;車内\u0026#34;, \u0026#34;trainchannel\u0026#34;, \u0026#34;運転台\u0026#34;, \u0026#34;運転室\u0026#34;, \u0026#34;トイレ\u0026#34;, \u0026#34;便所\u0026#34;, \u0026#34;洗面所\u0026#34;, \u0026#34;洗面台\u0026#34;, \u0026#34;モニター\u0026#34;, \u0026#34;カウンター\u0026#34;, \u0026#34;停車駅案内\u0026#34;, \u0026#34;案内表示器\u0026#34;, \u0026#34;パンタグラフ\u0026#34;, \u0026#34;エアクリーナー\u0026#34;, \u0026#34;集電装置\u0026#34;, \u0026#34;エアコン\u0026#34;, \u0026#34;クーラー\u0026#34;, ) CATEGORY_EXCLUDE_PATTERNS = ( \u0026#34;interior\u0026#34;, \u0026#34;inside\u0026#34;, \u0026#34;parts\u0026#34;, \u0026#34;seats\u0026#34;, \u0026#34;information display\u0026#34;, \u0026#34;mockup\u0026#34;,\u0026#34;green car\u0026#34; ) 上述的关键词，在文件名和 category 两个尺度下做了过滤，已经显著减少透过来的图片了，不过还有许多细节，例如可能座椅吊环等细节，还有台车等位置无法纳入。所以在后面又加上了 LLM 过滤。\n1 2 3 4 5 6 7 8 9 10 from openai import OpenAI client = OpenAI() SYSTEM_PROMPT_excluding = \u0026#34;\u0026#34;\u0026#34; 你是一个日本铁路图片分类助手，通过Wikipedia Commons图片的分类路径来判断图片所属铁路车辆的类别信息。请在必要时通过web_search查找相关信息辅助判断，不要随意猜测。 你将会收到每张图片的文件名以及其category_path（一个字符串列表，表示图片在Wikipedia Commons上的分类路径）。请根据这些信息判断图片是车辆整体照片还是内部照片或细节照片，例如显示屏，驾驶位，厕所等。 请在文件名或category_path中寻找明确信号，若无法判断则默认不排除。 请为每个图片输出一个JSON对象，给你的一个batch请将他们的结果输出为一个JSON数组。请保持图片的ID不变输出。每个JSON对象的格式如下： {\u0026#34;id\u0026#34;:\u0026lt;图片ID\u0026gt;, \u0026#34;exclude\u0026#34;: \u0026lt;是否排除，0表示不排除，1表示排除\u0026gt;, \u0026#34;reason\u0026#34;: \u0026lt;如果排除请给出理由，在interior, detail之间选择一个。\u0026gt;} \u0026#34;\u0026#34;\u0026#34; 发送请求和断点续传逻辑省略。考虑到任务非常简单，使用了 gpt-5.4-mini，并试图令其使用网络搜索；虽然这个代码在替换过程中被改掉了，但是在没有网络搜索的情况下，依然超出预期，能够通过台车型号过滤掉局部照片。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 def request_exclude_batch(batch_str: str) -\u0026gt; list[dict]: \u0026#34;\u0026#34;\u0026#34;Call the LLM for one batch and retry when the output is not valid JSON.\u0026#34;\u0026#34;\u0026#34; last_error = None for attempt in range(1, MAX_LLM_RETRIES + 1): response = client.responses.create( model=OPENAI_MODEL_NAME, input=[ {\u0026#34;role\u0026#34;: \u0026#34;system\u0026#34;, \u0026#34;content\u0026#34;: SYSTEM_PROMPT_excluding}, {\u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: batch_str}, ], reasoning={\u0026#34;effort\u0026#34;: \u0026#34;low\u0026#34;}, ) try: return parse_llm_json_array(response.output_text) except (json.JSONDecodeError, ValueError) as exc: last_error = exc print(f\u0026#34;JSON parse failed ({attempt}/{MAX_LLM_RETRIES}): {exc}\u0026#34;) print((response.output_text or \u0026#34;\u0026#34;)[:500]) time.sleep(1) raise RuntimeError(f\u0026#34;LLM JSON parse failed after retries: {last_error}\u0026#34;) 其实正常应该在 API 调用处加上 tools=[{\u0026quot;type\u0026quot;: \u0026quot;web_search\u0026quot;}]，不过发现没有居然也有较好的表现。因为没有 Ground Truth 没法测试召回率，我们会试图在后面继续解决这个问题。不过通过随机抽样依旧能发现有绿车内部图片进入，可能是在文件名上也完全没有提示信息。\n这里要使用对比学习语义对齐更强的模型，例如 CLIP。由于是非常粗的二分法，所以只需要其语义理解能力即可，不需要细粒度。除此之外可以使用其更强的版本，SigLIP-2。我们会在之后回到这个问题上。\nLLM 格式化处理车型，番台，特殊编成，涂装，运营公司 这部分依旧比较适合交给 LLM，因为它处理的不是图片画面，而是 Wikipedia Commons 的 category path 中带有的语义元数据。相同叶节点下的图片通常共享同一个 category path，因此只需要对唯一的 path 做一次结构化抽取，再把结果回写到所有同 path 图片即可。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 DETAIL_COLS = [\u0026#34;submodel\u0026#34;, \u0026#34;bandai\u0026#34;, \u0026#34;operator_en\u0026#34;, \u0026#34;operator_jp\u0026#34;, \u0026#34;special_formation\u0026#34;, \u0026#34;special_livery\u0026#34;] DETAILS_CSV = os.path.join(PROJECT_ROOT, \u0026#34;data\u0026#34;, \u0026#34;llm_category_details.csv\u0026#34;) if \u0026#34;llm_details\u0026#34; in locals(): details_df = llm_details.copy() else: details_df = pd.read_csv(DETAILS_CSV) def sql_null(v): \u0026#34;\u0026#34;\u0026#34;Coerce empty/sentinel strings to None so SQLite stores NULL.\u0026#34;\u0026#34;\u0026#34; if pd.isna(v): return None v = str(v).strip() return None if v.lower() in {\u0026#34;\u0026#34;, \u0026#34;nan\u0026#34;, \u0026#34;none\u0026#34;, \u0026#34;null\u0026#34;} else v details_df[DETAIL_COLS] = details_df[DETAIL_COLS].map(sql_null) with sqlite3.connect(db_path) as conn: existing = {row[1] for row in conn.execute(\u0026#34;PRAGMA table_info(images)\u0026#34;)} for col in DETAIL_COLS: if col not in existing: conn.execute(f\u0026#34;ALTER TABLE images ADD COLUMN {col} TEXT\u0026#34;) set_clause = \u0026#34;, \u0026#34;.join(f\u0026#34;{col} = ?\u0026#34; for col in DETAIL_COLS) update_rows = details_df[DETAIL_COLS + [\u0026#34;category_path_json\u0026#34;]].values.tolist() conn.executemany(f\u0026#34;UPDATE images SET {set_clause} WHERE category_path_json = ?\u0026#34;, update_rows) conn.commit() 有几个 trick。set_clause 进行了动态展开，最后的语句比较类似：\n1 2 3 4 5 6 7 8 9 UPDATE images SET submodel = ?, bandai = ?, operator_en = ?, operator_jp = ?, special_formation = ?, special_livery = ? WHERE category_path_json = ?; 这样既防注入，又只需要修改最上面的 list 就能修改需要插入的字段。然后使用 conn.executemany(sql, update_rows)，不需要逐条循环写入。\nGrounding DINO 进行车辆对象数量判断及 Boundary Box 生成 Grounding DINO 作为基于 Transformer 的 detector 模型，可以提供由 Transformer 架构带来的文本引导能力，所以可以直接给出一个个体描述，例如 \u0026quot;a train\u0026quot; 就可以让它进行侦测，并输出 boundary box 和 confidence。但是比起 VLM 来说，它没有 ViT 再 concat 文字 prompt 的过程，也就少了很多幻觉或者遮挡问题。当然也如最开始提到，语义引导做不到 zero-shot 细粒度，所以去掉内饰图片就很重要了。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 model_id = \u0026#34;IDEA-Research/grounding-dino-base\u0026#34; device = Accelerator().device processor = AutoProcessor.from_pretrained(model_id) model = AutoModelForZeroShotObjectDetection.from_pretrained(model_id).to(device) SAMPLE_SIZE = 6 with sqlite3.connect(db_path) as conn: sample_images = pd.read_sql_query( f\u0026#34;\u0026#34;\u0026#34; SELECT id, downloaded_path FROM images WHERE excluded = 0 AND download_status = \u0026#39;downloaded\u0026#39; ORDER BY RANDOM() LIMIT {SAMPLE_SIZE} \u0026#34;\u0026#34;\u0026#34;, conn, ) paths = [os.path.join(PROJECT_ROOT, \u0026#34;data\u0026#34;, p) for p in sample_images[\u0026#34;downloaded_path\u0026#34;]] images = [Image.open(path) for path in paths] text_labels = [[\u0026#34;a train\u0026#34;]] * len(images) inputs = processor( images=images, text=text_labels, return_tensors=\u0026#34;pt\u0026#34;, padding=True, ).to(device) with torch.no_grad(): outputs = model(**inputs) 之后需要一些后处理，例如 PIL.Image.size 是 (width, height)，而 target_sizes 需要 (height, width)。后处理阶段会把模型内部的归一化坐标还原回原始图片尺寸：\n1 2 3 4 5 6 7 8 9 target_sizes = [img.size[::-1] for img in images] results = processor.post_process_grounded_object_detection( outputs, inputs.input_ids, threshold=0.2, text_threshold=0.3, target_sizes=target_sizes, ) 结果来看识别主体还是非常准的，不过被遮挡就比较微妙：\n然后专门弄点多车辆的图来看看，特别对于连结的表现比较微妙，后面可能以此为基础判断之后，采用从样本中 few-shot 交叉检测的方式，看看能不能抓出真正的主体，或判断是否保留过滤。对于连结 BBox 重复的问题，可以继续尝试 NMS 去重。\n从 v1 到 v2：把画面过滤交给 SigLIP2 今天的主要调整是把“图片画面类型”的判断从 LLM 流程里拆出来。LLM 仍然适合处理 category path 中的结构化语义，例如番台、特殊涂装、运营公司；但它并不能真正看见图片，因此对“绿车内部”“座椅局部”“车端连接细节”这种没有明确文件名提示的样本，最终还是会漏。 不过目前发现SigLIP2仍然会漏掉一些外部细节和内饰，特别是他似乎没法判断非常模糊的所谓”细节“，例如外部方向幕。这个还需要研究\n所以 v2 笔记本改成了一个更清晰的职责划分：\n文件名和 category path 规则只做保守的内饰关键词过滤。 LLM 不再判断 interior / exterior / detail。 SigLIP2 直接对图片本体做 zero-shot 画面分类。 Grounding DINO 只在过滤后的候选外观图上做车辆主体检测和 bbox 生成。 当前使用的 SigLIP2 模型是：\n1 2 3 4 5 6 checkpoint = \u0026#34;google/siglip2-base-patch16-512\u0026#34; image_classifier = pipeline( model=checkpoint, task=\u0026#34;zero-shot-image-classification\u0026#34;, use_fast=True, ) 候选标签被压缩成三类：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 SIGLIP_VIEW_CANDIDATES = [ \u0026#34;an image of the interior of a train\u0026#34;, \u0026#34;an image of the exterior view of a train\u0026#34;, \u0026#34;an image of a detailed close-up of the exterior part of a train\u0026#34;, ] prompt_to_label = { \u0026#34;an image of the interior of a train\u0026#34;: \u0026#34;interior\u0026#34;, \u0026#34;interior\u0026#34;: \u0026#34;interior\u0026#34;, \u0026#34;an image of the exterior view of a train\u0026#34;: \u0026#34;exterior\u0026#34;, \u0026#34;exterior\u0026#34;: \u0026#34;exterior\u0026#34;, \u0026#34;an image of a detailed close-up of the exterior part of a train\u0026#34;: \u0026#34;detailed\u0026#34;, \u0026#34;detailed\u0026#34;: \u0026#34;detailed\u0026#34;, \u0026#34;uncertain\u0026#34;: \u0026#34;uncertain\u0026#34;, } 这里选择纯 SigLIP2，而不是继续让 LLM 通过 path 判断，是因为这一步的问题本质上是视觉语义对齐，而不是文本元数据清洗。文件名或 category path 里出现 seat、interior、wc 时当然可以用规则直接处理；但当一张图只叫 E231 series at ...，实际内容却是车内座椅时，规则和 LLM 都没有足够信息。SigLIP2 至少能从图像本身给出一个统一的 zero-shot 分数。\n规则过滤也随之收缩，只保留明显内饰相关关键词，不再把受电弓、logo、空调、车号等外部细节一并规则过滤。原因是外部细节边界很模糊：有些局部图对后续细粒度学习没有价值，有些车头、连结器、前照灯、涂装细节反而可能有用。因此这些外部细节先交给 SigLIP2 和后续检测步骤处理。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 INTERIOR_KEYWORD_PATTERNS = ( \u0026#34;interior\u0026#34;, \u0026#34;inside\u0026#34;, \u0026#34;seat\u0026#34;, \u0026#34;seats\u0026#34;, \u0026#34;seating\u0026#34;, \u0026#34;reclining\u0026#34;, \u0026#34;free-space\u0026#34;, \u0026#34;cab\u0026#34;, \u0026#34;cockpit\u0026#34;, \u0026#34;toilet\u0026#34;, \u0026#34;wc\u0026#34;, \u0026#34;route map\u0026#34;, \u0026#34;display\u0026#34;, \u0026#34;lcd\u0026#34;, \u0026#34;syanai\u0026#34;, \u0026#34;車内\u0026#34;, \u0026#34;運転台\u0026#34;, \u0026#34;運転室\u0026#34;, \u0026#34;トイレ\u0026#34;, \u0026#34;便所\u0026#34;, \u0026#34;洗面所\u0026#34;, \u0026#34;洗面台\u0026#34;, \u0026#34;停車駅案内\u0026#34;, \u0026#34;案内表示器\u0026#34;, \u0026#34;モニター\u0026#34;, ) def match_interior_keyword(*texts: str | None) -\u0026gt; str | None: \u0026#34;\u0026#34;\u0026#34;Return a normalized interior reason when obvious interior metadata is found.\u0026#34;\u0026#34;\u0026#34; haystack = \u0026#34; \u0026#34;.join(text or \u0026#34;\u0026#34; for text in texts).lower() for pattern in INTERIOR_KEYWORD_PATTERNS: if pattern.lower() in haystack: return \u0026#34;interior\u0026#34; return None 这里还有一个小的数据库清理：旧流程留下的 exclude_reason 中存在 llm:、file:、category: 这样的前缀。v2 中不再把 reason 当成来源追踪字段，而是只保留归一化后的技术原因，例如 interior、detailed、exterior。因此增加了一次性清理 cell，把 wc、display、seat 等细碎原因统一并入 interior。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 conn.execute( \u0026#34;\u0026#34;\u0026#34; UPDATE images SET exclude_reason = TRIM( REPLACE( REPLACE( REPLACE(exclude_reason, \u0026#39;llm:\u0026#39;, \u0026#39;\u0026#39;), \u0026#39;file:\u0026#39;, \u0026#39;\u0026#39; ), \u0026#39;category:\u0026#39;, \u0026#39;\u0026#39; ) ) WHERE exclude_reason IS NOT NULL AND ( exclude_reason LIKE \u0026#39;llm:%\u0026#39; OR exclude_reason LIKE \u0026#39;file:%\u0026#39; OR exclude_reason LIKE \u0026#39;category:%\u0026#39; ) \u0026#34;\u0026#34;\u0026#34; ) conn.execute( \u0026#34;\u0026#34;\u0026#34; UPDATE images SET exclude_reason = \u0026#39;interior\u0026#39; WHERE exclude_reason IN ( \u0026#39;inside\u0026#39;, \u0026#39;seat\u0026#39;, \u0026#39;seats\u0026#39;, \u0026#39;seating\u0026#39;, \u0026#39;reclining\u0026#39;, \u0026#39;free-space\u0026#39;, \u0026#39;cab\u0026#39;, \u0026#39;cockpit\u0026#39;, \u0026#39;toilet\u0026#39;, \u0026#39;wc\u0026#39;, \u0026#39;route map\u0026#39;, \u0026#39;display\u0026#39;, \u0026#39;lcd\u0026#39;, \u0026#39;syanai\u0026#39;, \u0026#39;車内\u0026#39;, \u0026#39;運転台\u0026#39;, \u0026#39;運転室\u0026#39;, \u0026#39;トイレ\u0026#39;, \u0026#39;便所\u0026#39;, \u0026#39;洗面所\u0026#39;, \u0026#39;洗面台\u0026#39;, \u0026#39;停車駅案内\u0026#39;, \u0026#39;案内表示器\u0026#39;, \u0026#39;モニター\u0026#39; ) \u0026#34;\u0026#34;\u0026#34; ) SigLIP2 的结果写回数据库时，也从循环构造处理每行的数据再 conn.execute(...) 改成了先构造 update_rows，再一次性 executemany(...)。这类批量写入对 SQLite 很重要，尤其是后续全量图片规模继续上升时，减少 Python 层循环内的SQL操作次数比较重要。唯一的问题是当之后 图片数量大量上升时一次性构造完将产生大量垃圾中间变量，对内存冲击较大。不过这个问题对整个pipeline都很显著，所以之后实验完毕迁移到脚本文件时基本都需要写盘中间文件然后分batch处理。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 update_rows = [] for img_path, result in zip(paths, outputs): top_result = siglip_top_filtered_to_label(result) label = top_result[\u0026#34;label\u0026#34;] exclude = 0 if label in {\u0026#34;interior\u0026#34;, \u0026#34;uncertain\u0026#34;} else 1 exclude_reason = None if exclude == 0 else label update_rows.append((exclude, exclude_reason, img_path)) with sqlite3.connect(db_path) as conn: conn.executemany( \u0026#34;\u0026#34;\u0026#34; UPDATE images SET excluded = ?, exclude_reason = ? WHERE downloaded_path = ? \u0026#34;\u0026#34;\u0026#34;, update_rows, ) conn.commit() 这一段仍然是实验中的写回策略，SigLIP2 的三类视觉结果已经可以写回数据库，不过为了 保持数据集自己的开放特性留下标签 后续可以根据下游任务决定保留外景整体、排除内饰、或把外部细节单独送入人工验证。\nManifest 级别的 MIME 过滤 在爬取 Commons manifest 后，还增加了一个更早的过滤点：直接在数据库里删除 MIME 不是图片的文件。不如说忘了为什么现在才做这个东西，结果下下来一堆ogg音频啥的其他页面资源。这个位置比下载阶段更合理；如果它们已经进入 manifest，后面每一步都要额外处理异常，而且还很占我的硬盘。这些就没什么好说的，直接杀掉整条entry。\n因此在 img_crawler.ipynb 中增加了 manifest 入库后的清理逻辑：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 def purge_non_image_manifest_records(conn: sqlite3.Connection) -\u0026gt; int: \u0026#34;\u0026#34;\u0026#34;Remove manifest rows whose MIME type is not an image.\u0026#34;\u0026#34;\u0026#34; non_image_ids = [ row[0] for row in conn.execute( \u0026#34;\u0026#34;\u0026#34; SELECT id FROM images WHERE LOWER(COALESCE(mime, \u0026#39;\u0026#39;)) NOT LIKE \u0026#39;image/%\u0026#39; \u0026#34;\u0026#34;\u0026#34; ) ] if not non_image_ids: return 0 conn.executemany(\u0026#34;DELETE FROM image_categories WHERE image_id = ?\u0026#34;, [(i,) for i in non_image_ids]) conn.executemany(\u0026#34;DELETE FROM images WHERE id = ?\u0026#34;, [(i,) for i in non_image_ids]) return len(non_image_ids) 因为这是返回pipeline前端进行的，所以这个设计还有一个好处：过滤逻辑直接作用在数据库，而不是只作用在当前 DataFrame。所以不仅可以幂等执行而且不需要中间变量，符合上面提到的真正的pipeline设计思路\n为什么必须引入 power_type Grounding DINO 的第一个实验只使用 \u0026quot;a train\u0026quot;，对动车组、普通列车整体照比较自然，但对机车会出现语义粒度不足的问题。机车既是 train 的一部分，又可以作为独立 locomotive 被识别；如果 prompt 只有 \u0026quot;a train\u0026quot;，模型在机辆编组、连结、站场多车场景里容易把整组、局部车辆和机车主体混在一起。\n如果全用train识别对象的效果（以及由于前面内饰没清洗干净的后果）：\n因此今天引入了 power_type 字段，用来区分：\nEMU DMU Electric Locomotive Diesel Locomotive Steam Locomotive Electro-diesel Multiple Unit Electro-diesel Multiple Unit最后这个是双模车，只有E001四季岛。但是不确定后面其他公司的掺合进来会不会产生新的，所以这个也要实时更新。\n原始数据里已经有日文的 type 字段，例如 電車、気動車、電気機関車，所以这里不需要 LLM 判断。规则映射更可控，也更容易保证幂等。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 POWER_TYPE_MAP = { \u0026#34;電車\u0026#34;: \u0026#34;EMU\u0026#34;, \u0026#34;新幹線電車\u0026#34;: \u0026#34;EMU\u0026#34;, \u0026#34;気動車\u0026#34;: \u0026#34;DMU\u0026#34;, \u0026#34;電気機関車\u0026#34;: \u0026#34;Electric Locomotive\u0026#34;, \u0026#34;ディーゼル機関車\u0026#34;: \u0026#34;Diesel Locomotive\u0026#34;, \u0026#34;蒸気機関車\u0026#34;: \u0026#34;Steam Locomotive\u0026#34;, \u0026#34;電気・ディーゼル両用（EDC方式）車両\u0026#34;: \u0026#34;Electro-diesel Multiple Unit\u0026#34;, } def map_power_type(type_value: str | None) -\u0026gt; str | None: \u0026#34;\u0026#34;\u0026#34;Map the Japanese rolling-stock type into the English power type taxonomy.\u0026#34;\u0026#34;\u0026#34; if pd.isna(type_value): return None return POWER_TYPE_MAP.get(str(type_value).strip()) 这个字段被加到了两个位置：\n导出的 jr_east_freight_series_wiki_commons.csv SQLite 的 images 表 为了兼容已有数据库，初始化函数里也加入了 migration：\n1 2 if \u0026#34;power_type\u0026#34; not in image_columns: conn.execute(\u0026#34;ALTER TABLE images ADD COLUMN power_type TEXT\u0026#34;) 之后在构造 image records 时，把车型级别的 power_type 随每张图片一起写入：\n1 2 3 4 5 6 record = { \u0026#34;series\u0026#34;: row[\u0026#34;series\u0026#34;], \u0026#34;wiki_title\u0026#34;: row[\u0026#34;wiki_title\u0026#34;], \u0026#34;power_type\u0026#34;: None if pd.isna(row.get(\u0026#34;power_type\u0026#34;)) else row.get(\u0026#34;power_type\u0026#34;), ... } 对应的 upsert 也加入 power_type=excluded.power_type，保证后续重复爬取、增量更新、修正 CSV 映射时都能刷新数据库。\n旧数据库则通过一次性修复 cell 补齐：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 def repair_image_power_type_from_series( db_path: str = IMAGE_DB_PATH, model_csv: str = COMMONS_MODEL_CSV, ) -\u0026gt; pd.DataFrame: \u0026#34;\u0026#34;\u0026#34;One-off repair: backfill images.power_type from the model CSV by series.\u0026#34;\u0026#34;\u0026#34; models = load_commons_models(model_csv) power_by_series = ( models[[\u0026#34;series\u0026#34;, \u0026#34;power_type\u0026#34;]] .dropna(subset=[\u0026#34;power_type\u0026#34;]) .drop_duplicates(subset=[\u0026#34;series\u0026#34;], keep=\u0026#34;last\u0026#34;) ) update_rows = power_by_series[[\u0026#34;power_type\u0026#34;, \u0026#34;series\u0026#34;]].values.tolist() conn = init_image_db(db_path) try: before_missing = conn.execute( \u0026#34;SELECT COUNT(*) FROM images WHERE power_type IS NULL OR power_type = \u0026#39;\u0026#39;\u0026#34; ).fetchone()[0] conn.executemany( \u0026#34;\u0026#34;\u0026#34; UPDATE images SET power_type = ? WHERE series = ? AND (power_type IS NULL OR power_type = \u0026#39;\u0026#39;) \u0026#34;\u0026#34;\u0026#34;, update_rows, ) # 下略 missing power_type 从 2363 补到了 0，说明现有图片都能通过 series -\u0026gt; power_type 映射补齐。\n动车组和机车使用不同的 Grounding DINO label 有了 power_type 后，Grounding DINO 的采样和 prompt 可以分流。今天的实验采用了一个简单写法：动车组、柴联车这类 multiple unit 使用 [\u0026quot;a train\u0026quot;]，机车类使用 [\u0026quot;a locomotive\u0026quot;, \u0026quot;a train\u0026quot;]。\n当前 notebook 中为了快速实验，按采样拼接顺序写。后面正式/全量测试改为按标签匹配。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 # 随机抽样几张图来看看判断多物体能力 SAMPLE_SIZE = 6 GDINO_BATCH_SIZE = 4 with sqlite3.connect(db_path) as conn: # 直接多种类采样 df1 = pd.read_sql_query(f\u0026#34;\u0026#34;\u0026#34; SELECT id, downloaded_path FROM images WHERE excluded = 0 AND download_status = \u0026#39;downloaded\u0026#39; AND power_type = \u0026#39;EMU\u0026#39; ORDER BY RANDOM() LIMIT {SAMPLE_SIZE}\u0026#34;\u0026#34;\u0026#34;, conn) df2 = pd.read_sql_query(f\u0026#34;\u0026#34;\u0026#34; SELECT id, downloaded_path FROM images WHERE excluded = 0 AND download_status = \u0026#39;downloaded\u0026#39; AND power_type = \u0026#39;DMU\u0026#39; ORDER BY RANDOM() LIMIT {SAMPLE_SIZE}\u0026#34;\u0026#34;\u0026#34;, conn) df3 = pd.read_sql_query(f\u0026#34;\u0026#34;\u0026#34; SELECT id, downloaded_path FROM images WHERE excluded = 0 AND download_status = \u0026#39;downloaded\u0026#39; AND power_type = \u0026#39;Electric Locomotive\u0026#39; ORDER BY RANDOM() LIMIT {SAMPLE_SIZE}\u0026#34;\u0026#34;\u0026#34;, conn) df4 = pd.read_sql_query(f\u0026#34;\u0026#34;\u0026#34; SELECT id, downloaded_path FROM images WHERE excluded = 0 AND download_status = \u0026#39;downloaded\u0026#39; AND power_type = \u0026#39;Diesel Locomotive\u0026#39; ORDER BY RANDOM() LIMIT {SAMPLE_SIZE}\u0026#34;\u0026#34;\u0026#34;, conn) sample_images = pd.concat([df1, df2, df3, df4], ignore_index=True, axis=0) paths = [os.path.join(PROJECT_ROOT, \u0026#34;data\u0026#34;, p) for p in sample_images[\u0026#34;downloaded_path\u0026#34;]] # paths = [\u0026#39;/Users/yukun/projects/wakareeru/data/img/E233系/1f5c3f_Toyota Vehicle Center.jpg\u0026#39;, \u0026#34;/Users/yukun/projects/wakareeru/data/img/EF510形/ca6c48_UetsuhonsenKomachiKoshuFreight.jpg\u0026#34;, # \u0026#39;/Users/yukun/projects/wakareeru/data/img/E231系/c5ea96_Ochanomizu Crossing 2020-03-17.jpg\u0026#39;] images = [Image.open(path) for path in paths] text_labels = [[\u0026#34;a train\u0026#34;]] * (len(images)//2) + [[\u0026#34;a locomotive\u0026#34;,\u0026#39;a train\u0026#39;]] * (len(images) - len(images)//2) # 针对动车组和机车用不同的标签，分别侦测整列车和机车自己。 ) 1 2 3 4 5 6 7 8 9 def gdino_labels_for_power_type(power_type: str) -\u0026gt; list[str]: if power_type in {\u0026#34;Electric Locomotive\u0026#34;, \u0026#34;Diesel Locomotive\u0026#34;, \u0026#34;Steam Locomotive\u0026#34;}: return [\u0026#34;a locomotive\u0026#34;, \u0026#34;a train\u0026#34;] return [\u0026#34;a train\u0026#34;] text_labels = [ gdino_labels_for_power_type(power_type) for power_type in sample_images[\u0026#34;power_type\u0026#34;] ] 这也是为什么 power_type 必须在图片入库时就存在，而不是等到后处理时临时查 CSV：后续 GDINO、裁图、主体选择、甚至多主体图过滤都会依赖这个字段。加上这个之后可以看看效果： 可以注意到对locomotive的分割起到了作用。为了表现对比参照我把\u0026quot;a train\u0026quot;这个label也放进去了。\nGrounding DINO batch 推理的内存占用 今天还处理了一个 batch 相关的问题。GDINO看似0.2B其实极其吃内存/显存，处理小patch的时候粗略看python进程来到了19GB，一旦触发了swap那推理速度就直接血崩了。所以最开始为了节省内存，把 Grounding DINO 推理改成了：\n1 2 3 4 5 with torch.no_grad(): outputs = [] for i in range(0, len(images), 4): inputs_batch = {k: v[i:i+4] for k, v in inputs.items()} outputs.append(model(**inputs_batch)) 但这样 outputs 变成了 list[GroundingDinoObjectDetectionOutput]。而 processor.post_process_grounded_object_detection(...) 需要的是单个 batch output 对象，它会访问：\n1 2 outputs.logits outputs.pred_boxes 所以直接把 list 传进去会报：\n1 AttributeError: \u0026#39;list\u0026#39; object has no attribute \u0026#39;logits\u0026#39; 兼容的 batch 方案有两种。第一种是每个 batch 推理后立刻 postprocess，再 extend 到总结果。第二种更适合 notebook 实验：把推理结果缓存下来，postprocess 单独放到下一个 cell。这样调 threshold 和 text_threshold 时不需要重新跑模型。\n当前采用的是第二种：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 GDINO_BATCH_SIZE = 4 target_sizes = [img.size[::-1] for img in images] gdino_batch_outputs = [] gdino_batch_input_ids = [] gdino_batch_target_sizes = [] with torch.no_grad(): for start in range(0, len(images), GDINO_BATCH_SIZE): end = start + GDINO_BATCH_SIZE inputs_batch = {k: v[start:end] for k, v in inputs.items()} gdino_batch_outputs.append(model(**inputs_batch)) gdino_batch_input_ids.append(inputs_batch[\u0026#34;input_ids\u0026#34;]) gdino_batch_target_sizes.append(target_sizes[start:end]) 然后在下一个 cell 只做后处理和输出：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 GDINO_BOX_THRESHOLD = 0.2 GDINO_TEXT_THRESHOLD = 0.3 results = [] for gdino_outputs, input_ids_batch, target_sizes_batch in zip( gdino_batch_outputs, gdino_batch_input_ids, gdino_batch_target_sizes, ): batch_results = processor.post_process_grounded_object_detection( gdino_outputs, input_ids_batch, threshold=GDINO_BOX_THRESHOLD, text_threshold=GDINO_TEXT_THRESHOLD, target_sizes=target_sizes_batch, ) results.extend(batch_results) 这里的两个 threshold 含义不同：\nthreshold 控制 bbox 置信度，越高越保守。 text_threshold 控制检测框和文本 prompt 的匹配强度，越高越要求语义贴合。 对于当前任务，如果只是想先找到车体 bbox 给后续裁图，初始值可以偏召回，例如 0.15 / 0.25。如果想看更干净的可视化结果，再提高到 0.3 / 0.35。\nGit 提交脉络 今天的实现可以从最近的提交记录中看到比较清晰的演化：\n1 2 3 4 5 1b08c47 Add V2 filter: SigLIP2 Only Interior Filtering, EMU/Locomotive separated object detection e94244d Add power_type field and mapping b9c328f update power_type to DB 880e4ff update e9ad493 Data Cleaning: Exclude Interior, LLM details detection, Grounding DINO multi target recognization e9ad493 是 v1 清洗主线：规则与 LLM 过滤内饰/细节，LLM 从 category path 抽取车型细节，Grounding DINO 初步测试多主体识别。\nb9c328f 和 e94244d 把 power_type 推进到数据层：先更新 CSV 和数据库，再在 crawler 中加入映射、schema migration、record 构建、upsert 和旧库修复 cell。\n1b08c47 则是 v2 方向：新增 img_filter_v2.ipynb，把画面类型判断从 LLM 改成 SigLIP2；同时开始按 EMU/DMU/Locomotive 分组测试 Grounding DINO prompt。\n技术路线因此变成：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 Commons category / file manifest | |-- MIME 过滤，去掉非 image/* | v SQLite images 表 | |-- series/type -\u0026gt; power_type 规则映射 |-- 明显内饰关键词快速过滤 | v SigLIP2 zero-shot 画面类型分类 | |-- interior |-- exterior |-- detailed exterior close-up | v Grounding DINO bbox 检测 | |-- EMU/DMU: [\u0026#34;a train\u0026#34;] |-- Locomotive: [\u0026#34;a locomotive\u0026#34;, \u0026#34;a train\u0026#34;] | v 后续：NMS、多主体图过滤、主体 bbox 选择、裁图与训练集构建 这个路线的核心取舍是：元数据问题用规则和 LLM，视觉问题用视觉模型。power_type 让后续的 bbox 生成可以按车辆动力类型选择不同的 prompt，增加了粒度。并且甚至可以在推理过程中用于zeroshot分流到动车组/机车识别。\n","date":"2026-05-03T23:41:57+08:00","image":"/p/wakareeru-data-2/cover.jpeg","permalink":"/p/wakareeru-data-2/","title":"わかれーる：细粒度的日本铁路分类模型：数据集清洗、内存/显存资源管理（1）"},{"content":"其实获得这个想法的经纬也很简单————打鸟佬们常用的“懂鸟”小程序。一半是想要自己做一点DL相关的东西而不是天天围绕训好的LLM做花式prompt engineering打转，二来还是想找点与自己相关的事情。其实曾经想过去模仿乗りつぶし这个乘车记录网站，做一个东南亚版的：至少他简单。不过本人确实无论如何对前端一无所知，靠着Claude Code才勉强写了一个Vue App用，不过因为归根结底还是一个较多放给Vibe Coding的项目，不可避免地背上了技术债+重构成本，确实有些放弃治疗了。也许等到有耐心的时候我会从头学吧。\n总之回到正题，当发现没有数据集可用的时候大概心里一瞬想到大量苦心爆肝数据集的前辈和一些著名数据集。突然觉得不给arxiv丢上去一篇report写写这个数据集恐怕对不起我的工伤（事实证明才刚刚开始）（开玩笑的）。\n对于模型选择上，暂定使用DINOv3+Head+SupCon Loss进行后训练调试。不过对于数据的筛选，打算使用对齐非常好的CLIP，针对复杂的图片（例如车辆正面，侧面，内饰，原理图，这些杂七杂八的图片都散布在Wiki Commons上，需要人工提取出来，即使用文件名匹配效果也不好）进行标记并分类。分类粒度暂定为系列，即不区分番台或仅区分差异较大或家族过于庞大的车型（例如E231系和他的无数孩子们）\n数据集阶段 一次性提取所有数据实在太痛苦，暂时分为几个阶段\nPhase 1 — JR東日本 Phase 2 — JR本州三社 (东、海、西） Phase 3 — JR全社（旅客6家+货物1家，但不包含客车货车） Phase 4 — 包含私铁车辆（大手私铁） 车辆型号提取 首先第一步肯定是抓到所有的车型，这样才有label和分类目标可用。最自然的数据来源当然是Wikipedia。日语版Wikipedia对JR各公司都有独立的「車両形式」页面，基本按照树状分类得比较好。车型基本都有内链到页面，省了解析型号的功夫。 不过注意给Media API发请求记得带上头，直接自己写一个就行，他们不会拒绝爬虫，也不用伪装UA。\n对于API的返回结构。query.pages是一个以pageid为key的字典，而pageid是Wikipedia内部自动分配的数字，在请求之前完全无从得知。想拿到页面内容得直接解包一下字典： pythonpageid, page = next(iter(resp[\u0026quot;query\u0026quot;][\u0026quot;pages\u0026quot;].items()))靠把这个字典变成迭代器再迭代一次直接拿Value，把Key扔了。然后page[\u0026quot;revisions\u0026quot;][0][\u0026quot;slots\u0026quot;][\u0026quot;main\u0026quot;][\u0026quot;*\u0026quot;]再这样一通拿到第一个revision正文就行。\n总的来说解析Wikitext本身倒是顺利，规律相当清晰：H2标题区分「現在の所属車両」和「過去の所属車両」（不同JR其实写法不一样，因为Wikipedia也不是统一的数据库），H3标题是车种大类（新幹線電車、電気機関車等），车型条目藏在[[]]双括号里，显示名就是系列名。唯一需要注意的是用途标签行（\u0026lsquo;\u0026lsquo;\u0026lsquo;営業用\u0026rsquo;\u0026rsquo;\u0026rsquo;、\u0026lsquo;\u0026lsquo;\u0026lsquo;事業用\u0026rsquo;\u0026rsquo;\u0026rsquo;）——这些粗体行不含车型链接，解析时要跳过，但同时需要把它们记录下来作为subtype字段，不能直接丢掉。之后出了个问题是解析出来的subtype因为没有清空应用到了不该出现的地方，比如冒出来一个北陆新干线用的蒸汽机关车，，，\n下面代码顺便让Claude Code代劳改成异步一次抓来。可以选择operator来选择爬取哪些公司。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import asyncio OPERATORS = [ (\u0026#34;JR東日本\u0026#34;, \u0026#34;JR East\u0026#34;, \u0026#34;JR東日本の車両形式\u0026#34;), #(\u0026#34;JR東海\u0026#34;, \u0026#34;JR Central\u0026#34;, \u0026#34;JR東海の車両形式\u0026#34;), #(\u0026#34;JR西日本\u0026#34;, \u0026#34;JR West\u0026#34;, \u0026#34;JR西日本の車両形式\u0026#34;), ] HEADERS = { \u0026#34;User-Agent\u0026#34;: \u0026#34;JapaneseTrainDatasetBuilder/1.0 (research project; fengyukunfyk@gmail.com)\u0026#34; } async def _fetch_one( client: httpx.AsyncClient, operator_jp: str, operator_en: str, page_title: str ) -\u0026gt; tuple[str, str, str, str]: params = { \u0026#34;action\u0026#34;: \u0026#34;query\u0026#34;, \u0026#34;titles\u0026#34;: page_title, \u0026#34;prop\u0026#34;: \u0026#34;revisions\u0026#34;, \u0026#34;rvprop\u0026#34;: \u0026#34;content\u0026#34;, \u0026#34;rvslots\u0026#34;: \u0026#34;main\u0026#34;, \u0026#34;format\u0026#34;: \u0026#34;json\u0026#34;, } resp = await client.get(\u0026#34;https://ja.wikipedia.org/w/api.php\u0026#34;, params=params) resp.raise_for_status() pages = resp.json()[\u0026#34;query\u0026#34;][\u0026#34;pages\u0026#34;] page = next(iter(pages.values())) print(f\u0026#34;页面：{page_title} 请求成功\u0026#34;) return page_title, operator_jp, operator_en, page[\u0026#34;revisions\u0026#34;][0][\u0026#34;slots\u0026#34;][\u0026#34;main\u0026#34;][\u0026#34;*\u0026#34;] async def fetch_all(operators: list[tuple[str, str, str]]) -\u0026gt; dict[str, tuple[str, str, str]]: async with httpx.AsyncClient(headers=HEADERS, timeout=30) as client: results = await asyncio.gather(*[_fetch_one(client, jp, en, page) for jp, en, page in operators]) # {page_title: (operator_jp, operator_en, wikitext)} return {page: (jp, en, wt) for page, jp, en, wt in results} wikitexts = await fetch_all(OPERATORS) wikitexts[\u0026#34;JR東日本の車両形式\u0026#34;][2][:500] # 预览 其实JR东的命名是最复杂的，除了国铁继承来的命名方法，例如纯数字（如485，205，211）、机车（E/D+轴数字母+功能，例如EF64，DD51），带形式（例如キハ、キヤ、モハ）的命名之外还有自己的命名，例如新的E开头的车辆（E231），混用的（キハE110），乱写的（GV-E400），放飞的（E001，四季岛用车），这在之后还导致去抓Wiki Commons的category名（纯英文）时需要复杂的转换，而且有很多case需要手动来。我真是脑子抽了来爬这个。后来带上本州三社发现JNR解体后继承的车大家有许多共同车型是个问题，还得把会社改成list。这还引入了不同会社的车型和不同番台之后怎么识别的问题，真是一坨大的。\n在去掉客车和无动力货车平车，还有旧型的事业用车和旅客用车之后大概捞出来的数据如下，我筛选了一些代表性的放出来，按照csv格式： 种类十分丰富（绝望）\n以及本州三社的部分数据\nCommons Category 检索 拿到了车型列表之后，下一步自然是去 Wikimedia Commons 按图索骥。Commons 上对大多数有一定知名度的列车型号都有专属的 category，还包含各种子分类，里面收录了各类摄影作品， 问题是 Commons 的 category 全是英文，只能自己写转换了。 Commons API 提供了 acprefix 参数，可以前缀搜索 category 名，只要能把日文型号名正确转换成英文前缀就行。\n命名规则 Commons 的命名规律大致如下：\n公司前缀：JR East E231、JR Central HC85、JR West 225 国铁继承车辆依然是JNR，统一用 JNR：JNR EF64、JNR Kiha 40 新干线系列用 Shinkansen：Shinkansen E5、Shinkansen N700 运营商前缀的判断不能单靠 operator_jp 字段，因为同一辆车（比如 EF64 形）可能被 JR东日本， JR货物继承，但 Commons 里还是叫 JNR EF64。需要检测 wiki_title 是否以「国鉄」开头来判断。\n片假名转罗马字 麻烦的是含片假名的型号。キハ40系 在 Commons 里是 JNR Kiha 40，キヤE195系 是 JR East Kiya E195。需要实现一个片假名→罗马字转换器。\n转换本身不难，就是查表——但有两个细节：\n双字节组合优先匹配。シャ（sha）、チャ（cha）这类拗音必须在匹配单个シ（shi）之前先检查，否则シャ会被拆成 shi + ya，输出 shiya 而不是 sha。\n空格处理。片假名前缀和后面的编号之间需要加空格：Kiha 40、Kiya E195。最初只对数字开头的情况加了空格（Kiha 40 ✓），漏掉了字母开头的（KiyaE195 ✗），结果有些型号搜不到。\n片假名前缀的不稳定性 更头疼的是片假名前缀在 Commons 里是否保留并不稳定。キヤE193系 对应的是 JR East E193（前缀被丢弃），而 キヤE195系 对应的是 JR East Kiya E195（前缀保留）。两个几乎一样的型号，规律完全不同，没法静态判断。好吧还是因为大家都没有命名规则 解决办法是生成两个候选前缀，顺序尝试，取第一个返回非空结果的：\n1 2 3 4 5 6 7 8 9 10 11 12 13 def series_to_commons_prefixes(series, operator_jp, series_type, wiki_title): op = _operator_prefix(operator_jp, series_type, wiki_title) name = re.sub(r\u0026#39;[系形]$\u0026#39;, \u0026#39;\u0026#39;, series) m = re.match(r\u0026#39;^([゠-ヿ]+)(.*)\u0026#39;, name) if m: romaji = _katakana_to_romaji(m.group(1)).capitalize() rest = m.group(2) with_kata = f\u0026#34;{op} {romaji} {rest}\u0026#34;.strip() # e.g. \u0026#34;JR East Kiya E195\u0026#34; without_kata = f\u0026#34;{op} {rest}\u0026#34; if rest else None # e.g. \u0026#34;JR East E195\u0026#34; return [with_kata, without_kata] if without_kata else [with_kata] else: return [f\u0026#34;{op} {name}\u0026#34;] Commons 合并分类的问题 还有一个更难处理的情况：Commons 有时会把关联型号合并到同一个 category 下。比如 481系、483系、485系、489系 在技术上属于同一家族，Commons 把它们全归在 JNR 485 下，直接搜 JNR 481 返回空。这类情况无法自动检测，只能先置空手动回来处理了。 至少每个车型为了写wiki页面一定有图，而且我相信日本人应该拍了很多。\n目前进展 现在已经对本州三社全部车型跑了一遍搜索，结果写回了 DataFrame：\n1 2 all_model[\u0026#34;commons_prefix\u0026#34;] = commons_prefixes # 命中的搜索前缀 all_model[\u0026#34;commons_cats\u0026#34;] = commons_cats # 返回的 category 列表 空结果的条目还留在表里，等人工过一遍——区分「确实没有 Commons category」和「前缀转换有误」两种情况，要么合并进去自己改然后再匹配。 下一步是对每个 category 递归拉取子 category 和图片 URL，然后进入异步批量下载。\nCommons 分类清洗与根分类寻找 Root Category 前面只是拿到了 Commons 的候选 category，但很快发现一个问题：allcategories(acprefix=...) 返回的列表并不等于“这个车型应该使用的最终分类”。它只是前缀搜索结果，里面可能混着：\n车型主类 子型号 番台 涂装 运营会社分类 博物馆保存车 车站照片 内饰/部件 例如 キハ20系 搜 JNR Kiha 20 会得到：\nJNR Kiha 20 JNR Kiha 20 series JNR Kiha 20 series (Hokkaido)\n差点直接用第一个结果，但这其实是错的。日文 label 是 キハ20系，语义上应该对应整个 family，也就是 JNR Kiha 20 series。JNR Kiha 20 反而是其中一个具体车型。所以我们应该找series。吗？\n虽然不在第一阶段（JR东+JR货物，为什么多了个JR货物？等会就知道了），但是有一个逆天的：\nJR West 521 -\u0026gt; JR West 521 series 好吧，看起来他们根本没关心命名规则。反正哭的是我。最后和codex拉了一套规则出来，其实就是从规则到发现特例，返工回去打补丁的循环。\n先生成搜索 prefix。 用 Commons allcategories 搜候选。 如果有 exact match，默认用 exact。 如果同时存在 prefix 和 prefix series，再查父子关系。 只有当 prefix series 是 prefix 的 parent，或者 prefix 是 prefix series 的 child 时，才提升到 series。 空结果和弱匹配保留给人工补。 大概的代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 def _promote_to_series(series_label, exact, series_cat): if not series_label.endswith(\u0026#34;系\u0026#34;): return False exact_parents = fetch_parent_categories(exact) or [] if series_cat in exact_parents: return True series_children = fetch_subcategories(series_cat) or [] if exact in series_children: return True return False 这样 キハ20系、キハ40系 会被提升到：\n1 2 JNR Kiha 20 series JNR Kiha 40 series 而 E231系 会保持：\n1 JR East E231 蒸爷来了 然后蒸汽机车又单独给我上了一课。\n普通国铁机车大多可以搜：\n1 2 JNR EF64 JNR DD51 但蒸汽机车不是。比如 C57形，Commons是：C57 steam locomotives\n注意还是复数。直接搜 C57 steam locomotive 会返回一堆：\n1 2 3 C57 steam locomotives C57 steam locomotives by number C57 steam locomotives in service 甚至有些型号会先返回单车、部件、车轮：\n1 2 3 D51 steam locomotive backheads D51 steam locomotive wheels D51 steam locomotives 所以对 蒸気機関車 单独加了 prefix：\n1 2 if series_type == \u0026#34;蒸気機関車\u0026#34;: prefixes.append(f\u0026#34;{name} steam locomotive\u0026#34;) 然后在 root 选择时，如果候选里有 prefix + \u0026quot;s\u0026quot;，优先选复数主类：\n1 2 3 plural = f\u0026#34;{prefix}s\u0026#34; if plural in candidates: return plural 多运营者问题：root 和 operator_roots 分开 其实是发现为什么EF510这个JR货的车搜出来第一个选项却是JR东的EF510-500番台？哦原来是因为JR东拿他们拉北斗星。而倒霉的是，JR Freight EF510和 JR East EF510-500是两个根category。\n你说你是不是有病？\n于是我把JR货也加进来第一阶段，希望先思考出来如何解决这个问题。如果只保留一个 commons_root_category，就会很尴尬：\n选 JR Freight EF510：车型 family 对了，但 JR East 的图片会被漏掉。 选 JR East EF510-500：当前 JR East row 对了，但车型总体又太窄。 所以我最后把数据结构改成：\n1 2 3 4 5 6 commons_root_category = \u0026#34;JR Freight EF510\u0026#34; commons_operator_roots = { \u0026#34;JR East\u0026#34;: \u0026#34;JR East EF510-500\u0026#34;, \u0026#34;JR Freight\u0026#34;: \u0026#34;JR Freight EF510\u0026#34;, } 也就是说：\ncommons_root_category 表示车型总体入口。 commons_operator_roots 表示按运营者细分时的入口。 这样以后如果做“全 JR 数据集”，同一车型合并成一行也行，照顾一下之后多阶段分类的需求。\n从 CSV 到 SQLite Manifest 确定 root category 后，下一步不是马上下载图片，而是先做一个 manifest 数据库。毕竟从wikipedia服务器上薅上万张图片总不可能一次性跑通，一定要有断点续传之类的。 所以我们可以先把manifest搞下来然后用它管理下载进度。\n于是建了一个 SQLite：\n1 data/commons_image_manifest.sqlite 目前有三张表：\n1 2 3 categories images image_categories 其中 images 是核心表，存图片级 metadata：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 series root_category category category_path_json file_title image_url thumb_url mime width height size sha1 excluded exclude_reason download_status downloaded_path 这里有几个设计点。\n保留 File: 前缀 Commons API 返回的文件标题是：\n1 File:E231 Niitsu+Kawaju.jpg 我一开始也想把 File: 去掉，但后来决定数据库里保留。因为这是 Commons 的标准 page title，后续继续查 API 时可以直接用：\n1 titles=File:E231 Niitsu+Kawaju.jpg 真正下载到本地时，再加上SHA1哈希的前几位生成一个清洗后的文件名。\ncategory_path_json 递归 subcategory 时，我没有单独建复杂的 graph 表。Commons category 本身是 DAG，不是树。同一个 category 可能从不同路径出现。比如 Kiha 40：\n1 2 3 4 5 6 7 JNR Kiha 40 series -\u0026gt; JNR Kiha 40 -\u0026gt; Kiha 40 (JR West) JNR Kiha 40 series -\u0026gt; JNR Kiha 40 series (JR West) -\u0026gt; Kiha 40 (JR West) 对，他可以出现在第二，第三层。不过这不重要，结构化的处理过滤根本没用，这种直接要把整个category trace丢给LLM分析，他很擅长这个。所以我其实只需要整个trace，存储成flatten就好了。\n所以直接在 images 里存：\n1 [\u0026#34;JNR Kiha 40 series\u0026#34;, \u0026#34;JNR Kiha 40\u0026#34;, \u0026#34;Kiha 40 (JR West)\u0026#34;] 字段叫：\n1 category_path_json root-only 时就是：\n1 [\u0026#34;JR East E231\u0026#34;] 这样既简单，又保留了足够上下文。\n断点续传 失败时打印原因：\n1 2 ↻ File:test.jpg: HTTP 429, retry in 3.0s (1/3) ✗ File:test.jpg: ConnectError: ... 用指数退避避免被封控，礼貌点不要请求太多了。\n目前的状态 现在已经可以完成一个小pipeline：\n从车型 CSV 读取 commons_root_category 按 root category 抓图片 metadata 可选递归 subcategory 写入 SQLite manifest 过滤明显内饰/部件图片 下载少量图片到 data/img 把下载状态写回数据库 例如测试样本：\n1 2 3 4 5 6 collection_manifest = crawl_root_manifest_sample( [\u0026#34;E231系\u0026#34;, \u0026#34;キハ40系\u0026#34;, \u0026#34;EF510形\u0026#34;], max_files_per_category=3, max_depth=1, ) download_result = download_manifest_dataframe(collection_manifest, limit=200) 现在比较明显的下一步是图片清洗。因为 Commons 里面混进来的东西实在太多：\n车辆外观 车站停靠照 编组局部 内饰 座席 车轮 铭牌 博物馆展品 模型 原理图 方向幕 文件名规则只能挡掉一部分。比如英文 wheel 可以过滤，但日文 動輪 就还没处理。之后应该扩展一批规则，再用 CLIP 或其他视觉模型对图片内容做二次筛选。\n粗略计划是：\n先用规则过滤明显错误： interior seat cab wheel parts 車内 座席 運転台 動輪 再用 CLIP 打标签： train exterior interior station scene detail/part diagram/map model/toy 最后把保留下来的图导出成 Hugging Face dataset。 到这里，数据集构建终于从“找到车型列表”进入了下载的阶段。不过，属于数据清洗的工作似乎都还没真正开始。有了正确角度的图片，接下来要筛选足够大的分辨率，色彩（老照片都是黑白），还要平衡，等等。\n好累啊啊啊！\n我说做这个我能发篇conference吗？\n","date":"2026-04-28T12:15:23+08:00","image":"/p/wakareeru1/cover.jpeg","permalink":"/p/wakareeru1/","title":"わかれーる：细粒度的日本铁路分类模型：数据集构建（1）"},{"content":"这一个月来做的大部分工作零零散散有笔记但是是以英文存在的，加之大部分是在框架探索阶段的思考决策，感觉缺乏工程上的技术总结，于是便有想法先总结过去的经验catchup，不过量实在太大，先请Claude代劳。后面进入SFT阶段后还是改用手写。\n背景 故事从一段实习开始。我加入时，项目已经到了末期——一条智能供应链的末端：自动导引车把零件从零件台送到装配台，UR10 机械臂完成最后的组装。CNC 加工、零件运输、机器人装配，三个子系统拼在一起。我的工作是负责机器人装配这一段。\n不过早先的 supervisor 也已离任，换来的是更加熟悉 AI 方向的新老板。此时项目基本要结题，但新老板提出可以以项目为背景继续探索：在这套系统上，LLM 能做什么？能做到什么程度？能不能实现真正的 Low-Code 机器人控制——操作员用自然语言描述任务，系统自动规划并执行？更进一步，有没有可能用这个场景生成训练数据，探索机器人专用 LLM 的 Fine-Tuning 路径，应用到未来的本地小模型上？\nRoboSkiAgent 就是在这个背景下开始的。\n一个约束驱动了整个架构 在动手之前，先确定了一条设计约束：LLM 层永远不能出现坐标数值。\nLLM是概率模型，无法从文字上做数学推理。让 LLM 说\u0026quot;把 Part_A 放到 Station_2\u0026ldquo;是合理的，让它说\u0026quot;在 x=312.5, y=-88.3 的位置上空100mm就位并放置\u0026quot;是强迫它做不擅长的事。坐标计算应该在底层完成，LLM 处理符号和意图。\n这条约束直接产生了一个分层问题：谁来算坐标？谁来理解意图？由此决定了 SkiLib 和 Agent 层的分离。\nSkiLib：先把手脚造好 在接入任何 LLM 框架之前，先把机器人的\u0026quot;手脚\u0026quot;封装成一个纯 Python 库，完全没有 LangGraph 依赖。\n这个选择不是偶然的，有两个具体动机：\n可独立测试：技能库可以单独 debug，Agent 框架只是调用它的客户，不会把测试环境和编排环境混在一起。\n底层平台可迁移：项目使用的是 RoboDK 仿真 + UR10，但后续有计划迁移到 Genesis——主要原因是 Genesis 更适合大规模并行仿真，可以用来生成 SFT 所需的大量 trajectory 数据。如果技能库和编排框架耦合在一起，换仿真平台就要同时改 Agent 逻辑；现在两者分离，换平台只需要重新实现 Primitive 层，Skill 逻辑和 Agent 编排完全不动。\n两层抽象：Primitive vs Skill 技能库分成两层：\nPrimitive（原语） 是最小的、平台相关的动作单元：MoveJ（关节插值运动）、MoveL（直线运动）、Grasp、Release。这一层可以 import robodk，知道所有硬件细节，负责跟机器人打交道。\nSkill（技能） 是平台无关的业务逻辑，禁止 import robodk，只依赖 BasePrimitive 接口。PickAndPlace 把 8 个步骤组合在一起：接近点 → 下降 → 抓取 → 提升 → 运输 → 下降 → 释放 → 撤离。\n1 2 3 4 5 6 7 8 class PickAndPlace(BaseSkill): REQUIRED_PRIMITIVES = [\u0026#39;MoveJ\u0026#39;, \u0026#39;MoveL\u0026#39;, \u0026#39;Grasp\u0026#39;, \u0026#39;Release\u0026#39;] def execute(self, pick_target: str, place_target: str, ...) -\u0026gt; SkillResult: # pick_target 是符号名，方法内部解析为 RoboDK Item ctx = RobotContext.instance() pick_item = ctx.RDK.Item(pick_target) ... 这个分法在写测试时价值立刻体现出来：Skill 层的逻辑可以用 mock primitives 做单元测试，完全不需要启动仿真软件。\nSkillResult：一道防火墙 SkillResult 是所有公开方法的统一返回类型，目的只有一个：LLM 永远看不到 Python traceback。\n所有底层异常必须在 Primitive 层内部被捕获，翻译成结构化描述，才能往上传：\n1 2 3 4 5 6 7 8 # LLM 看到的 { \u0026#34;success\u0026#34;: False, \u0026#34;phase\u0026#34;: \u0026#34;PLANNING\u0026#34;, \u0026#34;error_type\u0026#34;: \u0026#34;IK_FAILURE\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;No IK solution for Station_3 in current configuration.\u0026#34;, \u0026#34;suggestion\u0026#34;: \u0026#34;Try approaching from above.\u0026#34; } error_type 用的是字符串常量而不是枚举，原因很实际：不同 Primitive 有各自领域特有的错误类型，用枚举意味着每次加新 Primitive 都要改核心 base.py。字符串常量在各自模块里定义，不侵入核心文件。\nExecutionPhase 则用了枚举（VALIDATION / PLANNING / EXECUTION），因为这三个阶段对应 LLM 恢复时的三种决策分支，不会随 Primitive 扩展而变化——这两个选择背后的逻辑是一致的：稳定的抽象用枚举，容易变化的用字符串。\n@require_robot_active：硬件锁 1 2 @require_robot_active def execute(self, target: str) -\u0026gt; SkillResult: ... 系统挂起时（halt_flag=True），任何带这个装饰器的调用都直接返回 ERROR_ROBOT_INACTIVE，不触碰硬件。但这里有一个死锁风险：如果 resume() 也被拦截，系统就永远无法恢复。所以有个白名单机制：\n1 2 @require_robot_active(bypass_halt=True) def resume(self) -\u0026gt; SkillResult: ... bypass_halt=True 必须显式声明，没有隐式例外。漏掉这个声明会导致系统永久卡死，这种错误在运行时才会暴露，所以在文档里单独标注了。\nTOOL_METHODS：刻意不给 LLM 的权限 Skill 暴露给 LLM 的方法在基类层面就做了限制：\n1 TOOL_METHODS: tuple = (\u0026#34;check\u0026#34;, \u0026#34;try_execute\u0026#34;) # execute 故意不暴露 execute 会跳过 pre-flight 验证直接执行。LLM 不应该绕过校验——它要么用 check 先探测一下，要么用 try_execute 走完整流程。子类可以覆盖这个元组，但需要明确说明原因。\nAgent 层：Plan-and-Execute 状态机 整体是一个 LangGraph StateGraph，五个主要节点：Supervisor → Planner → PlanReview → Dispatcher → Executor，加上两个 interrupt 处理节点。\n有一点值得先说清楚：messages 字段在这个系统里最终不是通用事件总线。最初的设计上，我们曾试图使用LangGraph的Reduce修饰器来对LLM产生的消息链进行修剪，去掉中间状态只留结论给后面的节点。不过后来发现我们选择了直接隔离各个节点。所以它只在 LLM 推理链上流转——Supervisor 读取初始指令，输出分析结果；Planner 读取 Supervisor 的最后一条输出；replan 路径写入一条 HumanMessage 触发重规划。节点之间的实际状态传递靠的是专用字段：execution_log、current_task、halt_flag、last_result 等。这个设计避免了 LLM 在每轮推理时看到越来越多的执行噪音。\ngraph设计上有两个节点分别处理人类介入，不过为了区分人类操作和异常处理的Human-In-The-Loop (HITL)，将他们分开作为两个节点，也减少不必要的条件判断和复杂性。\nSupervisor：只做情报收集 Supervisor 不规划，不执行，只做一件事：把自然语言指令转化成\u0026quot;知识饱和\u0026quot;的符号描述。\n它有一套只读工具（T-skills）：list_targets()、list_objects()、check_item_exists() 等，全部只查询 RoboDK 场景，不做任何动作。输出是结构化的 SupervisorOutput：场景里有哪些目标点、哪些工件、哪些工具，全部用符号名表示。\n可用技能列表由代码注入 system prompt，LLM 不需要记住，也不需要填写——这个信息是运行时动态生成的。\nPlanner：用工具调用代替结构化 JSON 输出 Planner 有一个关键的设计演进。最初让 LLM 直接输出 todo_list JSON，问题是 LLM 需要记住每个 Skill 的参数格式，弱模型很容易输出不合法的结构。\n改成工具调用方式：为每个注册的 Skill 动态生成一个 add_\u0026lt;SkillName\u0026gt;_task 工具，复用 try_execute 的 args_schema。LLM 不需要知道 JSON 结构，只需要调用工具；Pydantic 自动验证参数；task_id 由代码自动分配，不会出现编号重复或跳号的问题。\n1 2 3 4 5 6 7 8 9 10 11 12 def _make_planner_tools(registry): plan = [] for skill_name in registry.list_skills(): skill = registry.get_skill(skill_name) # 复用 try_execute 的参数 schema，参数校验自动完成 try_exec = next(t for t in skill.as_tools() if t.name.endswith(\u0026#34;_try_execute\u0026#34;)) tools.append(StructuredTool( name=f\u0026#34;add_{skill_name}_task\u0026#34;, args_schema=try_exec.args_schema, ... )) return tools, plan PlanReview：结构性强制审批 这是一个 LangGraph interrupt，图结构保证每次 Planner 完成后必经此节点。\n之前尝试过在 prompt 里要求 LLM \u0026ldquo;在计划前插入一个 manual task 让操作员审批\u0026rdquo;。这在弱模型上不可靠——有时会忘记，有时位置不对。审批这件事不应该依赖 LLM 的执行意愿，而应该由图结构强制保证。\nreplan 路径把操作员反馈直接写入 messages 送回 Supervisor，比 abort + 重新输入指令效率高得多：\n1 2 3 if command == \u0026#34;replan\u0026#34;: return_state[\u0026#34;messages\u0026#34;] = [HumanMessage(content=f\u0026#34;Please replan: {feedback}\u0026#34;)] return_state[\u0026#34;todo_list\u0026#34;] = [] Dispatcher：执行槽语义 Dispatcher 是纯代码节点，设计了一个\u0026quot;执行槽\u0026quot;语义：\ncurrent_task == {} → 槽空闲，pop 下一个任务填入 current_task != {} → 槽被占用，跳过不覆盖 任务失败时，current_task 保留原始任务。操作员选 retry 后，Executor 拿到完整信息重试，不需要任何额外传递。这个\u0026quot;单一真相来源\u0026quot;的设计替代了之前用 last_result 作为隐式路由信号的方案，语义更清晰。\nExecutor：双层恢复 + 一个有意思的升级机制 先直接调 skill.try_execute()。失败了，启动 LLM 恢复循环，给它工具：这个 Skill 的 check/try_execute、list_targets，还有一个特殊工具 escalate_to_hitl。\nescalate_to_hitl 的实现值得单独说：\n1 2 3 4 5 6 7 def _escalate_to_hitl(error_type, reason, suggestion): raise _EscalateHITLException(error_type, reason, suggestion) escalate_tool = StructuredTool.from_function( func=_escalate_to_hitl, handle_tool_error=False, # 关键：让异常真正穿透 ) LLM 调这个工具时，实际上是在触发一个 Python 异常，被 Executor 节点的 try/except 捕获。handle_tool_error=False 是关键——LangChain 默认会把工具异常包成错误消息返回给 LLM，这里需要让它真正穿透出去。\n这样 LLM 有两条路：继续尝试其他参数，或者调 escalate_to_hitl 放弃交人工。选哪条完全由 LLM 判断，Executor 不做任何隐式决策。\nHITL 拆分：用结构消除非法操作组合 最初一个 HumanIntervention 节点处理两种入口：任务执行失败（TASK_FAILURE）和计划内人工步骤（MANUAL_TASK）。两种入口的合法 actions 不同——失败时可以 retry，但人工步骤 retry 毫无意义（机器人根本不会执行人工任务）。靠运行时 guard 防御这个非法组合，是一个设计异味。\n拆成两个独立节点后，非法组合从结构上消失：manual_intervention_handler 只提供 complete/abort，hitl_handler 只提供 retry/next_task/replan/abort。\nhitl_handler 里的 replan 路径是后来加的：某个任务彻底失败，操作员认为不是执行问题，而是整个规划方向有问题，这时可以直接触发重规划，不需要 abort 后重新输入指令。\n真正有意思的问题：LLM 和环境怎么交互？ 做这个系统的过程中，真正让人深思的问题不是怎么连 RoboDK API，而是：LLM 应该以什么粒度、什么方式跟机器人环境交互？这个设计选择直接决定了训练数据的形态和 SFT 的可行性。\n可以列出几种范式及其权衡：\nPrimitive+Skill 双层抽象（当前 V1）：确定性强，行为可预测，接近 PDDL 规划器的抽象方式。问题是 LLM 在这里基本只做参数填写，Skill 层的复杂逻辑是硬编码的，LLM 的能力没有被真正测试，生成的 trajectory 对 SFT 价值有限。\nPrimitive 完全 ReAct Loop：LLM 直接调用每一个原语动作，中间每步都观测环境状态。trajectory 很长，真正考验指令遵循能力，但极度低效，充满冗余的感知-决策-执行循环——而且很多冗余步骤（比如碰撞检测）完全可以用确定性代码处理，没必要浪费推理次数。\nCode-as-Policy：LLM 一次性生成完整的执行代码，代码里可以包含确定性的逻辑分支和异常处理，生成的代码还有复用价值。但一旦运行出错，需要把错误传回 LLM 重新生成，没有 ReAct 式的细粒度恢复机制。\n逻辑分组（一个折中方向）：LLM 一次性生成一段\u0026quot;不需要逻辑硬分叉\u0026quot;的动作序列，中间只做异常捕获，真正需要决策的错误才抛给 LLM。这样既减少了推理次数，又保留了 LLM 在关键节点的决策能力。\n这四种范式在效率、trajectory 长度、LLM 能力利用率、SFT 数据质量上各有取舍，没有最优解，选哪种取决于研究目标。\nV2：去掉 Python Skill 层，走向 Low-Code V1 的双层 Python 抽象在工程交付场景下是合理的，但对于当前的研究目标来说是过度设计——UR10 在这个项目里需要做的动作并不复杂，维护两层类的成本高，LLM 的能力也没有被真正测试。\nV2 的核心改动是：把 Skill 的 Python 编码换成自然语言描述的 skill.md 文件，Executor 直接调用 Primitive。\n这个改动服务于两个目标：\n真正的 Low-Code：新增一个技能不需要写 Python 类，只需要写一个描述文件，告诉 LLM 这个技能应该按什么顺序调用哪些 Primitive，需要注意什么边界条件。理想状态下，LLM 读取 skill.md 后可以自主组合 Primitive 完成任务，甚至通过观察执行轨迹归纳出新的 skill 描述——相当于让 LLM 自己写说明书。\n更长、更有价值的 trajectory：V2 让 LLM 直接面对 Primitive 级别的决策，trajectory 更长，更能反映模型的指令遵循能力。配合 Genesis 仿真的大规模并行能力，可以收集到更有价值的 agentic 训练数据。\n为了支持 V2 的感知决策，还在规划增加 Perceptron 层——传感器接口，让 LLM 能查询\u0026quot;夹爪现在是否抓住了东西\u0026rdquo;、\u0026ldquo;零件是否到位\u0026quot;等状态，而不只是盲目执行运动序列。这样 skill.md 里可以自然地描述检测步骤，LLM 也有机会在执行中途做判断，例如执行一次抓取后立即检测是否成功，而不是等整个序列跑完才发现失败。\n一次推理生成完整执行序列（类似 Code-as-Policy 的思路）也在考虑范围内：可以测试模型对 skill.md 的遵循程度，减少推理轮次，代价是减少了中途的环境感知机会。这个 tradeoff 需要在实验中评估。\n当前的技术债 LLM 恢复结果不透明：Executor 的 LLM 恢复循环成功时，现在靠\u0026quot;没有抛 escalation 异常\u0026quot;来判断成功，并构造一个 SkillResult(success=True, message=\u0026quot;Recovered by LLM retry.\u0026quot;)。实际执行结果没有被真正捕获。正确做法是 intercept 工具调用，把实际的 SkillResult 写回来。\n规划历史的消息累积：Supervisor 和 Planner 每轮产生的消息会写入 messages。replan 路径触发重规划时，历史消息还在，下一轮 Supervisor 会看到之前的执行上下文——这在某些场景下是有用的，但也可能引入噪音。RemoveMessage 清理的 ID 范围需要精确设计，实现还没完成。\nGenesis 迁移：当前仿真环境是项目遗留的 RoboDK + UR10，后续迁移到 Genesis 主要是为了大规模 trajectory 生成。SkiLib 的分层设计让这个迁移只需要重新实现 Primitive 层，但具体的 API 映射工作还没开始。\n这个系统从一个实习项目的工程实现出发，走到了一个关于\u0026quot;LLM 应该怎样学会操控机器人\u0026quot;的研究问题上。两层 Python 抽象适合工程交付，但对于研究而言，更有价值的可能恰恰是让 LLM 直面更复杂的决策情境——怎么设计这个情境，怎么收集有价值的 trajectory，是接下来要认真对待的问题。\n","date":"2026-04-15T15:35:00+08:00","image":"/p/skiagent/system_architecture.png","permalink":"/p/skiagent/","title":"RoboSkiAgent: 用 LLM 驱动工业机器人"},{"content":"Nihao! どうも\n总之这是此站的第一篇文章..只是为了跑通config还有部署流程存在的。总之，看了看朋友五年多的天文摄影笔记感慨颇深，恰好也受够了notion等网站的封闭性，试着自己搭一个笔记纯粹为了自己记录。主要因为都是静态的笔记，GitHub Pages也完全够用了，蛮省事的。\n","date":"2026-04-15T13:45:00+08:00","image":"cover.jpg","permalink":"/p/hello-world/","title":"Hello World"}]