SEG-Y格式详解:地震数据存储结构与读写实战
2026/9/15 18:45:40 网站建设 项目流程

干过地震数据处理的人,电脑里肯定堆满了后缀为.sgy.segy的文件。SEG-Y 这个格式从 1975 年发布到现在将近五十年,中间换了操作系统、换了编程语言、换了存储介质,但地震数据从野外采集回来到交到解释人员手里的最后一公里,几乎永远是 SEG-Y。哪怕现在各家处理系统内部都有自己的私有格式,到了成果交付、数据归档、跨软件交换这一步,大家还是老老实实回到 SEG-Y 这个公共语言上。

这篇东西我打算把 SEG-Y 的来龙去脉、文件结构、读写实操一次讲透。无论你是刚入行的处理员、地质工程师,还是被分派到地震数据整理任务的程序员,读完都应该能自己动手读一个 SEG-Y 文件、写一个合规的 SEG-Y 文件,并且在遇到“文件读不出来”“数据全是毛刺”“坐标对不上”这类经典问题时,知道从哪里下手排查。

1. 先弄清楚 SEG-Y 是什么:一个格式,背后的半部勘探史

SEG-Y 由勘探地球物理学家学会(SEG)在 1975 年正式发布,最初的目的是解决一个非常现实的问题:不同厂商的地震仪、不同公司的处理软件之间,数据没法互通。当年还在用九轨磁带记录,野外队用 A 公司的仪器采集完数据,回到室内发现处理系统只认 B 公司的格式,整条测线可能就要返工甚至报废。SEG-Y 的诞生相当于给整个行业定了一个统一的“装货标准”,从此不管前端用什么牌子采集,后端用什么软件处理,中间交换数据都走这一种格式。

这个格式能活这么久,核心原因有三点。第一,结构简单:文件无非就是卷头加道数据,卷头描述全局参数,道数据按顺序排队,没有复杂的索引和依赖关系。第二,模型贴合行业习惯:地震数据的组织方式是“道”(trace),一炮激发、多道接收,每一道记录一个接收点的振动时间序列,这种模型在过去的二维测线、现在的三维宽方位采集中都成立。第三,生态沉淀太深:全球所有主流地震软件都支持 SEG-Y 读写,新格式再好用,只要别人不认,你就没法用它做交付。

1.1 从磁带时代活到云时代:SEG-Y 中“道”的存储模型为什么能延续

理解 SEG-Y 的关键是建立“道”这个思维模型。可以这样想:一次地震勘探,地面上布置一排检波器,炸药或可控震源在某个点激发,地震波在地下传播、遇到地层界面反射回来,每个检波器把地面的振动记录下来,形成一条随时间变化的振幅曲线,这就是“一道”。一条测线几百上千炮,每炮几十到几百道,把所有道按顺序写进一个文件,文件开头用一段固定格式的头部信息说明采样率、道数、坐标系统这些公共属性,这就是 SEG-Y 的基本轮廓。

这种按道顺序存储的方式,在今天看起来不是最高效的组织形式。如果要从一个三维数据体里抽出一条 inline 线,SEG-Y 需要把整个文件扫描一遍或者靠道头信息跳读,而现在的数据体格式往往建立了两级索引可以快速切片。但在数据交换的场景中,“顺序读、顺序写”反而是优点:不依赖额外索引文件,拷走一个.sgy文件就等于拷走了所有可用的观测数据。很多老旧资料今天还能被重新处理解释,靠的就是当年这盘磁带上那个未经私有化的 SEG-Y 文件。

1.2 版本演进:rev0、rev1、rev2 分别改了什么

SEG-Y 一直在小步迭代,不是推倒重来,而是在旧框架里做加法。

  • rev0(1975):最初的规范,定义了文本卷头、二进制卷头、道头、道数据的基本结构。数据格式只有 IBM 浮点和定点整数,坐标按英尺或米记录。
  • rev1(2002):最重要的修订。新增了 IEEE 浮点格式支持,新增了可选的扩展文本卷头,明确了文本卷头中编码类型标识的写法,补充了三维勘探相关的道头字段定义。现在绝大多数 SEG-Y 文件都基于 rev1。
  • rev2(2023 年发布):最新版本,新增了扩展二进制卷头、更多的数据格式码(比如 8 字节浮点、24 位整数),引入了“节”(Section)的概念来更好地组织多分量数据,并且支持 8 字节坐标范围和更精细的采样间隔单位。

