开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

SoCC 论文解读:字节跳动如何在大规模集群中进行统一资源调

发表于 2024-04-19

公众号品牌.png

作为字节跳动在离线混部场景中最核心的调度系统,Gödel 提供丰富的资源 QoS 管理能力,可以统一调度在线和离线应用,极大提升资源利用率。

来源 | 字节跳动基础架构团队

开源 | github.com/kubewharf/godel-scheduler

本文解读了字节跳动基础架构编排调度团队发表在国际云计算顶级会议 SoCC 2023 上的论文“Gödel: Unified Large-Scale Resource Managment and Scheduling at Bytedance”。

图片

论文链接: dl.acm.org/doi/proceed…

论文介绍了字节跳动内部基于 Kubernetes 提出的一套支持在线任务和离线任务混部的高吞吐任务调度系统,旨在有效解决大规模数据中心中不同类型任务的资源分配问题,提高数据中心的资源利用率、弹性和调度吞吐率。

目前,该调度系统支持管理着数万节点的超大规模集群,提供包括微服务、batch、流式任务、AI 在内的多种类型任务的资源并池能力。自 2022 年开始在字节跳动内部各数据中心批量部署,Gödel 调度器已经被验证可以在高峰期提供 >60% 的 CPU 利用率和 >95% 的 GPU 利用率,峰值调度吞吐率接近 5,000 pods/sec。

引言

在过去的几年里,随着字节跳动各业务线的高速发展,公司内部的业务种类也越来越丰富,包括微服务、推广搜(推荐/广告/搜索)、大数据、机器学习、存储等业务规模迅速扩大,其所需的计算资源体量也在飞速膨胀。早期字节跳动的在线业务和离线业务有独立的资源池,业务之间采用分池管理。为了应对重要节日和重大活动时在线业务请求的爆炸性增长,基础设施团队往往需要提前做预案,将部分离线业务的资源拆借到在线业务的资源池中。虽然这种方法可以应对一时之需,但不同资源池之间的资源拆借流程长,操作复杂,效率很低。同时,独立的资源池导致在离线业务之间混部成本很高,资源利用率提升的天花板也非常有限。为了应对这一问题,论文中提出了在离线统一调度器 Gödel,旨在使用同一套调度器来统一调度和管理在离线业务,实现资源并池,从而在提升资源利用率和资源弹性的同时,优化业务成本和体验,降低运维压力。Gödel 调度器基于 Kubernetes 平台,可以无缝替换 Kubernetes 的原生调度器,在性能和功能上优于 Kubernetes 原生调度器和社区中其他调度器。

开发动机

字节跳动运营着数十个超大规模的多集群数据中心,每天有数以千万计容器化的任务被创建和删除,晚高峰时单个集群的平均任务吞吐 >1000 pods/sec。这些任务的业务优先级、运行模式和资源需求各不相同,如何高效、合理地调度这些任务,在保证高优任务 SLA 和不同任务资源需求的同时维持较高的资源利用率和弹性是一项很有挑战的工作。

图片

通过调研,目前社区常用的集群调度器都不能很好地满足字节跳动的要求:

  • Kubernetes 原生调度器虽然很适合微服务调度,也提供多种灵活的调度语义,但是它对离线业务的支持不尽如人意,同时因为 Kubernetes 原生调度器调度吞吐率低(< 200 pods/sec),支持的集群规模也有限(通常 <= 5000 nodes),它也无法满足字节跳动内部庞大的在线业务调度需求。
  • CNCF 社区的 Volcano 是一款主要针对离线业务的调度器,可以满足离线业务(e.g. batch, offline training 等)的调度需求(e.g. Gang scheduling)。但是其调度吞吐率也比较低,而且不能同时支持在线业务。
  • YARN 是另一款比较流行的集群资源管理工具,在过去很长一段时间一直是离线业务调度的首选。它不仅对 batch、offline training 等离线业务所需的调度语义有很好的支持,而且调度吞吐率也很高,可以支持很大规模的集群。但其主要弊端是对微服务等在线业务的支持不好,不能同时满足在线和离线业务的调度需求。

图片

因此,字节跳动希望能够开发一款结合 Kubernetes 和 YARN 优点的调度器来打通资源池、统一管理所有类型的业务。基于上述讨论,该调度器被期望具有下述特点:

  • Unified Resource Pool

集群中的所有计算资源对在线和离线的各种任务均可见、可分配。降低资源碎片率,和集群的运维成本。

  • Improved Resource Utilization

在集群和节点维度混部不同类型、不同优先级的任务,提高集群资源的利用率。

  • High Resource Elasticiy

在集群和节点维度,计算资源可以在不同优先级的业务之间灵活且迅速地流转。在提高资源利用率的同时,任何时候都保证高优业务的资源优先分配权和 SLA。

  • High Scheduling Throughput

相比于 Kubernetes 原生调度器和社区的 Volcano 调度器,不论是在线还是离线业务都要大幅提高调度吞吐率。满足 > 1000 pods/sec 的业务需求。

  • Topology-aware Scheduling

在做调度决策时而不是 kubelet admit 时就识别到候选节点的资源微拓扑,并根据业务需求选择合适的节点进行调度。

Gödel 介绍

Gödel Scheduler 是一个应用于 Kubernetes 集群环境、能统一调度在线和离线业务的分布式调度器,能在满足在离线业务功能和性能需求的前提下,提供良好的扩展性和调度质量。如下图所示,Gödel Scheduler 和 Kubernetes 原生调度器的结构类似,由三个组件组成:Dispatcher、Scheduler 和 Binder。不一样的是,为了支持更大规模的集群和提供更高的调度吞吐,它的 Scheduler 组件可以是多实例的,采用乐观并发调度, Dispatcher 和 Binder 则是单实例运行。图片

核心组件

Dispatcher 是整个调度流程的入口,主要负责任务排队、任务分发、节点分区等工作。它主要由几个部分构成:Sorting Policy Manager、Dispatching Policy Manager、Node Shuffler、Scheduler Maintainer。

图片

  • Sort Policy Manager:主要负责对任务进行排队,现在实现了 FIFO、DRF、FairShare 等排队策略,未来会添加更多排队策略,如:priority value based 等。
  • Dispatching Policy Manager:主要负责分发任务到不同的 Scheduler 实例,通过插件化配置支持不同的分发策略。现阶段的默认策略是基于 LoadBalance。
  • Node Shuffler:主要负责基于 Scheduler 实例个数,对集群节点进行 Partition 分片。每个节点只能在一个 Partition 里面。每个 Scheduler 实例对应一个 Partition,一个 Scheduler 实例工作的时候会优先选择自己 Partition 内的节点,没有找到符合要求的节点时才会去找其他 Partition 的节点。如果集群状态发生变化,例如增加或者删除节点,又或者 Scheduler 个数改变,node shuffle 会基于实际情况重新划分节点。
  • Scheduler Maintainer:主要负责对每个 Scheduler 实例状态进行维护,包括 Scheduler 实例健康状况、负载情况、Partition 节点数等。

Scheduler 从Dispatcher 接收任务请求,负责为任务做出具体的调度和抢占决策,但是不真正执行。和 Kubernetes 原生调度器一样,Gödel 的 Scheduler 也是通过一系列不同环节上的 plugins 来决定一个调度决策,例如通过下面两个 plugins 来寻找符合要求的节点。

  • Filtering plugins:基于任务的资源请求,过滤掉不符合要求的节点;
  • Scoring plugins:对上面筛选出来的节点进行打分,选出最合适的节点。

和 Kubernetes 原生调度器不同的是,Gödel 的 Scheduler 允许多实例分布式运行。对于超大规模的集群和对高吞吐有要求的场景,我们可以配置多个 scheduler 实例来满足需求。此时每个 scheduler 实例独立、并行地进行调度,选择节点时,优先从该实例所属的 partition 中选择,这样性能更好,但只能保证局部最优;本地 partition 没有合适的节点时,会从其他实例的 partition 中选择节点,但这可能会引起 conflict,即多个 scheduler 实例同时选中同一个节点,scheduler 实例数量越多,发生 conflict 的几率越大。因此,要合理设置实例的数量,不是越多越好。

另外,为了同时支持在线和离线任务,Gödel Scheduler 采用了两层调度语义,即支持代表 Pod Group 或 ReplicaSet 等业务部署的 Scheduling Unit 和 Pod 的 Running Unit 的两级调度。具体用法将在后面介绍。

Binder 主要负责乐观冲突检查,执行具体的抢占操作,进行任务绑定前的准备工作,比如动态创建存储卷等,以及最终执行绑定操作。总的来说,它和 Kubernetes 的 Binder 工作流程类似,但在 Gödel 中,Binder 要处理更多由于多 Scheduler 实例导致的冲突。一旦发现冲突,立即打回,重新调度。对于抢占操作,Binder 检查是否存在多个 Schduler 实例尝试抢占同一个实例(i.e. Victim Pod)。如果存在这样的问题,Binder 只处理第一个抢占并拒绝其余 Schduler 实例发出的抢占诉求。对于 Gang/Co-scheduling 而言,Binder 必须为 Pod Group 中的所有 Pod 处理冲突(如果存在的话)。要么所有 Pod 的冲突都得到解决,分别绑定每个 Pod;要么拒绝整个Pod Group 的调度。

CNR 代表 Custom Node Resource,是字节跳动为补充节点实时信息创建的一个 CRD。它虽然本身不是 Gödel Scheduler 的一部分,但可以增强 Gödel 的调度语义。该 CRD 不仅定义了一个节点的资源量和状态,还定义了资源的微拓扑,比如 dual-socket 节点上每个 socket 上的 CPU/Memory 消耗量和资源剩余量。使得调度器在调度有微拓扑亲和需求的任务时,可以根据 CNR 描述的节点状态筛选合适的节点。

相比于只使用 topology-manager 的原生 Kubernetes,使用 CNR 可以避免将 Pod 调度到不满足 topology 限制的节点上时 kubelet 碰到的 scheduling failure。如果一个 Pod 成功地在节点上创建,CNR 将会被隶属于 Katalyst 的 node agent 更新。

相关阅读:《Katalyst:字节跳动云原生成本优化实践》

两层调度

字节跳动在设计 Gödel 之初,一个主要的目标就是能够同时满足在线和离线业务的调度需求。为了实现这一目标,Gödel 引入了两层调度语义,即 Scheduling Unit 和 Running Unit。

前者对应一个部署的 job,由一个或多个 Running Unit 组成。例如,当用户通过 Kubernetes Deployment 部署一个 job 时,这个 job 映射为一个 Scheduling Unit,每个运行 task 的 Pod 对应一个 Running Unit。和原生 Kubernetes 直接面向 Pod 的调度不同,Gödel 的两级调度框架会始终以 Scheduling Unit 的整体状态为准入原则。当一个 Scheduling Unit 被认为可调度时,其包含的 Running Unit(i.e. Pod)才会被依次调度。

判断一个 Scheduling Unit 是否可调度的规则是有 >= Min_Member 个 Running Unit 满足调度条件,即调度器能够为一个 job 中足够多的 Pod 找到符合资源要求的节点时,该 job 被认为是可以被调度的。此时,每个 Pod 才会被调度器依次调度到指定的节点上。否则,所有的 Pod 均不会被调度,整个 job 部署被拒绝。

可以看出,Scheduling Unit 的 Min_Member 是一个非常重要的参数。设置不同的 Min_Member 可以应对不同场景的需求。Min_Member 的取值范围是[1, Number of Running Units]。

比如,当面向微服务的业务时,Min_Member 设置为 1。每个 Scheduling Unit 中只要有一个 Running Unit/Pod 的资源申请能够被满足,即可进行调度。此时,Gödel 调度器的运行和原生 Kubernetes 调度器基本一致。

当面向诸如 Batch、offline training 等需要 Gang 语义的离线业务时,Min_Member 的值等于 Running Unit/Pod 的个数(有些业务也可以根据实际需求调整为 1 到 Number of Running Units 之间的某个值),即所有 Pod 都能满足资源请求时才开始调度。Min_Member 的值会根据业务类型和业务部署 template 中的参数被自动设置。

性能优化

因为字节跳动自身业务的需求,对调度吞吐的要求很高。Gödel 的设计目标之一就是提供高吞吐。为此,Gödel 调度器把最耗时的筛选节点部分放在可并发运行的多实例 Scheduler 中。一方面因为多实例会碰到 conflict 的原因,Schduler 的实例数量不是越多越好;另一方面仅仅多实例带来的性能提高不足以应对字节单一集群上晚高峰 1000 - 2000 pods/s 的吞吐要求。为了进一步提高调度效率,Gödel 在以下几个方面做了进一步优化。

  • 缓存候选节点

在筛选节点的过程中,Filter 和 Prioritize 是最耗时的两个部分。前者根据资源请求筛选可用的节点,后者给候选节点打分寻找最适宜的节点。如果这两个部分的运行速度能够提高,则整个调度周期会被大幅压缩。

字节跳动开发团队观察到,虽然计算资源被来自不同业务部门的不同应用所使用,但是来自某一个业务用户的某个应用的所有或者大部分 Pods 通常有着相同的资源诉求。

例:某个社交 APP 申请创建 20,000 个 HTTP Server,每个 Server 需要 4 CPU core 和 8GB 内存。某个 Big Data 团队需要运行一个拥有 10,000 个子任务的数据分析程序,每个子任务需要 1 CPU core 和 4GB 内存。

这些大量创建的任务中多数 Pod 拥有相同的资源申请、相同的网段和设备亲和等需求。那么 Filter Plugin 筛选出来的候选节点符合第一个 Pod 的需求,也大概率满足该任务其他 Pod 的需求。

因此,Gödel 调度器会在调度第一个 Pod 后缓存候选节点,并在下一轮调度中优先从缓存中搜索可用的节点。除非集群状态发生变化(增加或删除节点)或者碰到不同资源诉求的 Pod,不需要每一轮都重新扫描集群中的节点。在调度的过程中没有资源可分配的节点会被移除缓存,并根据集群状态调整排序。这一优化可以明显优化节点筛选的过程,当调度同一个业务用户的一组 Pod 时,理想情况下可以把时间复杂度从 O(n) 降低到 O(1) 。

  • 降低扫描节点的比例

虽然上述优化可以降低候选节点的构建过程,但是如果集群状态或者资源申请发生变化,还是要重新扫描集群所有节点。

为了进一步降低时间开销,Gödel 调整了候选列表的扫描比例,用局部最优解作为全局最优解的近似替代。因为调度过程中需要为所有 Running Units/Pods 找到足够的候选节点,Gödel 至少会扫描 # of Running Units 个数的节点,根据历史数据的分析,Gödel 默认扫描 # of Running Units + 50 个节点来寻找候选节点。如果没有找到合适的,会再扫描相同的个数。该方法结合候选节点缓存,会大大降低调度器为Pod寻找合适节点的时间开销。

  • 优化数据结构和算法

除了上述两个优化外,Gödel 调度器还不断对数据结构和算法进行优化:

为了可以低成本地维护候选节点列表,避免频繁重建节点列表产生的开销。Gödel 重构了原生 Kubernetes 调度器的 NodeList 维护机制,通过离散化节点列表的方式解决了超大规模生产集群出现的性能问题,并以更低的开销获得了更好的节点离散效果;

为了提高整体资源利用率,字节跳动将高优的在线任务和低优的离线任务混合部署。由于业务的潮汐特点,晚高峰时伴随着大量在线业务的返场,往往需要高频地抢占低优的离线业务。抢占过程涉及到大量的搜索计算,频繁抢占严重地影响了调度器的整体工作效率。为了解决这一问题,Gödel 调度器引入了基于 Pod 和 Nodes 的多维剪枝策略,使得抢占吞吐能够快速回升、抢占时延大幅降低。

实验结果

论文评估了 Gödel 调度器在调度吞吐、集群规模等方面的性能。

首先,对于微服务业务,字节跳动将 Gödel(单实例)与 Kubernetes 原生调度器进行了对比。在集群规模上,原生 Kubernetes 默认最大只能支持 5,000 节点的集群,最大调度吞吐小于200 Pods/s。在使用字节开源的高性能 key-value store - KubeBrain 后,原生 Kubernetes 可以支持更大规模的集群,调度吞吐也明显提高。但 Kubernetes + KubeBrain 组合后的性能仍然远小于 Gödel。Gödel 在 5,000 节点规模的集群上可以达到 2,600 Pods/s 的性能,即使在 20,000 节点时仍然有约 2,000 Pods/s,是原生 Kubernetes 调度器性能的 10 倍以上。

为了取得更高的调度吞吐,Gödel 可以开启多实例。下面右图中描述的是 10,000 节点的集群中依次开启 1-6个 调度器实例,开始阶段吞吐逐渐增加,峰值可以达到约 4,600 Pods/s。但当实例数超过 5 个后,性能有所下降,原因是实例越多,实例间的冲突越多,影响了调度效率。所以,并不是调度实例越多越好。

图片

对于有 Gang 语义需求的离线任务,论文将 Gödel 和开源社区常用的 YARN 和 K8s-volcano 进行对比。可以明显看出,Gödel 的性能不但远远高于 K8s-volcano,也接近两倍于 YARN。Gödel 支持同时调度在线和离线任务,论文通过改变系统中提交的在离线任务的比例来模拟不同业务混部时的场景。可以看出,不论在离线业务的比例如何,Gödel的性能都比较稳定,吞吐维持在 2,000 Pods/s 左右。

图片

为了论证为什么 Gödel 会有如此大的性能提高,论文着重分析了两个主要的优化“缓存候选节点”和“降低扫描比例”产生的贡献。如下图所示,依次使用完整版 Gödel、只开启节点缓存优化的 Gödel 和只开启降低扫描比例的 Gödel 来重复前面的实验,实验结果证明,这两个主要的优化项分别贡献了约 60% 和 30% 的性能提升。

图片

除了用 benchmark 来评估 Gödel 的极限性能,论文还展示了字节跳动在生产环境中使用 Gödel 调度器带来的实际体验,表现出 Gödel 在资源并池、弹性和流转方面具备良好的能力。

下面左图描述的是某集群在某段时间内在线任务和离线任务的资源分配情况。开始阶段,在线任务消耗的资源不多,大量计算资源被分配给优先级较低的离线任务。当在线任务由于某个特殊事件(突发事件、热搜等)导致资源需求激增后,Gödel 立刻把资源分配给在线任务,离线任务的资源分配量迅速减少。当高峰过后,在线任务开始降低资源请求,调度器再次把资源转向离线任务。通过在离线并池和动态资源流转,字节跳动可以一直维持较高的资源利用率。晚高峰时间,集群的平均资源率达到 60%以上,白天波谷阶段也可以维持在 40% 左右。

图片

总结及未来展望

论文介绍了字节跳动编排调度团队设计和开发的统一在离线资源池的调度系统 Gödel。该调度系统支持在超大规模集群中同时调度在线和离线任务,支持资源并池、弹性和流转,并拥有很高的调度吞吐。Gödel 自 2022 年在字节跳动自有数据中心批量上线以来,满足了内场绝大部分业务的混部需求,实现了晚高峰 60% 以上的平均资源利用率和约 5,000 Pods/s 的调度吞吐。

未来,编排调度团队会继续推进 Gödel 调度器的扩展和优化工作,进一步丰富调度语义,提高系统响应能力,降低多实例情况下的冲突概率,并且会在优化初次调度的同时,构建和加强系统重调度的能力,设计和开发 Gödel Rescheduler。通过 Gödel Scheduler 和 Rescheduler 的协同工作,实现全周期内集群资源的合理分配。

