perf: 耀斑检测改为两趟分块,单波段方法不再整幅驻留

新增 _detect_glint_two_pass:第一趟分块统计 p2/p98 与 256-bin 直方图(Otsu 阈值由此求解),第二趟分块按阈值判定并写 uint8 掩膜,内存不随影像分辨率增长。

新增 _otsu_threshold_from_hist,复刻原 otsu() 的类间方差最大准则;zscore/percentile/iqr/otsu 四种单波段方法统一走该快速路径。

两趟路径下掩膜同样以 GDAL band 句柄按块读取,不再 ReadAsArray 整幅。
This commit is contained in:
duxin
2026-09-15 10:34:41 +08:00
parent 32832f122d
commit 76c1486e97

View File

@ -559,8 +559,234 @@ def create_water_mask_from_shp(shp_file, reference_raster):
raise
# ============================================================
# ★ v3 两趟式Two-Pass Chunking耀斑检测
#
# 旧实现把"单波段影像 + 水体掩膜"整幅读入内存后做全局统计与逐像素检测,
# 在 34914x191776.7 亿像素)影像上内存峰值可达 5~10 GB。
# 这里改为:
# 第一趟:按 block 抽样收集有效样本 → 求全局标量
# zscore: mean/stdpercentile: 分位iqr: Q1/Q3
# otsu: 2%/98% 拉伸分位 + 256-bin 直方图上的 Otsu 阈值)
# 第二趟:按 block 读取 → 逐块应用阈值 → WriteArray 写 uint8 掩膜。
# 全程每块只驻留 block_size² 数据,内存恒定。
#
# 限制说明(不影响默认 GUI 主路径):
# - 'adaptive' 是整幅邻域算子(局部窗口百分位),仍需整幅数组;
# - max_area连通域面积过滤/ buffer_size岸线缓冲是全局结构后处理
# 同样需整幅数组。
# 启用这些选项时仍走旧全幅路径,大影像上会告警。
# ============================================================
def _otsu_threshold_from_hist(hist):
"""由 256-bin 直方图求 Otsu 阈值0..255),复刻原 otsu() 的类间方差最大准则。"""
total = float(hist.sum())
if total <= 0:
return 0.0
vals = np.arange(256, dtype=np.float64)
sum_all = float((hist * vals).sum())
w_b = 0.0
sum_b = 0.0
max_between = -1.0
thr = 0.0
for t in range(256):
w_b += float(hist[t])
if w_b == 0:
continue
w_f = total - w_b
if w_f == 0:
break
sum_b += t * float(hist[t])
m_b = sum_b / w_b
m_f = (sum_all - sum_b) / w_f
between = w_b * w_f * (m_b - m_f) ** 2
if between > max_between:
max_between = between
thr = float(t)
return thr
def _remove_raster_output(path):
"""移除已有栅格输出(兼容 GTiff / ENVI .dat+.hdr"""
base = os.path.splitext(path)[0]
for c in dict.fromkeys([path, path + '.hdr', base + '.hdr', base + '.dat']):
if c and os.path.exists(c):
os.remove(c)
def _detect_glint_two_pass(img_path, mask_arg, out_path, bands_1based, weights,
method, z_threshold, percentile_value, iqr_multiplier,
sample_every=None, block_size=2048):
"""两趟分块耀斑检测并写出 uint8 掩膜method ∈ zscore/percentile/iqr/otsu
Args:
img_path: 输入影像路径
mask_arg: 水体掩膜路径(栅格/.shpNone/空串 → 全图皆为水域
out_path: 输出掩膜路径(.dat/.bil/.bsq→ENVI.tif/.tiff→GTiff
bands_1based: 参与检测的波段号列表1-based多波段时加权融合
weights: 波段权重None→等权
method: 'zscore' | 'percentile' | 'iqr' | 'otsu'
z_threshold / percentile_value / iqr_multiplier: 方法参数
"""
ds = gdal.Open(img_path)
if ds is None:
raise ValueError(f"无法打开影像文件: {img_path}")
cols, rows = ds.RasterXSize, ds.RasterYSize
geo, proj = ds.GetGeoTransform(), ds.GetProjection()
# 抽样步长自适应:目标全局样本量 ~20 万,兼顾统计代表性与内存
if sample_every is None:
px = max(1, cols * rows)
sample_every = min(256, max(1, int((px / 200000.0) ** 0.5)))
band_objs = [ds.GetRasterBand(b) for b in bands_1based]
nb = len(band_objs)
if nb == 0:
ds = None
raise ValueError("没有可用的检测波段")
if weights is None:
weights = [1.0] * nb
weights = [float(w) for w in weights]
total_w = sum(weights) or 1.0
# ---- 掩膜访问器:支持分块读取 ----
mask_band = None
wm_cleanup = None
if mask_arg is not None and str(mask_arg).strip() != "":
wm = str(mask_arg)
if wm.lower().endswith('.shp'):
wm_cleanup = out_path + '__glint_wm.tif'
from src.utils.extract_water_area import rasterize_shp
rasterize_shp(wm, wm_cleanup, img_path)
mds = gdal.Open(wm_cleanup)
else:
mds = gdal.Open(wm)
if mds is None:
raise ValueError(f"无法打开水域掩膜文件: {mask_arg}")
mask_band = mds.GetRasterBand(1)
if mask_band.XSize != cols or mask_band.YSize != rows:
raise ValueError("掩膜与影像尺寸不一致,两趟检测要求同网格")
def _read_fused(x, y, xs, ys):
if nb == 1:
return band_objs[0].ReadAsArray(x, y, xs, ys).astype(np.float32)
arr = None
for i, bo in enumerate(band_objs):
bi = bo.ReadAsArray(x, y, xs, ys).astype(np.float32) * (weights[i] / total_w)
arr = bi if arr is None else arr + bi
return arr
def _read_mask(x, y, xs, ys):
if mask_band is None:
return np.ones((ys, xs), dtype=bool)
return mask_band.ReadAsArray(x, y, xs, ys) > 0
def _create_output():
_remove_raster_output(out_path)
ext = os.path.splitext(out_path)[1].lower()
if ext in ('.tif', '.tiff'):
od = gdal.GetDriverByName('GTiff').Create(
out_path, cols, rows, 1, gdal.GDT_Byte, options=['COMPRESS=LZW'])
else:
od = gdal.GetDriverByName('ENVI').Create(out_path, cols, rows, 1, gdal.GDT_Byte)
od.SetGeoTransform(geo)
od.SetProjection(proj)
return od
try:
# ────────── 第一趟:抽样扫全局标量 ──────────
samples = []
for y in range(0, rows, block_size):
ys = min(block_size, rows - y)
for x in range(0, cols, block_size):
xs = min(block_size, cols - x)
blk = _read_fused(x, y, xs, ys)
mb = _read_mask(x, y, xs, ys)
sub = blk[::sample_every, ::sample_every]
mbs = mb[::sample_every, ::sample_every]
valid = mbs & (sub > 0) & np.isfinite(sub)
if valid.any():
samples.append(sub[valid])
allv = np.concatenate(samples) if samples else None
mean_val = std_val = None
thr = None
otsu_thr = None
p2 = p98 = None
if allv is not None:
if method == 'zscore':
mean_val = float(np.mean(allv))
std_val = float(np.std(allv))
if std_val == 0:
raise ValueError("耀斑波段标准差为 0无法使用 Z-score 方法")
print(f"[两趟] Z-score 全局: 均值={mean_val:.4f}, 标准差={std_val:.4f}, "
f"样本={allv.size}")
elif method == 'percentile':
thr = float(np.percentile(allv, percentile_value))
print(f"[两趟] 百分位数法: {percentile_value}% 分位={thr:.4f}, 样本={allv.size}")
elif method == 'iqr':
q1, q3 = np.percentile(allv, [25.0, 75.0])
thr = float(q3 + iqr_multiplier * (q3 - q1))
print(f"[两趟] IQR 法: Q1={q1:.4f}, Q3={q3:.4f}, 上界={thr:.4f}")
elif method == 'otsu':
p2, p98 = np.percentile(allv, [2.0, 98.0])
if p98 - p2 > 1e-12:
scaled = (np.clip(allv, p2, p98) - p2) / (p98 - p2) * 255.0
hist, _ = np.histogram(scaled, bins=256, range=(0.0, 255.0))
otsu_thr = _otsu_threshold_from_hist(hist)
else:
otsu_thr = 0.0
print(f"[两趟] Otsu 法: P2={p2:.4f}, P98={p98:.4f}, 阈值(0-255)={otsu_thr:.1f}")
# ────────── 创建输出 ──────────
od = _create_output()
ob = od.GetRasterBand(1)
ob.Fill(0)
# 无有效样本 → 全 0无耀斑
if allv is None:
ob.FlushCache()
od = None
print("警告: 水域内无有效正反射率样本,输出全 0 耀斑掩膜")
return out_path
# ────────── 第二趟:分块检测并写盘 ──────────
for y in range(0, rows, block_size):
ys = min(block_size, rows - y)
for x in range(0, cols, block_size):
xs = min(block_size, cols - x)
blk = _read_fused(x, y, xs, ys)
mb = _read_mask(x, y, xs, ys)
if method == 'zscore':
zs = np.zeros_like(blk, dtype=np.float32)
valid = mb & np.isfinite(blk)
zs[valid] = (blk[valid] - mean_val) / std_val
det = (zs > z_threshold)
elif method == 'percentile':
det = mb & (blk > thr)
elif method == 'iqr':
det = mb & (blk > thr)
elif method == 'otsu':
if p98 is not None and p98 - p2 > 1e-12 and otsu_thr is not None:
scaled = (np.clip(blk, p2, p98) - p2) / (p98 - p2) * 255.0
det = mb & (scaled > otsu_thr)
else:
det = np.zeros(blk.shape, dtype=bool)
ob.WriteArray(det.astype(np.uint8), x, y)
ob.FlushCache()
od = None
print(f"[两趟] 耀斑掩膜已写出: {out_path} ({cols}x{rows}, uint8)")
return out_path
finally:
if wm_cleanup is not None:
_remove_raster_output(wm_cleanup)
ds = None
@timeit
def find_severe_glint_area(img_path, water_mask, glint_wave=750, output_path=None,
def find_severe_glint_area(img_path, water_mask, glint_wave=750, output_path=None,
method='otsu', multi_band_waves=None, **kwargs):
"""
找到严重耀斑区域的主函数
@ -604,6 +830,49 @@ def find_severe_glint_area(img_path, water_mask, glint_wave=750, output_path=Non
num_bands = dataset.RasterCount
im_width = dataset.RasterXSize
im_height = dataset.RasterYSize
# ── ★ v3 两趟快速路径(内存安全)────────────────────────────
# 默认方法otsu/zscore/percentile/iqr/multi_band且未启用
# max_area/buffer_size 结构后处理时,直接走两趟分块检测,
# 全程不整幅驻留波段与掩膜。adaptive / 结构后处理仍走旧全幅路径。
max_area_cfg = kwargs.get('max_area', None)
buffer_cfg = kwargs.get('buffer_size', None)
post_needed = bool(max_area_cfg and max_area_cfg > 0) or \
bool(buffer_cfg and buffer_cfg > 0)
if not post_needed:
if method == 'multi_band':
waves = multi_band_waves if multi_band_waves else \
[glint_wave, glint_wave + 50, glint_wave + 100]
bands1 = []
for wv in waves:
bn = find_band_number(wv, img_path)
if 0 <= bn < dataset.RasterCount:
bands1.append(bn + 1)
sub = kwargs.get('sub_method', 'zscore')
if bands1 and sub in ('zscore', 'percentile', 'iqr', 'otsu'):
_detect_glint_two_pass(
img_path, water_mask, output_path, bands1,
kwargs.get('weights', None), sub,
z_threshold=kwargs.get('z_threshold', 2.5),
percentile_value=kwargs.get('percentile', 95.0),
iqr_multiplier=kwargs.get('iqr_multiplier', 1.5))
dataset = None
return output_path
elif method in ('zscore', 'percentile', 'iqr', 'otsu'):
bn = find_band_number(glint_wave, img_path)
if 0 <= bn < dataset.RasterCount:
_detect_glint_two_pass(
img_path, water_mask, output_path, [bn + 1], None, method,
z_threshold=kwargs.get('z_threshold', 2.5),
percentile_value=kwargs.get('percentile', 95.0),
iqr_multiplier=kwargs.get('iqr_multiplier', 1.5))
dataset = None
return output_path
else:
print("信息: 启用了连通域/岸线后处理,主检测走旧全幅路径。")
if im_height * im_width > 50_000_000:
print("警告: 影像超过 5000 万像素,后处理需整幅数组,内存占用会很大。")
# 读取水域掩膜如果water_mask为None或空字符串则创建全图掩膜
if water_mask is None or water_mask == "":