从读写角度,rev1 的兼容性最关键,rev2 的文件目前还比较少,但未来新采集的数据会越来越多地采用它。遇到 rev2 文件时不要慌,结构上仍然兼容 rev0 的基本布局,只是多了扩展区块,读取时跳过即可。

1.3 适合用 SEG-Y 的场景,以及它的边界

SEG-Y 不是万能的,哪些场景它最合适?哪些场景应该主动绕开?

  • 适合交换与归档:跨软件、跨单位、跨年份的数据交付和长期保存,SEG-Y 依然是首选。
  • 适合中等规模数据体的批量读取:比如一条二维测线几十 GB,单个三维工区几百 GB,用 SEG-Y 做初步的质量监控和属性提取完全够用。
  • 不适合高频随机切片:三维体里频繁按 inline/crossline 切片的处理解释场景,用专门的数据体格式(如 Petrel 的 Volume 或内部体格式)效率更高。
  • 不适合高精度浮点海量数据的计算密集场景:SEG-Y 的 4 字节 IEEE 浮点做常规处理没问题,但如果要做全精度反演或深度学习训练,通常会先转成更紧凑的二进制格式。

理解这个边界很重要。曾经遇到有人把几千GB的地震数据全部用 SEG-Y 直接喂给神经网络做训练,结果光 I/O 就卡了三天,后来转成内存映射格式才解决。格式选型本身就是一项工程能力。

2. 文件结构拆解:每一段字节都有它的使命

SEG-Y 的文件结构并不复杂,按顺序就是:文本卷头(3200 字节)、二进制卷头(400 字节)、可选扩展文本卷头(3200 字节的整数倍)、道数据块(每道 240 字节的道头 + 道数据)。解析一个 SEG-Y 文件,核心工作就是按偏移量读字节、按规则解释字节。

2.1 文本卷头:3200 字节的“封面页”

文件最开头的 3200 个字节是文本卷头(Textual File Header),相当于文件的封面页和档案摘要,里面按 40 行、每行 80 字符的固定版面记录工作区名称、测线号、采集日期、处理流程等可读信息。

这里有一个老文件特别容易踩的坑:文本卷头的编码。rev0 年代的文件大量使用 EBCDIC 编码,这是 IBM 大型机时代的产物,用普通编辑器打开会看到一堆乱码。rev1 开始允许使用 ASCII,并在字符卷头中增加了一个字节来标识编码类型。遇到 EBCDIC 的文本头,直接用 Python 的codecs模块转码即可:

import codecs with open("input.sgy", "rb") as f: raw = f.read(3200) # 尝试EBCDIC解码 try: text = codecs.decode(raw, "cp037") print(text[:400]) except Exception: # 如果EBCDIC解码失败,尝试ASCII try: text = raw.decode("ascii") print(text[:400]) except Exception: print("无法识别文本卷头编码")

注意,文本卷头在解析时通常直接跳过,它记录的信息是给人看的,不是给程序用的。读程序时真正依赖的是接下来这 400 个字节。

2.2 二进制卷头:400 字节里的全局参数

二进制卷头(Binary File Header)紧接着文本卷头,从文件偏移 3200 开始,共 400 字节。这一段的单个字段在整个文件中只有一个副本,描述的是整个文件的公共参数:采样间隔、采样点数、数据格式码、测量系统、版本号等。

下表是读取 SEG-Y 时最常用的几个字段位置(偏移量从文件最开头算起,0-based):

文件偏移字节数字段含义说明
3217–32182采样间隔单位微秒(1/1000000 秒)
3221–32222每道采样点数大部分文件这里非零
3225–32262数据格式码1=IBM浮点,2=4字节整数,3=2字节整数,5=IEEE浮点
3253–32542测量系统1=米,2=英尺
3505–35062SEG-Y版本号0/1/2