Gödel 调度器目前已开源,真诚欢迎社区开发者和企业加入社区,与我们一起参与项目共建,项目地址:github.com/kubewharf/g…!

kubewharf 小助手.png

扫码加入字节跳动开源社群

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

AI+前端 —— 实现图片识别功能

发表于 2024-04-18

前言

在当今的技术环境中,人工智能(AI)与前端开发的融合越来越普遍。其中一个显著的应用是在图像识别中,AI算法可以检测和标记图像中的对象,增强用户体验,并在网站或应用程序上实现创新功能。

下面将给出完成图片识别功能简易代码示例与讲解:

先决条件: 在继续之前,请确保您对HTML、CSS和JavaScript有基本的了解。另外,熟悉与AI和图像处理相关的概念将会有所帮助。

Step1:导入必要的模块

1
2
js复制代码import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0"
env.allowLocalModels = false;
  • 此代码从CDN(内容传送网络)中导入必要的模块。pipeline 和 env 是从 @xenova/transformers 包中导入的。
  • env.allowLocalModels = false; 将环境变量 allowLocalModels 设置为 false,表示不允许使用本地模型。

Step2:文件上传事件监听器

1
2
3
4
5
js复制代码const fileUpload = document.getElementById('file-upload');
const imageContainer = document.getElementById('image-container')
fileUpload.addEventListener('change', function (e) {
// 当选择文件时触发的事件监听器
});
  • 此部分代码从HTML文档中选择文件上传输入框和图像容器。
  • 它向文件上传输入框添加了一个事件监听器,当选择文件时触发一个函数。

Step3:FileReader 读取上传的图像

1
2
3
4
5
js复制代码const reader = new FileReader();
reader.onload = function (e2) {
// 文件读取完成时执行的函数
};
reader.readAsDataURL(file)
  • 当选择文件时,创建了一个 FileReader 对象。
  • 一个 onload 事件监听器被附加到读取器上,当文件读取完成时执行一个函数。
  • readAsDataURL 方法在读取器上被调用,将所选文件的内容读取为数据URL。

Step4:显示上传的图像

1
2
3
4
js复制代码javascriptCopy code
const image = document.createElement('img');
image.src = e2.target.result;
imageContainer.appendChild(image)
  • 在 onload 函数内部,创建了一个 <img> 元素。
  • 图像的 src 属性设置为读取文件作为数据URL的结果。
  • 图像元素被追加到HTML文档中的图像容器中。

Step5:启动AI检测

1
js复制代码detect(image)
  • 在显示上传的图像后,使用上传的图像作为参数调用 detect 函数。

Step6:使用AI模型进行对象检测

1
2
3
4
5
js复制代码const detector = await pipeline("object-detection", "Xenova/detr-resnet-50")
const output = await detector(image.src, {
threshold: 0.1,
percentage: true
})
  • 使用 pipeline 函数从指定的模型("Xenova/detr-resnet-50")实例化一个对象检测模型("object-detection")。
  • 使用 await 等待 detector 对象,确保模型完全加载后再继续。
  • 然后使用 detector 对象在上传的图像上执行对象检测。
  • output 包含对象检测任务的结果。

Step7:渲染检测到的框

1
js复制代码output.forEach(renderBox)
  • 对输出中检测到的每个对象,调用 renderBox 函数以渲染边界框。

Step8:渲染边界框

1
2
3
js复制代码function renderBox({ box, label }) {
// 渲染边界框的函数
}
  • renderBox 函数接受一个具有 box(边界框的坐标)和 label(检测到的对象的标签)的对象。
  • 在函数内部,创建一个 <div> 元素来表示边界框。
  • 应用 CSS 样式来根据检测到的对象的坐标来定位和样式化边界框。
  • 创建一个 <span> 元素来在边界框内显示检测到的对象的标签。

完整代码

1
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
js复制代码<!--
* @func 文件上传和对象检测功能
* @desc 实现了图片上传功能,并利用Transformer模型进行对象检测,并在图片上标记检测到的对象
* @author [Your Name]
* @data 2024-04-17
-->
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>nlp之图片识别,两种语言</title>
<!-- CSS 样式 -->
<style>
.container {
margin: 40px auto;
width: max(50vw, 400px);
display: flex;
flex-direction: column;
align-items: center;
}

.custom-file-upload {
display: flex;
align-items: center;
cursor: pointer;
gap: 10px;
border: 2px solid black;
padding: 8px 16px;
border-radius: 6px;
}

#file-upload {
display: none;
}

#image-container {
width: 100%;
margin-top: 20px;
position: relative;
}

#image-container>img {
width: 100%;
}

.bounding-box {
position: absolute;
box-sizing: border-box;
}

.bounding-box-label {
position: absolute;
color: white;
font-size: 12px;
}
</style>
</head>

<body>
<!-- 页面主体内容 -->
<main class="container">
<label for="file-upload" class="custom-file-upload">
<input type="file" accept="image/*" id="file-upload">
上传图片
</label>
<div id="image-container"></div>
<p id="status"></p>
</main>

<!-- JavaScript 代码 -->
<script type="module">
// 导入transformers nlp任务的pipeline和env对象
import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@xenova/transformers@2.6.0"
// 允许本地模型
env.allowLocalModels = false;

// 获取文件上传和图片容器元素
const fileUpload = document.getElementById('file-upload');
const imageContainer = document.getElementById('image-container')

// 监听文件上传事件
fileUpload.addEventListener('change', function (e) {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = function (e2) {
const image = document.createElement('img');
image.src = e2.target.result;
imageContainer.appendChild(image)
detect(image)
}
reader.readAsDataURL(file)
})

// 获取状态信息元素
const status = document.getElementById('status');

// 检测图片的AI任务
const detect = async (image) => {
status.textContent = "分析中..."
const detector = await pipeline("object-detection", "Xenova/detr-resnet-50")
const output = await detector(image.src, {
threshold: 0.1,
percentage: true
})
output.forEach(renderBox)
}

// 渲染检测框函数
function renderBox({ box, label }) {
const { xmax, xmin, ymax, ymin } = box
const boxElement = document.createElement("div");
boxElement.className = "bounding-box"
Object.assign(boxElement.style, {
borderColor: '#123123',
borderWidth: '1px',
borderStyle: 'solid',
left: 100 * xmin + '%',
top: 100 * ymin + '%',
width: 100 * (xmax - xmin) + "%",
height: 100 * (ymax - ymin) + "%"
})

const labelElement = document.createElement('span');
labelElement.textContent = label;
labelElement.className = "bounding-box-label"
labelElement.style.backgroundColor = '#000000'

boxElement.appendChild(labelElement);
imageContainer.appendChild(boxElement);
}
</script>
</body>

</html>

效果图

还需要调整参数,加强精确度

image.png

总结

这篇文章,我们探讨了将AI对象检测与前端Web开发无缝集成的方法。通过按照所述步骤并利用现成的AI库,开发人员可以为其Web应用程序增加强大的图像识别功能。这种AI和前端技术的融合为在Web上创建智能和交互式用户体验开启了广阔的可能性。

前往揭秘前端+ai的真相:juejin.cn/post/735919…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官:说说单点登录都是怎么实现的? 1、什么是单点登录?

发表于 2024-04-18
大家好,我是石头~


在数字化时代,用户账户安全和便捷体验成为了众多互联网产品设计的重要考量。


而“单点登录”(Single Sign-On, SSO)作为提升用户体验、简化登录流程的关键技术,已经成为各类企业应用的标准配置。


那么,当你在面试现场被问到‘单点登录是如何实现的?’,你是否已经胸有成竹?

u=2915442786,1302359599&fm=253&fmt=auto&app=138&f=JPEG.webp

1、什么是单点登录?

很早期的公司,一家公司可能只有一个服务,用户在使用这个公司的产品时,只要一次登录就可以了。


慢慢地,随着公司的业务扩展,服务开始变多了。每个服务都要进行注册登录,退出的时候又要一个个退出,这样的用户体验很不好。


可以想象一下,上豆瓣要登录豆瓣FM、豆瓣读书、豆瓣电影、豆瓣日记......真的会让人崩溃。


这个时候,就有人提出了单点登录的想法,它允许用户使用一组身份凭证登录一次,就可以访问所有相互信任的多个应用程序或系统。


这样,不仅能极大提升用户体验,降低忘记密码和登录疲劳等问题,还有助于提高整体系统的安全性。因为在单一认证中心下,用户的身份管理更加集中和可控,可以更容易地实现多层次的安全策略,包括双因素认证、动态口令、设备绑定等多种安全措施。


那么,单点登录又是怎么实现的呢?


接下来,就让我们一起揭开单点登录的神秘面纱,探索它背后的多种实现方案。

ab3cfe70df63da58.png

2、OAuth2.0的魔法棒

       OAuth2.0虽不是直接针对SSO设计,但它通过授权码流转,如同施展了一个无形的魔法,让用户在授权第三方应用访问自身数据的同时,也实现了某种程度的单点登录效果。


具体来说,用户向授权服务器请求一个令牌,这个令牌便成为了用户身份在各应用间的通行证,简化了登录流程。


就像哈利·波特手中的魔杖,轻轻一点,就打开了各个魔法房间的大门。

20191123231151293.png

3、CAS的中央认证咒语

CAS(Central Authentication Service)则是专门为SSO打造的一款开源神器。


在它的魔法阵中,用户仅需在一处——中央认证服务器登录,然后,借助CAS发放的票证(Ticket),即可跨越各种关联系统,实现一键登录。


就如同霍格沃茨的传送门,一踏进门,就能瞬间抵达校园内的任何角落。

15765672123182.jpg

4、OIDC的时空穿越之术

OIDC(OpenID Connect)则是OAuth2.0家族中的升级版魔法师,它在原有授权功能基础上增添了身份验证维度,使得用户通过OIDC身份提供商的身份验证后,能够在支持OIDC的不同应用之间畅通无阻。


就如同时间转换器,登录一次,即可随时穿越到支持OIDC的任一线上空间。

OIDC.png

5、SAML的身份声明符咒

最后登场的是SAML(Security Assertion Markup Language)。这位魔法师擅长使用XML文书形式传输身份声明,用户在身份提供方登录后,会收到一份魔法信件——SAML断言,凭借这份断言,可以解锁众多服务提供商的入口。


就像巫师们互相发送的身份证明信函,有了它,无论走到哪个魔法学院,都能被迅速识别并接纳。

image.png

6、结尾

当我们明白了这四位魔法师各自的神通之后,面对具体的业务场景,该如何做出最优选择呢?这就涉及到了安全性、易用性、集成难易度以及成本等多个考量因素。


比如,如果你身处一家需要高度安全和严格控制的大型组织,可能会倾向于选择SAML,因为它提供了强大的安全性和严格的控制手段。而在追求灵活快捷的互联网产品开发中,OAuth2.0与OIDC可能是更好的搭档,它们能够方便地与其他第三方服务集成,并优化用户体验。


亲爱的读者朋友,你在实际项目中是否也曾面临过选择单点登录技术的困境?面对不同业务需求,又是如何权衡利弊、因地制宜来选用最适合的方案呢?快在留言区分享你的实战心得,我们一同探讨,让这场关于单点登录的“魔法”对话持续发酵吧!

**MORE | 更多精彩文章**

  • JWT重放漏洞如何攻防?你的系统安全吗?
  • JWT vs Session:到底哪个才是你的菜?
  • JWT:你真的了解它吗?
  • 别再这么写POST请求了~
  • 揭秘布谷鸟过滤器:一场数据过滤中的“鸠占鹊巢”大戏!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

手撸一个星系,送给心爱的姑娘!(Threejs Shade

发表于 2024-04-17

前言

之前在小红书上刷到上海「深空未来」展的图片,看到这个宇宙星球的粒子效果觉得挺酷的,也很多人喜欢。

于是古柳想起曾经见过的这个 Three.js Shader 实现的粒子系统星系效果,它的形状、颜色、动画令人难忘,可惜当初水平有限,有些地方没有理解,这次重新勾起兴趣看了下源码,发现又搞懂不少地方,可以讲解下,因此想带大家一起手撸一个星系。

  • 链接:actium.co.jp/
  • 链接:codepen.io/prisoner849…

当然像本文这样实现一个具体完整的 shader 效果的文章,和前面八篇「手把手带你入门 Three.js Shader 系列」教程按部就班讲解一个个知识点还是不太一样,并且本文涉及的粒子系统、BufferGeometry、顶点上设置属性等也都是系列教程里还未涉及的(当然也不难),理想情况下在系列教程讲完那些内容后,再紧跟着来这么一篇完整效果的文章最好。

但有时看到酷炫 shader 的效果、起了兴致就想和大家分享,就也顾不上许多(何况老是犯懒,等系列教程更新完基础内容还不知道要到什么时候)。再者之前说过后续会在本公众号出个24篇付费进阶系列类似这样讲完整效果的文章(欢迎➕我「xiaoaizhj」方便获取最新消息,也可进交流群),这样想写什么有趣的内容提笔就能写,因此不妨以这篇作为开篇让大家看看这类文章是啥样子的。

言归正传,复现完这个星系效果后照旧套了下之前的 AR 模板,欢迎大家用手机 Google Chrome 浏览器访问看看(必须!电脑或手机其他浏览器均不行)。不过由于很多手机不支持 ARCore 可能不少人看不了,大家可以通过第二个链接看看自己的手机型号是否在支持列表里。

  • 链接:desertsx.github.io/galaxy-part…
  • 链接:developers.google.com/ar/devices?…

另外,本文代码已放到 Codepen 并将同步 GitHub,欢迎大家学习:

  • 链接:codepen.io/GuLiu/pen/W…
  • 链接:github.com/DesertsX/th…

最简单的粒子系统

我们从显示一个白色、线框模式下的球体开始讲起。可以看到球体表面相交的位置就是一个个顶点。

1
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
39
40
js复制代码import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

let w = window.innerWidth;
let h = window.innerHeight;

const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(75, w / h, 0.01, 1000);
camera.position.set(0, 0, 24);
camera.lookAt(new THREE.Vector3());

const renderer = new THREE.WebGLRenderer({
antialias: true,
// alpha: true,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(w, h);
renderer.setClearColor(0x160016, 1);
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);

const geometry = new THREE.SphereGeometry(10);
const material = new THREE.MeshBasicMaterial({
color: 0xffffff,
wireframe: true,
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

const clock = new THREE.Clock();
function render() {
const time = clock.getElapsedTime() * 0.5;
mesh.rotation.y = time;
renderer.render(scene, camera);
requestAnimationFrame(render);
}

render();

想在 Three.js 里实现粒子系统,最简单的就是用现成的几何体如 SphereGeometry 搭配 PointsMaterial 材质,再丢给 Points 来替代 Mesh,即可在几何体顶点处放置粒子,默认粒子为方形。其中在 PointsMaterial 里可以统一设置粒子的颜色和大小。

  • 链接:threejs.org/docs/api/en…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
js复制代码const geometry = new THREE.SphereGeometry(10);
const material = new THREE.PointsMaterial({
size: 0.4,
color: 0xffffff,
});

const points = new THREE.Points(geometry, material);
scene.add(points);

function render() {
// ...
// mesh.rotation.y = time;
points.rotation.y = time;
}

不过使用 SphereGeometry 有个很大的问题,粒子在球体两极密集、中间分散,空间上分布不均匀。

一种解决办法是用 IcosahedronGeometry 正二十面体,传入半径和细分数两个参数,细分数越大顶点越多,此时粒子分布很均匀。

  • 链接:threejs.org/docs/#api/e…
1
js复制代码const geometry = new THREE.IcosahedronGeometry(10, 6);

材质换成 ShaderMaterial

为了更灵活的控制粒子效果,可以把材质换成 ShaderMaterial,和此前系列文章里的 shader 不同之处在于这里可通过 gl_PointSize 另外设置粒子大小,如果用一个固定数值的话粒子都一样大。

1
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
js复制代码const vertexShader = /* GLSL */ `
uniform float uTime;
varying vec2 vUv;

void main() {
vUv = uv;

vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = 7.0;
// gl_PointSize = 100.0 / -mvPosition.z;
gl_Position = projectionMatrix * mvPosition;
}
`;

const fragmentShader = /* GLSL */ `
varying vec2 vUv;

void main() {
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
// gl_FragColor = vec4(vUv, 0.0, 1.0);
}
`;

const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
},
vertexShader,
fragmentShader,
});

function render() {
// ...
material.uniforms.uTime.value = time;
}

想要使靠近相机的粒子大、远离相机的粒子小,就需要对 mvPosition.z 值取倒数。经过 modelViewMatrix 后相机在原点处,3D物体顶点都在 z 轴负方向上,所以这里要加个负号,近大远小取倒数,再通过前面的数值调整大小即可。

1
C#复制代码gl_PointSize = 100.0 / -mvPosition.z;

方形粒子变成圆形

我们还可以在 shader 里将粒子变成圆形。在「手把手带你入门 Three.js Shader 系列(三) - 牛衣古柳 - 20230725」一文里,我们借助 uv 就能在一个 plane 上绘制圆形。

粒子系统看起来像由许多小 plane 组成,如果每个粒子有自己单独的 uv 坐标事情就好办了。

先直接用 uv 作为颜色看看,此时 uv 还是几何体上面的坐标而不是每个粒子单独的。

1
C#复制代码gl_FragColor = vec4(vUv, 0.0, 1.0);

幸运的是粒子系统里 gl_PointCoord 就是每个粒子上的(0,0)到(1,1)坐标,直接拿来替代 uv 就行,此时每个粒子上都是熟悉的青绿色。

1
C#复制代码gl_FragColor = vec4(gl_PointCoord, 0.0, 1.0);

对 gl_PointCoord 减去0.5将坐标范围变化到(-0.5,-0.5)到(0.5,0.5)进行居中,接着通过 length 计算离粒子中心的距离,再通过 step 使得距离小于0.5半径的值为1.0,大于0.5的为0.0,然后作为颜色即可绘制出圆形,但此时粒子半径之外是黑色的而不是透明的,可以通过 discard 丢弃、不绘制对应片元/像素。

1
2
3
4
5
C#复制代码void main() {
float mask = step(length(gl_PointCoord - 0.5), 0.5);
if(mask < 0.5) discard;
gl_FragColor = vec4(vec3(mask), 1.0);
}

自定义几何体顶点坐标

除了用 Three.js 现成的几何体外,我们还能通过 BufferGeometry 来自定义几何体的 position 顶点坐标,这样想在哪放粒子就能在哪放。

  • 链接:threejs.org/docs/#api/e…

下面演示用圆圈范围内随机出的顶点坐标组成几何体、再组成粒子系统的流程。

在半径0-10、角度0-2xPI范围内随机出一个个顶点的 xy 坐标,将 z 统一设成0,依次放到数组里,再用 geometry.setAttribute 设置到顶点属性上,命名为 position,且通过 Float32BufferAttribute 表示该数组数据是三个为一组,组成 vec3,这样在顶点着色器里用 attribute vec3 position 就能声明和使用,只不过 ShaderMaterial 里 position 默认已经声明,所以直接用就行。

这是设置顶点属性的惯用方式,后续还会用到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
js复制代码const geometry = new THREE.BufferGeometry();

const positions = [];
for (let i = 0; i < 5000; i++) {
const radius = 10 * Math.random();
const angle = Math.PI * 2 * Math.random();
const x = Math.sin(angle) * radius;
const y = Math.cos(angle) * radius;
positions.push(x, y, 0);
}

geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3)
);

