Spark Dataframe列转行实战:宽表转长表的三种方案与封装
上个月我接了一个需求数据团队给了一张季度指标宽表每个业务线一行后面跟了2023年四个季度、2024年两个季度的六列指标。需求方说想看这六个季度之间的环比趋势说得很轻松——可我一打开表就明白宽表根本没法直接画趋势线。像这种把多列摊成键值对长表的操作在Spark里就叫列转行英文社区叫unpivot也有人直接说wide to long。这正是本篇要解决的问题在Spark Dataframe上做列转行把一行业务线、多列指标变成一行业务线、一列季度、一列数值的标准长表。如果你也经常和宽表、透视表打交道或者刚接手一堆需要聚合分析的项目数据这篇文章应该能帮你省下不少折腾时间。我第一次接触这个需求的时候第一反应是这不就是pivot反过来吗但真上手才发现Spark的Dataframe API里并没有一个直观的unpivot方法老版本里甚至只能用stack表达式硬拼新版本3.4倒是有了原生unpivot但很多人项目还在用老版本。这篇博文就把我实际用过的三种方案、踩过的坑、最后封装的通用函数完整写出来配合代码和踩坑记录你可以直接抄到项目里用。1. 宽表转长表的需求从哪来1.1 一个具体的宽表困局先还原一下我遇到的那张表简化之后结构是这样的业务线q1_2023q2_2023q3_2023q4_2023q1_2024q2_2024华东区100120140160180200华南区8090110130150170看着挺整齐可真要做趋势分析就麻烦大了。想算某个业务线所有季度的平均值得在SQL里把六列加起来除以6写一大堆(q1_2023 q2_2023 ...) / 6想按季度分组对比各业务线更是无从下手。数据可视化工具通常只认维度列 指标列这种宽表喂进去折线图都画不出来。我当时的需求是把六列季度数值转成两列一列叫quarter存季度名称一列叫revenue存对应数值。转换后每一行就是一个业务线在某个季度的记录这样无论是groupBy聚合还是直接扔给报表工具都顺理成章。这里要特别说明一下术语列转行unpivot和行转列pivot是互为镜像的操作。行转列是把长表里的键值对摊开成多列比如把quarter列的各个值变成表头的列列转行则是反过来把多列折叠成一个键列加一个值列。Spark自带的groupBy.pivot做行转列很熟练但列转行一直比较绕所以才有了这篇文章。1.2 为什么宽表在项目里无处不在可能你会问既然宽表这么不方便当初为啥要生成宽表道理也简单——宽表是从业务库或报表系统同步过来的原始形态很多上游系统为了展示方便就是按一个指标一列来存储的。数据仓库的同学有时候也会为了查询性能把多个维度的度量字段冗余到一张大宽表里。这就导致下游分析师拿到手的数据十有八九需要先做一步列转行才能进入正常的数据处理流程。所以在项目里列转行不是一个花活而是数据清洗环节的基础操作。掌握它的意义在于遇到任何多列同质指标的脏数据或宽表数据你都能快速把它整理成适合进一步分析的长表结构。2. 三种列转行实现stack、unpivot、explode选哪个2.1 stack一句话SQL几乎全版本通用stack是Spark SQL内置的生成器函数语法是stack(n, expr1, expr2, ..., exprn)它会把后面的表达式分成n行输出。核心用法是配合selectExpr使用。比如针对前面那张表可以这样写df.selectExpr( 业务线, stack(6, q1_2023, q1_2023, q2_2023, q2_2023, q3_2023, q3_2023, q4_2023, q4_2023, q1_2024, q1_2024, q2_2024, q2_2024) as (quarter, revenue) )运行完的结果就是长表业务线quarterrevenue华东区q1_2023100华东区q2_2023120.........stack函数第一个参数6表示要生成6行后面是成对出现的列名常量和列值引用。行数必须和参数对的个数对得上这个是硬性约束写错了直接报错。这个方案的优点是很直白Spark 2.x和3.x全版本都能跑缺点是表达式要手写列一多就特别啰嗦。2.2 unpivotSpark 3.4的新写法如果你的项目已经升级到Spark 3.4及以上那就有福了。Dataframe API直接提供了unpivot方法不需要拼SQL只要指定保留哪些列作为id列、哪些列作为待转换列、新键列名和新值列名df.unpivot( ids Array(业务线), values Array(q1_2023, q2_2023, q3_2023, q4_2023, q1_2024, q2_2024), variableColumnName quarter, valueColumnName revenue )这个写法和SQL标准里的UNPIVOT思想是一致的代码可读性高很多。但它有两个限制一是版本要求高很多生产集群还停在Spark 3.1或3.3用不了二是values必须显式列出来遇到几十上百列的大宽表依然要生成一个超长数组。所以我会说unpivot适合新项目、列数量可控的场景老项目还是得靠stack或者下面第三种方案。2.3 explode array当你的场景需要更多控制第三种方案是用array配合struct把多个列包装成数组再用explode拆开。思路是先把每个待转换列做成一个struct(列名, 列值)放进一个数组里然后炸开。import org.apache.spark.sql.functions._ df.withColumn( tmp, explode(array( struct(lit(q1_2023), col(q1_2023)), struct(lit(q2_2023), col(q2_2023)), struct(lit(q3_2023), col(q3_2023)), struct(lit(q4_2023), col(q4_2023)), struct(lit(q1_2024), col(q1_2024)), struct(lit(q2_2024), col(q2_2024)) )) ).select( col(业务线), col(tmp.col1).as(quarter), col(tmp.col2).as(revenue) )相比stack这种方式的优势在于每个列名/列值对本身就是一个结构体你可以在放进数组之前做条件过滤、类型转换甚至调整输出顺序。缺点也很明显语法比stack复杂想象一下六列就这样六十列会写到手软。所以它适合那些需要在列转行过程中嵌入更多业务逻辑的复杂场景而不是简单的批量转换。2.4 三种方案怎么选我用一个简表总结一下这几种方案的实际区别方便你按项目情况对号入座方案版本要求代码复杂度适用场景stack表达式Spark 2.x低但列多时噪音大老版本主力方案字段数在20个以内很推荐原生unpivotSpark 3.4最低新集群、列名固定、追求可读性explode arraySpark 2.x中等偏高需要在列转行时嵌入类型转换、条件逻辑等我在实际项目里默认选stack因为兼容性好逻辑直观出了问题也好排查。除非确认生产环境版本够新我才会考虑unpivot。3. 实战封装写一个通用列转行组件3.1 动态读取Schema从列名生成stack表达式手写stack表达式终究不是长久之计尤其是当你的宽表有几十个季度列、或者列名是程序自动生成的后缀时手动维护一个超长字符串就是灾难。我在项目里做了一件事读取Dataframe的schema自动过滤出需要转换的列然后动态拼接stack表达式。假设我们的宽表里id列叫业务线其他以q_开头的列都是待转换指标列那么动态生成的逻辑可以这样写import org.apache.spark.sql.DataFrame import org.apache.spark.sql.functions._ def unpivotWithStack( df: DataFrame, idCols: Seq[String], valueColPattern: String, keyColName: String, valueColName: String ): DataFrame { // 1. 根据列名前缀过滤出待转换列 val valueCols df.columns.filter(c c.startsWith(valueColPattern)).toSeq // 2. 生成stack表达式的列名常量, 列引用对 val stackPairs valueCols.flatMap { c Seq(s${c}, s${c}) }.mkString(, ) // 3. 拼接selectExpr val stackExpr sstack(${valueCols.size}, ${stackPairs}) as ($keyColName, $valueColName) df.selectExpr(idCols.map(c s${c}) : stackExpr: _*) }用的时候就是一行val longDf unpivotWithStack(df, Seq(业务线), q_, quarter, revenue)这里有两个细节值得注意。第一列名引用我用的是反引号包裹因为批量处理的宽表里很可能会出现中文列名、特殊字符甚至数字开头列名反引号能保证它们在SQL表达式里被正确解析。第二stack的第一参数是行数我直接用valueCols.size这样不管列数怎么变都不会出现数量不匹配的报错。3.2 类型统一与空值处理别让脏数据拆家如果你只是简单拼接stack表达式往往会遇到一个很隐蔽的坑stack要求所有列值表达式的类型一致。假设q1_2023是int类型q1_2024因为上游数据异常被推断成了string类型那selectExpr执行栈会直接抛类似cannot resolve ... due to data type mismatch的异常。解决办法很简单在动态生成表达式之前先把所有待转换列统一cast成同一种类型。因为指标列一般都要参与后续聚合计算我通常会统一cast成doubleval castedDf valueCols.foldLeft(df) { (tmpDf, colName) tmpDf.withColumn(colName, col(colName).cast(double)) }然后才是调用上面那个unpivotWithStack。这样处理之后类型不一致的坑一次解决。另一个关键问题是空值。stack函数会把null值也照常炸出一行也就是说某个业务线某个季度没有数据长表里就会多出一行quarternull或某季度、revenuenull的记录。如果后续聚合用的是avgnull会被忽略结果没问题但如果用的聚合函数是countnull就会变成计数的一部分导致统计口径错误。我习惯在列转行之后加一个过滤条件把值列为null的行去掉longDf.filter(col(revenue).isNotNull)这样下游无论怎么聚合都不会被null干扰。如果你希望保留null值做特殊业务标记那也可以反过来单独把null行挑出来打标签——这属于业务逻辑的取舍但数据清洗阶段我强烈建议至少把它显式处理掉不要让脏数据静默流到结果里。3.3 完整示例与输出效果把上面的片段拼起来一个完整的调用就是import org.apache.spark.sql.SparkSession val spark SparkSession.builder() .appName(UnpivotDemo) .master(local[*]) .getOrCreate() import spark.implicits._ val df Seq( (华东区, 100, 120, 140, 160, 180, 200), (华南区, 80, 90, 110, 130, 150, 170) ).toDF(业务线, q1_2023, q2_2023, q3_2023, q4_2023, q1_2024, q2_2024) val result unpivotWithStack(df, Seq(业务线), q_, quarter, revenue) .filter(col(revenue).isNotNull) result.show()输出----------------------- |业务线 |quarter |revenue| ----------------------- |华东区 |q1_2023 |100 | |华东区 |q2_2023 |120 | |华东区 |q3_2023 |140 | |华东区 |q4_2023 |160 | |华东区 |q1_2024 |180 | |华东区 |q2_2024 |200 | |华南区 |q1_2023 |80 | |华南区 |q2_2023 |90 | |华南区 |q3_2023 |110 | |华南区 |q4_2023 |130 | |华南区 |q1_2024 |150 | |华南区 |q2_2024 |170 | -----------------------到这一步宽表转长表的核心目标就达成了。后续无论是groupBy(quarter).avg(revenue)还是直接输出给报表工具都是水到渠成的事。4. 几种必须小心的列名和脏数据场景4.1 特殊字符列名、中文列名怎么处理真实项目的宽表列名往往长得不那么规整。我就遇到过列名叫2023Q1收入万元这种带括号、带中文单位、甚至数字开头的字段。这种列名如果直接拼到stack表达式里很容易让Spark的SQL解析器犯迷糊。前面动态方案里我用反引号包裹列名就是专门应对这种情况的。你可以把反引号理解成SQL世界的转义符——凡是列名里有特殊字符、空格、中文、数字开头用反引号包一层就对了。但还有一个容易忽略的点生成的键列值。stack表达式里每个列名常量也会成为长表里keyCol列的值如果这个常量里带了括号、单位、逗号后续查询同样麻烦。建议在生成${c}这个常量时顺手做一个清洗比如用正则把非字母数字的字符替换掉再把原始列名存成另一列作为溯源字段。这样既保证键值列干净又不丢失原始信息。这里给个参考实现把清洗逻辑加到动态生成里def cleanKeyName(colName: String): String { colName.replaceAll([^0-9a-zA-Z\\u4e00-\\u9fa5], _) }然后在拼接常量的时候用cleanKeyName(c)替换原始的c。实测下来下游无论是做报表还是做分析都能少踩很多字符串匹配的坑。4.2 空值语义保留还是剔除前面说了stack会把null一起炸出来但null的语义其实要分情况看。如果一张表里某个业务线某季度指标确实没有产生那null代表无数据剔除它会让长表更干净但如果null代表这个界面应该显示0那直接剔除就会丢掉信息导致同比环比算出来是空值。我遇到过的一个典型案例是接入了某外部渠道的数据该渠道在早期季度不产生数据但业务方希望长表里保留这些行只是标记一个channel_id方便之后找数据产品单独处理。这种时候null行就不能简单丢而是应该加一个标记列longDf.withColumn( is_missing, when(col(revenue).isNull, lit(1)).otherwise(lit(0)) )所以我的建议是先明确业务口径再决定null的去留。如果你只是做通用清洗组件默认剔除null但把开关做成参数暴露出来让下游调用方自己决定。这样做最灵活也最符合数据工程里通用组件少做隐式假设的实践。5. 性能真相与典型坑位5.1 为什么不要用多个union堆叠可能有人会想列转行嘛我用union一条条拼不行吗先select 业务线, q1_2023 as quarter, q1_2023 as revenue from df再union第二个季度的最后把所有union结果合并。技术上当然能跑但性能非常糟糕。原因在于union会把每个分支都当成一个独立的逻辑计划执行时会产生多份全量数据的复制和合并尤其是当宽表本身有几百GB甚至上TB时这种一条SQL挂几十个union的计划会让Spark的优化器非常难受执行时间可能膨胀到stack方案的十倍以上。stack方案本质上是在单个selectExpr里生成多行执行计划精简得多数据只需要扫描一遍就能完成行展开。用一句话总结列转行是纯行内数据重排根本不需要union这种重量级操作。5.2 大宽表场景的注意事项如果你要处理的宽表有上百个指标列stack表达式会很长这里有三个实战注意点。第一确保driver端的内存够用。动态拼接一个几百列的长表达式生成的字符串虽然不大但Spark会将整个表达式序列化到executor端表达式越长解析开销越大。我建议如果列数超过200可以在应用层分批次转或者直接改用explode array方式因为array方式在物理计划里更模块化。第二stack生成的行数会显著放大数据量。假如原来有1亿行、100个指标列转成长期末可能是100亿行。这不是计算上的瓶颈而是存储和下游读取的瓶颈一定要提前和下游确认好产出表的分区策略避免一次性写出超大文件导致任务OOM或下游查询卡死。第三如果源表本身有分区列列转行之前一定要检查待转换的列里是否混入了分区列。分区列的值在不同分区里范围不同一旦被误当成指标列展开会产生大量重复键后续聚合结果会完全失真。正确做法是将分区列放进idCols只把真正的度量列放进转换范围。5.3 踩坑记录表我把这段时间踩过的坑汇总成一张列表方便你对照自查现象根因解决办法selectExpr报data type mismatch多列类型不一致stack要求类型相同转列前统一cast成double长表出现大量null行stack把null值也炸出来了根据业务口径选择过滤或加标记中文列名导致解析失败列名有特殊字符生成表达式时用反引号包裹结果键列值含括号单位列名常量未清洗用正则清洗键列常量数据量被异常放大把分区列当成了指标列检查schema分区列加入idCols用了union导致任务极慢union多分支全量数据复制改用stack或explodearray这些坑单独看都不大但组合起来真的能让人在调试上耗掉一整天。提前规避比事后排查划算得多。6. 最后的实操体会写这篇文章的时候我又把老项目里那个列转行函数翻出来看了一眼。最初版本是用几十行union拼出来的后来重构成stack动态生成再后来新集群支持了unpivot我也在测试环境体验了一把原生API的爽快但生产代码还是不打算整体迁移——原因是依赖的Spark版本还没统一升级完stack方案虽然在写法上老派却胜在稳、兼容、好维护。如果你现在就打算在项目里落地列转行我给一个具体的行动顺序先在测试环境确认Spark版本如果等于或高于3.4且列数不多直接用unpivot代码最干净如果还在老版本把本文第三节的unpivotWithStack函数复制过去配合cast和null过滤基本就能覆盖大多数需求遇到特殊字符列名或超大宽表再参考第四节和第五节的注意事项做调整。最后再分享一个小技巧列转行的产出表写出去之后最好顺手做一个数据质量校验比如assert(长表行数 原表行数 * 指标列数)。别小看这一步它能在数据链路出问题时帮你瞬间定位是转换逻辑的问题还是上游数据的问题省下来的排查时间绝对物超所值。

相关新闻

mruby-struct 深入解析:mruby 中的 Struct 类定义、keyword_init 模式与模式匹配支持

mruby-struct 深入解析:mruby 中的 Struct 类定义、keyword_init 模式与模式匹配支持

编程语言语言运行时编译器解释器嵌入式 【免费下载链接】mruby Lightweight Ruby 项目地址: https://gitcode.com/gh_mirrors/mr/mruby 点击查看 免费下载 导读 mruby-struct 是 mruby 标准库中提供 Struct 类的 mrbgem。它允许你无需手写类定义,即可将…

2026/10/13 4:07:00 阅读更多 →
SHIB 250%暴涨背后:真假突破与链上数据的判断逻辑

SHIB 250%暴涨背后:真假突破与链上数据的判断逻辑

这几天,SHIB三个字母几乎是币圈社群里被刷得最多的词。一只柴犬主题的Meme币,价格在短时间窗口里拉出约百分之250的涨幅,于是“重大价格突破或即将到来”的说法,迅速从各个分析帖里蔓延开来。很多人心里其实都在打鼓:这…

2026/10/13 4:07:00 阅读更多 →
Linux页框回收机制深度解析:从水位线到回收路径

Linux页框回收机制深度解析:从水位线到回收路径

1. 内存明明够用,系统却在悄悄回收页框:从一次延迟尖峰说起1.1 现象:物理内存白白闲置,负载却出现换页先讲一个真实场景。前两年我维护一台容器节点,物理内存 128G,日常用量也就 60G 左右,内存看…

2026/10/13 4:07:00 阅读更多 →

最新新闻

Android ANR问题全链路分析:从判定机制到测试解决方案

Android ANR问题全链路分析:从判定机制到测试解决方案

我最早接触ANR,是线上用户反馈一个列表页“点了没反应”,过一会儿系统弹窗“进程无响应”。当时拿着一个logcat日志,翻来覆去只能看到一行“ANR in”,完全不知道怎么下手。后来带测试团队、又参与线上性能治理,踩了无数…

2026/10/13 4:56:18 阅读更多 →
微信小程序视频点播系统:从技术选型到部署答辩全攻略

微信小程序视频点播系统:从技术选型到部署答辩全攻略

每年到毕业设计选题的时候,"基于微信小程序的视频点播系统"都会出现在很多人的备选清单里。这个题目听起来很友好,一个小程序端负责展示和播放,一个管理端负责传视频、管分类,但真从设计走到实现,不少人会发…

2026/10/13 4:56:18 阅读更多 →
多租户NPU隔离:PASID与IOMMU如何锁住DMA越界

多租户NPU隔离:PASID与IOMMU如何锁住DMA越界

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 4:56:18 阅读更多 →
智能合约可升级性实战:透明代理、delegatecall与存储布局全解析

智能合约可升级性实战:透明代理、delegatecall与存储布局全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 4:56:18 阅读更多 →
Photoshop CS6解压即用版全攻略:绿化原理与启动故障排查

Photoshop CS6解压即用版全攻略:绿化原理与启动故障排查

简介:Photoshop CS6绿色免安装打包版本,压缩包采用zip格式,容量约83.1MB,面向需要快速获得完整图像编辑能力的设计师、摄影师及初学者,解压后即可启动主程序,省去官方安装包的下载、安装及激活流程。压缩包…

2026/10/13 4:56:18 阅读更多 →
AI画图总翻车?四万星开源项目教你用约束策略告别Mermaid Slop

AI画图总翻车?四万星开源项目教你用约束策略告别Mermaid Slop

1. 当AI画的架构图开始“辣眼睛”,这个四万星项目到底在解决什么如果你最近一年用过任何主流大模型来生成架构图、流程图或者时序图,大概率经历过这样一种崩溃:你满怀期待地输入一段描述,AI 信心满满地吐出一大段 Mermaid 代码&am…

2026/10/13 4:55:17 阅读更多 →

日新闻

物联网系统集成避坑指南:从选型到验收的关键要点

物联网系统集成避坑指南:从选型到验收的关键要点

做了这么多年物联网系统集成的项目,我最大的感受是,大部分坑不是出在技术前沿,而是出在一些看起来很平常的地方。设备选了便宜的、协议没问清楚、现场没摸透、验收标准写得含糊——这些细枝末节,最后都会变成延期、超支和扯皮的大…

2026/10/13 0:00:57 阅读更多 →
ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

如果你玩ESP32有一阵子了,很难不碰到舵机控制这道坎。做智能小车要转方向,做机械臂要抬关节,做云台要偏航俯仰,背后都是同一个东西在干活:舵机。而我见过太多人卡在“代码能编译、舵机就是不听话”这个阶段&#xff0c…

2026/10/13 0:00:57 阅读更多 →
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

1. 为什么CAN总线至今仍是工业现场的“硬通货”?——从汽车底盘到智能温室的真实需求你有没有拆过一辆老款燃油车的中控台?或者调试过某高校实验室里那台跑着Modbus协议却频频丢帧的PLC控制柜?又或者,在某个农业物联网项目里&…

2026/10/13 0:00:57 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/13 2:54:56 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/12 0:16:38 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 21:05:09 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 14:48:49 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 18:11:17 阅读更多 →