有个细节值得注意:很多软件在写采样间隔时,直接把数值写到 3217–3218,但在三维数据和多分量数据中,这个值不一定和道头里的采样间隔一致,读取时必须以道头里面的值为准。更常见的做法是:二进制卷头只作为全局默认值,实际解析每一道时都从道头重新读取采样点数和采样间隔,确保任何一道有异常都不会连累整条测线。

2.3 道头:240 字节里的坐标、道号和采样信息

每一个地震道由 240 字节的道头(Trace Header)和紧随其后的道数据组成。道头里最关键的信息是:道序号、野外记录号、CDP 号、震源和检波点的坐标、采样点数、采样间隔、道类型。字段位置如下(偏移量从该道头起点开始算,0-based):

道头偏移字节数字段名说明
0–34tracl道序号(从1开始)
8–114fldr野外记录号
20–234cdpCDP(共深度点)号
28–292trid道识别码,1=活道,2=死道
72–754sx震源 X 坐标
76–794sy震源 Y 坐标
80–834gx检波点 X 坐标
84–874gy检波点 Y 坐标
88–892scalco坐标缩放系数
114–1152ns本道采样点数
116–1172dt本道采样间隔(微秒)

单看每个道头,它描述的是一个“空间位置 + 一个时间函数”的完整单元。把整条测线的道头串起来,就能还原出实际的观测系统布局。所以道头解析的正确性直接决定了后续所有空间计算(速度分析、偏移成像、属性成图)的准确性。

2.4 道数据与格式码:IBM 浮点、IEEE 浮点还是整数

道数据是真正的振幅序列。每个采样点占多少字节,由二进制卷头里的数据格式码决定。常见的格式码有:

格式码含义采样点字节数
14字节 IBM 浮点4
24字节定点整数4
32字节定点整数2
54字节 IEEE 浮点4
81字节无符号整数1
98字节无符号整数8

实际工作中,格式码 1 和 5 占绝对主流。老的陆地数据、早期海洋数据基本都是格式码 1,也就是 IBM 浮点;2000 年之后处理系统导出的数据基本是格式码 5,IEEE 浮点。这两种格式的字节布局完全不同,直接互相解释会导致振幅变成天文数字或一条直线。

IBM 浮点的结构是:1 位符号位 + 7 位 16 进制指数(余 64 偏移)+ 24 位尾数。数值计算公式为:

value = (-1)^sign × (fraction / 16^6) × 16^(exponent - 64)

换算成二进制位运算,就是:

value = (-1)^sign × fraction × 16^(exponent - 70)

其中 fraction 是把 24 位尾数当作无符号整数得到的结果。这段不要求手算,但要记住一个判断原则:同一批数据,用 IEEE 和 IBM 两种方式分别解释,振幅范围相差巨大,前者正常的记录振幅一般在几千到几万,后者往往在零点几到几之间,出现这种量级差异,基本就是格式码看错了。

2.5 字节序:SEG-Y 的默认规矩是大端

SEG-Y 定稿的年代还是摩托罗拉芯片主宰机房的时代,所以整个规范默认按照大端字节序存储,也就是高字节在前。到今天几乎所有的 SEG-Y 库和软件都按大端解析。如果你自己写解析代码时不小心用了小端(比如直接读成numpy.float32的本地字节序),读出来的数据会完全乱掉。

判断一个文件是不是大端,有个快速办法:读二进制卷头里的采样点数,正常情况下是几百到几千,如果用结构体解包出来是几万、几十万甚至负数,大概率是字节序搞反了。还有一个小技巧:很多 SEG-Y 文件的采样间隔是 2000 微秒即 2ms,对应十六进制是0x07D0,如果读出来是0xD007,那肯定有问题。

3. 读 SEG-Y:从手写解析到用成熟库

读 SEG-Y 这件事,按数据规模和工程效率不同,有两条路线:一是自己写解析器,适合学习原理和处理小文件;二是用成熟的开源库,适合生产环境处理大批量数据。两条路线我都建议掌握。

3.1 手写一个最简读取器:不装第三方库也能干活

先上代码,一个比较完整的手写读取器,只依赖标准库和 numpy,能读取格式码 1 和格式码 5 的文件:

import struct import numpy as np def ibm_to_float(ibm_int): """把32位无符号整数表示的IBM浮点转为Python float""" sign = (ibm_int >> 31) & 0x1 exponent = ((ibm_int >> 24) & 0x7F) - 64 fraction = ibm_int & 0xFFFFFF value = fraction * (16.0 ** (exponent - 6)) / (2.0 ** 24) # 简化后也可写作: value = fraction * 16.0 ** (exponent - 70) return -value if sign else value def read_segy_chunk(filepath, max_traces=None): """读取SEG-Y文件的基本信息并返回所有道数据""" results = {"text_header": "", "sample_interval": 0, "num_samples": 0, "format_code": 0, "traces": []} with open(filepath, "rb") as f: # 跳过并简单读取文本卷头 raw_text = f.read(3200) try: results["text_header"] = raw_text.decode("ascii") except UnicodeDecodeError: results["text_header"] = "<EBCDIC 或不可识别编码>" # 读取二进制卷头 binary_header = f.read(400) results["sample_interval"] = struct.unpack(">H", binary_header[17:19])[0] results["num_samples"] = struct.unpack(">H", binary_header[21:23])[0] results["format_code"] = struct.unpack(">H", binary_header[25:27])[0] # 计算单道字节数(先假设4字节采样点,整数格式会不同) if results["format_code"] in (1, 2, 5): sample_bytes = 4 elif results["format_code"] == 3: sample_bytes = 2 else: raise NotImplementedError(f"暂不支持格式码 {results['format_code']}") trace_size = 240 + results["num_samples"] * sample_bytes count = 0 while True: block = f.read(trace_size) if len(block) < trace_size: break trace_header = block[:240] trace_data = block[240:] trid = struct.unpack(">H", trace_header[28:30])[0] sx = struct.unpack(">i", trace_header[72:76])[0] sy = struct.unpack(">i", trace_header[76:80])[0] gx = struct.unpack(">i", trace_header[80:84])[0] gy = struct.unpack(">i", trace_header[84:88])[0] scalco = struct.unpack(">h", trace_header[88:90])[0] if scalco > 0: sx, sy, gx, gy = sx / scalco, sy / scalco, gx / scalco, gy / scalco elif scalco < 0: sx, sy, gx, gy = sx * (-scalco), sy * (-scalco), gx * (-scalco), gy * (-scalco) if results["format_code"] in (1, 5): amps = np.frombuffer(trace_data, dtype=np.uint32) if results["format_code"] == 1: values = np.array([ibm_to_float(x) for x in amps]) else: values = amps.view(np.float32).astype(np.float64) elif results["format_code"] in (2, 3): dtype = ">i4" if results["format_code"] == 2 else ">i2" values = np.frombuffer(trace_data, dtype=dtype).astype(np.float64) else: values = np.zeros(0) results["traces"].append({ "index": struct.unpack(">I", trace_header[0:4])[0], "cdp": struct.unpack(">I", trace_header[20:24])[0], "trid": trid, "sx": sx, "sy": sy, "gx": gx, "gy": gy, "ns": struct.unpack(">H", trace_header[114:116])[0], "dt": struct.unpack(">H", trace_header[116:118])[0], "data": values }) count += 1 if max_traces and count >= max_traces: break return results if __name__ == "__main__": segy_info = read_segy_chunk("demo.sgy", max_traces=5) print(segy_info["text_header"][:200]) print(f"采样间隔: {segy_info['sample_interval']} us") print(f"每道采样点数: {segy_info['num_samples']}") print(f"格式码: {segy_info['format_code']}") for tr in segy_info["traces"]: print(f"道号 {tr['index']} CDP {tr['cdp']} 检波点X/Y: {tr['gx']}/{tr['gy']} 振幅范围: {tr['data'].min():.3f} ~ {tr['data'].max():.3f}")

这段代码足够对付大多数小型 SEG-Y 文件的日常质量检查。核心思路就是:每次按“240 + 采样点数×单点字节数”固定读取一道,然后根据格式码和字节序把道数据变成浮点数组。注意我在 IBM 浮点转换时用了逐元素循环,对于几百万道的巨大文件效率不高,生产环境建议用向量化写法(见后文)。