const material = new THREE.ShaderMaterial({ ... });
const points = new THREE.Points(geometry, material);
scene.add(points);

// 适当调小粒子大小
// gl_PointSize = 30.0 / -mvPosition.z;

开始复现原作

以上,古柳带大家简单入门粒子系统,对于本身就会的朋友来说很简单,但肯定有人此前没接触过这块内容,而且目前更新的八篇「手把手带你入门 Three.js Shader 系列」教程里也还没讲到粒子系统、BufferGeometry、设置顶点属性等内容,因此有必要简单讲下,对齐一下颗粒度。

有了上面的基础,接下来就可以进入正题,开始复现原作、手撸一个星系了。

  • 链接:codepen.io/prisoner849…

观察原作会发现星系由中心的球体和外面的圆盘/圆柱两部分组成。

中心球体

首先生成中心球体的顶点坐标。在 for 循环里分别生成5万个粒子的球体坐标、10万个粒子的圆盘坐标,统一放到 positions 数组里,再设置到一个 BufferGeometry 上,这里没有分成两个设置。

原作里用 THREE.Vector3().randomDirection() 生成球体上的单位向量长度的顶点,然后设置向量长度到9.5-10作为球体半径。

  • 链接:threejs.org/docs/api/en…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
js复制代码const count1 = 50000;
const count2 = 100000;
const geometry = new THREE.BufferGeometry();
const positions = [];
for (let i = 0; i < count1 + count2; i++) {
// 球体部分
if (i < count1) {
let { x, y, z } = new THREE.Vector3()
.randomDirection()
.multiplyScalar(Math.random() * 0.5 + 9.5);
positions.push(x, y, z);
} else {
// 圆盘/圆柱部分
}
}

geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3)
);

// gl_PointSize = 30.0 / -mvPosition.z;

但后续在 shader 里会让粒子沿球体表面运动,原作的实现方式我觉得有些地方蛮困惑,因此自己用更好理解的方式去“改进”下。

具体来说就是,球体顶点是由半径 r,方位角 theta 和极角 phi 的球坐标计算得到 xyz,并且后续会将 theta、phi 也设置到顶点属性上、传入 shader 里,这样每个顶点沿球体表面运动时,只需在 shader 里分别给 theta、phi 加上一定角度,再对新的 theta、phi 用球坐标算出新的 xyz,就是偏移后的顶点 position……

和原作的关键区别就是这里的 theta 和 phi 串起了 position 顶点坐标和 shader 里运动,这样理解起来也更容易(后续写到粒子运动时才逐渐搞懂原作运动实现的逻辑,其实这里根本不需要 theta、phi 和初始 position.xyz 对应、新 position 也不是这么计算的,自己的方式还是有些问题但先保留,等粒子运动时再进行更正)。如果你不知道我在说些什么,不急,跟着文章看下去并结合代码理解即可。

我们用0-2xPI 的方位角 theta、0-PI 的极角 phi、9.5-10的半径 r 计算出球体上的任意顶点坐标 xyz,这里无需纠结 xyz 坐标系和上面配图不一样、哪个用 sin cos 等,直接按代码这么写效果ok就行。theta、phi 圆盘坐标里也用到所以写在 if 前面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
js复制代码const count1 = 50000;
const count2 = 100000;
const geometry = new THREE.BufferGeometry();
const positions = [];
for (let i = 0; i < count1 + count2; i++) {
let theta = Math.random() * Math.PI * 2;
// let phi = Math.random() * Math.PI; // 两极密集
let phi = Math.acos(Math.random() * 2 - 1); // 分布更均匀
if (i < count1) {
// let r = 10;
let r = Math.random() * 0.5 + 9.5;
let x = r * Math.sin(phi) * Math.cos(theta);
let y = r * Math.cos(phi);
let z = r * Math.sin(phi) * Math.sin(theta);
positions.push(x, y, z);
}
else {
// 圆盘/圆柱部分
}
}

需要注意的是 phi 是通过反余弦函数 acos 对-1-1求出角度得到,这样顶点分布更均匀,直接通过 Math.random() * Math.PI 的话会不均匀、两极更密集。

粒子大小更随机

目前中心球体的粒子效果大致出来了,但靠近球体表面细看时会发现粒子大小都差不多大,此时粒子大小仅取决于离相机的距离,而粒子在球体半径范围9.5-10之间和相机距离差别不大,所以大小也差不多。

为了使粒子大小更随机,可以给每个顶点设置一个随机值属性,这样在顶点着色器里就能使用。这里 size 值为0.5-2(具体范围可自行调整),对于球体和圆盘上的顶点都生成一个数值,通过 setAttribute 设置到几何体顶点属性上,在 Float32BufferAttribute 里表明一个顶点一个数值。然后在顶点着色器里通过 attribute float aSize 就能拿到数值,乘到 gl_PointSize 上即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
js复制代码const positions = [];
const sizes = [];
for (let i = 0; i < count1 + count2; i++) {
let theta = Math.random() * Math.PI * 2;
let phi = Math.acos(Math.random() * 2 - 1); // 分布更均匀

let size = Math.random() * 1.5 + 0.5; // 0.5-2.0
sizes.push(size);
// ...
}

geometry.setAttribute("aSize", new THREE.Float32BufferAttribute(sizes, 1));

const vertexShader = /* GLSL */ `
attribute float aSize;
uniform float uTime;

void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
// gl_PointSize = 30.0 / -mvPosition.z;
gl_PointSize = aSize * 30.0 / -mvPosition.z;
gl_Position = projectionMatrix * mvPosition;
}
`;

咋看起来可能变化并不明显,但所以小的细节累加起来才能达到漂亮、令人满意的效果。

应用颜色

中心球体形状确定后,我们接着应用颜色让效果更出彩。原作里用顶点离中心距离去 mix 插值下面两种颜色。

目前球体上下 position.y 的范围是-10-10,我们不妨将其除以10变到-1-1,再乘0.5加0.5变到0-1,然后在上下方向插值不同颜色。将颜色传给片元着色器并进行使用,此时 mask 仅用于 discard 舍弃掉圆圈外围的像素。

1
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
C#复制代码// vertexShader
attribute float aSize;
uniform float uTime;
varying vec3 vColor;

void main() {
// rgb(227, 155, 0) #E39B00
// rgb(100, 50, 255) #6432FF
vec3 color1 = vec3(227., 155., 0.);
vec3 color2 = vec3(100., 50., 255.);

float d = position.y / 10.0 * 0.5 + 0.5;
vColor = mix(color1, color2, d) / 255.;

vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * 30.0 / -mvPosition.z;
gl_Position = projectionMatrix * mvPosition;
}

// fragmentShader
varying vec3 vColor;

void main() {
float mask = step(length(gl_PointCoord - 0.5), 0.5);
if(mask < 0.5) discard;
// gl_FragColor = vec4(vec3(mask), 1.0);
gl_FragColor = vec4(vColor, 1.0);
}

也可以 abs 取绝对值后,使得中间0、上下1,此时效果看起来就和原作接近了。

1
2
C#复制代码float d = abs(position.y) / 10.0;
vColor = mix(color1, color2, d) / 255.;

原作的设置

虽然接近,但还是不同。我们不妨改成原作的设置方式,原作里对顶点坐标除以一个 vec3(40.,10.,40.) 再用 length 计算距离 d,其中这里的10是中心球体的半径,也是圆盘的内半径,40是圆盘的外半径;通过 clamp 截取到0-1,超过1的都为1,小于0的都为0,再 mix 插值两种颜色。具体这里为什么要除以这个 vec3、对颜色的变化效果如何产生影响,我也不太理解,有待高手解答吧,总之先把整体效果跑通再说!另外把粒子大小再调大些。

1
2
3
4
5
C#复制代码float d = length(abs(position) / vec3(40., 10., 40.));
d = clamp(d, 0., 1.);
vColor = mix(color1, color2, d) / 255.;

gl_PointSize = aSize * 50.0 / -mvPosition.z;

在片元着色器里,计算每个顶点上的像素离自身中心的距离,然后大于0.5的舍弃,通过 smoothstep 设置透明度,距离小于0.1的取1,0.1-0.5的从1平滑过渡到到0,大于0.5的为0且会舍弃。这样粒子圆圈就会是模糊朦胧的效果。

1
2
3
4
5
6
7
8
C#复制代码// fragmentShader
varying vec3 vColor;

void main() {
float d = length(gl_PointCoord - 0.5);
if (d > 0.5) discard;
gl_FragColor = vec4(vColor, smoothstep(0.5, 0.1, d));
}

此时颜色很怪,因为透明度没生效,设置 transparent 为 true 颜色就正常了;设置 blending 为 THREE.AdditiveBlending 这样粒子重叠后的颜色会变白发亮,可以看到球体边缘一圈微微发亮。

1
2
3
4
5
6
7
8
9
10
js复制代码 const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
},
vertexShader,
fragmentShader,
transparent: true,
blending: THREE.AdditiveBlending,
depthTest: false,
});

另外设置 depthTest 为 false 以避免左侧粒子黑边的效果,最终放大后的粒子效果如右图所示,圆圈朦胧、重叠变白发亮。

让粒子动起来(纠正错误)

中心球体的效果更加漂亮了,现在让粒子动起来。在2D里想让粒子在圆圈上运行,需要不断改变角度 angle,同样3D里想让粒子在球体上运动,需要改变 theta 和 phi 两个角度,就像地球仪上从一点到另一点要改变经度和纬度一般。

让我们再给顶点属性上设置和运动相关的数值。theta 和 phi 可以定位出粒子初始位置,angle 为很小的随机角度值表示移动的角度大小或速率,strength 为0.1-1类似运动幅度,将这4个数值设置到每个顶点上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
js复制代码const positions = [];
const sizes = [];
const shifts = [];
for (let i = 0; i < count1 + count2; i++) {
let theta = Math.random() * Math.PI * 2;
let phi = Math.acos(Math.random() * 2 - 1);
let angle = (Math.random() * 0.9 + 0.1) * Math.PI * 0.1;
let strength = Math.random() * 0.9 + 0.1; // 0.1-1
shifts.push(theta, phi, angle, strength);

let size = Math.random() * 1.5 + 0.5;
sizes.push(size);
// ...
}

geometry.setAttribute("aShift", new THREE.Float32BufferAttribute(shifts, 4));

在顶点着色器里可以通过 xyzw 分别拿到 aShift 里的4个值。aShift.x 是原始 theta,加上 aShift.z * uTime 就是角度不断变化,mod 对 2xPI 取余数使角度不断在 0-2xPI 之间变化,从而得到新的 theta 角度;同理得到新的 phi 角度,注意这里 phi 也是要对 2xPI 取余数,虽然不太理解,但换成 PI 就会出现粒子闪烁的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
C#复制代码attribute float aSize;
attribute vec4 aShift;
uniform float uTime;
varying vec3 vColor;

const float PI = 3.1415925;

void main() {
vec3 color1 = vec3(227., 155., 0.);
vec3 color2 = vec3(100., 50., 255.);

float d = length(abs(position) / vec3(40., 10., 40.));
d = clamp(d, 0., 1.);
vColor = mix(color1, color2, d) / 255.;

vec3 transformed = position;
float theta = mod(aShift.x + aShift.z * uTime, PI * 2.);
float phi = mod(aShift.y + aShift.z * uTime, PI * 2.);
transformed += vec3(sin(phi) * cos(theta), cos(phi), sin(phi) * sin(theta)) * aShift.w;

// vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vec4 mvPosition = modelViewMatrix * vec4(transformed, 1.0);
gl_PointSize = aSize * 50.0 / -mvPosition.z;
gl_Position = projectionMatrix * mvPosition;
}

这是原作粒子运动逻辑的代码,如上所说,一开始古柳以为要让粒子在球体表面运动,是需要更新 theta、phi 后像 JS 里设置顶点坐标时一样根据球坐标算出新的 position/transformed 坐标。

1
2
3
4
5
6
C#复制代码vec3 transformed = position;
float theta = mod(aShift.x + aShift.z * uTime, PI * 2.);
float phi = mod(aShift.y + aShift.z * uTime, PI * 2.);
transformed += vec3(sin(phi) * cos(theta), cos(phi), sin(phi) * sin(theta)) * aShift.w;

vec4 mvPosition = modelViewMatrix * vec4(transformed, 1.0);

那么新的顶点坐标这里应该用 = 而不是 +=,然后 aShift.w 应该是半径 9.5-10.0,而不是0.1-1,这就对不上了。虽然上面粒子也已经动起来,但有必要搞清楚这里代码的逻辑。

1
C#复制代码transformed = vec3(sin(phi) * cos(theta), cos(phi), sin(phi) * sin(theta)) * aShift.w; // * 10.0

一番思索后古柳逐渐明白是之前自己的理解出了偏差,被粒子要在球体表面运动然后就得通过更新 theta phi 来计算新顶点这一想法所“遮蔽”。

其实运动的逻辑并非如此,对于一维的点如x=10,加减一个速度值如0.1,然后乘时间就是 x+0.1*t 点就能运动起来;二维的点如 (x=10,y=20) 可以沿自身为中心周围一圈的任意方向去移动,可以通过(cos(a), sin(a))单位向量表示方向,同样乘时间就是 (x,y)+(cos(a), sin(a))*t 点就能运动起来;三维的点如 (x=10,y=20,z=30) 可以沿自身为中心周围一圈球体的任意方向去移动,可以通过 sin(phi) * cos(theta), cos(phi), sin(phi) * sin(theta) 单位向量表示方向,同样乘时间就是 (x,y,z)+(sin(phi) * cos(theta), cos(phi), sin(phi) * sin(theta))*t 点就能运动起来。

所以这里的 theta、phi 其实是每个顶点处单位球体上的运动方向,而不是一开始中心球体的两个角度,两者根本不需要对齐、不需要相关,甚至不相关可能更好。shader 里直接对每个顶点坐标加上自己的运动方向乘以 aShift.w 运动幅度0.1-1,只不过因为该值较小,所以看起来粒子还像是在球体上运动,这就是运动的逻辑。因而 JS 里生成中心球体坐标的代码切换回原来 randomDirection 的方式。

1
2
3
4
5
6
7
8
9
10
11
js复制代码if (i < count1) {
let r = Math.random() * 0.5 + 9.5;
// let x = r * Math.sin(phi) * Math.cos(theta);
// let y = r * Math.cos(phi);
// let z = r * Math.sin(phi) * Math.sin(theta);

let { x, y, z } = new THREE.Vector3()
.randomDirection()
.multiplyScalar(r);
positions.push(x, y, z);
}

圆盘粒子

粒子的颜色和运动都搞定后,最后把外围的圆盘粒子也补全,幸运的是上述颜色和运动都能沿用,所以很方便。

圆盘粒子在半径10-40之间,通过 THREE.Vector3().setFromCylindricalCoords() 设置半径、角度、高度来随机生成。

1
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
js复制代码const count1 = 50000;
const count2 = 100000;
const geometry = new THREE.BufferGeometry();
const positions = [];
const sizes = [];
const shifts = [];
for (let i = 0; i < count1 + count2; i++) {
let theta = Math.random() * Math.PI * 2;
let phi = Math.acos(Math.random() * 2 - 1);
let angle = (Math.random() * 0.9 + 0.1) * Math.PI * 0.1;
let strength = Math.random() * 0.9 + 0.1; // 0.1-1.0 radius
shifts.push(theta, phi, angle, strength);

let size = Math.random() * 1.5 + 0.5;
sizes.push(size);

if (i < count1) {
// 中心球体粒子
let r = Math.random() * 0.5 + 9.5;
let { x, y, z } = new THREE.Vector3()
.randomDirection()
.multiplyScalar(r);
positions.push(x, y, z);
} else {
// 圆盘粒子
let r = 10;
let R = 40;
let rand = Math.pow(Math.random(), 1.5);
let radius = Math.sqrt(R * R * rand + (1 - rand) * r * r);
let { x, y, z } = new THREE.Vector3().setFromCylindricalCoords(
radius,
Math.random() * 2 * Math.PI,
(Math.random() - 0.5) * 2
);
positions.push(x, y, z);
}
}

唯一需要注意的是这里半径 radius 的生成稍微多了些步骤。

1
2
3
4
5
6
7
8
9
10
11
javascript复制代码// 圆盘粒子
let r = 10;
let R = 40;
let rand = Math.pow(Math.random(), 1.5);
let radius = Math.sqrt(R * R * rand + (1 - rand) * r * r);
let { x, y, z } = new THREE.Vector3().setFromCylindricalCoords(
radius, // 半径
Math.random() * 2 * Math.PI, // 角度
(Math.random() - 0.5) * 2 // 高度y -1-1
);
positions.push(x, y, z);

用 random=0-1 取 pow,再作为0-1的数值去插值内外半径的平方,取平方根后作为最后的半径,这里大概是为了让粒子在圆盘上分布更均匀,半径平方相当于按面积大小来采样,不至于越靠近中心粒子越多。

但似乎直接用 random 10-40 的效果看起来也差不多,没想象中那么不均匀,可能是粒子足够小的缘故,总之原作里的方式大家也可以学学,万一用得上呢!

1
2
3
4
5
6
7
js复制代码let radius = Math.random() * 30 + 10;
let { x, y, z } = new THREE.Vector3().setFromCylindricalCoords(
radius,
Math.random() * 2 * Math.PI,
(Math.random() - 0.5) * 2
);
positions.push(x, y, z);

最后优化细节

最后调整相机角度;使粒子系统沿z轴的稍微倾斜,并随时间不断沿y轴旋转,这里还更改旋转顺序为 ZYX 轴。

  • 链接:threejs.org/docs/#api/e…
1
2
3
4
5
6
7
8
9
10
11
12
13
js复制代码camera.position.set(0, 3, 24);

const points = new THREE.Points(geometry, material);
points.rotation.order = "ZYX";
points.rotation.z = 0.2;
scene.add(points);

const clock = new THREE.Clock();
function render() {
const time = clock.getElapsedTime();
points.rotation.y = time * 0.01;
material.uniforms.uTime.value = time;
}

小结

最终我们手撸出了一个非常漂亮的粒子系统星系效果(当然受限 GIF 导出后上传文章里的文件大小所限上面看着有些糊,大家可去 Codepen 看效果),大家还可以根据自己需要去调整参数、改改配色等。

  • 链接:codepen.io/GuLiu/pen/W…

虽然源码里仍有几处设置古柳没完全吃透,但不妨碍我们整体跑通整个流程。

记得最初不理解源码里的顶点设置和粒子怎么运动的、不懂 theta/phi/moveS/moveT/cos/sin 球坐标等用途、不知道 material 里的 onBeforeCompile 是什么东西和一般自己写 shader 有什么区别……(下面就是源码里 material 部分的代码,本次复现时也改成了更好里记得方式)

  • 链接:codepen.io/Gu-Liu/pen/…
1
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
39
40
41
42
43
44
45
46
47
48
49
js复制代码let m = new THREE.PointsMaterial({
size: 0.125,
transparent: true,
depthTest: false,
blending: THREE.AdditiveBlending,
onBeforeCompile: shader => {
shader.uniforms.time = gu.time;
shader.vertexShader = `
uniform float time;
attribute float sizes;
attribute vec4 shift;
varying vec3 vColor;
${shader.vertexShader}
`.replace(
`gl_PointSize = size;`,
`gl_PointSize = size * sizes;`
).replace(
`#include <color_vertex>`,
`#include <color_vertex>
float d = length(abs(position) / vec3(40., 10., 40));
d = clamp(d, 0., 1.);
vColor = mix(vec3(227., 155., 0.), vec3(100., 50., 255.), d) / 255.;
`
).replace(
`#include <begin_vertex>`,
`#include <begin_vertex>
float t = time;
float moveT = mod(shift.x + shift.z * t, PI2);
float moveS = mod(shift.y + shift.z * t, PI2);
transformed += vec3(cos(moveS) * sin(moveT), cos(moveT), sin(moveS) * sin(moveT)) * shift.w;
`
);
//console.log(shader.vertexShader);
shader.fragmentShader = `
varying vec3 vColor;
${shader.fragmentShader}
`.replace(
`#include <clipping_planes_fragment>`,
`#include <clipping_planes_fragment>
float d = length(gl_PointCoord.xy - 0.5);
//if (d > 0.5) discard;
`
).replace(
`vec4 diffuseColor = vec4( diffuse, opacity );`,
`vec4 diffuseColor = vec4( vColor, smoothstep(0.5, 0.1, d)/* * 0.5 + 0.5*/ );`
);
//console.log(shader.fragmentShader);
}
});

幸运地是时过境迁后,终于能大致搞懂并复现以前看过的 shader 效果,很是欣慰。希望看完本文大家也能有所收获。最后完整源码附上。

  • 链接:codepen.io/GuLiu/pen/W…

相关阅读

「手把手带你入门 Three.js Shader 系列」目录如下:

  • 「断更19个月,携 Three.js Shader 归来!(上)- 牛衣古柳 - 20230416」
  • 「断更19个月,携 Three.js Shader 归来!(下)- 牛衣古柳 - 20230421」
  • 「手把手带你入门 Three.js Shader 系列(八)- 牛衣古柳 - 20240229」
  • 「手把手带你入门 Three.js Shader 系列(七)- 牛衣古柳 - 20230206」
  • 「手把手带你入门 Three.js Shader 系列(六)- 牛衣古柳 - 20231220」
  • 「手把手带你入门 Three.js Shader 系列(五)- 牛衣古柳 - 20231126」
  • 「手把手带你入门 Three.js Shader 系列(四)- 牛衣古柳 - 20231121」
  • 「手把手带你入门 Three.js Shader 系列(三)- 牛衣古柳 - 20230725」
  • 「手把手带你入门 Three.js Shader 系列(二)- 牛衣古柳 - 20230716」
  • 「手把手带你入门 Three.js Shader 系列(一)- 牛衣古柳 - 20230515」

照例

如果你喜欢本文内容,欢迎以各种方式支持,这也是对古柳输出教程的一种正向鼓励!

最后欢迎加入「可视化交流群」,进群多多交流,对本文任何地方有疑惑的可以群里提问。加古柳微信:xiaoaizhj,备注「可视化加群」即可。

欢迎关注古柳的公众号「牛衣古柳」,并设置星标,以便第一时间收到更新。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【自建插件调用sapceX数据集+通义万相生图】 让每个少年

发表于 2024-04-17

粉丝的难言之隐

从小我就对火箭充满了好奇和兴趣。记得第一次看到火箭发射的画面时,那种震撼和激动的感觉至今难忘。在火箭领域spaceX无疑是行业的翘楚。随着马斯克的热潮,我发现网上关于SpaceX的信息虽然多,却往往聚焦在爆点的新闻当中,没有专业官方的数据格式,对于一个狂热粉丝来说实在是不忍直视。

image.png

但是令人兴奋的是,r/SpaceX在Github上开源了SpaceX火箭相关的数据——SpaceX-API。REST API包含三大部分,clients、app(应用)和原始数据。其中,API Clients是对现有关于SpaceX的api合集,降低了以往信息查找、汇总的难度。整理的API,基本包含了SPaceX成立迄今的所有火箭数据。
github地址:https://github.com/r-spacex/SpaceX-API
image.png

作为一个粉丝来说我们应该怎么去实时获取这些信息呢?常见的几种方式:

1. docker/本地拉下来整个仓库进行部署,进行查询

2. 在线api调用查询

但是常见的两种方式都比较麻烦,没有对话式的体验,json格式的返回也难以阅读,单一的信息源也会导致输出不丰富。

用插件封装API进行信息的查询和调用

但是现在我们有了Coze,我们来看看coze插件的官方定义:
插件是一个工具集,一个插件内可以包含一个或多个工具(API)。
目前,扣子集成了超过 60 种类型的插件,包括资讯阅读、旅游出行、效率办公、图片理解等 API 及多模态模型。使用这些插件,可以帮助您拓展 Bot 能力边界。
如果扣子集成的插件不满足您的使用需求,您还可以创建自定义插件来集成需要使用的 API。

先来看看传统的api调用:

1
js[复制代码  {curl --location 'https://api.spacexdata.com/v3/capsules'}]

我们来看看返回结果,SpaceX最早的几款龙飞船最开始的C101,C102(至今还悬挂在spaceX在加州总部房顶上和肯尼迪航天中心的展览馆里面)等都有具体的发射信息,但是信息繁杂,可视化程度低,且数据过于单调。

1
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
js[复制代码  {
‘ "capsule_serial": "C101",
"capsule_id": "dragon1",
"status": "retired",
"original_launch": "2010-12-08T15:43:00.000Z",
"original_launch_unix": 1291822980,
"missions": [
{
"name": "COTS 1",
"flight": 7
}
],
"landings": 0,
"type": "Dragon 1.0",
"details": "Reentered after three weeks in orbit",
"reuse_count": 0
},
{
"capsule_serial": "C102",
"capsule_id": "dragon1",
"status": "retired",
"original_launch": "2012-05-02T07:44:00.000Z",
"original_launch_unix": 1335944640,
"missions": [
{
"name": "COTS 2",
"flight": 8
}
],
"landings": 1,
"type": "Dragon 1.0",
"details": "First Dragon spacecraft",
"reuse_count": 0
},
{
"capsule_serial": "C103",
"capsule_id": "dragon1",
"status": "unknown",
"original_launch": "2012-10-08T00:35:00.000Z",
"original_launch_unix": 1349656500,
"missions": [
{
"name": "CRS-1",
"flight": 9
}
],
"landings": 1,
"type": "Dragon 1.0",
"details": "First of twenty missions flown under the CRS1 contract",
"reuse_count": 0
},
{

}
]

接下来我们是否能够通过插件把我们的api进行封装。
image.png

我们定义输入的参数为多种:capsules/Cores/Dragons等的型号,输出会自动解析出来:
image.png

然后我们试一下效果,看看返回效果如何

image.png
image.png
可以看到返回的信息非常规整,信息也十分准确。

该插件上传中(名称就是space-X),如果审核通过,大家也可以加入到自己的bot之中进行信息的获取。同时还可以通过bot的memory能力:
持久化的记忆能力

例如创建一个数据库来记录阅读,有了数据库,Bot就可以把我们的记录对话写入到数据库之中,就可以做到信息的积累,把火箭的信息记录到我们一行一行的数据库中。

为梦想插上腾飞的翅膀

如果说单单只是简单的一个调用,还够不到媲美火箭的想象力。其实绝大多数爱好者对火箭,对飞船,甚至对宇宙的热忱都起源于一张张照片。我们能通过Coze来一饱眼福呢?答案是肯定的,Coze提供了丰富的插件功能来实现图片的调用。

image.png
在这里我们使用必应搜索这个插件来搜索相关信息,在我的bot中添加这个插件,来实现通过我输入的描述在必应上搜索相关的图片。

image.png

image.png

image.png
在这个过程中涉及到两个插件的调用,在我的bot中当我输入C113的信息时,他会调用我自创的插件进行查询,当我问到第二个问题的时候,封装的api无法查询到例如飞船和火箭的依赖关系时,就会通过必应来搜索二者的关系并返回相关的图片。

让火箭就在我们的手边

在coze的试用发布的时候,我发现coze和其他的大模型平台不一样的是,coze提供了丰富的发布平台,掘金飞书微信等等等等。

平时由于工作的原因,我也是一个飞书的一个重度使用者。我在想能否让这个火箭小助手给我提供和火箭相关的生活灵感?例如电脑壁纸?手机屏保等等等等(常规的搜索引擎搜火箭壁纸质量堪忧)。

在这个过程中我引入了新的一个插件:通义万相(官方描述:通义万相,提供了一系列的图像生成能力。支持根据用户输入的文字内容,生成符合语义描述的不同风格的图像,或者根据用户输入的图像,生成不同用途的图像结果)。可以看到官网的效果还是非常好的。

image.png

**我们该如何在我们的bot中调用这个插件呢?梳理一下我们目前的思路:已经存在两个插件:自建的spaceX插件,搜索引擎。于是我们有了两个新思路:

1:一次输入,自动给我三个结果,分别是火箭的参数,实物图片,以及对这个实物的描述。在此基础上建立工作流,让这个实物的描述作为万相的输入,来生成的图片。

2:查询和生成是两套逻辑,查询时输出参数和实物图片,生成时再向万象给予相应的参数。**

这里我对两套方案都进行了相关的实操,然后考虑到到了两个点,首先是function call的稳定性,在经过测试之后发现,第一套方案的输出稳定性较差,其次通过搜索引擎给出的描述,来用万相生成图片,会存在较大的差距。
这是第一套方案的工作流逻辑:

image.png

在这里我们选择了第二个方案,就是把查询和生成作为两套逻辑去进行,在prompt中进行相关的描述,描述模版如图所示。
image.png

ce0123284a9842bd80279321a6fa61d9_3.png

可以看到效果非常亮眼,后续将该bot发布到飞书,就可以做到在你的飞书中每天获得一张全世界独一无二,AI无限的壁纸。

结语

站在一个粉丝的视角,通过coze创建了一个简单的插件和bot,但是他带来的生命力和创造力是令人心潮澎湃且久久不能平复的,且用coze生成的一首五言绝句作为结语:

**火箭破云霄,数码逐浪高。

云思络绎计,智算览未遥。**

bot id:7357967824075882548

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Pandas数据分析学习笔记 前言 一、数据读取 二、数据结

发表于 2024-04-17

前言

开刷Pandas数据分析,看起来很好理解,不过没做笔记没敲代码心里总是不安稳,所以复现下课程代码并演示其中遇到的问题,顺便水一水笔记好了

参考资料:

课程视频链接:Pandas数据分析从入门到实战

数据及代码示例:ant-learn-pandas: pandas学习课程代码仓库 (gitee.com)

一、数据读取

  1. 数据类型

数据类型 说明 Pandas读取方法
csv, tsv, txt 用逗号、tab或其它字符分割的文本文件 read_csv
excel xls或xlsx文件 read_excel
mysql 关系型数据表 read_sql
  1. read_csv

1.1 读取csv文件

csv是以逗号分割的文本文件,如下:

1
2
3
4
5
csv复制代码userId,movieId,rating,timestamp
1,1,4.0,964982703
1,3,4.0,964981247
1,6,4.0,964982224
1,47,5.0,964983815

直接使用read_csv读取该文件

1
2
3
py复制代码fpath = "../datas/ml-latest-small/ratings.csv"
# 读取
ratings = pd.read_csv(fpath)

1.2 指定分割符

已知access_pvuv.txt如下:

1
2
3
4
5
6
7
8
9
10
txt复制代码2019-09-10  139    92
2019-09-09 185 153
2019-09-08 123 59
2019-09-07 65 40
2019-09-06 157 98
2019-09-05 205 151
2019-09-04 196 167
2019-09-03 216 176
2019-09-02 227 148
2019-09-01 105 61

为read_csv添加参数delimiter (或seq) 指定分隔符,header=None表示没有第一行列名称

1
2
3
4
5
6
7
8
py复制代码fpath = "../datas/crazyant/access_pvuv.txt"

pvuv = pd.read_csv(
fpath,
delimiter='\t',
header=None,
names=['date', 'pv', 'uv']
)
  1. read_excel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
py复制代码fpath = "../datas/crazyant/access_pvuv.xlsx"
pvuv = pd.read_excel(fpath)
print(pvuv)

'''
输出结果
日期 PV UV
0 2019-09-10 139 92
1 2019-09-09 185 153
2 2019-09-08 123 59
3 2019-09-07 65 40
4 2019-09-06 157 98
5 2019-09-05 205 151
6 2019-09-04 196 167
7 2019-09-03 216 176
8 2019-09-02 227 148
9 2019-09-01 105 61
'''
  1. read_sql

3.1 使用pymysql

连接数据库,选择编码方式

1
2
3
4
5
6
7
8
9
10
py复制代码import pandas as pd
import pymysql

conn = pymysql.connect(
host='localhost',
user='root',
password='password',
database='dbname',
charset='utf8'
)
1
2
3
4
5
6
7
8
9
py复制代码table = pd.read_sql("select * from tbname", con=conn)
print(table)
'''
输出结果
id preorder_traversal_string
0 1 4_2_1_0_#_#_#_3_#_#_8_7_#_#_11_#_#_
1 2 5_4_2_#_#_1_7_#_6_#_#_#_3_0_#_5_1_#_#_#_6_#_#_
2 3 6_#_7_5_3_#_#_1_#_#_2_8_#_#_#_
'''

但是会报警告:

UserWarning: pandas only supports SQLAlchemy connectable (engine/connection) or database string URI or sqlite3 DBAPI2 connection. Other DBAPI2 objects are not tested. Please consider using SQLAlchemy.

这里建议我们使用SQLAlchemy

3.2 使用SQLAlchemy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
py复制代码import pandas as pd

from sqlalchemy import create_engine

host = "127.0.0.1"
user = "root"
password = "password"
database = "dbname"

engine = create_engine(f"mysql+pymysql://{user}:{password}@{host}/{database}")
sql = 'select * from tbname'
table = pd.read_sql(sql=sql, con=engine)

print(table)
'''
输出结果
id preorder_traversal_string
0 1 4_2_1_0_#_#_#_3_#_#_8_7_#_#_11_#_#_
1 2 5_4_2_#_#_1_7_#_6_#_#_#_3_0_#_5_1_#_#_#_6_#_#_
2 3 6_#_7_5_3_#_#_1_#_#_2_8_#_#_#_
'''
  1. 查看数据格式

1
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
39
40
41
42
43
44
py复制代码# 查看前几行数据,默认为5行
print(ratings.head())
'''
输出结果
userId movieId rating timestamp
0 1 1 4.0 964982703
1 1 3 4.0 964981247
2 1 6 4.0 964982224
3 1 47 5.0 964983815
4 1 50 5.0 964982931
'''

# 查看数据的形状,返回(行数、列数)
print(ratings.shape)

'''
输出结果
(100836, 4)
'''

# 查看列名列表
print(ratings.columns)
'''
输出结果
Index(['userId', 'movieId', 'rating', 'timestamp'], dtype='object')
'''

# 查看索引列
print(ratings.index)
'''
输出结果
RangeIndex(start=0, stop=100836, step=1)
'''

# 查看每列的数据类型
print(ratings.dtypes)
'''
输出结果
userId int64
movieId int64
rating float64
timestamp int64
dtype: object
'''

二、数据结构

  1. Series

1
2
3
4
5
6
7
8
9
10
11
12
py复制代码import pandas as pd

# 创建Series
s1 = pd.Series(list('abcd'))
print(s1)
'''
0 a
1 b
2 c
3 d
dtype: object
'''
1
2
3
4
5
6
7
8
9
10
11
12
py复制代码# 指定索引创建Series
s2 = pd.Series(list('efgh'), index=list('abcd'))
print(s2)
print(s2.index)
'''
a e
b f
c g
d h
dtype: object
Index(['a', 'b', 'c', 'd'], dtype='object')
'''
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
py复制代码# 字典创建Series
dict = {
'a': 'e',
'b': 'f',
'c': 'g',
'd': 'h'
}
s3 = pd.Series(dict)
print(s3)
'''
a e
b f
c g
d h
dtype: object
'''
  1. DataFrame

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
py复制代码import pandas as pd

# 字典创建DataFrame
data = {
'state': ['Ohio', 'Ohio', 'Ohio', 'Nevada', 'Nevada'],
'year': [2000, 2001, 2002, 2001, 2002],
'pop': [1.5, 1.7, 3.6, 2.4, 2.9]
}
df = pd.DataFrame(data)

print(df)
'''
state year pop
0 Ohio 2000 1.5
1 Ohio 2001 1.7
2 Ohio 2002 3.6
3 Nevada 2001 2.4
4 Nevada 2002 2.9
'''
1
2
3
4
5
6
7
8
9
10
11
12
py复制代码# 输出DataFrame的索引、列标签以及数据类型
print(df.index, '\n\n', df.columns, '\n\n', df.dtypes)
'''
RangeIndex(start=0, stop=5, step=1)

Index(['state', 'year', 'pop'], dtype='object')

state object
year int64
pop float64
dtype: object
'''

三、查询数据

  1. 查询方法

  1. df.loc :基于标签索引,结果包含最后一个标签的值
  2. df.iloc:基于位置索引,结果不包含最后一个位置的值
  3. df.where
  4. df.query

本节主要介绍df.loc

  1. 数据预处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
py复制代码import pandas as pd

# 数据预处理
df = pd.read_csv("../datas/beijing_tianqi/beijing_tianqi_2018.csv")

print(df.head())
'''
ymd bWendu yWendu tianqi fengxiang fengli aqi aqiInfo aqiLevel
0 2018-01-01 3℃ -6℃ 晴~多云 东北风 1-2级 59 良 2
1 2018-01-02 2℃ -5℃ 阴~多云 东北风 1-2级 49 优 1
2 2018-01-03 2℃ -5℃ 多云 北风 1-2级 28 优 1
3 2018-01-04 0℃ -8℃ 阴 东北风 1-2级 28 优 1
4 2018-01-05 3℃ -6℃ 多云~晴 西北风 1-2级 50 优
'''
1
2
py复制代码# 设定索引为日期,方便按日期筛选
df.set_index('ymd', inplace=True)
1
2
3
4
5
py复制代码# 替换掉温度的后缀℃
df.loc[:, "bWendu"] = df["bWendu"].str.replace("℃", "").astype('int32')
df.loc[:, "yWendu"] = df["yWendu"].str.replace("℃", "").astype('int32')
# df[df.columns["bWendu"]] = df["bWendu"].str.replace("℃", "").astype('int32')
# df[df.columns["yWendu"]] = df["yWendu"].str.replace("℃", "").astype('int32')
1
2
3
4
5
6
7
8
9
10
py复制代码print(df.head())
'''
bWendu yWendu tianqi fengxiang fengli aqi aqiInfo aqiLevel
ymd
2018-01-01 3 -6 晴~多云 东北风 1-2级 59 良 2
2018-01-02 2 -5 阴~多云 东北风 1-2级 49 优 1
2018-01-03 2 -5 多云 北风 1-2级 28 优 1
2018-01-04 0 -8 阴 东北风 1-2级 28 优 1
2018-01-05 3 -6 多云~晴 西北风 1-2级 50 优
'''

这里会报一个警告:

1
2
3
cmd复制代码 DeprecationWarning: In a future version, `df.iloc[:, i] = newvals` will attempt to set the 
values inplace instead of always setting a new array. To retain the old behavior, use either
`df[df.columns[i]] = newvals` or, if columns are non-unique, `df.isetitem(i, newvals)`
  1. 按数值、列表、区间查询

1
2
py复制代码# 得到单个值
single_value = df.loc['2018-01-03', 'bWendu']
1
2
3
py复制代码# 得到一列/一行
s1 = df.loc['2018-01-03', ['bWendu', 'yWendu']]
s2 = df.loc[['2018-01-03', '2018-01-04', '2018-01-05'], 'bWendu']
1
2
py复制代码# 得到DataFrame
df2 = df.loc[['2018-01-03','2018-01-04','2018-01-05'], ['bWendu', 'yWendu']]
1
2
py复制代码# 按区间查询
df3 = df.loc['2018-01-03':'2018-01-05', 'bWendu':'fengxiang']
  1. 条件查询