提示:上面代码里ibm_to_float的公式用了fraction * (16.0 ** (exponent - 6)) / (2.0 ** 24),和fraction * 16.0 ** (exponent - 70)是等价的。前面那个写法更容易检验:当字节为0x41100000时,sign=0,exponent=65,fraction=1048576,代入得1048576 × 16^(59) / 2^24 = 1.0,验证通过。

3.2 带我绕过 IBM 浮点的坑:向量化转换

手写版本里 IBM 浮点转换用了 Python 循环,读十万道的时候会慢到怀疑人生。建议改成 numpy 向量化实现:

def ibm2ieee_vectorized(ibm_uint_array): """将numpy uint32数组表示的IBM浮点批量转为IEEE float64""" ibm_uint = np.asarray(ibm_uint_array, dtype=np.uint32) sign = np.where((ibm_uint >> 31) & 0x1, -1.0, 1.0) exponent = ((ibm_uint >> 24) & 0x7F).astype(np.float64) - 64.0 fraction = (ibm_uint & 0xFFFFFF).astype(np.float64) # fraction / 2^24 * 16^(exponent) # = fraction * 16^(exponent - 6) / 2^24 values = sign * fraction * np.power(16.0, exponent - 6.0) / (2.0 ** 24) return values

这个版本一次处理几十万采样点毫秒级完成。实际工作中 IBM 浮点转 IEEE 是读取老资料时的最高频操作,性能差距非常明显。

3.3 用成熟库 segyio 和 segpy:生产环境怎么省事

自己写解析器能帮你建立对格式的直觉,但生产环境我更推荐直接用成熟库,最常用的是segyio。它是用 C 实现的 SEG-Y 读写库,Python 绑定经过充分优化,对超大文件、三维数据体的支持非常完善,也是很多商业软件内部实际使用的解析引擎。

安装和基本读取:

pip install segyio
import segyio import numpy as np with segyio.open("survey.sgy", "r") as f: # 获取全局信息 print("采样点数:", f.bin["ns"]) print("采样间隔(us):", f.bin["dt"]) print("道数:", len(f.trace)) print("格式码:", f.bin["format"]) # 读第100道完整振幅 trace_100 = f.trace[100] # 读全部道(返回二维数组,shape=n_traces, ns) all_traces = f.trace[:] # 读取道头 cdp_list = [f.header[i]["cdp"] for i in range(len(f.header))]

segyio 最方便的一点是它把道头字段的偏移封装成了语义化的键名,比如"cdp""sx""gx""ns""dt",不用自己记偏移量。读取三维数据体还能做到按 inline 或 crossline 切片:

with segyio.open("survey3d.sgy", "r") as f: # 按inline数切片 iline = f.iline[100] # 取第100个inline的所有道 # 或按crossline数切片 xline = f.xline[200]

另一个轻量级纯 Python 库segpy也值得提一句,它的优点是代码层面非常直观、便于阅读和学习,但性能不如 segyio,适合做格式研究和脚本工具,不适合大规模数据处理。

3.4 超大文件的内存控制:mmap 和分块读取

单个三维 SEG-Y 文件经常有几十 GB 到几百 GB,一次性f.read()读进内存基本会崩。处理超大文件的关键是不要追求一次读完,而是要能精准跳转

一种做法是用mmap做内存映射,让操作系统按需加载文件内容,配合numpy.frombuffer零拷贝解析:

import mmap import numpy as np import struct def read_segy_fast(path, trace_index): """用内存映射快速读取指定道数据""" with open(path, "rb") as fp: with mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ) as mm: # 读取二进制卷头关键字段 num_samples = struct.unpack(">H", mm[3221:3223])[0] fmt_code = struct.unpack(">H", mm[3225:3227])[0] sample_bytes = 4 if fmt_code in (1, 2, 5) else 2 # 计算道偏移并读取 offset = 3600 + trace_index * (240 + num_samples * sample_bytes) trace_header = mm[offset:offset + 240] trace_data = mm[offset + 240: offset + 240 + num_samples * sample_bytes] if fmt_code == 5: values = np.frombuffer(trace_data, dtype=">f4").astype(np.float64) elif fmt_code == 3: values = np.frombuffer(trace_data, dtype=">i2").astype(np.float64) else: values = np.zeros(num_samples) return trace_header, values