1
2
3
4
5
6
7
8
9
10
py复制代码# 查询最高温度小于30度,并且最低温度大于15度,并且是晴天,并且天气为优的数据
df4 = df.loc[(df["bWendu"] <= 30) & (df["yWendu"] >= 15)
& (df["tianqi"] == '晴') & (df["aqiLevel"] == 1), :]
print(df4)
'''
bWendu yWendu tianqi fengxiang fengli aqi aqiInfo aqiLevel
ymd
2018-08-24 30 20 晴 北风 1-2级 40 优 1
2018-09-07 27 16 晴 西北风 3-4级 22 优 1
'''

其中,条件表达式返回的是一个布尔值的Series

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
py复制代码# 观察条件表达式
print(df["yWendu"] < -10)
'''
2018-01-01 False
2018-01-02 False
2018-01-03 False
2018-01-04 False
2018-01-05 False
...
2018-12-27 True
2018-12-28 True
2018-12-29 True
2018-12-30 True
2018-12-31 False
Name: yWendu, Length: 365, dtype: bool
'''
  1. 函数查询

1
2
py复制代码# 直接写lambda表达式
df5 = df.loc[lambda df : (df["bWendu"] <= 30) & (df["yWendu"] >= 15), :]
1
2
3
4
5
py复制代码# 编写自己的函数,查询9月份,空气质量好的数据
def query_my_data(df):
return df.index.str.startswith("2018-09") & (df["aqiLevel"] == 1)

df6 = df.loc[query_my_data, :]

四、新增数据列

  1. 直接赋值

1
py复制代码df.loc[:, 'wencha'] = df['bWendu'] - df['yWendu']
  1. df.apply

传入一个函数并选定axis:

  1. 当axis=1,函数的参数为一行的Series(常用)
  2. 当axis=0,函数的参数为一列的Series
1
2
3
4
5
6
7
8
py复制代码def get_wendu_type(x):
if x["bWendu"] > 33:
return '高温'
if x["yWendu"] < -10:
return '低温'
return '常温'

df.loc[:, "wendu_type"] = df.apply(get_wendu_type, axis=1)
  1. df.assign

df.assign总是会创建一个新的copy

利用lambda表达式,处理原来的数据得到新列

1
2
3
4
5
6
ini复制代码# 可以同时添加多个新的列
df.assign(
yWendu_huashi = lambda x : x["yWendu"] * 9 / 5 + 32,
# 摄氏度转华氏度
bWendu_huashi = lambda x : x["bWendu"] * 9 / 5 + 32
)
  1. 条件选择分组后赋值

1
2
py复制代码# 先创建空列(这是第一种创建新列的方法)
df['wencha_type'] = ''

错误示例:

1
2
3
py复制代码df.loc[df["bWendu"]-df["yWendu"]>10]["wencha_type"] = "温差大"

df.loc[df["bWendu"]-df["yWendu"]<=10]["wencha_type"] = "温差正常"

两个[]的链式操作相当于

1
py复制代码df.get(condition).set(wen_cha)

这里get得到的结果可能是view也可能是copy,存在歧义

正确示范:

1
2
3
py复制代码df.loc[df["bWendu"]-df["yWendu"]>10, "wencha_type"] = "温差大"

df.loc[df["bWendu"]-df["yWendu"]<=10, "wencha_type"] = "温差正常"

五、聚合查询

  1. describe输出统计结果

1
2
py复制代码# 一下子提取所有数字列统计结果
df.describe()
1
2
3
4
5
6
7
8
9
cmd复制代码           bWendu      yWendu         aqi    aqiLevel      wencha
count 365.000000 365.000000 365.000000 365.000000 365.000000
mean 18.665753 8.358904 82.183562 2.090411 10.306849
std 11.858046 11.755053 51.936159 1.029798 2.781233
min -5.000000 -12.000000 21.000000 1.000000 2.000000
25% 8.000000 -3.000000 46.000000 1.000000 8.000000
50% 21.000000 8.000000 69.000000 2.000000 10.000000
75% 29.000000 19.000000 104.000000 3.000000 12.000000
max 38.000000 27.000000 387.000000 6.000000 18.000000

describe只能得到数值列的统计结果

  1. 非数值列统计

2.1 unique唯一去重

1
py复制代码print(df['tianqi'].unique())
1
2
3
4
cmd复制代码['晴~多云' '阴~多云' '多云' '阴' '多云~晴' '多云~阴' '晴' '阴~小雪' '小雪~多云' '小雨~ 阴' '小雨~雨夹雪'
'多云~小雨' '小雨~多云' '大雨~小雨' '小雨' '阴~小雨' '多云~雷阵雨' '雷阵雨~多云' '阴~ 雷阵雨' '雷阵雨'
'雷阵雨~大雨' '中雨~雷阵雨' '小雨~大雨' '暴雨~雷阵雨' '雷阵雨~中雨' '小雨~雷阵雨' '雷 阵雨~阴' '中雨~小雨'
'小雨~中雨' '雾~多云' '霾']

2.2 value_counts按值计数

1
py复制代码print(df['wencha_type'].value_counts())
1
2
3
cmd复制代码温差正常    187
温差大 178
Name: wencha_type, dtype: int64
  1. 协方差和相关系数

1
py复制代码print(df.cov(), '\n\n', df.corr())
1
2
3
4
5
6
7
8
9
10
11
12
13
cmd复制代码              bWendu      yWendu          aqi   aqiLevel     wencha
bWendu 140.613247 135.529633 47.462622 0.879204 5.083614
yWendu 135.529633 138.181274 16.186685 0.264165 -2.651641
aqi 47.462622 16.186685 2697.364564 50.749842 31.275937
aqiLevel 0.879204 0.264165 50.749842 1.060485 0.615038
wencha 5.083614 -2.651641 31.275937 0.615038 7.735255

bWendu yWendu aqi aqiLevel wencha
bWendu 1.000000 0.972292 0.077067 0.071999 0.154142
yWendu 0.972292 1.000000 0.026513 0.021822 -0.081106
aqi 0.077067 0.026513 1.000000 0.948883 0.216523
aqiLevel 0.071999 0.021822 0.948883 1.000000 0.214740
wencha 0.154142 -0.081106 0.216523 0.214740 1.000000

六、缺失值处理

  1. 处理方式

Pandas使用这些函数处理缺失值:

  • isnull和notnull:检测是否是空值,可用于df和series
  • dropna:丢弃、删除缺失值
    • axis : 删除行还是列,{0 or ‘index’, 1 or ‘columns’}, default 0
    • how : 如果等于any则任何值为空都删除,如果等于all则所有值都为空才删除
    • inplace : 如果为True则修改当前df,否则返回新的df
  • fillna:填充空值
    • value:用于填充的值,可以是单个值,或者字典(key是列名,value是值)
    • method : 等于ffill使用前一个不为空的值填充forword fill;等于bfill使用后一个不为空的值填充backword fill
    • axis : 按行还是列填充,{0 or ‘index’, 1 or ‘columns’}
    • inplace : 如果为True则修改当前df,否则返回新的df
  1. 数据清洗示例

image.png

2.1 检测空值

1
2
3
4
5
py复制代码# 跳过前面两空行
studf = pd.read_excel("../datas/student_excel/student_excel.xlsx", skiprows=2)

# 检测空值
print(studf.isnull())
1
2
3
4
5
6
7
8
9
10
11
12
cmd复制代码    Unnamed: 0     姓名     科目     分数
0 True False False False
1 True True False False
2 True True False False
3 True True True True
4 True False False False
5 True True False True
6 True True False False
7 True True True True
8 True False False False
9 True True False False
10 True True False False

2.2 dropna示例

1
2
3
4
5
py复制代码# 删除全为空的行和列
studf.dropna(axis=1, how='all', inplace=True)
studf.dropna(axis=0, how='all', inplace=True)

print(studf)
1
2
3
4
5
6
7
8
9
10
cmd复制代码     姓名  科目    分数
0 小明 语文 85.0
1 NaN 数学 80.0
2 NaN 英语 90.0
4 小王 语文 85.0
5 NaN 数学 NaN
6 NaN 英语 90.0
8 小刚 语文 85.0
9 NaN 数学 80.0
10 NaN 英语 90.0

2.3 fillna示例

1
2
3
4
5
6
py复制代码# 将空的分数填充为0
# 将空的姓名填充为上一个值
studf['分数'].fillna(value=0, inplace=True)
studf['姓名'].fillna(method='ffill', inplace=True)

print(studf)
1
2
3
4
5
6
7
8
9
10
cmd复制代码    姓名  科目    分数
0 小明 语文 85.0
1 小明 数学 80.0
2 小明 英语 90.0
4 小王 语文 85.0
5 小王 数学 0.0
6 小王 英语 90.0
8 小刚 语文 85.0
9 小刚 数学 80.0
10 小刚 英语 90.0

七、数据排序

  1. 排序方法

  1. Series的排序:

Series.sort_values(ascending=True, inplace=False)

参数说明:

  • ascending:默认为True升序排序,为False降序排序
  • inplace:是否修改原始Series
  1. DataFrame的排序:

DataFrame.sort_values(by, ascending=True, inplace=False)

参数说明:

  • by:字符串或者List<字符串>,单列排序或者多列排序
  • ascending:bool或者bool的列表,升序还是降序,如果是list对应by的多列
  • inplace:是否修改原始DataFrame
  1. 排序示例

1
2
py复制代码# Series的排序
print(df['aqi'].sort_values())
1
2
3
4
5
6
7
8
9
10
11
12
13
cmd复制代码ymd
2018-09-29 21
2018-10-09 21
2018-09-07 22
2018-09-30 22
2018-10-29 22
...
2018-11-14 266
2018-03-13 287
2018-04-02 287
2018-03-14 293
2018-03-28 387
Name: aqi, Length: 365, dtype: int64
1
2
3
py复制代码# DataFrame的排序
df.sort_values(by=['aqi', 'bWendu'], ascending=[False, True], inplace=True)
print(df[['aqi', 'bWendu']])
1
2
3
4
5
6
7
8
9
10
11
12
13
cmd复制代码            aqi  bWendu
ymd
2018-03-28 387 25
2018-03-14 293 15
2018-03-13 287 17
2018-04-02 287 26
2018-11-14 266 13
... ... ...
2018-10-29 22 15
2018-09-30 22 19
2018-09-07 22 27
2018-10-09 21 15
2018-09-29 21 22

八、字符串处理

  1. Pandas的str

Pandas的字符串处理:

  1. 使用方法:先获取Series的str属性,然后在属性上调用函数;
  2. 只能在字符串列上使用,不能数字列上使用;
  3. Dataframe上没有str属性和处理方法
  4. Series.str并不是Python原生字符串,而是自己的一套方法,不过大部分和原生str很相似;
  1. 基础用法

获取str属性,并调用各种方法,如replace, isnumeric, len

1
py复制代码print(df['wencha_type'].str.len())
1
2
3
4
5
6
7
8
9
10
11
12
13
cmd复制代码ymd
2018-03-28 3
2018-03-14 4
2018-03-13 3
2018-04-02 3
2018-11-14 4
..
2018-10-29 3
2018-09-30 4
2018-09-07 3
2018-10-09 3
2018-09-29 3
Name: wencha_type, Length: 365, dtype: int64
  1. 条件查询

或使用contains, startswith等得到bool的Series做条件查询

1
py复制代码print(df.loc[df['tianqi'].str.startswith('多云'), ['tianqi', 'fengxiang']])
1
2
3
4
5
6
7
8
9
10
11
12
13
cmd复制代码           tianqi fengxiang
ymd
2018-03-28 多云~晴 东风
2018-03-14 多云~阴 东北风
2018-04-02 多云 北风
2018-11-14 多云 南风
2018-11-26 多云 东南风
... ... ...
2018-01-25 多云 东北风
2018-10-10 多云~晴 西北风
2018-02-03 多云 北风
2018-09-30 多云 西北风
2018-10-09 多云~晴 西北风
  1. 正则表达式

由于Series.str天然支持正则表达式,示例如下:

匹配字符集合并做替换:

1
2
3
4
5
6
7
8
9
py复制代码# 添加新列
def get_nianyueri(x):
year,month,day = x["ymd"].split("-")
return f"{year}年{month}月{day}日"
df["中文日期"] = df.apply(get_nianyueri, axis=1)

# 尝试将 年 月 日 去除
df.loc[:, '中文日期'] = df['中文日期'].str.replace('[年月日]', '')
print(df['中文日期'])
1
2
3
4
5
6
7
8
9
10
11
12
cmd复制代码86     20180328
72 20180314
71 20180313
91 20180402
317 20181114
...
301 20181029
272 20180930
249 20180907
281 20181009
271 20180929
Name: 中文日期, Length: 365, dtype: object

捕获组提取数据:

1
2
py复制代码extracted_fengli = df['fengli'].str.extract(r'(\d)-(\d)')
print(extracted_fengli.head())
1
2
3
4
5
6
7
8
9
10
11
12
cmd复制代码     0  1
86 1 2
72 1 2
71 1 2
91 1 2
317 1 2
.. .. ..
301 3 4
272 4 5
249 3 4
281 4 5
271 3 4

九、索引

  1. 索引的作用

选择恰当的索引可以加速查询性能

  1. 当索引是唯一的时,Pandas会用哈希表优化性能,时间复杂度为O(1)
  2. 当索引不唯一,但是单调时,Pandas会使用二分查找,时间复杂度为O(log n)
  3. 当索引既不唯一且不单调时,Pandas只能遍历,时间复杂度为O(n)

因此,我们要判断当前索引是否为以上类型,尽可能选择唯一的索引,单调次之

  1. 选择索引示例

原始数据如下:

1
py复制代码print(df.head())
1
2
3
4
5
6
cmd复制代码   userId  movieId  rating  timestamp
0 1 1 4.0 964982703
1 1 3 4.0 964981247
2 1 6 4.0 964982224
3 1 47 5.0 964983815
4 1 50 5.0 964982931

判断每一列是否存在唯一约束:

1
py复制代码print(df.nunique() == len(df))
1
2
3
4
5
cmd复制代码userId       False
movieId False
rating False
timestamp False
dtype: bool

判断每一列是否单调:

1
2
3
4
5
6
7
py复制代码
# 使用这一句会报FutureWarning
# is_monotonic = df.apply(lambda x: x.is_monotonic)

is_monotonic_increasing = df.apply(lambda x: x.is_monotonic_increasing)
is_monotonic_decreasing = df.apply(lambda x: x.is_monotonic_decreasing)
print(is_monotonic_increasing, '\n\n', is_monotonic_decreasing)
1
2
3
4
5
6
7
8
9
10
11
cmd复制代码userId        True
movieId False
rating False
timestamp False
dtype: bool

userId False
movieId False
rating False
timestamp False
dtype: bool
  1. 设置索引示例

DataFrame.set_index(keys, append=False, drop=True, inplace=False)

keys代表被用作索引的列

append代表是否保留原来的索引

drop表示是否将指定的列在原数据列中删除

inplace表示是否在原数据上修改

1
2
py复制代码df.set_index('userId',append=True, drop=False, inplace=True)
print(df.head())
1
2
3
4
5
6
7
cmd复制代码          userId  movieId  rating  timestamp
userId
0 1 1 1 4.0 964982703
1 1 1 3 4.0 964981247
2 1 1 6 4.0 964982224
3 1 1 47 5.0 964983815
4 1 1 50 5.0 964982931

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Android native crash sdk实现之cra

发表于 2024-04-17

背景

本文主要是简单介绍一下 native crash 的发生过程,如何捕获,以及如何抓取并生成 Android tombstone 文件中的信息。

native crash 的发生

以下面代码中的空指针问题为例来看下 crash 的发生:

1
2
3
static int getValue() {
return *(int*)nullptr;
}

当我们调用上面的方法后,进程会异常退出:Segmentation fault。如果我们用lldb去运行他,可以看到更详细的信息:stop reason = signal SIGSEGV: address not mapped to object (fault address: 0x0)

我们先反编译一下上面的代码,看下他做了什么,以及为什么会导致进程异常退出:

1
2
3
4
getValue:
mov x0, 0
ldr w0, [x0]
ret

这里可以看到他是从虚拟地址0处读取32bits数据写入w0寄存器(x0的低32位,高32位自动清0),然而操作系统不会为虚拟地址0建立映射,因此MMU在将虚拟地址0转换到物理地址时找不到相应的页表项,于是MMU会产生一个 Data Abort,cpu exception level会切换到 EL1(操作系统内核所在的特权级),并执行内核启动时所预设的exception handler。

以上面的case为例,内核可以根据 ESR_EL1 寄存器中的信息判断异常原因,从 FAR_EL1 寄存器中获得导致异常的虚拟地址(比如上面的 0x0)。然后内核会向对应进程发送信号(比如上面的 SIGSEGV),然后在从内核态返回用户态之前会处理下信号,以SIGSEGV为例,默认行为就是杀死进程,所以我们看到进程crash了。

native crash 的捕获

上面提过native代码发生异常时,系统会向进程发送信号,而大多数信号是可以设置信号处理器的,如果设置了自定义的 signal handler,那么系统会调用我们设置的signal handler,在这个handler中我们可以收集一下进程的状态信息,这就完成了crash的捕获以及信息的收集了。(不是所有的信号都能捕获,比如 SIGKILL,SIGSTOP)

下面是一个简单的设置 signal handler 的代码:

1
2
3
4
5
6
7
8
9
10
struct sigaction action;
action.sa_flags = SA_ONSTACK | SA_SIGINFO;
sigfillset(&action.sa_mask);
action.sa_sigaction = crashHandler;

for (auto& sig : targetSignals) {
if (sigaction(sig.signum, &action, &sig.oldAction) != 0) {
LOGE("failed to set sig action for signal: %d, with error: %s", sig.signum, strerror(errno));
}
}

Android tombstone 文件信息

在发生native crash的时候,系统会为我们抓取非常详细的信息写入 tombstone 文件中供我们分析,我们先来看看 tombstone 文件中有哪些信息,长什么样子(不同Android 版本生成的 tombstone 信息有些许差异,但主要信息是一致的):

1
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
// Build fingerprint, ABI, Timestamp 等信息

// crash进程id,线程id,线程名,进程名
pid: 5577, tid: 5577, name: rashkitdemo.app >>> com.crashkitdemo.app <<<

// 具体的 signal,不同的 signal 信息会有些许不同
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000000

// crash 线程的寄存器信息
x0 0000000000000000 x1 00000075bd01f048 x2 000000730bd4dc88 x3 b400007444a23b20
x4 000000730bd4dc88 x5 0000007ff5d5d0f4 x6 0000000000000004 x7 0000000000000004
x8 0000000000000002 x9 0000000000000000 x10 0000000000000007 x11 0000000000000007
x12 0000007310415000 x13 0000007ff5d5cdf0 x14 00000075bd01f049 x15 00000000ebad6a89
x16 000000729a3569e4 x17 0000007ff5d5e1a0 x18 00000075e4b10000 x19 b400007444a23b20
x20 0000000000000000 x21 b400007444a23be8 x22 0000000000000000 x23 0000000000000000
x24 0000007ff5d5e2e0 x25 00000075bd01f048 x26 0000007ff5d5e320 x27 00000075bd01f068
x28 0000007ff5d5e1b0 x29 0000007ff5d5e190
lr 000000729a356a00 sp 0000007ff5d5e170 pc 000000729a356a60 pst 0000000060001000