mmap 的好处是读多少加载多少,不会占用大量物理内存。如果是逐道顺序处理整条测线,用普通文件句柄配合循环读取就足够了,因为操作系统本身也会做缓存。

4. 写 SEG-Y:从零生成一个合规文件

读是入门的必修课,写才是检验你是否真正理解这个格式的标尺。

4.1 写 SEG-Y 的常见场景:为什么你要自己造一个文件

写 SEG-Y 的需求通常来自三类场景。

第一类是成果输出:处理系统跑完了去噪、叠加、偏移,要把结果交给解释组或甲方,这时候就要把内部数据导出成 SEG-Y。第二类是格式转换:原始采集数据可能是别的格式(比如 SEG-D 野外带),需要转成 SEG-Y 才能进入常规处理流程。第三类是测试数据的构造:开发算法、调试流程、验证软件功能时,需要大量仿真地震数据,手工拼一个包含规定道数、坐标和振幅特征的 SEG-Y 文件,比说服别人给你拷真实数据要快得多。

不管哪个场景,写 SEG-Y 的底层逻辑都一样:把计算好的浮点数组按照规定的偏移位置和字节序写到文件里,同时把卷头和道头的描述性字段填正确。

4.2 手动构造一个最小可用的 SEG-Y 文件

下面这个函数会生成一个包含指定道数和采样点数的 SEG-Y 文件,格式码为 5(IEEE 浮点),每条道是带时移的正弦波模拟信号,坐标按间距 25 米排列:

import struct import numpy as np def write_minimal_segy(filepath, data, sample_interval_us=2000): """ 参数: data: 2D numpy数组, shape=(n_traces, num_samples) sample_interval_us: 采样间隔,微秒 说明: 生成的文件坐标按道号每道间隔25米排列,格式码固定为5(IEEE浮点) """ n_traces, num_samples = data.shape with open(filepath, "wb") as f: # 1. 文本卷头 3200字节 text_lines = [ "C 1 CLIENT: DEMO", "C 2 LINE : TEST-LINE-01", "C 3 AREA : TEST AREA", "C 4 SAMPLE INTERVAL (US): %d" % sample_interval_us, "C 5 NUM SAMPLES : %d" % num_samples, "C 6 FORMAT CODE : 5 (IEEE FLOAT)", ] text_block = "\n".join(text_lines).ljust(3200, " ") f.write(text_block.encode("ascii")) # 2. 二进制卷头 400字节 bin_header = bytearray(400) struct.pack_into(">H", bin_header, 17, sample_interval_us) # 采样间隔 struct.pack_into(">H", bin_header, 19, sample_interval_us) # 原始采样间隔 struct.pack_into(">H", bin_header, 21, num_samples) # 每道采样点数 struct.pack_into(">H", bin_header, 23, num_samples) # 原始采样点数 struct.pack_into(">H", bin_header, 25, 5) # 格式码 struct.pack_into(">H", bin_header, 55, 1) # 测量系统 1=米 f.write(bytes(bin_header)) # 3. 逐道写数据 for i in range(n_traces): trace_header = bytearray(240) struct.pack_into(">I", trace_header, 0, i + 1) # tracl 道序号 struct.pack_into(">I", trace_header, 4, i + 1) # tracr struct.pack_into(">I", trace_header, 8, 1) # fldr 野外记录号 struct.pack_into(">I", trace_header, 12, i + 1) # tracf struct.pack_into(">I", trace_header, 20, (i // 100) + 1) # cdp struct.pack_into(">H", trace_header, 28, 1) # trid 活道 struct.pack_into(">i", trace_header, 72, 1000 + i * 25) # sx struct.pack_into(">i", trace_header, 76, 5000) # sy struct.pack_into(">i", trace_header, 80, 1000 + i * 25) # gx struct.pack_into(">i", trace_header, 84, 5500) # gy struct.pack_into(">h", trace_header, 88, 1) # scalco, 1表示不缩放 struct.pack_into(">H", trace_header, 114, num_samples) # ns struct.pack_into(">H", trace_header, 116, sample_interval_us) # dt f.write(bytes(trace_header)) # 道数据,>f4 即大端4字节IEEE浮点 f.write(data[i].astype(">f4").tobytes()) print(f"已写入 {filepath}, 道数={n_traces}, 采样点数={num_samples}, 格式码=5") # 生成模拟数据 n_traces = 100 num_samples = 501 t = np.arange(num_samples) * 2.0 # ms data = np.zeros((n_traces, num_samples)) for i in range(n_traces): delay = i * 0.5 # 每道延迟0.5ms,模拟反射波同相轴 phase = 2 * np.pi * 30 * (t - delay) / 1000 # 30Hz子波 data[i] = np.sin(phase) * np.exp(-((t - 50) ** 2) / 200) write_minimal_segy("synthetic.sgy", data, sample_interval_us=2000)

运行完你可以用前面手写的读取器把synthetic.sgy读回来,验证一下振幅范围、道头坐标是否对得上。这种“写完再读回来检查”的习惯,能帮你快速发现字节序、偏移量上的低级错误。

4.3 写文件之前必须检查的 5 个字段

手工写 SEG-Y 最容易出错的不是数据部分,而是卷头和道头的元信息。分享一个我自己的“写前检查清单”:

  1. 格式码:二进制卷头里的格式码必须和你实际写入的数据类型一致。写的是 IEEE 浮点,格式码就是 5;写的是 IBM 浮点,格式码就是 1。格式码写错,文件到了别人手里会被自动按错的方式解释。
  2. 采样点数:二进制卷头的采样点数要和每一道实际写的 float 个数一致。如果只改了数据数组长度忘了改这里,读出来的文件要么道大小算错、要么文件尾部残留垃圾数据。
  3. 坐标缩放系数:如果坐标值本身就是整数且不需要缩放,scalco 写 0 或 1 都可以,但很多软件约定 0 表示“不知道缩放方式”,为了保险建议显式写 1。
  4. 版本号:在二进制卷头偏移 3505–3506 写上版本号,rev0 的文件写 0,rev1 写 1,不写默认按 rev0 解析,虽然老软件也能读,但遇到扩展字段时可能误解。
  5. 道头里的 ns 和 dt:每个道头里面都有独立的 ns 和 dt,如果物理道有重采样或者坏道补零,必须逐道更新,否则某些软件逐道解析时会把整道数据读错长度。

4.4 用 segyio 写文件:脱离手动偏移量

手写一遍之后,再用 segyio 写文件,你会明显体会到封装的好处:

import segyio import numpy as np n_traces = 120 num_samples = 501 # 定义输出规格 spec = segyio.spec() spec.samples = np.arange(num_samples) * 2 # 采样点在毫秒单位 spec.n_traces = n_traces spec.format = 5 # IEEE浮点 spec.tracecount = n_traces # 生成模拟数据 data = np.random.randn(n_traces, num_samples).astype(np.float32) with segyio.create("output_sgyio.sgy", spec) as f: f.bin["dt"] = 2000 # 采样间隔 2000us f.bin["format"] = 5 f.bin["ns"] = num_samples for i in range(n_traces): f.trace[i] = data[i] f.header[i]["cdp"] = 100 + i f.header[i]["sx"] = 1000 + i * 25 f.header[i]["gx"] = 1000 + i * 25 f.header[i]["sy"] = 5000 f.header[i]["gy"] = 5500

segyio 会自动处理大端字节序和字段偏移,你只需要关心业务字段的含义。实际工程中我也会先看一下生成的 SEG-Y 文件能否被其他软件正常打开,比如用 OpendTect 或者 VISTA 随便加载一下,能正常显示同相轴才算真正写对了。

5. 常见问题与排查技巧实录

技术博客如果只写“怎么做”不写“做坏了怎么办”,价值少一半。下面这些是我在真实项目中踩过、也看着别人踩过的坑。

5.1 文件打开就报错或者读出来全是乱码

现象:用 segyio 打开文件时抛异常,或者读出来的振幅数组数值范围离谱、前后道完全不一致。
排查步骤

  1. 先打开二进制卷头位置,手工看几个关键字段:采样间隔是否为 500、1000、2000 这些常见值;采样点数是否在合理范围;格式码是不是 1、2、3、5 之一。
  2. 检查字节序。把刚才读到的十六进制值和预期值对比,如果大端小端互换,采样点数的数值就会变成几千乘 256 之类的怪数。
  3. 检查文件是否损坏。SEG-Y 老文件在磁带拷贝和 FTP 传输时经常出现中间缺块的情况,导致每个道的字节数发生错位。可以用segyio.tools.wrap或者segyio.tools.dt快速验证,也可以用xxd之类的工具看文件头 32 字节是否符合预期。
  4. 如果头文件正常但数据全是零或恒定值,可能是静校正值太大把有效信号移出了记录窗口,这不是读写问题,是处理参数问题,注意区分。