// 调用栈信息
backtrace:
#00 pc 0000000000000a60 /data/app/~~DhNE9mAtTabYo-_2ImmDcw==/com.crashkitdemo.app-qJya3FZwp4w4-QjqVnVlOA==/lib/arm64/libcrashkitdemo.so (BuildId: 4b4514f52a0aaa0f2c3a61c53b2ebce22daa6f90)
#01 pc 00000000000009fc /data/app/~~DhNE9mAtTabYo-_2ImmDcw==/com.crashkitdemo.app-qJya3FZwp4w4-QjqVnVlOA==/lib/arm64/libcrashkitdemo.so (BuildId: 4b4514f52a0aaa0f2c3a61c53b2ebce22daa6f90)
#02 pc 0000000000377030 /apex/com.android.art/lib64/libart.so (art_quick_generic_jni_trampoline+144) (BuildId: b10f5696fea1b32039b162aef3850ed3)

// 以寄存器值为地址取出附近的内存数据
memory near x1 ([anon:dalvik-LinearAlloc]):
00000075bd01f020 000000730fb77110 1039000112c78888 .q..s.........9.
00000075bd01f030 ffee000100000003 00000075dc728160 ........`.r.u...
00000075bd01f040 000000730fb77110 1020010a12c78888 .q..s......... .
00000075bd01f050 fffe000200000004 000000729a3569e4 .........i5.r...
00000075bd01f060 000000730fb76e60 1838000412c78888 `n..s.........8.
00000075bd01f070 ffdf016900000005 00000075dc728178 ....i...x.r.u...
00000075bd01f080 000000730fb77110 0000000000000000 .q..s...........
00000075bd01f090 70a3524870979e50 0000000012c78888 P..pHR.p........
00000075bd01f0a0 704c0a007047f100 0000000000000000 ..Gp..Lp........
00000075bd01f0b0 0000000071250e68 00000000712524a8 h.%q.....$%q....
00000075bd01f0c0 00000075bd01f008 00000075bd01f028 ....u...(...u...
00000075bd01f0d0 00000075bd01f048 00000075bd01f068 H...u...h...u...
00000075bd01f0e0 000000007068f190 0000000000000000 ..hp............
00000075bd01f0f0 0000000000000000 0000000000000000 ................
00000075bd01f100 0000000000000000 0000000000000000 ................
00000075bd01f110 0000000000000000 0000000000000000 ................

// 进程的内存映射信息
memory map (2775 entries):
--->Fault address falls at 00000000'00000000 before any mapped regions
00000000'12c00000-00000000'2abfffff rw- 0 18000000 [anon:dalvik-main space (region space)]
00000000'7047f000-00000000'70737fff rw- 0 2b9000 [anon:dalvik-/system/framework/boot.art]
00000000'70738000-00000000'70780fff rw- 0 49000 [anon:dalvik-/system/framework/boot-core-libart.art]
00000000'70781000-00000000'707aafff rw- 0 2a000 [anon:dalvik-/system/framework/boot-okhttp.art]
00000000'707ab000-00000000'707e8fff rw- 0 3e000 [anon:dalvik-/system/framework/boot-bouncycastle.art]
00000000'707e9000-00000000'707e9fff rw- 0 1000 [anon:dalvik-/system/framework/boot-apache-xml.art]

// 其他线程的寄存器、调用栈信息
--- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
Cmdline: com.crashkitdemo.app
pid: 5577, tid: 5600, name: Runtime worker >>> com.crashkitdemo.app <<<
uid: 10190
tagged_addr_ctrl: 0000000000000001 (PR_TAGGED_ADDR_ENABLE)
pac_enabled_keys: 000000000000000f (PR_PAC_APIAKEY, PR_PAC_APIBKEY, PR_PAC_APDAKEY, PR_PAC_APDBKEY)
x0 b4000074a4a270a8 x1 0000000000000080 x2 0000000000000000 x3 0000000000000000
x4 0000000000000000 x5 0000000000000000 x6 0000000000000000 x7 7f7f7f7f7f7f7f7f
x8 0000000000000062 x9 b8fd705938aff065 x10 0000000000000002 x11 0000000000000020
x12 0000000100000000 x13 0000000000000000 x14 0000000000000000 x15 00000075e41c8000
x16 000000731020fad0 x17 00000075cca95e00 x18 00000072f4f20000 x19 b4000074a4a27098
x20 b400007444a272c0 x21 b4000074a4a270a8 x22 0000000000000000 x23 b4000074a4a27098
x24 00000075e41c7cb0 x25 00000075e41c8000 x26 0000000000000001 x27 0000000000014000
x28 0000000000016000 x29 00000075e41c7b40
lr 000000730fa2cfb0 sp 00000075e41c7b30 pc 00000075cca95e1c pst 0000000060001000

7 total frames
backtrace:
#00 pc 0000000000062e1c /apex/com.android.runtime/lib64/bionic/libc.so (syscall+28) (BuildId: a87908b48b368e6282bcc9f34bcfc28c)
#01 pc 000000000022cfac /apex/com.android.art/lib64/libart.so (art::ConditionVariable::WaitHoldingLocks(art::Thread*)
--- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
Cmdline: com.crashkitdemo.app
pid: 5577, tid: 5601, name: Runtime worker >>> com.crashkitdemo.app <<<
uid: 10190
tagged_addr_ctrl: 0000000000000001 (PR_TAGGED_ADDR_ENABLE)
pac_enabled_keys: 000000000000000f (PR_PAC_APIAKEY, PR_PAC_APIBKEY, PR_PAC_APDAKEY, PR_PAC_APDBKEY)
x0 b4000074a4a270a8 x1 0000000000000080 x2 0000000000000000 x3 0000000000000000
x4 0000000000000000 x5 0000000000000000 x6 0000000000000000 x7 7f7f7f7f7f7f7f7f
x8 0000000000000062 x9 b8fd705938aff065 x10 0000000000000001 x11 0000000000000020
x12 0000000100000000 x13 0000000000000000 x14 0000000000000000 x15 00000075e4128000
x16 000000731020fad0 x17 00000075cca95e00 x18 00000072f6c34000 x19 b4000074a4a27098
x20 b400007444a256f0 x21 b4000074a4a270a8 x22 0000000000000000 x23 b4000074a4a27098
x24 00000075e4127cb0 x25 00000075e4128000 x26 0000000000000001 x27 0000000000014000
x28 0000000000016000 x29 00000075e4127b40
lr 000000730fa2cfb0 sp 00000075e4127b30 pc 00000075cca95e1c pst 0000000060001000

7 total frames
backtrace:
#00 pc 0000000000062e1c /apex/com.android.runtime/lib64/bionic/libc.so (syscall+28) (BuildId: a87908b48b368e6282bcc9f34bcfc28c)
#01 pc 000000000022cfac /apex/com.android.art/lib64/libart.so (art::ConditionVariable::WaitHoldingLocks(art::Thread*)+140) (BuildId: b10f5696fea1b32039b162aef3850ed3)
//...

// 打开的 fd 信息
open files:
fd 0: /dev/null
fd 1: /dev/null
fd 2: /dev/null
fd 3: socket:[55224]
fd 4: /sys/kernel/tracing/trace_marker
fd 5: /dev/null
fd 6: /apex/com.android.art/javalib/core-oj.jar
fd 7: /apex/com.android.art/javalib/core-libart.jar
fd 8: /apex/com.android.art/javalib/okhttp.jar
fd 9: /apex/com.android.art/javalib/bouncycastle.jar
fd 10: /apex/com.android.art/javalib/apache-xml.jar
fd 11: /system/framework/framework.jar
fd 12: /system/framework/framework-graphics.jar
fd 13: /system/framework/ext.jar
fd 14: /system/framework/telephony-common.jar
fd 15: /system/framework/voip-common.jar
fd 16: /system/framework/ims-common.jar

// logcat 信息
--------- log main
03-12 13:52:34.149 5577 5577 I rashkitdemo.app: Late-enabling -Xcheck:jni
03-12 13:52:34.167 5577 5577 I rashkitdemo.app: Using CollectorTypeCC GC.
03-12 13:52:34.228 5577 5577 D CompatibilityChangeReporter: Compat change id reported: 171979766; UID 10190; state: ENABLED
03-12 13:52:34.228 5577 5577 D CompatibilityChangeReporter: Compat change id reported: 242716250; UID 10190; state: ENABLED
03-12 13:52:34.232 5577 5577 W ziparchive: Unable to open '/data/app/~~DhNE9mAtTabYo-_2ImmDcw==/com.crashkitdemo.app-qJya3FZwp4w4-QjqVnVlOA==/base.dm': No such file or directory
03-12 13:52:34.232 5577 5577 W ziparchive: Unable to open '/data/app/~~DhNE9mAtTabYo-_2ImmDcw==/com.crashkitdemo.app-qJya3FZwp4w4-QjqVnVlOA==/base.dm': No such file or directory
03-12 13:52:34.233 5577 5577 D nativeloader: Configuring clns-6 for other apk /data/app/~~DhNE9mAtTabYo-_2ImmDcw==/com.crashkitdemo.app-qJya3FZwp4w4-QjqVnVlOA==/base.apk. target_sdk_version=34, uses_libraries=, library_path=/data/app/~~DhNE9mAtTabYo-_2ImmDcw==/com.crashkitdemo.app-qJya3FZwp4w4-QjqVnVlOA==/lib/arm64:/data/app/~~DhNE9mAtTabYo-_2ImmDcw==/com.crashkitdemo.app-qJya3FZwp4w4-QjqVnVlOA==/base.apk!/lib/arm64-v8a, permitted_path=/data:/mnt/expand:/data/user/0/com.crashkitdemo.app
// ...

tombstone 信息生成

从上面的例子可以看到,Android 系统生成的 tombstone 中主要包含的信息有:

  1. crash进程id,进程名,线程id,线程名
  2. signal 信息
  3. crash线程寄存器信息
  4. crash线程调用栈
  5. crash线程寄存器值为地址附近的内存数据
  6. crash进程内存映射信息
  7. 其他线程寄存器&调用栈信息
  8. 打开的fd信息
  9. logcat中的日志信息

这些信息已经相当丰富,但是我们没有权限读取系统生成的tombstone信息。(有root权限是可以的,通过 adb bugreport 也可以拿到,但是对于获取线上crash信息而言都是不行的)所以下面我们自己来实现这些信息的获取。

暂停crash进程的执行

上面提到我们需要获取内存数据,各个线程的寄存器、调用栈等信息,因此我们在收到signal的时候应该尽可能快的“冻结”crash进程中所有线程的执行,以避免破坏现场。于是我们在收到signal的时候会fork一个子进程,子进程会先“冻结”crash进程的执行,然后在子进程中完成上面列出来的一系列信息的收集。

fork子进程&传递crash信息

我们需要将crash的一些关键信息传递给子进程,比如crash的线程id,siginfo,ucontext_t,以及抓取的信息要写入的位置等。有多种方式可以用来传递这些信息,下面的示例代码我们以管道来实现:

1
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
39
40
if (int pipeFds[2]; pipe(pipeFds) == 0) {
switch (pid_t pid; pid = fork()) {
case -1: {
LOGE("failed to fork process: %s", strerror(errno));
break;
}
case 0: {
// child process, close pipe's write end
close(pipeFds[1]);
if (pipeFds[0] != STDIN_FILENO) {
if (dup2(pipeFds[0], STDIN_FILENO) == -1) {
LOGE("failed to redirect stdin to pipe's read end: %s", strerror(errno));
_exit(1);
}
close(pipeFds[0]);
}

setenv("LD_LIBRARY_PATH", gNativeLibPath, 1);

std::string dumpperPath(gNativeLibPath);
if (dumpperPath.back() != '/') {
dumpperPath += '/';
}
dumpperPath += "libcrashdumpper.so";
execl(dumpperPath.c_str(), "crashdumpper", (char*)nullptr);
LOGE("execl failed: %s", strerror(errno));
_exit(1);
}
default: {
// parent process, close pipe's read end
close(pipeFds[0]);
// write crash info into pipe
if (write(pipeFds[1], &crashInfo, sizeof(CrashInfo)) != sizeof(CrashInfo)) {
LOGE("failed to write crash info into pipe");
}
// wait dumpper process to finish
waitDumpperProcess(pid);
}
}
}

几个小点稍微解释一下:

  1. 子进程中我们通过dup2将管道的读端重定向到stdin,方便后续可执行文件读取
  2. 设置LD_LIBRARY_PATH环境变量是因为我们的dumper程序依赖apk内的libc++_shared.so,这样方便动态链接器查找到
  3. libcrashdumpper.so其实是一个可执行程序,这样命名并放到lib中,安装时系统会自动解压到nativeLibraryDir中,并有可执行权限
  4. waitDumpperProcess是通过waitpid等待dumper进程执行完成
暂停crash进程的执行

在本文开头曾提到“不是所有的信号都能捕获,比如 SIGKILL,SIGSTOP”,此处SIGSTOP就派上用场了,当一个进程(此处其实应该说是线程,只是信号机制是出现在线程机制之前的,所以这块的api以及部分描述有时候会用process)收到SIGSTOP后内核会停止对其的调度直到收到SIGCONT。

因此如果我们要暂停某个线程的执行就可以向他发送SIGSTOP,如果要暂停整个进程的执行就可以向这个进程下的所有线程发送SIGSTOP。不过后续我们需要获取crash进程的内存、寄存器等信息,使用ptrace api 比较方便,我们也不用自己发送SIGSTOP,PTRACE_ATTACH到目标线程即可(PTRACE_ATTACH 请求也会发送SIGSTOP)。

获取crash进程的所有线程id

/proc/${pid}/task 目录为每个子线程包含一个子目录,目录名就是线程的id,因此我们可以获取到crash进程的所有子线程的id:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
std::vector<pid_t> loadThreads(pid_t pid) {
std::string path("/proc/");
path += std::to_string(pid);
path += "/task";

std::vector<pid_t> tids;
DIR* dir = opendir(path.c_str());
if (!dir) {
return tids;
}

dirent* ent;
while((ent = readdir(dir))) {
int tid = parseTid(ent->d_name);
if (tid != -1) {
tids.push_back(tid);
}
}
closedir(dir);

return tids;
}
暂停所有线程的执行

上面提到通过 ptrace attach 到指定线程可以暂停其执行,不过 ptrace 方法返回后对应线程可能还没有停止执行,可以通过 waitpid 确保其停止执行,因此我们可以先向crash进程的所有线程发送PTRACE_ATTACH请求,然后再wait。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void suspendThreads(const std::vector<pid_t>& tids) {
for (auto tid : tids) {
if (ptrace(PTRACE_ATTACH, tid, nullptr, nullptr) == -1) {
LOGE("failed ptrace attach to thread: %d, with error: %s", tid, strerror(errno));
}
}

// wait for stop
for (auto tid : tids) {
errno = 0;
while (waitpid(tid, nullptr, __WALL) < 0) {
if (errno != EINTR) {
LOGE("waitpid: %d failed, %s", tid, strerror(errno));
break;
}
errno = 0;
}
}
}

获取crash进程id,进程名,线程id,线程名

  1. crash进程是我们dumper进程的父进程,因此通过getppid可获取其进程id
  2. 通过/proc/${pid}/cmdline可获取进程名
  3. crash线程id已经通过管道传递过来了
  4. 通过/proc/${tid}/comm可获取线程名(Linux中线程是全局唯一的,因此 /proc/pid/task/{pid}/task/pid/task/{tid} 和 /proc/${tid} 指向同一个目录)

signal 信息

可以通过siginfo拿到signal number,fault addr等信息,如果要拿到siginfo以及ucontext_t,在注册signal action的时候需要添加SA_SIGINFOflag。

1
2
3
4
5
6
7
8
9
static void printSignalInfo(const siginfo_t& info) {
char faultAddr[17];
if (signalHasFaultAddr(info.si_signo)) {
snprintf(faultAddr, sizeof(faultAddr), "%p", info.si_addr);// context->uc_mcontext.fault_address
} else {
snprintf(faultAddr, sizeof(faultAddr), "--------");
}
LOGI("signal: %d, code: %d, %s, fault addr: %s", info.si_signo, info.si_code, strsignal(info.si_signo), faultAddr);
}
  • signalHasFaultAddr: 并非所有的异常都有 fault addr,所以此处有个简单的判断,如果没有的话,就输出: fault addr: ——–

crash线程寄存器信息

crash 线程寄存器信息是在ucontext_t数据结构中,已经通过管道传递给dumper进程了,我们只需要按照tombstone格式输出就行,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static void printRegisters(FILE* out, const ucontext_t& context) {
for (int i = 0; i < 28; i += 4) {
fprintf(out, "x%-2d %016llx x%-2d %016llx x%-2d %016llx x%-2d %016llx"
, i, context.uc_mcontext.regs[i]
, i + 1, context.uc_mcontext.regs[i + 1]
, i + 2, context.uc_mcontext.regs[i + 2]
, i + 3, context.uc_mcontext.regs[i + 3]
);
}
fprintf(out, "x28 %016llx x29 %016llx", context.uc_mcontext.regs[28], context.uc_mcontext.regs[29]);

fprintf(out, "sp %016llx lr %016llx pc %016llx pst %016llx"
, context.uc_mcontext.sp
, context.uc_mcontext.regs[30]
, context.uc_mcontext.pc
, context.uc_mcontext.pstate
);
}

crash线程调用栈

栈回溯

这个就是要实现栈回溯功能,但是在Android上实现栈回溯还是比较麻烦的,有很多文章介绍这方面内容,此处就以最简单最高效的基于fp的栈回溯方案来实现一下~

这个方案的原理是:如果编译的时候启用了-fno-omit-frame-pointer选项(target是aarch64时,通常是启用的),那么编译器会用x29寄存器(也就是fp)保存当前栈帧的起始地址,而fp指向的栈元素中保存上一个栈帧的起始地址(也就是 pre fp),紧靠着的下一个栈元素存放函数的返回地址(lr)。因此根据fp我们能找到一个个栈帧的起始位置,也就能找到一个个栈帧的返回地址(lr),而函数调用地址就是对应返回地址的上一条指令,因此就完成了回溯。

我们现在是在dumper进程中,没法直接读取crash进程的内存数据,不过上面提到过可以借助ptrace系统调用来实现,示例代码如下:

1
2
3
4
5
6
7
8
std::optional<long> readData(pid_t pid, void* addr) {
errno = 0;
long data = ptrace(PTRACE_PEEKDATA, pid, addr, nullptr);
if (errno != 0) {
return {};
}
return data;
}

然后我们可以实现一个简版的栈回溯:

1
2
3
4
5
6
7
8
9
10
11
12
13
for (int i = 0; i < max; ++i) {
auto preFp = readData(pid, (void*)fp);
if (!preFp) {
return;
}
auto preLr = readData(pid, (uint64_t*)fp + 1);
if (!preLr) {
return;
}
uint64_t prePc = preLr.value() - 4;
// todo process pc
fp = preFp.value();
}

几点补充:

  1. 某些so可能没有启用-fno-omit-frame-pointer,另外穿过jni、jit、oat等代码时可能也会存在问题,
    所以回溯过程中可能会出现SIGSEGV等问题,一般可以通过 sigsetjmp,siglongjmp做一下保护
    ,不过上面是通过ptrace系统调用读取的,如果地址访问存在问题会设置errno,不会出现异常(signal),
    在我们的readData中已经做过判断,这种情况下会返回std::nullopt
  2. 也可以加个优化:通过pthread_attr_getstack获取线程栈的地址范围,如果fp超出范围就提前终止回溯
  3. 我们上面通过lr - 4来获取上一条指令的地址,这对于aarch64来讲没问题,因为指令长度固定4字节,
    因此我们可以精确计算。但如果是aarch32,或者是x86这种变长指令集的话怎么处理呢?一种简单的方法是使用lr - 1,
    这个地址一定落在上一条指令中,通过他获取对应的行号信息也是准确的。
pc处代码所在文件的路径
1
#02 pc 0000000000377030  /apex/com.android.art/lib64/libart.so (art_quick_generic_jni_trampoline+144) (BuildId: b10f5696fea1b32039b162aef3850ed3)

调用栈中通常会输出pc处代码所在文件的路径,这个比较好实现:/proc/${pid}/maps中存储了所有内存映射信息,包括映射起始虚拟地址,权限,路径名等信息,因此根据上一步拿到的pc虚拟地址就可以从maps中找到对应的路径信息

pc处代码在文件中的偏移
1
#02 pc 0000000000377030  /apex/com.android.art/lib64/libart.so (art_quick_generic_jni_trampoline+144) (BuildId: b10f5696fea1b32039b162aef3850ed3)

在输出的调用栈信息中pc的值其实不是上面取到的虚拟地址,因为每次运行其虚拟地址都是变化的,输出这个没有意义,因此他输出的是pc指向的代码在其文件中的偏移。这个可以通过pc虚拟地址 - 对应elf的load bias来获得。elf的load bias在Android PLT-GOT hook 实现提到过,感兴趣的话可以看一下

符号名(函数名)获取

函数的符号名存储在 elf 文件的符号表(.symtab SHT_SYMTAB)中,配合字符串表(.strtab)可以加载出所有的符号信息,离上一步获取的相对pc(文件内偏移量)最近的(symbol.st_value <= relative_pc)的类型为STT_FUNC的符号即是我们要找的符号名(函数名)。

因为.symtab & .strtab不是运行时需要的section,所以有可能会被strip掉,即使没有strip掉,他们大概率也不会被映射进内存。我们可以先check一下,如果这2个section已经被映射进内存,那么我们直接读内存数据解析,否则我们直接解析elf文件。下面给个解析elf文件中所有符号的偏移&符号名的示例代码:

1
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
39
40
41
42
43
44
45
46
static std::vector<std::pair<uint64_t, std::string>> loadSymbolsFromPath(const std::string& path) {
auto mapedFile = MapedFile::mapFile(path.c_str());
if (!mapedFile) {
LOGE("failed to mmap file: %s", path.c_str());
return {};
}

auto ptr = (uint64_t)mapedFile->ptr();
auto ehdr = *(ElfW(Ehdr)*)ptr;

auto shdrStart = ptr + ehdr.e_shoff;
int symtabIdx = -1;
for (int i = 0; i < ehdr.e_shnum; ++i) {
auto shdr = *(ElfW(Shdr)*)(shdrStart + ehdr.e_shentsize * i);
if (shdr.sh_type == SHT_SYMTAB) {
symtabIdx = i;
break;
}
}
if (symtabIdx == -1) {
LOGE("symtab not found in file");
return {};
}

auto symtab = *(ElfW(Shdr)*)(shdrStart + ehdr.e_shentsize * symtabIdx);
auto strtab = *(ElfW(Shdr)*)(shdrStart + ehdr.e_shentsize * symtab.sh_link);
assert(strtab.sh_type == SHT_STRTAB);

auto symtabStart = ptr + symtab.sh_offset;
auto symtabEnd = symtabStart + symtab.sh_size;
auto strtabStart = ptr + strtab.sh_offset;
// parse symbol
symtabStart += symtab.sh_entsize;// skip first: STN_UNDEF
auto symbolCount = symtab.sh_size / symtab.sh_entsize - 1;
std::vector<std::pair<uint64_t, std::string>> syms;
syms.reserve(symbolCount);

do {
auto sym = *(ElfW(Sym)*)symtabStart;
if (ELF_ST_TYPE(sym.st_info) == STT_FUNC) {
syms.emplace_back(sym.st_value, (const char*) (strtabStart + sym.st_name));
}
symtabStart += symtab.sh_entsize;
} while (symtabStart < symtabEnd);
return syms;
}

elf中动态符号的查找之前在ELF 通过 Sysv Hash & Gnu Hash 查找符号的实现及对比中提过,感兴趣的话可以看下。

(art_quick_generic_jni_trampoline+144) 符号名后面的 +xxx 指的是pc距离符号基地址的偏移:relative_pc - symble.st_value

获取elf的BuildId

build-id 是linker根据输入使用md5、sha1等算法计算的一个checksum,用于标记一次编译的。比如上面我们提到要获取elf文件的符号信息需要符号表,字符串表,但通常我们发布的elf都是strip过的,同时保留一个未strip版本的elf,然后我们的crash sdk在获取到elf的 build-id 以及 relative-pc 信息后上报到服务端,在服务端就可以根据build-id来找到对应的未strip的elf,然后来解析相应的符号信息。

build-id信息是存储在PT_NOTE类型的segment中的,其name的值是“GNU”,因此我们可以像如下代码那样读取 build-id 的信息:

1
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
for (int i = 0; i < ehdr.e_phnum; ++i) {
auto phdr = *(ElfW(Phdr)*)((uint64_t)phdrData + i * ehdr.e_phentsize);
switch (phdr.p_type) {
// ...
case PT_NOTE: {
auto noteStart = (const char*)(loadBias_ + phdr.p_vaddr);
auto noteEnd = noteStart + phdr.p_memsz;
do {
auto note = *(ElfW(Nhdr)*) noteStart;
if (strncmp(noteStart + sizeof(ElfW(Nhdr)), "GNU", sizeof("GNU")) == 0) {
auto descStart = noteStart + sizeof(ElfW(Nhdr)) + note.n_namesz;
auto descEnd = descStart + note.n_descsz;

std::stringstream buf;
buf.fill('0');
buf.setf(std::ios_base::hex, std::ios_base::basefield);
for (; descStart < descEnd; ++descStart) {
buf.width(2);
buf << (uint32_t) *descStart;
}
buildId_ = buf.str();
break;
}

noteStart += sizeof(ElfW(Nhdr)) + note.n_namesz + note.n_descsz;
} while (noteStart < noteEnd);
break;
}
}
}

crash线程寄存器值为地址附近的内存数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
memory near x1 ([anon:dalvik-LinearAlloc]):
00000075bd01f020 000000730fb77110 1039000112c78888 .q..s.........9.
00000075bd01f030 ffee000100000003 00000075dc728160 ........`.r.u...
00000075bd01f040 000000730fb77110 1020010a12c78888 .q..s......... .
00000075bd01f050 fffe000200000004 000000729a3569e4 .........i5.r...
00000075bd01f060 000000730fb76e60 1838000412c78888 `n..s.........8.
00000075bd01f070 ffdf016900000005 00000075dc728178 ....i...x.r.u...
00000075bd01f080 000000730fb77110 0000000000000000 .q..s...........
00000075bd01f090 70a3524870979e50 0000000012c78888 P..pHR.p........
00000075bd01f0a0 704c0a007047f100 0000000000000000 ..Gp..Lp........
00000075bd01f0b0 0000000071250e68 00000000712524a8 h.%q.....$%q....
00000075bd01f0c0 00000075bd01f008 00000075bd01f028 ....u...(...u...
00000075bd01f0d0 00000075bd01f048 00000075bd01f068 H...u...h...u...
00000075bd01f0e0 000000007068f190 0000000000000000 ..hp............
00000075bd01f0f0 0000000000000000 0000000000000000 ................
00000075bd01f100 0000000000000000 0000000000000000 ................
00000075bd01f110 0000000000000000 0000000000000000 ................
  1. 第一行中的 [anon:dalvik-LinearAlloc] 是以x1寄存器的值为虚拟地址,在/proc/${pid}/maps中找到对应的内存映射项的pathname
  2. 第一列是虚拟地址,中间两列是内存值,最后一列是内存值的ascii表示,不可打印的字符用’.’代替

这个信息有时候是有用的,比如数组越界的case,有可能会发现相同的字符串序列多次出现,就可以查看下相关代码是否有问题。

crash进程内存映射信息

这个上面已经提到过了,读取/proc/${pid}/maps就OK了

其他线程寄存器&调用栈信息

对于crash的线程,发生crash时的寄存器信息是由操作系统给我们的(context.uc_mcontext.regs)不用我们去获取。其他线程的寄存器信息可以通过ptrace PTRACE_GETREGS or PTRACE_GETREGSET来获取,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bool getThreadRegs(pid_t tid, user_regs_struct& regs) {
#ifdef PTRACE_GETREGS
if (ptrace(PTRACE_GETREGS, tid, nullptr, &regs) == -1) {
LOGE("PTRACE_GETREGS failed: %s", strerror(errno));
return false;
}
#else
iovec iovec;
iovec.iov_base = &regs;
iovec.iov_len = sizeof(regs);
if (ptrace(PTRACE_GETREGSET, tid, NT_PRSTATUS, &iovec) == -1) {
LOGE("PTRACE_GETREGSET failed: %s", strerror(errno));
return false;
}
#endif
return true;
}

获取其他线程的调用栈信息跟上面提到的crash线程栈回溯实现一致,此处就忽略了。

有一点需要注意的是:对于crash线程的信息(寄存器值&调用栈)都是发生crash时的准确信息,而其他线程的寄存器值、调用栈都是crash之后一段时间的状态,所以我们文章开头提到要尽可能快的“冻结”crash进程中的所有线程,尽量接近crash现场。

打开的fd信息

在/proc/${pid}/fd中保存了进程打开的所有fd信息,都是符号连接,文件名是fd的数值,内容是fd对应的名称,读取fd信息的示例代码如下:

1
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
void dumpOpenFds(pid_t pid) {
char pathBuf[PATH_MAX];
snprintf(pathBuf, sizeof(pathBuf), "/proc/%d/fd", pid);

DIR* dir = opendir(pathBuf);
if (!dir) {
LOGE("failed to open dir: /proc/%d/fd", pid);
return;
}

char fdValue[PATH_MAX];

dirent* ent;
while((ent = readdir(dir))) {
if (ent->d_type == DT_LNK) {
snprintf(pathBuf, sizeof(pathBuf), "/proc/%d/fd/%s", pid, ent->d_name);
auto count = readlink(pathBuf, fdValue, sizeof(fdValue) - 1);
if (count < 0) {
continue;
}
fdValue[count] = '\0';
LOGI("fd: %s -> %s", ent->d_name, fdValue);
}
}
closedir(dir);
}

logcat中的日志信息

crash的时候收集最近的logcat信息通常是有帮助的,有些crash需要依赖系统日志来分析,如果我们编译时没有移除app内打印logcat的字节码的话,crash附近的业务log对分析、定位问题通常也有帮助,要收集logcat信息比较简单:fork一个子进程执行/system/bin/logcat即可,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void dumpLogcat(const char* output) {
switch (pid_t pid; pid = fork()) {
case -1: {
LOGE("fork failed: %s", strerror(errno));
break;
}
case 0: {
execl("/system/bin/logcat", "-t", "10000", "-d", "-v", "threadtime", "-f", output, (char*) nullptr);
LOGE("execl failed: %s", strerror(errno));
_exit(1);
}
default: {
waitLogcatProcess(pid);
}
}
}

几个注意点

要实现好crash捕获sdk还是比较复杂的,还有挺多地方要考虑,比如:预留一部分内存以应对oom类的crash,设置一个备用信号栈以应对stack overflow,预留一些fd以应对fd不足的crash等等。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

聊聊 CSS 的 marker

发表于 2024-04-16

::marker 是一个 CSS 的另一个伪元素,有点类似于 CSS 的 ::before 和 ::after 伪元素。只不过,它常用于给列表标记框定制样式。简而言之,使用::marker伪元素,可以对列表做一些有趣的事情,在本文中,我们将深入的聊聊该伪元素。

初识 CSS 的 ::marker

::marker 是 CSS 的伪元素,现在被纳入到 CSS Lists Module Level 3 规范中。在该规范中涵盖了列表和计算数器相关的属性,比如我们熟悉的list-style-type、list-style-position、list-style、list-item、counter-increment、counter-reset、counter()和counters()等属性。

在 CSS 中 display 设置 list-item 值之后就会生成一个 Markers 标记以及控制标记位置和样式的几个属性,而且还定义了计数器(计数器是一种特殊的数值对象),而且该计数器通常用于生成标记(Markers)的默认内容。

一时之间,估计大家对于Markers标记并不熟悉,但对于一个列表所涉及到的相关属性应该较为熟悉,对于一个CSS List,它可以涵盖了下图所涉及到的相关属性:

如果你对CSS List中所涉及到的属性不是很了解的话,可以暂时忽略,随着后续的知识,你会越来越清楚的。

解构一个列表

虽然我们在 Web 的制作中经常会用到列表,但大家可能不会过多的考虑列表相关的属性或使用。就 HTML语义化出发,如果遇到无序列表的时候会使用 <ul>,遇到有序列表的时候会使用 <ol>,但在有些场景(或不追求语义化的同学)会采用其他的标签元素,比如说 <div>。针对这个场景,会采用 display 设置为list-item。如此一来会创建一个块级别的框,以及一个附加的标记框。同时也会自动增加一个隐含列表项计算数器。

ul 和 ol 元素默认情况之下会带有list-style-type、list-style-image和list-style-position属性,可以用来设置列表项的标记样式。同样的,带有display:list-item的元素生成的标记框,也可以使用这几个属性来设置标记项样式。

list-style-type的属性有很多个值:

取值不同时,列表符号(也就是Marker标识符)会有不同的效果,比如下面这个示例所示:

Demo 地址:codepen.io/airen/full/…

在 CSS 中给列表项设置类型的样式风格可以通过 list-style-type 和 list-style-image 来实现,但这两个属性设置列表项的样式风格会有所限制。比如要实现类似下图的列表项样式风格:

值得庆幸的是,CSS 的 ::marker 给予我们更大的灵活性,可以让我们实现更多的列表样式风格,而且灵活性也更大。

创建 marker 标记框

HTML 中的 ul 和 ol 元素会自动创建 marker 标记框。如果通过浏览器调试器来查看的话,你会发现,不管是 ul 还是 ol 的子元素 li 会自带 display:list-item 属性设计(客户端默认属性),另外会带有一个默认的list-style-type样式设置:

这样一来,它自身就默认创建了一个 marker 标记框,同时我们可以通过 ::marker 伪元素来设置列表项的样式风格,比如下面这个示例:

1
2
3
4
5
6
CSS复制代码ul ::marker,
ol ::marker {
font-size: 200%;
color: #00b7a8;
font-family: "Comic Sans MS", cursive, sans-serif;
}

你会看到效果如下所示:

Demo 地址:codepen.io/airen/full/…

对于非列表元素,可以通过display: list-item来创建 Marker 标记,这样就可以在元素上使用 ::marker 伪元素来设置项目符号的样式。虽然通过display:list-item在形式上看上去像列表项,但在语义化上并没有起到任何的作用。

在深入探讨 ::marker 使用之前,大家要知道,元素必须要具备一个Marker标记框,对于非列表项的元素需要显式的使用 display:list-item 来创建Marker标记框。

CSS的display属性是一个非常重要的属性,现在被纳入在CSS Display Module Level 3中。CSS的display属性可以改变任何一个元素的框模型。而且在Level 3规范中给display引用了两个值的语法,比如使用display: inline list-item可以创建一个内联列表项。

::marker 的基本使用

前面的小示例中,其实我们已经领略到了::marker的魅力。在列表项li中,其实已经带有Marker标记框,可以借助::marker伪元素来设置列表标记的样式。

我们先来回忆一下,CSS的::marker还未出现(或者说不支持的浏览器)时,要对列表项设置不同的样式,都是通过li上来控制(看上去继承了li上的样式)。虽然能设置列表样式,但还是具有一定的局限性,灵活度不够大 —— 特别是当列表项标记样式和内容要区分时。

CSS的::marker会让我们变得容易的多。从前面的示例中我们可以了解到, ::marker 伪元素和列表项内容是分开的,正因此,我们可以独立为两个部分设计不同的样式。这在以前的CSS版本中是不可能的(除非借助::before伪元素来模拟,稍后也会介绍这一部分)。比如说,我们更改ul或li的color或font-size时也会更改标记的color和font-size。为了达到两者的区分,往往需要在HTML中做一些结构上的调整,比如列表项用一个子元素来包裹(比如span元素或::before伪元素)。

更了大家更易于理解::marker的作用,我们在上面的示例基础上做一些调整:

1
2
3
4
5
6
7
8
9
10
11
CSS复制代码.box:nth-child(odd) li {
font-size: 200%;
color: #00b7a8;
font-family: "Comic Sans MS", cursive, sans-serif;
}

.box:nth-child(even) ::marker {
font-size: 200%;
color: #00b7a8;
font-family: "Comic Sans MS", cursive, sans-serif;
}

代码中的具体作用不做介绍,很简单的代码,但效果却有很大的差异性,如下图所示:

很神奇吧!在浏览器中查看源码,你会发现使用::marker和未使用::marker的差异性:

虽然::marker易于帮助我们控制标记的样式风格,但有一点需要特别注意,如果显式的设置了list-style-type: none时,::marker标记内容就会丢失不可见。在这个时候,不管是否显式的设置了::marker的样式都将会看不到。比如:

大家是否还记得,在::marker还没有出现之前,要对列表项设置别的标记符,比如Emoji。我们就需要通过别的方式来完成,最为常见的是修改HTML的结构,或者借助CSS伪元素::before和CSS的content属性,例如下面这个示例:

Demo 地址:codepen.io/airen/full/…

事实上,CSS的::marker和伪元素::before类似,也可以通过content和attr()一起来控制Marker标记的效果。需要记住,生成个性化Marker标记内容需要做到几下几点:

  • 非列表项li元素需要显式的设置display:list-item (内联列表项需要使用display: inline list-item)
  • 需要显式设置list-style-type为none
  • 使用content添加内容(也可以通过attr()配合data-*来添加内容)

来看一个小示例:

1
2
3
CSS复制代码li::marker {
content: attr(data-emoji);
}

::marker伪元素自从可以使用content来添加内容之后,让我们可操作的空间更大了。对于列表标记(即,带有Marker标记)的元素再也不需要额外的通过::before伪元素和content来生成标记内容。而且,我们还可以结合计算数器相关的特性,让列表标记可造性空间更大。如果你感兴趣的话,请继续往下阅读。

::marker 与计数器的结合

对于无序列表,或者说统一使用同样的标记符,那么::marker和content结合就可以解决。但是如果面对的是一个有顺列表,那么我们就需要用到CSS计数器的相关特性。

先来回忆一下CSS的计数器相关的特性。在CSS中计数器有三个属性:

  • counter-reset:设置一个计数器,定义计数器名称,用来标识计数器作用域
  • counter-set:将计数器设置为给定的值。它操作现有计数器的值,并且只在元素上还没有给定名称的计数器时才创建新的计数器
  • counter-increment:用来标识计数器与实际关联元素范围,可接受两个值,第一个值必须是counter-reset定义的标识符,第二个值是可选值,是一个整数值(正负值都可以),用来预设递增的值

以及两个相关的函数:

  • counter() :主要配合content一起使用,用来调用定义好的计数器标识符
  • counters() :支持嵌套计数器,如果有指定计数器的当前值,则返回一个表示这些计数器的当前值的串联字符串。counters()有两种形式counters(name, string)和counters(name, string, style)。通常和伪元素一起使用,但理论上可以支持<string>值的任何地方使用

一般情况之下,counter-reset、counter-increment和counter()即可满足一个计数器的需求。

CSS的计数器使用非常的简单。在元素的父元素上显式设置:

1
2
3
CSS复制代码body {
counter-reset: section
}

使用counter-reset声明了一个计数器标识符叫section。然后再需要使用计算器的元素上(一般配合伪元素::before)使用counter-increment来调用counter-reset已声明的计数器标识符,然后使用counter(section)来计数:

1
2
3
4
CSS复制代码h3::before {
counter-increment: section
content: "Section " counter(section) ": "
}

下图会更详尽一些,把计数器可能会用的值都罗列出来了,可供参考:

回到我们的列表设置中来。::marker还没有得到浏览器支持之前,一般都是使用CSS的计数器来实现一些带有个性化的有顺序列表,比如下面这样的效果:

也可以借助计数器做一些其他的效果比如:

Demo 地址:codepen.io/snookca/ful…

更为厉害的是,CSS的计数器配合复选框或单选按钮还可以做一些小游戏,比如 @una教程中向我们展示的一个效果:

Demo 地址:codepen.io/jak_e/full/…

@kizmarh使用同样的原理,做了一个黑白棋的小游戏:

是不是很有意思,有关于CSS计数器相关的特性暂且搁置。我们回到::marker的世界中来。

::marker配合content可以定制个性化Marker标记风格。借助CSS计数器,可以更轻易的构建带有顺序的Marker标记。同样可以让Marker标记和内容分离。更易于实现可定制化的样式风格。

接下来,我们来看一个简单的示例,看看::marker生成的标记符和以往生成的标记符效果上有何差异没。

结果很简单,这里使用的是一个无序列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
HTML复制代码<ul>
<li>
Item1
<ul>
<li>Item 1-1</li>
<li>Item 1-2</li>
<li>Item 1-3</li>
</ul>
</li>
<li>Item2</li>
<li>Item3</li>
<li>Item4</li>
<li>Item5</li>
</ul>

你可以根据自己的爱好来选择标签元素。先来看::before和content配合counter()和counters()的一个效果:

1
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
CSS复制代码/* counter() */ 
.box:nth-child(1) {
ul {
counter-reset: item;
}
li {
counter-increment: item;
&::before{
content: counter(item);
/* ... */
}
}
}

/* counters() */
.box:nth-child(2) {
ul {
counter-reset: item;
}
li {
counter-increment: item;
&::before{
content: counters(item, '.');
/* ... */
}
}
}

对于上面的效果,大家可能也猜到了。我们再来看一下::marker的使用:

1
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
CSS复制代码/* counter() */ 
.box:nth-child(3) {
ul {
counter-reset: item;
}
li {
counter-increment: item;
}
::marker {
content: counter(item);
/* ... */
}
}

/* counters() */
.box:nth-child(4) {
ul {
counter-reset: item;
}
li {
counter-increment: item;
}
::marker {
content: counters(item, '.');
/* ... */
}
}

可以看到::marker和前面::before效果是一样的:

另外使用::marker还有一特殊之处。不管是列表元素还是设置了display:list-item的非列表元素,不需要显式的使用counter-reset声明计数器标识符,也无需使用counter-increment调用已声明的计数器标识符。它可以直接在 ::marker 伪元素的 content 中使用 counter(list-item) 或 counters(list-item, '.') 。

但是非列表元素,哪怕是设置了display:list-item,直接在::marker的content中使用counters(list-item, '.')所起的效果和我们预期的有所不同。如果在非列表元素的::marker的content中使用counters()达到我们想要的效果,需要使counter-reset先声明计数器标识符,然后counter-increment调用已声明的计数器标识符(回归到以前::before的使用)。具本的可以看下面的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
CSS复制代码::marker {
content: counter(list-item);
padding: 5px 30px 5px 12px;
background: linear-gradient(to right, #f36, #f09);
font-size: 2rem;
clip-path: polygon(0% 0%, 75% 0, 75% 51%, 100% 52%, 75% 65%, 75% 100%, 0 100%);
border-radius: 5px;
color: #fff;
text-shadow: 1px 1px 1px rgba(#09f, .5);
}
.box:nth-child(2n) ::marker {
content: counters(list-item, '.');
}

.box:nth-child(3) {
section {
counter-reset: item;
}
article {
counter-increment: item;
}
::marker {
content: counters(item, '.');
}
}

具体效果如下:

是不是觉得::marker非常有意思,特别是给元素添加Marker标记的时候。换句话说,就是在定制个性化列表符号时,使用::marker伪元素要比::before之类的较为方便。而且::marker是元素原生的列表标记符(::marker)。

一旦::marker伪元素得到所有浏览器支持之后,我们要让列表标记符和内容分离就会多了一种方案:

  • 调整HTML结构
  • 伪元素::before和content
  • 伪元素 ::marker 和 content

前面也向大家展示了,::marker也可以像::before一样,借助CSS计数器属性,可以更好的实现有序列表,甚至是嵌套的列表。

写在最后

虽然 ::marker 的出现允许我们为列表标记定制样式,但它也有一定的限制性,至少到目前为止是这样。比如,我们在 ::marker 伪元素上可控样式还是有限,要实现下面这样的个性化效果是不可能的:

庆幸的是,CSS 中除了 ::marker 伪元素之外,还可以使用 ::before 或 ::after 来生成内容,然后通过 CSS 来实现更具个性化的列表标记样式。如果你对这方面的内容感兴趣的话,请猛击这里!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

移动开发中关于视频的一些基本概念 颜色编码 视频基本概念 资

发表于 2024-04-16

颜色编码

视频,本质上就是一连串的静态图片的播放过程。因此我们对于视频的的讨论都可以回到图片中去。

对于图片而言,可以分为两种颜色编码方法来表示图片:RGB和YUV。

RGB

现如今我们使用的屏幕设备都是RGB屏幕,主要有红,绿,蓝三种原色组成,其他的所有颜色都是这三种颜色通过比例组合而成。

image.png

由于RGB设备的普及,所以图片/视频的展示都是使用RGB的颜色编码方法来展示的。

但是我们都知道一张RGB图片占用的磁盘空间有多大,假如1920x1080的图片,单个像素格式为RGB_888,就是一个像素占位24bit,那么一张图片的大小则是:

size = 1920x1080x3 byte = 5.93M

YUV颜色编码的图片则可以实现RGB近似的效果占用空间却明显小于后者,它也是视频在存储时的主要颜色编码形式。

YUV

不同于RGB的颜色分类方法,YUV则是把颜色分为亮度和色度。

Y指的是亮度(luma,Luminance),U,V则指的是色度,这种编码方法在不同的领域名称上会有细微的差别:比如YCbCr,Y’UV,YPbPr,这些都可以统称为YUV,一般YUV/Y‘UV都用于编码电视信号(模拟信号),而YCbCr则用于编码数字图像。在本文中我们说到YUV时,主要指的是YCbCr。

Y:亮度分量,(可以理解为图像的黑白部分)

Cb:色度的蓝色分量,(照片蓝色部分去掉亮度Y)

Cr:色度的红色分量,(照片红色部分去掉亮度Y)

一张图片通过YUV编码方式进行分离后的效果如下:

1704534596273.png

关于YUV,如果对于YUV取值范围进行细分还可以分为两种类型:

  • TV range(Limit range):Y∈[16,235],Cb∈[16-240] ,Cr∈[16-240] ,主要是广播电视采用的数字标准。
  • Full range:Y、Cb、Cr∈[0-255] ,主要是PC端采用的标准,所以也称PC range。

YUV与RGB的转换

YUV和RGB这两种颜色编码方法是可以相互转换的,也就是知道其中一个就可以求取另一个,而而且这个转换方法是由国际相关组织提供的一些固定的公式(当然不同版本不同类型的协议公式有所不同):

以根据ITU-R BT.601 标准为例

在TV Range的的范围下

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ini复制代码// TV Range
RGB to YUV:
Y = 0.299R+0.587G+0.114B // Y本质上就是从原图得到的一张灰度图
Cr = V = 0.713(R−Y)=0.500R−0.419G−0.081B
Cb = U = 0.564(B−Y)=−0.169R−0.331G+0.500B

YUV to RGB:
R = 1.164(Y−16)+1.596(V−128)
G = 1.164(Y−16)−0.813(V−128)−0.391(U−128)
B = 1.164(Y−16)+2.018(U−128)



//RGB的范围是[0,255],Y的范围是[16,235],UV的范围是[16,239]

同样的标准下,Full range的公式则有所不同用:

1
2
3
4
5
6
7
8
9
10
11
12
ini复制代码
RGB to YUV:
Y = 0.299 * R + 0.587 * G + 0.114 * B    
Cr = V = -0.169 * R - 0.331 * G + 0.500 * B
Cb = U = 0.500 * R - 0.439 * G - 0.081 * B

YUV to RGB:
R = Y + 1.400V - 0.7
G = Y - 0.343U - 0.711V + 0.526
B = Y + 1.765U - 0.883

// RGB的范围是[0,255],Y的范围是[0,255],UV的范围是[0,255]

YUV采样方法

经过大量研究实验表明,视觉系统对色度的敏感度远小于亮度,因此再编码YUV图片时,常常会保证Y分量数据不变,相应比例减少U V分量的数据,但是图像的质量基本不会改变。

  • 4:4:4表示4个Y,对应4个Cb以及4个Cr。
    • 完全采样,占用空间size=wh3 byte
  • 4:2:2 表示4个Y,对应2个Cb以及2个Cr。
    • size=wh2 byte,节省1/3空间
  • 4:2:0 表示4个Y,对应1个Cb以及1个Cr。
    • size=wh1.5 byte 节省1/2的空间
  • 4:1:1 表示4个Y,对应1个Cb以及1个Cr。
    • size = wh1.5 byte 节省1/2的空间

以上几种采样方法,YUV420是视频图片帧中最常用的采样比例,在这个比例下,存储空间节省一半,但是图片质量变化不大。

YUV存储方式

对于YUV数据,有两种存储方式:

  • packed format
    • 紧缩格式是把数据混合在一起,交错存储。比如yuv422 packed可能是这样的

image.png

  • Planar format(主要的)
    • 平面格式则是把不同的分量数据分开存储:先连续存储所有的Y,在连续存储所有的U,然后V……(下面的例子主要以palanar为主)

YUV存储格式

根据不同的采样方式以及不同的存储方式,可以生成不同的YUV存储格式。比如YV12/I420/YU12/NV12/NV21 都属于 YUV420。

YUV格式有YUY2、YUYV、YVYU、UYVY、AYUV、Y41P、Y411、Y211、IF09、IYUV、YV12、YVU9、YUV411、YUV420等,其中比较常见的YUV420分为两种:YUV420P和YUV420SP。

YV12/I420/YU12

YV12与YV12都称为YUV420P(Planar格式),表示他们都是4:2:0,而且是Planar的存储方式。他俩的区别在于在分开存储时,u分量和v分量谁在前面(Y分量总是在最前面)。

存储示意图

  • YU12: YYYYYYYY UU VV
  • YV12: YYYYYYYY VV UU

YU12格式在Android平台中也称作I420

YU12/I420存储格式

1704535078874.png

YV12存储格式

YV12的存储方式就是把上图中的U和V分量的存储位置对调即可。

NV21/NV12格式

这两种格式都属于YUV420SP(Semi-Planar)格式,这种格式和planar类似,但是UV分量时交错存储的。

因此NV21和NV12的区别主要在于UV分量交错存储时,谁在前面:

  • NV12: YYYYYYYY UVUV
  • NV21: YYYYYYYY VUVU

NV12存储格式

1704535237005.png

NV21存储格式

在NV12的基础上,把U和V的分量位置对调即可

虽然YUV的存储格式繁多但是对于一般的开发者而言,掌握了上述几种基本可以满足需要了。

小结

显示RGB

存储YUV

视频基本概念

复用(封装)与解复用(解封装)

  • muxer(复用)
+ 把音频,视频,字幕合并到一个封装格式中 .在Android中实现复用的是MediaMuxer
  • demuxer(解复用)
+ 从媒体封装格式把视频,音频,字幕等拆分开来,在Android中,实现解复用的是MediaExtractor

帧率

即视频在一秒钟之内的帧数,电影一般24,手机设备录制一般30,60。

帧类型

视频中存在三种帧,I帧,P帧,B帧:

  • I帧: 也称关键帧,依靠自身的数据即可解码出一帧图像。编码压缩比例不高
  • P帧:预测帧,利用时间和空间的相关性,依靠前一帧和自身来解码处一帧数据 编码压缩比例较高
  • B帧: 双向预测帧,即需要利用前后两帧的数据来解码自身这一帧的数据,编码压缩比例极高。

因为B帧这种特殊的存在,所以未解码的视频流中,帧的解码顺序与播放顺序是不一致的。

假如正常帧播放顺序是I B P,那么在视频流中这三帧的顺序应该是I P B,因为B帧的生成需要前后两帧数据,所以会出现先解码P帧,再解码B帧的情况。

一般视频中I帧较少,P帧,B帧占大多数,所以才能把一部电影压缩到几个G的大小。

PTS和DTS

  • PTS 显示时间戳。指示帧应该在什么时间点被呈现给用户。PTS 是在解码后确定的时间戳,用于视频帧或音频帧的渲染或播放顺序
  • DTS 解码时间戳。 指示解码器应该在什么时间点开始解码该帧(解码顺序)。DTS 是在解码之前确定的时间戳,由解码器按正确的顺序解码帧。(它是在编码时由编码器设置的)

有了解码时间戳,就可以保证I/P/B帧的按照正确顺序解码,有了PTS,则可以保证I/P/B帧的按照正确顺序播放

音视频同步

由于我们说的视频往往是指音频+视频,而这两者又是独立的数据,分别由独立的设备进行播放,因此再播放时,往往需要主要音视频的同步。音视频同步的基本原理就是管理音视频的数据,按照PTS的时间顺序播放。

实际操作来看,一般需要选取一个参考时间轴,这个参考每次音频或者视频解码完成之后,通过比对现实时间戳和参考时间轴时间来控制播放进度。

而这个参考时间轴,可以选择音频时间轴,视频时间轴,或独立时间轴,一般选择独立时间轴。

编码标准

前面虽然讲到,从RGB转向YUV的过程是可以显著节省视频的存储空间,但是这远远不够,最终视频存储空间还是要靠编码来进行压缩。因此市面上有很多对视频帧进行编码的编码标准:h263,h264(重点),h265,vp8,vp9…

其中,H.264是目前使用最广泛,支持最广泛的适配编码器,而随着4K/8K的逐步普及,视频编码器应该会逐渐向H.265 过渡。

视频编码标准的算法一般是非常复杂的,对于一般开发者而言不需要深入接触

封装格式

对于视频而言,一般指的是视频+音频。而这两种数据本质上是独立的数据,因此需要把他们按照特定的规则协议封装成一个文件让播放器播放。

目前市面上主要的封装格式有:MP4(主要),MKV,AVI,FLV,TS…

这些封装格式主要可以分为两类:存储类,流媒体类

  • 存储类: 面向存储使用
    • MP4,MKV,AVI
  • 流媒体类: 面向流媒体使用
    • FLV,TS

之所以会出现这样的区别,主要是看封装格式是否能支持一遍下载,一边解码,一边播放。

资料参考

ibabyblue.github.io/2020/04/27/…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官:为什么忘记密码要重置而不是告诉你原密码?

发表于 2024-04-16

这是一个挺有意思的面试题,挺简单的,不知道大家平时在重置密码的时候有没有想过这个问题。回答这个问题其实就一句话:因为服务端也不知道你的原密码是什么。如果知道的话,那就是严重的安全风险问题了。

重置帐号密码

我们这里来简单分析一下。

做过开发的应该都知道,服务端在保存密码到数据库的时候,绝对不能直接明文存储。如果明文存储的话,风险太大,且不说数据库的数据有被盗的风险,如果被服务端的相关人员特别是有数据库权限的恶意利用,那将是不可预估的风险。

一般情况下,我们都是通过哈希算法来加密密码并保存。

哈希算法也叫散列函数或摘要算法,它的作用是对任意长度的数据生成一个固定长度的唯一标识,也叫哈希值、散列值或消息摘要(后文统称为哈希值)。

哈希算法效果演示

哈希算法可以简单分为两类:

  1. 加密哈希算法:安全性较高的哈希算法,它可以提供一定的数据完整性保护和数据防篡改能力,能够抵御一定的攻击手段,安全性相对较高,但性能较差,适用于对安全性要求较高的场景。例如 SHA2、SHA3、SM3、RIPEMD-160、BLAKE2、SipHash 等等。
  2. 非加密哈希算法:安全性相对较低的哈希算法,易受到暴力破解、冲突攻击等攻击手段的影响,但性能较高,适用于对安全性没有要求的业务场景。例如 CRC32、MurMurHash3、SipHash 等等。

除了这两种之外,还有一些特殊的哈希算法,例如安全性更高的慢哈希算法。

关于哈希算法的详细介绍,可以看我写的这篇文章:哈希算法和加密算法总结 。

目前,比较常用的是通过 MD5 + Salt 的方式来加密密码。盐(Salt)在密码学中,是指通过在密码任意固定位置插入特定的字符串,让哈希后的结果和使用原始密码的哈希结果不相符,这种过程称之为“加盐”。

不过,这种方式已经不被推荐,因为 MD5 算法的安全性较低,抗碰撞性差。详细介绍可以阅读我写的这篇文章:简历别再写 MD5 加密密码了! 。你可以使用安全性较高的加密哈希算法+ Salt(盐)(例如 SHA2、SHA3、SM3,更高的安全性更强的抗碰撞性)或者直接使用慢哈希(例如 Bcrypt,更推荐这种方式)。

假如我们这里使用 SHA-256 + Salt 这种方式。

这里写了一个简单的示例代码:

1
2
3
4
5
6
7
8
9
10
11
java复制代码String password = "123456";
String salt = "1abd1c";
// 创建SHA-256摘要对象
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
messageDigest.update((password + salt).getBytes());
// 计算哈希值
byte[] result = messageDigest.digest();
// 将哈希值转换为十六进制字符串
String hexString = new HexBinaryAdapter().marshal(result);
System.out.println("Original String: " + password);
System.out.println("SHA-256 Hash: " + hexString.toLowerCase());

输出:

1
2
yaml复制代码Original String: 123456
SHA-256 Hash: 424026bb6e21ba5cda976caed81d15a3be7b1b2accabb79878758289df98cbec

在这个例子中,服务端保存的就是密码“123456”加盐哈希之后的数据,也就是“424026bb6e21ba5cda976caed81d15a3be7b1b2accabb79878758289df98cbec” 。

当你输入密码登录之后,服务端会先把你的密码对应的盐取出,然后再去执行一遍获取哈希值的过程。如果最终计算出来的哈希值和保存在数据库中的哈希值一直,那就说明密码是正确的。否则的话,密码就不是正确的。

哈希算法的是不可逆的,你无法通过哈希之后的值再得到原值,这样的话,服务端也不知道你的原密码到底是什么,自然没办法告诉你原密码是什么。

那有的朋友又有疑问了,为什么很多网站改密码不可与原密码相同呢?这是过程实际和验证密码正确性一样的流程,计算一遍哈希值比较即可!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…383940…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%