5.2 文本卷头是乱码,EBCDIC 还是 UTF-8?

老野外磁带里大量 EBCDIC 编码,现代软件如果用 ASCII 读自然是一堆乱码。识别方法很简单:EBCDIC 的典型特征是C1C2D9这类连续区域,看着像二进制却不是二进制的规则;ASCII 正常文本总会有大量 0x20(空格)和 0x0D/0x0A。转码用cp037或者cp500都行,主要看是美式还是国际版字符集。很多老文件文本头信息本身就残缺,转不出来也不影响数据解析,直接跳过即可。

5.3 坐标对不上:scalco 缩放系数是个坑

有一种很常见的情况:读出来的 CDP 坐标,在局部范围看是光滑的,但和实际地理坐标差了三个数量级。多半是坐标缩放系数没处理。SEG-Y 规范里坐标字段经常存放整数,为了保留小数精度,用一个缩放到道头里标出来。规则是:

  • scalco > 0:实际坐标 = 存储坐标 / scalco
  • scalco < 0:实际坐标 = 存储坐标 × (-scalco)
  • scalco = 0:无缩放定义,按 1 处理

比如存储坐标是 5423000,scalco 是 -100,实际坐标就是 5423000×100 = 542300000 cm?不对,实际坐标是 5423000×100 = 542300000,但单位是厘米?不,这里要注意:如果 scalco=-100,实际坐标 = 存储坐标×100 = 542300000,但如果单位是米,这个值明显过大。真实情况是:存储值往往已经缩放,比如真实坐标 542300.25 米,存储为 54230025,scalco=100,那么实际=54230025/100=542300.25,正确。

所以我平时都建议拿到不熟悉的 SEG-Y 文件,先找一个已知坐标的道手工演算一遍。比如从道头读 CDP X 坐标和 scalco,算出来的值应该落在测区坐标范围附近,如果差了数量级,那基本都是缩放处理没做对。

5.4 死道与道类型:如何处理 trid 为 2 的道

使用多分量数据或者处理坏道时,常遇到 trid(道识别码)不是 1 的道。常见值:1 是活性地震道,2 是死道,3 是哑道,4 是时间道。读取时如果直接拿死道的振幅去做能量统计,结果肯定是错的。最佳实践是:解析每个道头时,记录 trid;计算属性或绘制剖面时,把 trid 为 2 的道标记出来,不参与统计,但在道序上保留位置。丢弃死道会导致后续 CDP 排列错乱,这是新手最常犯的错。

5.5 大文件读取性能优化:先索引再访问

几十 GB 的三维 SEG-Y 文件,如果你需要一个一个道读出来做处理,效率会很低。实际项目里我会把“读取”和“访问”分开:

  1. 先用一次顺序扫描生成一个轻量索引文件,记录每道的文件偏移量、CDP 号、inline/crossline 号、坐标。
  2. 后续所有随机访问都通过索引跳转,不再扫描整个文件。
  3. 如果频繁按 inline 切片,可以按 inline 号排序后批量读取。

segyio 的底层对这种模式已经有优化,但你做上层业务时仍然需要有这种“先建索引、后随机访问”的意识,尤其是涉及几十万道、上千个 inline 的三维工区,好的 I/O 策略能省下几小时的运行时间。

一点个人经验

最后分享一个我自己的习惯:拿到任何新的 SEG-Y 数据,第一件事不是急着跑算法,而是先写一个“体检脚本”,打印文本卷头、二进制卷头的关键字段,再随机抽几道检查振幅范围、坐标缩放和死道比例。这套体检流程跑一遍,能过滤掉绝大多数元数据问题,之后再做批量处理就安心得多。SEG-Y 技术含量不高,但格式没读对,后面全盘皆输,细节永远是魔鬼。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询