Skill: tianyima96/diffraction-scatter-background-substract (version 1.0.0)

--- name: bgsub description: | BGsub — X-ray diffraction (SAXS/WAXS/XRD) background subtraction toolkit for 2D images and 1D curves. X射线衍射(SAXS/WAXS/XRD)数据背景扣除工具，覆盖2D图像处理和1D曲线处理。 Use this skill whenever the user's task involves ANY of the following, even if they don't explicitly mention "BGsub" or "background subtraction": 请在以下场景触发此 skill，即使用户未明确提及 "BGsub" 或 "背景扣除"： - "背景扣除", "background subtraction", "扣除背景", "去掉背景", "减背景" - "基线校正", "基线扣除", "基线拟合", "baseline correction", "baseline subtraction", "subtract background", "background estimation" - "SAXS", "WAXS", "XRD", "GIWAXS", "散射", "衍射", "小角散射背景扣除", "small-angle scattering background subtraction", "diffraction pattern" - "SSRF电离室", "电离室", "ionchamber", "ion chamber", "ionchamber file", "透射率校正", "透射率修正", "transmission correction", "transmission", "透过率", "T-背景", "T-background", "T修正", "T-correction" - "批量处理TIFF/EDF/H5文件", "batch process images", "batch background subtraction", "批量背景扣除", "格式转换", "图像格式转换", "file format conversion", "convert TIFF to EDF" - "PBS数据", "生物SAS", "biological SAS", "蛋白散射", "protein scattering", "生物样品散射" - "1D曲线处理", "1D curve processing", "曲线背景扣除", "XY曲线", "XY data", "积分曲线", "integrated curve", "curve smoothing", "基线拟合" - "形态学背景", "morphological background", "形态学", "多项式拟合", "polynomial fit", "滚球算法", "rolling ball" - User uploaded .Ionchamber files and needs X-ray data processing - User wants to subtract a known background image from signal images - User wants to estimate and subtract background from 1D diffraction curves - User mentions "T-背景", "T-background", "透射率修正", or asks how to correct for transmission in XRD data compatibility: opencode --- # BGsub Skill — 2D Image & 1D Curve Background Subtraction / 2D图像与1D曲线背景扣除 BGsub is a **self-contained** CLI toolkit for background subtraction of X-ray diffraction data. It covers **2D detector image processing** (TIFF/EDF/H5) and **1D curve processing** (XY/DAT/CSV), with SSRF ionchamber transmission auto-correction. **No external BGsub package installation is required** — all dependencies are bundled in `lib/`, though standard scientific Python dependencies are still needed. BGsub 是一套**自包含**的 X 射线衍射数据背景扣除 CLI 工具集，覆盖**2D探测器图像处理**(TIFF/EDF/H5)和**1D曲线处理**(XY/DAT/CSV)，集成 SSRF 电离室透射率自动校正。**无需安装外部 BGsub 包**——所有依赖已打包在 `lib/` 中，但仍需标准科学计算 Python 依赖。 Latest 1D highlights / 最新 1D 特性： - **T-背景扣除** 为首要模式 / **T-background subtraction** is the primary mode - 支持多列文本结构 `single / xyxy / xyyy` - 支持 **逐一文件** 与 **合并单文件** 导出 - 合并导出支持文本/CSV/HDF5；多列模式下暂不支持电离室自动 T --- ## Core Capabilities / 核心能力 | # | Capability / 能力 | Domain / 领域 | Script / 脚本 | |---|---|---|---| | 1 | Reference-based 2D background subtraction / 有参考2D背景扣除 | 2D Image | `bg_subtract.py` | | 2 | Batch 2D processing with ionchamber / 批量2D处理+电离室 | 2D Image | `bg_batch.py` | | 3 | Ionchamber stats + sample/background transmission / 电离室统计与样品/背景透过率 | Shared | `ionchamber.py` | | 4 | 1D curve background estimation & T-corrected reference subtraction / 1D曲线算法背景估计与T修正参考扣除 | 1D Curve | `curve_process_1d.py` | | 5 | Batch 1D curve processing with unified / per-file / ionchamber T / 批量1D曲线处理（统一T/分别设置/电离室） | 1D Curve | `curve_batch_1d.py` | | 6 | Format conversion (TIFF/EDF/H5) / 格式转换 | 2D Image | `format_convert.py` | | 7 | silx image comparison popup / silx图像对比弹窗 | 2D Image | `compare_images.py` | --- ## Processing Flow / 处理流程 ### 2D Image Background Subtraction / 2D图像背景扣除 ``` Sample image + Ionchamber file / 样品图像 + 电离室文件 ↓ Calculate T = I_sample / I_background / 计算透射率 ↓ result = sample / T - background ↓ Save result image / 保存结果图像 ``` ### 1D Curve Background Estimation / 1D曲线背景估计 ``` Load 1D curve file (XY/DAT/CSV) / 加载1D曲线文件 ↓ Choose column layout: single / xyxy / xyyy / 选择列结构 ↓ Estimate background using algorithm / 使用算法估计背景 (Morphological / Polynomial / Rolling Ball) ↓ Subtract: result = curve - background / 扣除背景 ↓ Save raw, background, subtracted / 保存原始、背景、扣除结果 ``` ### 1D Reference Background Subtraction / 1D参考背景扣除 ``` Sample curve + background curve / 样品曲线 + 背景曲线 ↓ Transmission source / 透过率来源 - manual unified / 手动统一值 - manual per-file / 手动分别设置 - ionchamber auto / 电离室自动计算 ↓ result = sample / (T/100) - background ↓ Save raw, background, subtracted / 保存原始、背景、扣除结果 ``` --- ## Quick Usage / 快速使用 ### Run Scripts Directly / 直接运行脚本 Scripts are **self-contained** — they import from the bundled `lib/` package instead of requiring the BGsub package to be installed. Run from the project root directory. Only standard scientific dependencies are needed: `numpy`, `scipy`, `fabio`, `h5py`, `pandas`. 脚本为**自包含可执行文件**——从内含的 `lib/` 包导入，无需安装 BGsub 包。在项目根目录下运行。仅需标准科学计算依赖：`numpy`、`scipy`、`fabio`、`h5py`、`pandas`。 ```bash # 2D: Single file background subtraction / 单文件背景扣除 python BGsub_skills/scripts/bg_subtract.py sample.tif background.tif --ion-dir ./ionchamber -o result.tif # 2D: Batch processing / 批量处理 python BGsub_skills/scripts/bg_batch.py ./data background.tif --ion-dir ./ionchamber -o ./results # 2D/1D: Ionchamber analysis / 电离室数据分析 python BGsub_skills/scripts/ionchamber.py sample.Ionchamber # 2D/1D: Sample-vs-background transmission / 样品-背景透过率 python BGsub_skills/scripts/ionchamber.py sample.Ionchamber --background-ion background.Ionchamber # 2D: Format conversion / 格式转换 python BGsub_skills/scripts/format_convert.py input.tif -o output.edf --format edf # 2D: Image comparison (silx popup) / 图像对比(silx弹窗) python BGsub_skills/scripts/compare_images.py image1.tif image2.tif # 1D: Single curve processing / 单曲线处理 python BGsub_skills/scripts/curve_process_1d.py curve.xy --method morph --radius 50 -o result.xy # 1D: Multi-column XYYY merged export / 多列 XYYY 合并导出 python BGsub_skills/scripts/curve_process_1d.py curve.txt --parse-mode xyyy --output-mode merged -o merged_curves.h5 # 1D: Reference background + manual T / 参考背景 + 手动T python BGsub_skills/scripts/curve_process_1d.py curve.xy --background bg.xy --transmission 87.5 -o result.xy # 1D: Reference background + ionchamber / 参考背景 + 电离室 python BGsub_skills/scripts/curve_process_1d.py curve.xy --background bg.xy --ion-dir ./ionchamber -o result.xy # 1D: Batch curve processing / 批量曲线处理 python BGsub_skills/scripts/curve_batch_1d.py ./curves --method morph --radius 50 -o ./output # 1D: Batch reference background + per-file T / 批量参考背景 + 分别设置T python BGsub_skills/scripts/curve_batch_1d.py ./curves --background bg.xy --transmission-map transmissions.csv -o ./output ``` --- ## SSRF Ionchamber File Format / SSRF电离室文件格式 Plain text format (extension `.Ionchamber` or `.txt`) / 纯文本格式： ``` # Time Ionchamber0 Ionchamber1 Ionchamber2 2026-01-19 12:34:34.130671238 2.807135e-7 2.388761e-8 9.71103e-10 ``` | Channel / 通道 | Description / 说明 | |---|---| | Ionchamber0 | Incident beam intensity / 入射光强度 | | Ionchamber1 | Transmitted beam (intermediate) / 透射光(中间) | | Ionchamber2 | Transmitted beam (primary measurement) / 透射光(主测量) | **Transmission / 透射率**: `T = I_sample / I_background × 100%` Where `I_sample` and `I_background` are user-selectable channel statistics (mean / median / trimmed_mean). 其中 `I_sample` 与 `I_background` 由用户选择通道和统计方式（mean / median / trimmed_mean）。 ### File Matching Rules / 文件匹配规则 Ionchamber files auto-match with images by number / 电离室文件通过编号自动匹配： ``` PBS-sd1-1s_00046.tif ←→ PBS-sd1-1s_046.Ionchamber (auto-match _00046.tif → _046.Ionchamber) ``` --- ## 1D Curve Processing Details / 1D曲线处理详情 ### Supported Input Formats / 支持的输入格式 | Format / 格式 | Extensions / 扩展名 | |---|---| | XY / DAT / TXT | `.xy`, `.dat`, `.txt` (single XY / xyxy / xyyy) | | CSV | `.csv` (single XY / xyyy) | | GR | `.gr` | ### Background Estimation Methods / 背景估计方法 | Method / 方法 | Key Parameter / 关键参数 | Use Case / 适用场景 | |---|---|---| | `morph` (Morphological) / 形态学 | `--radius` (default: 50) | Broad features, smooth background / 宽衍射峰, 平滑背景 | | `poly` (Polynomial) / 多项式 | `--degree` (default: 4), `--quantile` | Amorphous halo fitting / 非晶晕拟合 | | `rolling_ball` / 滚球 | `--radius` (default: 50) | Complex baseline / 复杂基线 | ### 1D Output Format / 1D输出格式 Default batch output is per-file split export. Merged single-file output is also supported / 默认批处理为逐文件拆分导出，也支持合并单文件导出： ``` 原始: sample_001.xy → sample_001_raw.xy (raw data / 原始数据) → sample_001_bg.xy (estimated background / 估计背景, optional / 可选) → sample_001_sub.xy (subtracted result / 扣除结果, optional / 可选) ``` ### 1D Transmission Sources / 1D透过率来源 - `--transmission 85` → unified manual transmission / 手动统一透过率 - `--transmission-map transmissions.csv` → per-file transmission / 分别设置透过率 - `--ion-dir ./ionchamber` → auto-match ionchamber files and calculate T / 自动匹配电离室并计算逐文件 T ### Multi-column Constraints / 多列约束 - `--parse-mode xyxy`：按 `(x1,y1)(x2,y2)...` 展开多条曲线 - `--parse-mode xyyy`：按 `x,y1,y2,...` 展开多条曲线 - 多列模式下若使用 `--background`，当前**不支持** `--ion-dir` 自动电离室 T When `--background` is provided, BGsub uses the traditional reference formula: 当提供 `--background` 时，BGsub 使用传统参考扣除公式： ```text result = sample / (T/100) - background ``` --- ## Reference Documents / 参考文档 When the user's request goes beyond the quick usage examples above, read the appropriate reference file for detailed guidance. Each file serves a specific purpose — only read what you need. 当用户的需求超出上方快速使用示例时，请阅读对应的参考文档获取详细指引。每个文件有特定用途——只读你需要的。 | When to read / 何时阅读 | File / 文件 | Content / 内容 | |---|---|---| | User is new to BGsub or needs step-by-step walkthrough / 新手入门或需要分步引导 | `references/quickstart.md` | 5-minute quick start with 4 real scenarios / 5分钟快速入门，含4个实际场景 | | User asks about specific CLI flags, parameters, or default values / 用户询问具体 CLI 参数或默认值 | `references/cli_commands.md` | Full CLI command reference for all 7 scripts / 全部 7 个脚本的 CLI 命令参考 | | User provides real data and wants end-to-end processing guidance / 用户提供真实数据需要端到端处理指引 | `references/examples.md` | 9 real-world SSRF PBS processing examples / 9 个 SSRF PBS 实际处理示例 | --- ## Script Reference / 脚本参考 ### Directory Structure / 目录结构 ``` BGsub_skills/ ├── SKILL.md # This file / 本文件 ├── lib/ # Self-contained library (no external BGsub needed) / 自包含库（无需外部BGsub） │ ├── __init__.py │ ├── image_io.py # Image I/O (TIFF/EDF/H5) / 图像读写 │ ├── common.py # Ionchamber & transmission utilities / 电离室与透射率工具 │ ├── curve_data.py # 1D curve data model / 1D曲线数据模型 │ ├── curve_io.py # 1D curve I/O / 1D曲线读写 │ ├── curve_processor.py # Background estimation algorithms / 背景估计算法 │ └── task_pipeline.py # Batch pipeline engine / 批处理管线引擎 ├── scripts/ # CLI scripts (import from lib/) / CLI脚本（从lib/导入） └── references/ # Detailed docs / 详细文档 ``` ### Scripts / 脚本 | Script / 脚本 | Function / 功能 | |---|---| | `scripts/bg_subtract.py` | Single 2D image background subtraction / 单文件2D背景扣除 | | `scripts/bg_batch.py` | Batch 2D image processing / 批量2D处理 | | `scripts/ionchamber.py` | Ionchamber data analysis / 电离室数据分析 | | `scripts/curve_process_1d.py` | Single 1D curve processing / 单曲线1D处理 | | `scripts/curve_batch_1d.py` | Batch 1D curve processing / 批量1D曲线处理 | | `scripts/format_convert.py` | TIFF/EDF/H5 format conversion / 格式转换 | | `scripts/compare_images.py` | silx CompareImages popup / silx图像对比弹窗 | Usage / 使用方法: `python BGsub_skills/scripts/<script>.py --help`

path: lib/common.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
common.py — Common utility functions
通用工具函数
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from difflib import SequenceMatcher
from typing import Dict, List, Optional, Tuple

import numpy as np
import pandas as pd


SAFE_ION_STRATEGIES = {"regex", "exact", "base_state", "base_num_best_state"}


@dataclass
class IonMatchResult:
    """
    电离室透过率匹配结果。
    Ionchamber transmission matching result.
    """

    sample_path: str
    sample_name: str
    ion_path: Optional[str]
    strategy: str
    score: float
    state_similarity: float
    transmission_percent: Optional[float]
    sample_intensity: Optional[float]
    background_intensity: Optional[float]
    success: bool
    error_message: Optional[str] = None


def format_size(n_bytes: int) -> str:
    """
    Format byte size to human readable string.
    格式化字节大小为人类可读字符串。

    Parameters
    ----------
    n_bytes : int
        Number of bytes / 字节数

    Returns
    -------
    str
        Formatted string like '1.5 MB' / 格式化字符串
    """
    size = float(n_bytes)
    for unit in ["B", "KB", "MB", "GB", "TB"]:
        if abs(size) < 1024.0:
            return f"{size:.1f} {unit}"
        size /= 1024.0
    return f"{size:.1f} PB"


def format_array_info(arr: np.ndarray) -> str:
    """
    Format numpy array info as a readable string.
    格式化 numpy 数组信息为可读字符串。

    Parameters
    ----------
    arr : np.ndarray
        Input array / 输入数组

    Returns
    -------
    str
        Formatted string with shape, dtype, etc. / 包含形状、数据类型等的格式化字符串
    """
    if arr is None:
        return "None"

    info = f"shape={arr.shape}, dtype={arr.dtype.name}"
    if arr.ndim > 0:
        finite = arr[np.isfinite(arr)]
        if len(finite) > 0:
            info += f", min={finite.min():.3g}, max={finite.max():.3g}"
    return info


def find_common_shape(shapes: List[Tuple[int, ...]]) -> Optional[Tuple[Optional[int], ...]]:
    """
    Find the common shape among multiple shapes (broadcasting compatible).
    找出多个形状的公共形状（广播兼容）。

    Parameters
    ----------
    shapes : List[Tuple[int, ...]]
        List of shapes / 形状列表

    Returns
    -------
    Tuple[int, ...] or None
        Common shape or None if incompatible / 公共形状或不兼容时返回 None
    """
    if not shapes:
        return None

    max_ndim = max(len(s) for s in shapes)
    aligned = []
    for s in shapes:
        diff = max_ndim - len(s)
        aligned.append((1,) * diff + s)

    result = []
    for dims in zip(*aligned):
        non_one = [d for d in dims if d != 1]
        if not non_one:
            result.append(1)
        elif len(set(non_one)) == 1:
            result.append(non_one[0])
        else:
            return None
    return tuple(result)


def clamp(value: float, min_val: float, max_val: float) -> float:
    """
    Clamp a value between min and max.
    将值限制在最小和最大之间。

    Parameters
    ----------
    value : float
        Input value / 输入值
    min_val : float
        Minimum value / 最小值
    max_val : float
        Maximum value / 最大值

    Returns
    -------
    float
        Clamped value / 限制后的值
    """
    return max(min_val, min(max_val, value))


def load_ionchamber_table(path: str) -> Optional[pd.DataFrame]:
    """
    加载 SSRF 电离室文本文件。
    Load an SSRF ionchamber text file.
    """
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as handle:
            content = handle.read()
        lines = [line for line in content.strip().splitlines() if line and not line.startswith("#")]
        rows = []
        for line in lines:
            parts = line.strip().split()
            if len(parts) >= 5:
                rows.append([" ".join(parts[:2])] + [float(value) for value in parts[2:5]])
        if not rows:
            return None
        return pd.DataFrame.from_records(
            rows,
            columns=pd.Index(["Time", "Ionchamber0", "Ionchamber1", "Ionchamber2"]),
        )
    except Exception:
        return None


def calc_ion_intensity(df: pd.DataFrame, channel: str, method: str) -> Optional[float]:
    """
    计算电离室强度统计值。
    Calculate a summary intensity from one ionchamber channel.
    """
    try:
        values = df[channel].to_numpy(dtype=float)
        if method == "median":
            return float(np.median(values))
        if method == "trimmed_mean":
            sorted_values = np.sort(values)
            if len(sorted_values) > 2:
                return float(np.mean(sorted_values[1:-1]))
            return float(np.mean(sorted_values))
        return float(np.mean(values))
    except Exception:
        return None


def _split_name(stem: str) -> Tuple[str, Optional[str], Optional[int]]:
    """
    解析文件名，提取主名字、状态段与末尾编号。
    Split a stem into base token, state token, and trailing number.
    """
    normalized = stem.replace("_", "-")
    parts = [part for part in normalized.split("-") if part]
    if not parts:
        return stem.lower(), None, None

    base = parts[0].lower()
    if len(parts) >= 2 and re.fullmatch(r"\d+", parts[-1]):
        number = int(parts[-1])
        middle = parts[1:-1]
    else:
        number = None
        middle = parts[1:]
    state = "-".join(part.lower() for part in middle) if middle else None
    return base, state, number


def _state_similarity(left: Optional[str], right: Optional[str]) -> float:
    """
    计算状态片段相似度。
    Calculate similarity between state fragments.
    """
    if left is None and right is None:
        return 1.0
    if left is None or right is None:
        return 0.0
    return SequenceMatcher(None, left, right).ratio()


def match_ionchamber_detail(
    data_name: str,
    ion_paths: List[str],
    user_regex: Optional[str] = None,
) -> Tuple[Optional[str], str, float, float]:
    """
    使用与 2D GUI 相近的规则匹配电离室文件。
    Match an ionchamber file with rules aligned to the 2D GUI.
    """
    data_stem = re.sub(r"\.[^.]+$", "", data_name)
    data_base, data_state, data_number = _split_name(data_stem)

    ion_info: List[Tuple[str, str, Optional[str], Optional[int]]] = []
    for path in ion_paths:
        ion_stem = re.sub(r"\.[^.]+$", "", path.split("\\")[-1].split("/")[-1])
        ion_info.append((path, *_split_name(ion_stem)))

    if user_regex:
        try:
            pattern = re.compile(user_regex)
            for path, ion_base, ion_state, ion_number in ion_info:
                if pattern.search(ion_base):
                    return path, "regex", 1.0, _state_similarity(data_state, ion_state)
        except re.error:
            pass

    for path, ion_base, ion_state, ion_number in ion_info:
        if ion_base == data_base and ion_state == data_state and ion_number == data_number:
            return path, "exact", 1.0, _state_similarity(data_state, ion_state)

    same_base_state = [
        (path, ion_state, ion_number)
        for path, ion_base, ion_state, ion_number in ion_info
        if ion_base == data_base and ion_state == data_state
    ]
    if data_state is not None and same_base_state:
        chosen = min(
            same_base_state,
            key=lambda item: item[2] if item[2] is not None else float("inf"),
        )
        score = 1.0 if len(same_base_state) == 1 else 0.95
        return chosen[0], "base_state", score, _state_similarity(data_state, chosen[1])

    if data_number is not None:
        same_base_number = [
            (path, ion_state, ion_number)
            for path, ion_base, ion_state, ion_number in ion_info
            if ion_base == data_base and ion_number == data_number
        ]
        if same_base_number:
            chosen = max(same_base_number, key=lambda item: _state_similarity(data_state, item[1]))
            similarity = _state_similarity(data_state, chosen[1])
            return chosen[0], "base_num_best_state", 0.80 + similarity * 0.2, similarity

    same_base = [
        (path, ion_state)
        for path, ion_base, ion_state, ion_number in ion_info
        if ion_base == data_base
    ]
    if same_base:
        chosen = max(same_base, key=lambda item: _state_similarity(data_state, item[1]))
        similarity = _state_similarity(data_state, chosen[1])
        return chosen[0], "base_only_best_state", 0.65 + similarity * 0.1, similarity

    best_path: Optional[str] = None
    best_score = 0.0
    best_similarity = 0.0
    for path, ion_base, ion_state, ion_number in ion_info:
        score = SequenceMatcher(None, data_base, ion_base).ratio()
        if ion_state is not None and data_state is not None and ion_state == data_state:
            score += 0.25
        if ion_number is not None and data_number is not None and ion_number == data_number:
            score += 0.25
        if ion_base.startswith(data_base) or data_base.startswith(ion_base):
            score += 0.10
        if score > best_score:
            best_path = path
            best_score = score
            best_similarity = _state_similarity(data_state, ion_state)

    if best_path is not None and best_score >= 0.4:
        return best_path, "fuzzy", best_score, best_similarity
    return None, "none", 0.0, 0.0


def build_transmission_map(
    sample_paths: List[str],
    background_path: str,
    ion_paths: List[str],
    background_channel: str = "Ionchamber1",
    background_method: str = "median",
    sample_channel: str = "Ionchamber1",
    sample_method: str = "median",
    user_regex: Optional[str] = None,
    min_score: float = 0.60,
) -> Tuple[Dict[str, float], List[IonMatchResult], Optional[IonMatchResult]]:
    """
    根据样品/背景曲线与电离室文件计算逐文件透过率。
    Build per-file transmission values from sample/background curves and ionchamber files.
    """
    background_name = background_path.split("\\")[-1].split("/")[-1]
    bg_ion_path, bg_strategy, bg_score, bg_state_similarity = match_ionchamber_detail(
        background_name,
        ion_paths,
        user_regex=user_regex,
    )
    bg_table = load_ionchamber_table(bg_ion_path) if bg_ion_path else None
    background_intensity = (
        calc_ion_intensity(bg_table, background_channel, background_method)
        if bg_table is not None
        else None
    )
    background_result = IonMatchResult(
        sample_path=background_path,
        sample_name=background_name,
        ion_path=bg_ion_path,
        strategy=bg_strategy,
        score=bg_score,
        state_similarity=bg_state_similarity,
        transmission_percent=None,
        sample_intensity=background_intensity,
        background_intensity=None,
        success=bg_ion_path is not None
        and background_intensity is not None
        and background_intensity != 0
        and bg_strategy in SAFE_ION_STRATEGIES
        and bg_score >= min_score,
        error_message=(
            None
            if bg_ion_path is not None
            and background_intensity is not None
            and background_intensity != 0
            and bg_strategy in SAFE_ION_STRATEGIES
            and bg_score >= min_score
            else (
                "无匹配背景电离室文件"
                if not bg_ion_path
                else (
                    "背景电离室匹配策略过宽"
                    if bg_strategy not in SAFE_ION_STRATEGIES
                    else (
                        "背景电离室匹配分数过低" if bg_score < min_score else "背景电离室强度无效"
                    )
                )
            )
        ),
    )

    transmissions: Dict[str, float] = {}
    match_results: List[IonMatchResult] = []
    if not background_result.success:
        return transmissions, match_results, background_result

    assert background_intensity is not None
    for sample_path in sample_paths:
        sample_name = sample_path.split("\\")[-1].split("/")[-1]
        ion_path, strategy, score, state_similarity = match_ionchamber_detail(
            sample_name,
            ion_paths,
            user_regex=user_regex,
        )
        sample_table = load_ionchamber_table(ion_path) if ion_path else None
        sample_intensity = (
            calc_ion_intensity(sample_table, sample_channel, sample_method)
            if sample_table is not None
            else None
        )
        transmission_percent = None
        success = False
        error_message = None
        if ion_path is None:
            error_message = "无匹配电离室文件"
        elif strategy not in SAFE_ION_STRATEGIES:
            error_message = f"电离室匹配策略过宽 ({strategy})"
        elif score < min_score:
            error_message = f"电离室匹配分数过低 ({score:.3f} < {min_score:.2f})"
        elif sample_intensity is None:
            error_message = "样品电离室强度无效"
        elif background_intensity == 0:
            error_message = "背景电离室强度为 0"
        else:
            transmission_percent = sample_intensity / background_intensity * 100.0
            transmissions[sample_path] = transmission_percent
            success = True

        match_results.append(
            IonMatchResult(
                sample_path=sample_path,
                sample_name=sample_name,
                ion_path=ion_path,
                strategy=strategy,
                score=score,
                state_similarity=state_similarity,
                transmission_percent=transmission_percent,
                sample_intensity=sample_intensity,
                background_intensity=background_intensity,
                success=success,
                error_message=error_message,
            )
        )

    return transmissions, match_results, background_result


path: lib/curve_data.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
curve_data.py — 1D curve data model and processing mode definitions
一维曲线数据模型与处理模式定义

Defines the core data structures for 1D curve processing in BGsub:
  - ProcessMode: three independent processing modes
  - Curve1D: lightweight immutable-style container for x/y + metadata
  - CurveMetadata: descriptive metadata attached to each curve
"""

from __future__ import annotations

from enum import Enum
from dataclasses import dataclass, field
from typing import Optional, Dict, Any

import numpy as np


# ---------------------------------------------------------------------------
# Processing modes / 处理模式
# ---------------------------------------------------------------------------


class ProcessMode(Enum):
    """
    Three independent processing modes for 1D curve pipeline.
    三种独立的 1D 曲线处理模式。

    MORPH_1D           – Load existing 1D curve, morphological background estimation,
                         subtract and output.
                         加载已有 1D 曲线，形态学背景估计后扣除输出。
    FIT_1D             – Load existing 1D curve, polynomial fitting background
                         estimation, subtract and output.
                         加载已有 1D 曲线，多项式拟合背景估计后扣除输出。
    T_BG_SUBTRACT      – Load sample/background 1D curves and apply
                         transmission-corrected subtraction.
                         加载样品/背景 1D 曲线并执行透过率修正后的扣除。
    PHYS_FIT           – Load existing 1D curve, physics-based attenuation simulation
                         (air + optional sample absorption), scale and subtract.
                         加载已有 1D 曲线，物理衰减仿真（空气+可选样品吸收），缩放后扣除。
    """

    MORPH_1D = "morph_1d"
    FIT_1D = "fit_1d"
    T_BG_SUBTRACT = "t_bg_subtract"
    PHYS_FIT = "phys_fit"


# ---------------------------------------------------------------------------
# Metadata / 元数据
# ---------------------------------------------------------------------------


@dataclass
class CurveMetadata:
    """
    Descriptive metadata for a 1D curve.
    一维曲线的描述性元数据。

    Attributes / 属性
    -----------------
    source_file : str
        Original file path or identifier / 原始文件路径或标识符
    source_type : str
        '1d_curve' / 数据来源类型
    x_label : str
        Axis label for x (e.g. 'q (A^-1)', '2theta (deg)')
        x 轴标签
    y_label : str
        Axis label for y (e.g. 'I', 'Intensity')
        y 轴标签
    x_unit : str
        Unit of x axis / x 轴单位
    process_mode : Optional[ProcessMode]
        Which mode produced this curve / 产生该曲线的处理模式
    extra : dict
        Arbitrary extra key-value metadata / 任意附加键值元数据
    """

    source_file: str = ""
    source_type: str = ""
    x_label: str = "x"
    y_label: str = "I"
    x_unit: str = ""
    process_mode: Optional[ProcessMode] = None
    extra: Dict[str, Any] = field(default_factory=dict)


# ---------------------------------------------------------------------------
# Curve1D data container / 一维曲线数据容器
# ---------------------------------------------------------------------------


@dataclass
class Curve1D:
    """
    Lightweight container for a single 1D curve.
    单条一维曲线的轻量容器。

    Design notes / 设计说明:
      - Stores x and y as numpy arrays; does NOT copy on construction
        to keep overhead minimal.
      - ``metadata`` carries provenance information for downstream
        consumers (GUI, export).
      - ``background`` and ``subtracted`` are optionally populated by
        processing steps; callers should check for ``None``.

    Attributes / 属性
    -----------------
    x : np.ndarray
        Radial / angular axis values (float64) / 径向/角度轴值
    y : np.ndarray
        Intensity values (float64) / 强度值
    metadata : CurveMetadata
        Provenance metadata / 来源元数据
    background : np.ndarray or None
        Estimated background (set by processor) / 估计的背景（由处理器填充）
    subtracted : np.ndarray or None
        Background-subtracted result / 扣除背景后的结果
    """

    x: np.ndarray
    y: np.ndarray
    metadata: CurveMetadata = field(default_factory=CurveMetadata)
    background: Optional[np.ndarray] = None
    subtracted: Optional[np.ndarray] = None

    def __post_init__(self):
        """
        Validate array consistency and convert to float64.
        校验数组一致性并转为 float64。
        """
        self.x = np.asarray(self.x, dtype=np.float64)
        self.y = np.asarray(self.y, dtype=np.float64)
        if self.x.shape != self.y.shape:
            raise ValueError(
                f"x and y must have the same shape, got {self.x.shape} vs {self.y.shape}"
            )

    @property
    def n_points(self) -> int:
        """Number of data points / 数据点数"""
        return self.x.shape[0]

    @property
    def x_range(self) -> tuple:
        """(min, max) of x axis / x 轴范围"""
        return float(self.x.min()), float(self.x.max())

    @property
    def y_range(self) -> tuple:
        """(min, max) of y axis / y 轴范围"""
        finite = self.y[np.isfinite(self.y)]
        if finite.size == 0:
            return (0.0, 0.0)
        return float(finite.min()), float(finite.max())

    def copy(self) -> Curve1D:
        """
        Return a deep copy of this curve (arrays copied).
        返回此曲线的深拷贝（数组已复制）。
        """
        metadata = CurveMetadata(
            source_file=self.metadata.source_file,
            source_type=self.metadata.source_type,
            x_label=self.metadata.x_label,
            y_label=self.metadata.y_label,
            x_unit=self.metadata.x_unit,
            process_mode=self.metadata.process_mode,
            extra=self.metadata.extra.copy(),
        )
        return Curve1D(
            x=self.x.copy(),
            y=self.y.copy(),
            metadata=metadata,
            background=self.background.copy() if self.background is not None else None,
            subtracted=self.subtracted.copy() if self.subtracted is not None else None,
        )

    def memory_bytes(self) -> int:
        """
        Estimated memory footprint in bytes.
        估算内存占用字节数。
        """
        total = self.x.nbytes + self.y.nbytes
        if self.background is not None:
            total += self.background.nbytes
        if self.subtracted is not None:
            total += self.subtracted.nbytes
        return total

    def __repr__(self) -> str:
        return (
            f"Curve1D(n={self.n_points}, "
            f"x_range={self.x_range}, "
            f"source='{self.metadata.source_file}')"
        )


path: lib/curve_io.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
curve_io.py — 1D curve file I/O
一维曲线文件读写

Handles loading and saving of 1D curve files (.txt, .csv, .dat, .xy, .gr).
处理一维曲线文件的加载与保存。
"""

from __future__ import annotations

import csv
import io
import os
from dataclasses import dataclass
from typing import Optional, Tuple, List, Dict, Any

import h5py
import numpy as np

from .curve_data import Curve1D, CurveMetadata

try:
    import fabio

    HAS_FABIO = True
except ImportError:
    fabio = None
    HAS_FABIO = False

# Supported 1D curve file extensions / 支持的一维曲线文件扩展名
CURVE_EXTS = {".txt", ".csv", ".dat", ".xy", ".gr"}

CURVE_OUTPUT_EXTS = {".txt", ".csv", ".dat", ".xy", ".gr", ".npy", ".h5", ".hdf5"}


@dataclass
class CurveColumnSpec:
    """
    Column mapping for one logical curve in a text table.
    文本表格中一条逻辑曲线的列映射。
    """

    x_column: int
    y_column: int
    label: str


def _split_text_line(line: str, delimiter: Optional[str]) -> List[str]:
    """Split one text line with optional delimiter. 用可选分隔符拆分文本行。"""
    if delimiter == "tab":
        return [part.strip() for part in line.split("\t")]
    if delimiter == "comma":
        return [part.strip() for part in line.split(",")]
    if delimiter == "space":
        return [part for part in line.split()]
    if delimiter:
        return [part.strip() for part in line.split(delimiter)]
    return line.split()


def _iter_numeric_rows(
    path: str,
    skip_header: int = 0,
    comment: str = "#",
    delimiter: Optional[str] = None,
    max_rows: int = 20,
) -> List[List[float]]:
    """
    Read a few numeric rows from a curve text file.
    从曲线文本文件中读取若干数值行。
    """
    rows: List[List[float]] = []
    with open(path, "r", encoding="utf-8", errors="replace") as handle:
        for _ in range(skip_header):
            next(handle, None)
        for raw_line in handle:
            line = raw_line.strip()
            if not line or line.startswith(comment):
                continue
            parts = _split_text_line(line, delimiter)
            try:
                numeric = [float(part) for part in parts if str(part).strip() != ""]
            except ValueError:
                continue
            if numeric:
                rows.append(numeric)
            if len(rows) >= max_rows:
                break
    return rows


def inspect_curve_layout(
    path: str,
    parse_mode: str = "single",
    skip_header: int = 0,
    comment: str = "#",
    delimiter: Optional[str] = None,
    x_column: int = 0,
    y_column: int = 1,
) -> Dict[str, Any]:
    """
    Inspect numeric layout and derive logical curve column specs.
    检查数值列布局并推导逻辑曲线列映射。
    """
    rows = _iter_numeric_rows(
        path,
        skip_header=skip_header,
        comment=comment,
        delimiter=delimiter,
    )
    column_count = max((len(row) for row in rows), default=0)
    specs: List[CurveColumnSpec] = []
    normalized_mode = (parse_mode or "single").lower()

    if column_count == 0:
        return {
            "column_count": 0,
            "curve_specs": [],
            "preview_rows": rows,
            "parse_mode": normalized_mode,
        }

    if normalized_mode == "xyxy":
        pair_count = column_count // 2
        if pair_count <= 0:
            raise ValueError("XYXY 模式至少需要 2 列数值数据")
        specs = [
            CurveColumnSpec(
                x_column=pair_index * 2,
                y_column=pair_index * 2 + 1,
                label=f"curve_{pair_index + 1:02d}",
            )
            for pair_index in range(pair_count)
        ]
    elif normalized_mode == "xyyy":
        if column_count < 2:
            raise ValueError("XYYY 模式至少需要 2 列数值数据")
        specs = [
            CurveColumnSpec(
                x_column=x_column,
                y_column=col_index,
                label=f"curve_{curve_index + 1:02d}",
            )
            for curve_index, col_index in enumerate(range(1, column_count))
        ]
    else:
        if column_count <= max(x_column, y_column):
            raise ValueError(
                f"列数不足：检测到 {column_count} 列，但请求 x={x_column}, y={y_column}"
            )
        specs = [CurveColumnSpec(x_column=x_column, y_column=y_column, label="curve_01")]

    return {
        "column_count": column_count,
        "curve_specs": specs,
        "preview_rows": rows,
        "parse_mode": normalized_mode,
    }


def detect_curve_format(path: str) -> str:
    """
    Detect the format of a 1D curve file based on extension and content.
    根据扩展名和内容检测一维曲线文件格式。

    Returns one of: 'xy', 'csv', 'pyfai_dat', 'auto'
    """
    ext = os.path.splitext(path)[1].lower()
    if ext == ".csv":
        return "csv"
    if ext in {".dat", ".xy"}:
        return "xy"
    if ext == ".gr":
        return "xy"
    if ext == ".txt":
        # Peek at first non-comment line to distinguish pyFAI .dat from plain xy
        try:
            with open(path, "r", encoding="utf-8", errors="replace") as f:
                for line in f:
                    line = line.strip()
                    if not line or line.startswith("#"):
                        continue
                    # pyFAI .dat files typically have 3 columns: q  I  sigma
                    parts = line.split()
                    if len(parts) >= 3:
                        return "pyfai_dat"
                    break
        except Exception:
            pass
        return "xy"
    return "auto"


def load_curve_file(
    path: str,
    x_column: int = 0,
    y_column: int = 1,
    skip_header: int = 0,
    comment: str = "#",
    delimiter: Optional[str] = None,
) -> Optional[Curve1D]:
    """
    Load a 1D curve from a text file.
    从文本文件加载一维曲线。

    Supports: plain space/tab-delimited xy, CSV, pyFAI .dat (3+ columns).

    Parameters / 参数
    -----------------
    path : str
        File path / 文件路径
    x_column : int
        Column index for x axis / x 轴列索引
    y_column : int
        Column index for y axis / y 轴列索引
    skip_header : int
        Number of header lines to skip / 跳过的头部行数
    comment : str
        Comment character / 注释字符
    delimiter : str or None
        Column delimiter (None = whitespace) / 列分隔符

    Returns / 返回
    ---------------
    Curve1D or None
        Loaded curve, or None on failure
    """
    fmt = detect_curve_format(path)
    try:
        if fmt == "csv":
            return _load_csv(path, x_column, y_column, skip_header, comment)
        elif fmt == "pyfai_dat":
            return _load_pyfai_dat(path, x_column, y_column, comment)
        else:
            return _load_xy(path, x_column, y_column, skip_header, comment, delimiter)
    except Exception:
        return None


def load_curve_collection(
    path: str,
    parse_mode: str = "single",
    x_column: int = 0,
    y_column: int = 1,
    skip_header: int = 0,
    comment: str = "#",
    delimiter: Optional[str] = None,
) -> List[Curve1D]:
    """
    Load one or more logical curves from the same text table.
    从同一个文本表格中加载一条或多条逻辑曲线。
    """
    layout = inspect_curve_layout(
        path=path,
        parse_mode=parse_mode,
        skip_header=skip_header,
        comment=comment,
        delimiter=delimiter,
        x_column=x_column,
        y_column=y_column,
    )
    curves: List[Curve1D] = []
    for spec in layout["curve_specs"]:
        curve = load_curve_file(
            path=path,
            x_column=spec.x_column,
            y_column=spec.y_column,
            skip_header=skip_header,
            comment=comment,
            delimiter=delimiter,
        )
        if curve is None:
            continue
        curve.metadata.extra["curve_label"] = spec.label
        curve.metadata.extra["x_column"] = spec.x_column
        curve.metadata.extra["y_column"] = spec.y_column
        curve.metadata.extra["parse_mode"] = layout["parse_mode"]
        curves.append(curve)
    return curves


def _load_xy(
    path: str,
    x_col: int,
    y_col: int,
    skip_header: int,
    comment: str,
    delimiter: Optional[str],
) -> Curve1D:
    """Load space/tab delimited xy file."""
    x_vals: List[float] = []
    y_vals: List[float] = []
    with open(path, "r", encoding="utf-8", errors="replace") as f:
        for _ in range(skip_header):
            next(f, None)
        for line in f:
            line = line.strip()
            if not line or line.startswith(comment):
                continue
            parts = line.split(delimiter) if delimiter else line.split()
            if len(parts) > max(x_col, y_col):
                try:
                    x_vals.append(float(parts[x_col]))
                    y_vals.append(float(parts[y_col]))
                except ValueError:
                    continue
    return Curve1D(
        x=np.array(x_vals, dtype=np.float64),
        y=np.array(y_vals, dtype=np.float64),
        metadata=CurveMetadata(
            source_file=path,
            source_type="1d_curve",
            x_label="x",
            y_label="I",
        ),
    )


def _load_csv(
    path: str,
    x_col: int,
    y_col: int,
    skip_header: int,
    comment: str,
) -> Curve1D:
    """Load CSV file with header detection."""
    x_vals: List[float] = []
    y_vals: List[float] = []
    with open(path, "r", encoding="utf-8", errors="replace", newline="") as f:
        reader = csv.reader(f)
        for _ in range(skip_header):
            next(reader, None)
        for row in reader:
            if not row:
                continue
            if row[0].startswith(comment):
                continue
            try:
                x_vals.append(float(row[x_col]))
                y_vals.append(float(row[y_col]))
            except (ValueError, IndexError):
                continue
    return Curve1D(
        x=np.array(x_vals, dtype=np.float64),
        y=np.array(y_vals, dtype=np.float64),
        metadata=CurveMetadata(
            source_file=path,
            source_type="1d_curve",
            x_label="x",
            y_label="I",
        ),
    )


def _load_pyfai_dat(
    path: str,
    x_col: int,
    y_col: int,
    comment: str,
) -> Curve1D:
    """Load pyFAI .dat file (typically 3 columns: q  I  sigma)."""
    x_vals: List[float] = []
    y_vals: List[float] = []
    with open(path, "r", encoding="utf-8", errors="replace") as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith(comment):
                continue
            parts = line.split()
            if len(parts) > max(x_col, y_col):
                try:
                    x_vals.append(float(parts[x_col]))
                    y_vals.append(float(parts[y_col]))
                except ValueError:
                    continue
    return Curve1D(
        x=np.array(x_vals, dtype=np.float64),
        y=np.array(y_vals, dtype=np.float64),
        metadata=CurveMetadata(
            source_file=path,
            source_type="1d_curve",
            x_label="q",
            y_label="I",
            x_unit="A^-1",
        ),
    )


def save_curve_file(
    curve: Curve1D,
    path: str,
    fmt: str = "auto",
    include_header: bool = True,
    delimiter: str = "\t",
    data_type: str = "raw",
) -> bool:
    """
    Save a 1D curve to a text file.
    保存一维曲线到文本文件。

    Parameters / 参数
    -----------------
    curve : Curve1D
        Curve to save / 要保存的曲线
    path : str
        Output file path / 输出文件路径
    fmt : str
        'auto', 'xy', 'csv' / 格式
    include_header : bool
        Whether to write comment header / 是否写入注释头部
    delimiter : str
        Column delimiter / 列分隔符
    data_type : str
        Which data to save: 'raw' (main y), 'background', 'subtracted'
        要保存的数据类型：'raw'(主y值)、'background'(背景)、'subtracted'(扣除后)

    Returns / 返回
    ---------------
    bool
        Success / 是否成功
    """
    try:
        ext = os.path.splitext(path)[1].lower()
        meta = curve.metadata
        if fmt == "auto":
            if ext == ".csv":
                fmt = "csv"
            elif ext in {".h5", ".hdf5"}:
                fmt = "h5"
            else:
                fmt = "xy"

        # Select y data based on type / 根据类型选择y数据
        if data_type == "background":
            if curve.background is None:
                return False
            y_data = curve.background
            y_label = f"{meta.y_label}_background"
        elif data_type == "subtracted":
            if curve.subtracted is None:
                return False
            y_data = curve.subtracted
            y_label = f"{meta.y_label}_subtracted"
        else:  # raw
            y_data = curve.y
            y_label = meta.y_label

        lines: List[str] = []
        if include_header:
            lines.append(f"# source: {meta.source_file}")
            lines.append(f"# x_label: {meta.x_label}")
            lines.append(f"# y_label: {y_label}")
            lines.append(f"# data_type: {data_type}")
            if meta.x_unit:
                lines.append(f"# x_unit: {meta.x_unit}")
            if meta.process_mode:
                lines.append(f"# process_mode: {meta.process_mode.value}")
            lines.append(f"# n_points: {curve.n_points}")
            if meta.extra:
                for k, v in meta.extra.items():
                    lines.append(f"# {k}: {v}")

        if fmt == "npy":
            payload = np.column_stack((curve.x, y_data)).astype(np.float64)
            np.save(path, payload)
            return True
        if fmt == "h5":
            with h5py.File(path, "w") as handle:
                handle.create_dataset("x", data=curve.x.astype(np.float64))
                handle.create_dataset("y", data=y_data.astype(np.float64))
                meta_group = handle.require_group("metadata")
                meta_group.attrs["source_file"] = meta.source_file
                meta_group.attrs["x_label"] = meta.x_label
                meta_group.attrs["y_label"] = y_label
                meta_group.attrs["data_type"] = data_type
                if meta.x_unit:
                    meta_group.attrs["x_unit"] = meta.x_unit
                if meta.process_mode:
                    meta_group.attrs["process_mode"] = meta.process_mode.value
                for key, value in meta.extra.items():
                    meta_group.attrs[str(key)] = str(value)
            return True
        if fmt == "csv":
            if include_header:
                x_lbl = meta.x_label
                lines.append(f"{x_lbl},{y_label}")
            for i in range(curve.n_points):
                lines.append(f"{curve.x[i]:.8e},{y_data[i]:.8e}")
        else:
            for i in range(curve.n_points):
                lines.append(f"{curve.x[i]:.8e}{delimiter}{y_data[i]:.8e}")

        with open(path, "w", encoding="utf-8") as f:
            f.write("\n".join(lines) + "\n")
        return True
    except Exception:
        return False


def save_curve_with_background(
    curve: Curve1D,
    path: str,
    include_bg: bool = True,
    include_subtracted: bool = True,
    fmt: str = "xy",
) -> bool:
    """
    Save a 1D curve with optional background and subtracted columns.
    保存一维曲线，可选包含背景列和扣除列。
    """
    try:
        normalized_fmt = "csv" if fmt == "csv" else ("h5" if fmt == "h5" else "xy")
        lines: List[str] = []
        meta = curve.metadata
        lines.append(f"# source: {meta.source_file}")
        if meta.process_mode:
            lines.append(f"# process_mode: {meta.process_mode.value}")

        has_bg = include_bg and curve.background is not None
        has_sub = include_subtracted and curve.subtracted is not None
        bg_values = curve.background if has_bg else None
        sub_values = curve.subtracted if has_sub else None

        header_parts = [meta.x_label, meta.y_label]
        if has_bg:
            header_parts.append("background")
        if has_sub:
            header_parts.append("subtracted")
        separator = "," if normalized_fmt == "csv" else "\t"
        lines.append("# " + separator.join(header_parts))

        if normalized_fmt == "h5":
            with h5py.File(path, "w") as handle:
                handle.create_dataset("x", data=curve.x.astype(np.float64))
                handle.create_dataset("raw", data=curve.y.astype(np.float64))
                if bg_values is not None:
                    handle.create_dataset(
                        "background", data=np.asarray(bg_values, dtype=np.float64)
                    )
                if sub_values is not None:
                    handle.create_dataset(
                        "subtracted", data=np.asarray(sub_values, dtype=np.float64)
                    )
                meta_group = handle.require_group("metadata")
                meta_group.attrs["source_file"] = meta.source_file
                if meta.process_mode:
                    meta_group.attrs["process_mode"] = meta.process_mode.value
                for key, value in meta.extra.items():
                    meta_group.attrs[str(key)] = str(value)
            return True

        for i in range(curve.n_points):
            row = [f"{curve.x[i]:.8e}", f"{curve.y[i]:.8e}"]
            if bg_values is not None:
                row.append(f"{bg_values[i]:.8e}")
            if sub_values is not None:
                row.append(f"{sub_values[i]:.8e}")
            lines.append(separator.join(row))

        with open(path, "w", encoding="utf-8") as f:
            f.write("\n".join(lines) + "\n")
        return True
    except Exception:
        return False


def save_curve_collection(
    curves: List[Curve1D],
    path: str,
    fmt: str = "xy",
    include_bg: bool = True,
    include_subtracted: bool = True,
) -> bool:
    """
    Save multiple processed curves into one merged file.
    将多条处理后的曲线合并保存到单个文件。
    """
    try:
        if not curves:
            return False
        normalized_fmt = "csv" if fmt == "csv" else ("h5" if fmt == "h5" else "xy")
        if normalized_fmt == "h5":
            with h5py.File(path, "w") as handle:
                meta_group = handle.require_group("metadata")
                meta_group.attrs["curve_count"] = len(curves)
                for index, curve in enumerate(curves):
                    curve_group = handle.require_group(f"curves/curve_{index:03d}")
                    curve_group.create_dataset("x", data=curve.x.astype(np.float64))
                    curve_group.create_dataset("raw", data=curve.y.astype(np.float64))
                    if include_bg and curve.background is not None:
                        curve_group.create_dataset(
                            "background", data=np.asarray(curve.background, dtype=np.float64)
                        )
                    if include_subtracted and curve.subtracted is not None:
                        curve_group.create_dataset(
                            "subtracted", data=np.asarray(curve.subtracted, dtype=np.float64)
                        )
                    curve_group.attrs["source_file"] = curve.metadata.source_file
                    curve_group.attrs["curve_label"] = curve.metadata.extra.get(
                        "curve_label", f"curve_{index + 1:02d}"
                    )
            return True

        separator = "," if normalized_fmt == "csv" else "\t"
        lines: List[str] = [f"# merged_curve_count: {len(curves)}"]
        for index, curve in enumerate(curves):
            label = curve.metadata.extra.get("curve_label", f"curve_{index + 1:02d}")
            lines.append(f"# curve[{index}]: {curve.metadata.source_file} | label={label}")

        column_labels: List[str] = []
        matrix_parts: List[np.ndarray] = []
        for index, curve in enumerate(curves):
            label = str(curve.metadata.extra.get("curve_label", f"curve_{index + 1:02d}"))
            matrix_parts.append(curve.x.reshape(-1, 1))
            column_labels.append(f"x_{label}")
            matrix_parts.append(curve.y.reshape(-1, 1))
            column_labels.append(f"raw_{label}")
            if include_bg and curve.background is not None:
                matrix_parts.append(np.asarray(curve.background, dtype=np.float64).reshape(-1, 1))
                column_labels.append(f"background_{label}")
            if include_subtracted and curve.subtracted is not None:
                matrix_parts.append(np.asarray(curve.subtracted, dtype=np.float64).reshape(-1, 1))
                column_labels.append(f"subtracted_{label}")

        matrix = np.hstack(matrix_parts)
        lines.append("# " + separator.join(column_labels))
        for row in matrix:
            lines.append(separator.join(f"{float(value):.8e}" for value in row))
        with open(path, "w", encoding="utf-8") as handle:
            handle.write("\n".join(lines) + "\n")
        return True
    except Exception:
        return False


def is_1d_curve_file(path: str) -> bool:
    """Check if a file is a 1D curve format / 检查文件是否为 1D 曲线格式"""
    ext = os.path.splitext(path)[1].lower()
    return ext in CURVE_EXTS


path: lib/curve_processor.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
curve_processor.py — Three processing modes for 1D curves
三种一维曲线处理模式

Implements the core processing logic for each mode:
  1. MORPH_1D       – 1D curve → morphological bg estimate → subtract → Curve1D
  2. FIT_1D         – 1D curve → polynomial fit bg estimate → subtract → Curve1D
  3. T_BG_SUBTRACT  – sample curve / (T/100) - background curve

Adapts bg_math algorithms for 1D.
适配 bg_math 算法到 1D。
"""

from __future__ import annotations

import logging
import os
from typing import Optional, Tuple, List, Dict, Any

import numpy as np
from scipy import ndimage
from scipy.ndimage import minimum_filter1d, maximum_filter1d

from .curve_data import Curve1D, CurveMetadata, ProcessMode

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# NaN / Inf cleaning / NaN / Inf 清洗
# ---------------------------------------------------------------------------


def clean_curve_data(
    x: np.ndarray,
    y: np.ndarray,
) -> Tuple[np.ndarray, np.ndarray]:
    """
    Remove NaN and Inf values from x/y arrays, keeping only finite points.
    去除 x/y 数组中的 NaN 和 Inf 值，仅保留有效数据点。

    If leading/trailing points are removed, the arrays are trimmed.
    If NaN/Inf appear in the middle, those points are dropped
    (no interpolation — the user requested cleaning, not repair).

    Parameters / 参数
    -----------------
    x, y : np.ndarray
        Input arrays / 输入数组

    Returns / 返回
    --------------
    (x_clean, y_clean) : Tuple[np.ndarray, np.ndarray]
        Cleaned arrays / 清洗后的数组
    """
    finite_mask = np.isfinite(x) & np.isfinite(y)
    n_removed = int((~finite_mask).sum())
    if n_removed > 0:
        logger.info(
            "数据清洗：移除 %d 个无效点（NaN/Inf），保留 %d / %d",
            n_removed,
            finite_mask.sum(),
            len(finite_mask),
        )
    return x[finite_mask], y[finite_mask]


# ---------------------------------------------------------------------------
# 1D background estimation algorithms / 1D 背景估计算法
# ---------------------------------------------------------------------------


def morphological_background_1d(
    y: np.ndarray,
    radius: int = 50,
    iterations: int = 1,
) -> np.ndarray:
    """
    Estimate background of a 1D curve using morphological opening.
    使用形态学开运算估计 1D 曲线的背景。

    1D equivalent of bg_math.morphological_background.
    """
    size = 2 * radius + 1
    result = y.astype(np.float64).copy()
    for _ in range(iterations):
        eroded = minimum_filter1d(result, size=size, mode="nearest")
        result = maximum_filter1d(eroded, size=size, mode="nearest")
    return np.minimum(result, y)


def polynomial_background_1d(
    x: np.ndarray,
    y: np.ndarray,
    degree: int = 4,
    quantile: Optional[float] = None,
    n_segments: int = 1,
) -> np.ndarray:
    """
    Estimate background of a 1D curve using polynomial fitting.
    使用多项式拟合估计 1D 曲线的背景。

    Optionally fits only to data below a quantile threshold
    to avoid peak contributions.
    """
    if n_segments <= 1:
        return _polyfit_segment(x, y, degree, quantile)

    seg_len = max(1, len(x) // n_segments)
    bg = np.zeros_like(y, dtype=np.float64)
    for i in range(n_segments):
        start = i * seg_len
        end = (i + 1) * seg_len if i < n_segments - 1 else len(x)
        bg[start:end] = _polyfit_segment(x[start:end], y[start:end], degree, quantile)
    return bg


def _polyfit_segment(
    x: np.ndarray,
    y: np.ndarray,
    degree: int,
    quantile: Optional[float],
) -> np.ndarray:
    if len(x) <= degree:
        return np.full_like(y, np.median(y), dtype=np.float64)

    if quantile is not None and 0 < quantile < 1:
        threshold = np.quantile(y, quantile)
        mask = y <= threshold
        if mask.sum() > degree:
            coeffs = np.polyfit(x[mask], y[mask], degree)
        else:
            coeffs = np.polyfit(x, y, degree)
    else:
        coeffs = np.polyfit(x, y, degree)

    bg = np.polyval(coeffs, x)
    return np.minimum(bg, y)


def summarize_curve_quality(
    curve: Curve1D,
    background: np.ndarray,
    subtracted: np.ndarray,
    method: str,
) -> Dict[str, Any]:
    """
    Summarize non-fatal quality signals for logging/UI.
    汇总用于日志/UI 的非致命质量信号。
    """
    y = np.asarray(curve.y, dtype=np.float64)
    bg = np.asarray(background, dtype=np.float64)
    sub = np.asarray(subtracted, dtype=np.float64)

    warnings: List[str] = []
    finite_bg_ratio = float(np.isfinite(bg).sum()) / float(bg.size) if bg.size else 0.0
    finite_sub_ratio = float(np.isfinite(sub).sum()) / float(sub.size) if sub.size else 0.0
    zero_fraction = float(np.mean(np.isclose(sub, 0.0, atol=1e-12))) if sub.size else 0.0
    bg_ratio = 0.0
    y_mean = float(np.mean(np.abs(y))) if y.size else 0.0
    if y_mean > 1e-12:
        bg_ratio = float(np.mean(np.abs(bg))) / y_mean

    if finite_bg_ratio < 1.0:
        warnings.append("background_contains_non_finite_values")
    if finite_sub_ratio < 1.0:
        warnings.append("subtracted_contains_non_finite_values")
    if zero_fraction >= 0.98:
        warnings.append("subtracted_nearly_all_zero")
    if method == "poly" and bg_ratio >= 0.98:
        warnings.append("poly_background_almost_matches_raw")
    if method in {"morph", "rolling_ball"} and bg_ratio <= 0.02:
        warnings.append("background_estimate_nearly_zero")

    return {
        "quality_method": method,
        "quality_bg_ratio": round(bg_ratio, 6),
        "quality_zero_fraction": round(zero_fraction, 6),
        "quality_finite_bg_ratio": round(finite_bg_ratio, 6),
        "quality_finite_sub_ratio": round(finite_sub_ratio, 6),
        "quality_warnings": warnings,
    }


def rolling_ball_background_1d(
    y: np.ndarray,
    radius: float = 50.0,
) -> np.ndarray:
    """
    Estimate background of a 1D curve using rolling ball method.
    使用滚球法估计 1D 曲线的背景。
    """
    y_f = y.astype(np.float64)
    size = max(3, int(2 * radius + 1))
    if size % 2 == 0:
        size += 1

    bg = minimum_filter1d(y_f, size=size, mode="nearest")
    if radius > 2:
        bg = ndimage.gaussian_filter1d(bg, sigma=max(1, radius / 4.0))
    return np.minimum(bg, y_f)


# ---------------------------------------------------------------------------
# CurveProcessorConfig / 曲线处理器配置
# ---------------------------------------------------------------------------


class CurveProcessorConfig:
    """Configuration for CurveProcessor. 曲线处理器配置。"""

    def __init__(
        self,
        process_mode: ProcessMode = ProcessMode.MORPH_1D,
        transmission: float = 100.0,
        morph_radius: int = 50,
        morph_iterations: int = 1,
        poly_degree: int = 4,
        poly_quantile: Optional[float] = 0.3,
        poly_n_segments: int = 1,
        rolling_ball_radius: float = 50.0,
        bg_method_1d: str = "morph",
        phys_formula: str = "",
        phys_density: float = 1.0,
        phys_thickness_mm: float = 1.0,
        phys_sd_mm: float = 200.0,
        phys_energy_eV: float = 12700.0,
        phys_wavelength_A: float = 0.976,
        phys_sample_abs_on: bool = False,
        phys_unit: str = "q",
        phys_scale_mode: str = "auto",
        phys_scale_factor: float = 1.0,
    ):
        self.process_mode = process_mode
        self.transmission = transmission
        self.morph_radius = morph_radius
        self.morph_iterations = morph_iterations
        self.poly_degree = poly_degree
        self.poly_quantile = poly_quantile
        self.poly_n_segments = poly_n_segments
        self.rolling_ball_radius = rolling_ball_radius
        self.bg_method_1d = bg_method_1d
        self.phys_formula = phys_formula
        self.phys_density = phys_density
        self.phys_thickness_mm = phys_thickness_mm
        self.phys_sd_mm = phys_sd_mm
        self.phys_energy_eV = phys_energy_eV
        self.phys_wavelength_A = phys_wavelength_A
        self.phys_sample_abs_on = phys_sample_abs_on
        self.phys_unit = phys_unit
        self.phys_scale_mode = phys_scale_mode
        self.phys_scale_factor = phys_scale_factor


# ---------------------------------------------------------------------------
# CurveProcessor / 曲线处理器
# ---------------------------------------------------------------------------


class CurveProcessor:
    """
    Main processor implementing the three 1D curve processing modes.
    实现三种一维曲线处理模式的主处理器。
    """

    def __init__(self, config: Optional[CurveProcessorConfig] = None):
        self.config = config or CurveProcessorConfig()

    def estimate_background_1d(self, curve: Curve1D) -> np.ndarray:
        """Estimate background of a 1D curve using configured method."""
        method = self.config.bg_method_1d
        if method == "morph":
            return morphological_background_1d(
                curve.y,
                radius=self.config.morph_radius,
                iterations=self.config.morph_iterations,
            )
        elif method == "poly":
            return polynomial_background_1d(
                curve.x,
                curve.y,
                degree=self.config.poly_degree,
                quantile=self.config.poly_quantile,
                n_segments=self.config.poly_n_segments,
            )
        elif method == "rolling_ball":
            return rolling_ball_background_1d(
                curve.y,
                radius=self.config.rolling_ball_radius,
            )
        else:
            raise ValueError(f"Unknown 1D bg method: {method}")

    def apply_background_subtraction(self, curve: Curve1D) -> Curve1D:
        """
        Estimate and subtract background from a 1D curve.
        Returns a new Curve1D with background and subtracted populated.
        """
        bg = self.estimate_background_1d(curve)
        sub = np.maximum(curve.y - bg, 0.0)
        result = Curve1D(
            x=curve.x.copy(),
            y=curve.y.copy(),
            metadata=curve.metadata,
            background=bg,
            subtracted=sub,
        )
        result.metadata.extra.update(
            summarize_curve_quality(result, bg, sub, self.config.bg_method_1d)
        )
        return result

    def process_morph_1d(self, curve: Curve1D) -> Curve1D:
        """
        Mode 3: Morphological background estimation on 1D curve.
        模式 3：对 1D 曲线进行形态学背景估计。
        """
        result = self.apply_background_subtraction(curve)
        result.metadata.process_mode = ProcessMode.MORPH_1D
        result.metadata.extra["morph_radius"] = self.config.morph_radius
        result.metadata.extra["morph_iterations"] = self.config.morph_iterations
        return result

    def process_fit_1d(self, curve: Curve1D) -> Curve1D:
        """
        Mode 4: Polynomial fitting background estimation on 1D curve.
        模式 4：对 1D 曲线进行多项式拟合背景估计。
        """
        bg = polynomial_background_1d(
            curve.x,
            curve.y,
            degree=self.config.poly_degree,
            quantile=self.config.poly_quantile,
            n_segments=self.config.poly_n_segments,
        )
        sub = np.maximum(curve.y - bg, 0.0)
        result = Curve1D(
            x=curve.x.copy(),
            y=curve.y.copy(),
            metadata=curve.metadata,
            background=bg,
            subtracted=sub,
        )
        result.metadata.process_mode = ProcessMode.FIT_1D
        result.metadata.extra["poly_degree"] = self.config.poly_degree
        result.metadata.extra["poly_quantile"] = self.config.poly_quantile
        result.metadata.extra.update(summarize_curve_quality(result, bg, sub, "poly"))
        return result

    def process_t_bg_subtract(
        self,
        sample: Curve1D,
        background: Curve1D,
        transmission_percent: float,
    ) -> Curve1D:
        """
        Apply transmission-corrected reference subtraction on 1D curves.
        对 1D 曲线执行透过率修正的参考背景扣除。
        """
        if transmission_percent <= 0:
            raise ValueError("Transmission percent must be > 0")

        transmission_ratio = transmission_percent / 100.0
        subtracted = sample.y / transmission_ratio - background.y
        result = sample.copy()
        result.background = background.y.copy()
        result.subtracted = subtracted
        result.metadata.process_mode = ProcessMode.T_BG_SUBTRACT
        result.metadata.extra["transmission_percent"] = transmission_percent
        result.metadata.extra["background_source_file"] = background.metadata.source_file
        result.metadata.extra.update(
            summarize_curve_quality(result, background.y, subtracted, "t_bg")
        )
        return result

    def process_phys_fit(self, curve: Curve1D) -> Curve1D:
        """
        Physics-based attenuation simulation: use air (and optional sample)
        absorption to generate a simulated background curve.
        物理衰减仿真：利用空气（和可选样品）吸收生成仿真背景曲线。
        """
        # phys_fit module requires full BGsub package installation
        # phys_fit 模块需要完整安装 BGsub 包
        from BGsub.core.phys_fit import (
            simulate_phys_curve,
            auto_scale_factor,
        )

        I_sim, info = simulate_phys_curve(
            x=curve.x,
            unit=self.config.phys_unit,
            energy_eV=self.config.phys_energy_eV,
            wavelength_A=self.config.phys_wavelength_A,
            sd_mm=self.config.phys_sd_mm,
            formula=self.config.phys_formula,
            density=self.config.phys_density,
            thickness_mm=self.config.phys_thickness_mm,
            sample_abs_on=self.config.phys_sample_abs_on,
        )

        if self.config.phys_scale_mode == "auto":
            scale = auto_scale_factor(curve.y, I_sim)
        else:
            scale = self.config.phys_scale_factor

        bg = scale * I_sim
        sub = np.maximum(curve.y - bg, 0.0)
        result = Curve1D(
            x=curve.x.copy(),
            y=curve.y.copy(),
            metadata=curve.metadata,
            background=bg,
            subtracted=sub,
        )
        result.metadata.process_mode = ProcessMode.PHYS_FIT
        result.metadata.extra["phys_scale_factor"] = scale
        result.metadata.extra["phys_scale_mode"] = self.config.phys_scale_mode
        result.metadata.extra.update(info)
        result.metadata.extra.update(summarize_curve_quality(result, bg, sub, "phys_fit"))
        return result

    def process(self, input_data: Curve1D) -> Curve1D:
        """
        Dispatch to the correct processing mode based on config.
        NaN/Inf points are cleaned before processing.
        根据配置分发到正确的处理模式。处理前清洗 NaN/Inf 数据点。

        Parameters / 参数
        -----------------
        input_data : Curve1D
            1D curve to process / 要处理的一维曲线
        """
        x_clean, y_clean = clean_curve_data(input_data.x, input_data.y)
        if len(x_clean) < 3:
            raise ValueError(f"清洗后仅剩 {len(x_clean)} 个有效数据点，不足以处理")
        cleaned = Curve1D(
            x=x_clean,
            y=y_clean,
            metadata=input_data.metadata,
        )

        mode = self.config.process_mode

        if mode == ProcessMode.MORPH_1D:
            return self.process_morph_1d(cleaned)

        elif mode == ProcessMode.FIT_1D:
            return self.process_fit_1d(cleaned)

        elif mode == ProcessMode.PHYS_FIT:
            return self.process_phys_fit(cleaned)

        elif mode == ProcessMode.T_BG_SUBTRACT:
            raise ValueError(
                "T_BG_SUBTRACT requires sample/background curves; use process_t_bg_subtract()"
            )

        else:
            raise ValueError(f"Unknown process mode: {mode}")


path: lib/image_io.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
image_io.py — Image file I/O
图像文件输入输出

Adapted from substract_bg_gui.py for standalone use.
从 substract_bg_gui.py 适配，支持 TIFF/EDF/H5 格式。
"""

from __future__ import annotations

import os
import re
import json
import tempfile
import uuid
from contextlib import nullcontext
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING

import h5py
import numpy as np

try:
    import fabio

    HAS_FABIO = True
except ImportError:
    fabio = None
    HAS_FABIO = False


TIFF_EXTS = {".tif", ".tiff", ".edf"}
H5_EXTS = {".h5", ".hdf5"}
H5_TRANSMISSION_KEYS: Tuple[str, ...] = (
    "transmission",
    "Transmission",
    "T",
    "trans",
    "Trans",
    "transmittance",
    "Transmittance",
    "t_percent",
    "T_percent",
)


def load_image_file(path: str) -> Tuple[Optional[np.ndarray], Optional[dict]]:
    """
    Load a 2D image file (TIFF/EDF) using fabio.
    使用 fabio 加载 2D 图像文件。

    Parameters
    ----------
    path : str
        Path to the image file / 图像文件路径

    Returns
    -------
    data : np.ndarray or None
        Image data as float32 for memory efficiency / 图像数据（为节省内存使用float32）
    header : dict or None
        Image header metadata / 图像头信息
    """
    if not HAS_FABIO:
        raise ImportError("fabio is required for image loading. Install with: pip install fabio")

    try:
        fabio_module: Any = fabio
        with fabio_module.open(path) as img:
            # Use float32 to save 50% memory compared to float64
            # 使用float32节省50%内存
            return img.data.astype(np.float32), dict(img.header)
    except Exception:
        return None, None


def load_h5_stack(path: str) -> Optional[Dict[str, Any]]:
    """
    Load an HDF5 file and extract the best image dataset.
    加载 HDF5 文件并提取最佳图像数据集。

    Supports shapes:
      (H, W)         -> normalized to (1, H, W)
      (N, H, W)      -> 3D stack
      (N, C, H, W)   -> 4D Eiger-style

    Parameters
    ----------
    path : str
        Path to HDF5 file / HDF5 文件路径

    Returns
    -------
    dict with keys:
        data : np.ndarray (float64)
        ndim : int (3 or 4)
        shape : tuple
        n_frames : int
        n_channels : int or None
        dataset_path : str
        transmissions : np.ndarray or None
        filename : str
    """
    candidates: Dict[str, Dict[str, Any]] = {}
    try:
        with h5py.File(path, "r") as fh:

            def _visitor(name: str, obj) -> None:
                if isinstance(obj, h5py.Dataset) and obj.ndim >= 2:
                    candidates[name] = {
                        "shape": obj.shape,
                        "ndim": obj.ndim,
                        "size": obj.size,
                    }

            fh.visititems(_visitor)
    except Exception:
        return None

    if not candidates:
        return None

    best_path = max(candidates, key=lambda k: (candidates[k]["ndim"], candidates[k]["size"]))

    with h5py.File(path, "r") as fh:
        dataset: Any = fh[best_path]
        if not isinstance(dataset, h5py.Dataset):
            return None
        # Use float32 to save 50% memory / 使用float32节省50%内存
        raw = np.asarray(dataset[...], dtype=np.float32)

    ndim = raw.ndim
    if ndim == 2:
        raw = raw[np.newaxis, :]
        ndim = 3

    if ndim == 3:
        n_frames, n_channels = raw.shape[0], None
    elif ndim == 4:
        n_frames, n_channels = raw.shape[0], raw.shape[1]
    else:
        return None

    return {
        "data": raw,
        "ndim": ndim,
        "shape": raw.shape,
        "n_frames": n_frames,
        "n_channels": n_channels,
        "dataset_path": best_path,
        "transmissions": find_h5_transmissions(path),
        "filename": os.path.basename(path),
    }


def find_h5_transmissions(h5_path: str) -> Optional[np.ndarray]:
    """
    Search an HDF5 file for per-frame transmission values.
    在 HDF5 文件中搜索逐帧透过率数据。

    Parameters
    ----------
    h5_path : str
        Path to HDF5 file / HDF5 文件路径

    Returns
    -------
    np.ndarray or None
        Transmission values / 透过率值数组
    """
    keys_lower = {k.lower() for k in H5_TRANSMISSION_KEYS}
    try:
        with h5py.File(h5_path, "r") as fh:
            for key in H5_TRANSMISSION_KEYS:
                if key in fh:
                    dataset: Any = fh[key]
                    if isinstance(dataset, h5py.Dataset):
                        return np.asarray(dataset[...], dtype=np.float64)

            found: List[Tuple[str, np.ndarray]] = []

            def _visit(name: str, obj) -> None:
                leaf = name.split("/")[-1].lower()
                if isinstance(obj, h5py.Dataset) and leaf in keys_lower:
                    found.append((name, np.asarray(obj[...], dtype=np.float64)))

            fh.visititems(_visit)
            if found:
                return found[0][1]

            for key in H5_TRANSMISSION_KEYS:
                if key in fh.attrs:
                    return np.atleast_1d(np.asarray(fh.attrs[key], dtype=np.float64))

    except Exception:
        pass
    return None


def save_image(
    data: np.ndarray,
    path: str,
    format: str = "auto",
    header: Optional[dict] = None,
) -> bool:
    """
    Save image data to file.
    保存图像数据到文件。

    Parameters
    ----------
    data : np.ndarray
        Image data / 图像数据
    path : str
        Output path / 输出路径
    format : str
        'auto', 'edf', 'tif', 'tiff' / 格式
    header : dict, optional
        Header metadata for EDF files / EDF 文件头信息

    Returns
    -------
    bool
        Success / 是否成功
    """
    if not HAS_FABIO:
        raise ImportError("fabio is required for image saving")

    try:
        fabio_module: Any = fabio
        ext = os.path.splitext(path)[1].lower()
        if format == "auto":
            if ext in {".edf"}:
                format = "edf"
            elif ext in {".tif", ".tiff"}:
                format = "tif"
            else:
                format = "edf"

        data = np.asarray(data, dtype=np.float32)

        if format == "edf":
            h = header.copy() if header else {}
            h.update(
                {
                    "Dim_1": str(data.shape[1]),
                    "Dim_2": str(data.shape[0]),
                    "DataType": "Float32",
                }
            )
            img = fabio_module.edfimage.EdfImage(data=data, header=h)
            img.write(path)
        else:
            img = fabio_module.tifimage.TifImage(data=data, header=header or {})
            img.write(path)

        return True
    except Exception:
        return False


# ---------------------------------------------------------------------------
# Cache directory lifecycle / 缓存目录生命周期
# ---------------------------------------------------------------------------


class CacheDir:
    """
    Managed temporary cache directory with guaranteed cleanup.
    受控临时缓存目录，保证清理。

    Usage / 用法::

        with CacheDir() as cache:
            path = cache.make_path("output.tif")
            fabio.tifimage.TifImage(data, hdr).write(path)
            with open(path, "rb") as f:
                zf.writestr("output.tif", f.read())
        # cache directory is automatically cleaned up
        # 缓存目录自动清理

    Cleanup guarantee / 清理保证:
        - Normal exit: ``__exit__`` calls ``cleanup()``
          正常退出时 ``__exit__`` 调用 ``cleanup()``
        - Exception: ``__exit__`` calls ``cleanup()`` before propagating
          异常时 ``__exit__`` 在传播前调用 ``cleanup()``
        - SIGKILL: OS temp dir cleanup (best-effort)
          SIGKILL 时由操作系统清理临时目录（尽力而为）
    """

    _MARKER = ".bgsub_cache_marker"

    def __init__(self, prefix: str = "bgsub_pkg_"):
        self._tmpdir: Optional[tempfile.TemporaryDirectory] = None
        self._prefix = prefix

    @property
    def path(self) -> str:
        """Return the cache directory path / 返回缓存目录路径。"""
        if self._tmpdir is None:
            raise RuntimeError("CacheDir not entered — use 'with' statement")
        return self._tmpdir.name

    def make_path(self, name: str) -> str:
        """Create a unique file path in the cache directory.
        在缓存目录中创建唯一文件路径。"""
        return os.path.join(self.path, name)

    def cleanup(self) -> None:
        """Force cleanup of the cache directory.
        强制清理缓存目录。"""
        if self._tmpdir is not None:
            try:
                self._tmpdir.cleanup()
            except (OSError, PermissionError):
                pass
            self._tmpdir = None

    def __enter__(self) -> "CacheDir":
        self._tmpdir = tempfile.TemporaryDirectory(prefix=self._prefix)
        # Write marker file for identification / 写入标记文件用于识别
        marker = os.path.join(self.path, self._MARKER)
        try:
            with open(marker, "w") as f:
                f.write("BGsub cache directory\n")
        except OSError:
            pass
        return self

    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool:
        self.cleanup()
        return False  # Do not suppress exceptions / 不抑制异常


# ---------------------------------------------------------------------------
# In-memory image encoding (no disk I/O for EDF) / 内存图像编码（EDF无磁盘I/O）
# ---------------------------------------------------------------------------


def encode_edf_to_bytes(
    data: np.ndarray,
    header: Optional[dict] = None,
) -> bytes:
    """
    Encode a 2D array as EDF format bytes (no disk I/O).
    将2D数组编码为EDF格式字节（无磁盘I/O）。

    Produces a valid EDF file readable by ``fabio.open()``.
    产生可被 ``fabio.open()`` 读取的有效EDF文件。

    Parameters
    ----------
    data : np.ndarray
        2D image data / 2D图像数据
    header : dict, optional
        Additional header metadata / 额外文件头信息

    Returns
    -------
    bytes
        Complete EDF file content / 完整EDF文件内容
    """
    data = np.asarray(data, dtype=np.float32)
    if data.ndim != 2:
        raise ValueError(f"Expected 2D array, got {data.ndim}D")

    rows, cols = data.shape
    data_bytes_size = data.size * data.dtype.itemsize

    h: Dict[str, str] = {}
    if header:
        h.update({str(k): str(v) for k, v in header.items()})
    h["Dim_1"] = str(cols)
    h["Dim_2"] = str(rows)
    h["DataType"] = "FloatValue"
    h["Size"] = str(data_bytes_size)
    h["EDF_BinarySize"] = str(data_bytes_size)
    h["ByteOrder"] = "LowByteFirst"

    lines = ["{"]
    for k, v in h.items():
        lines.append(f"{k} = {v} ;")
    lines.append("}")
    header_text = "\n".join(lines) + "\n"

    header_bytes = header_text.encode("latin-1", errors="replace")
    h["EDF_HeaderSize"] = str(len(header_bytes))
    lines_with_hdr = ["{"]
    for k, v in h.items():
        lines_with_hdr.append(f"{k} = {v} ;")
    lines_with_hdr.append("}")
    header_text = "\n".join(lines_with_hdr) + "\n"
    header_bytes = header_text.encode("latin-1", errors="replace")

    data_bytes = data.astype("<f4").tobytes()

    return header_bytes + data_bytes


def encode_image_to_bytes(
    data: np.ndarray,
    fmt: str = "edf",
    header: Optional[dict] = None,
    cache: Optional[CacheDir] = None,
) -> bytes:
    """
    Encode a 2D array as image format bytes.
    将2D数组编码为图像格式字节。

    - EDF: Pure Python encoder, zero disk I/O
      EDF: 纯Python编码器，零磁盘I/O
    - TIFF: Uses fabio with managed cache directory (preserves float32)
      TIFF: 使用fabio和受控缓存目录（保留float32精度）

    Parameters
    ----------
    data : np.ndarray
        2D image data / 2D图像数据
    fmt : str
        ``'edf'`` or ``'tif'`` / 格式
    header : dict, optional
        Header metadata / 文件头信息
    cache : CacheDir, optional
        Required for TIFF — provides managed temp directory
        TIFF必须提供 — 提供受控临时目录

    Returns
    -------
    bytes
        Encoded image data / 编码后的图像数据

    Raises
    ------
    ValueError
        If TIFF encoding requested without a CacheDir
    ImportError
        If fabio is not available for TIFF encoding
    """
    if fmt == "edf":
        return encode_edf_to_bytes(data, header)
    elif fmt in ("tif", "tiff"):
        if cache is None:
            raise ValueError(
                "CacheDir is required for TIFF in-memory encoding. TIFF 内存编码需要 CacheDir。"
            )
        return _encode_tiff_via_cache(data, header, cache)
    else:
        # Default to EDF / 默认使用EDF
        return encode_edf_to_bytes(data, header)


def _encode_tiff_via_cache(
    data: np.ndarray,
    header: Optional[dict],
    cache: CacheDir,
) -> bytes:
    """
    Encode TIFF via managed cache file (preserves float32 precision).
    通过受控缓存文件编码TIFF（保留float32精度）。

    Uses fabio's TifImage writer to ensure byte-level compatibility
    with the existing ``save_image()`` output.
    使用 fabio 的 TifImage 写入器确保与现有 ``save_image()`` 输出字节级兼容。
    """
    if not HAS_FABIO:
        raise ImportError("fabio is required for TIFF encoding. Install with: pip install fabio")

    fabio_module: Any = fabio

    unique_name = f"_{uuid.uuid4().hex[:8]}.tif"
    tmp_path = cache.make_path(unique_name)

    data = np.asarray(data, dtype=np.float32)
    img = fabio_module.tifimage.TifImage(data=data, header=header or {})
    img.write(tmp_path)

    with open(tmp_path, "rb") as f:
        return f.read()


# ---------------------------------------------------------------------------
# H5 export / H5导出
# ---------------------------------------------------------------------------


def export_h5_results(
    processed_data: np.ndarray,
    transmission_values: np.ndarray,
    bg_name: str,
    sample_name: str,
    dataset_path: str,
) -> bytes:
    """
    Export background subtraction results to HDF5.
    将背景扣除结果导出为 HDF5。

    Parameters
    ----------
    processed_data : np.ndarray
        Processed image data / 处理后的图像数据
    transmission_values : np.ndarray
        Per-frame transmission values / 逐帧透过率
    bg_name : str
        Background filename / 背景文件名
    sample_name : str
        Sample filename / 样品文件名
    dataset_path : str
        Source dataset path / 源数据集路径

    Returns
    -------
    bytes
        HDF5 file content as bytes / HDF5 文件内容
    """
    from io import BytesIO

    buf = BytesIO()
    ndim = processed_data.ndim
    formula = (
        "processed[n,c] = sample[n,c] / T[n] - background[n,c]"
        if ndim == 4
        else "processed[n]   = sample[n]   / T[n] - background[n]"
    )

    with h5py.File(buf, "w") as fh:
        meta = fh.create_group("metadata")
        meta.attrs["created"] = datetime.now().isoformat()
        meta.attrs["background_file"] = bg_name
        meta.attrs["sample_file"] = sample_name
        meta.attrs["source_dataset"] = dataset_path
        meta.attrs["processing_formula"] = formula
        meta.attrs["transmission_unit"] = "percent"

        fh.create_dataset(
            "transmissions",
            data=transmission_values.astype(np.float32),
            compression="gzip",
        )
        fh.create_dataset(
            "data",
            data=processed_data.astype(np.float32),
            compression="gzip",
            compression_opts=4,
        )

    buf.seek(0)
    return buf.read()


# ---------------------------------------------------------------------------
# Index-first probe: header-only reads (no pixel data loaded)
# 索引优先探针：仅读取头信息（不加载像素数据）
# ---------------------------------------------------------------------------


def _probe_tiff_header(path: str) -> Tuple[Optional[tuple], Optional[dict]]:
    """
    Parse TIFF IFD tags for image dimensions WITHOUT loading pixel data.
    解析 TIFF IFD 标签获取图像尺寸，不加载像素数据。

    Only reads the first ~200 bytes of the file to extract width/height.
    仅读取文件前约200字节以提取宽高信息。
    """
    import struct

    with open(path, "rb") as f:
        # Byte order mark / 字节序标记
        bom = f.read(2)
        if bom == b"II":
            endian = "<"
        elif bom == b"MM":
            endian = ">"
        else:
            return None, None

        magic = struct.unpack(endian + "H", f.read(2))[0]
        if magic != 42:
            return None, None

        ifd_offset = struct.unpack(endian + "I", f.read(4))[0]
        f.seek(ifd_offset)
        num_entries = struct.unpack(endian + "H", f.read(2))[0]

        width = height = None
        header: Dict[str, str] = {}

        for _ in range(num_entries):
            tag_bytes = f.read(12)
            if len(tag_bytes) < 12:
                break
            tag, typ, count = struct.unpack(endian + "HHI", tag_bytes[:8])
            val_bytes = tag_bytes[8:12]

            if tag == 256:  # ImageWidth
                if typ == 3:  # SHORT
                    width = struct.unpack(endian + "H", val_bytes[:2])[0]
                elif typ == 4:  # LONG
                    width = struct.unpack(endian + "I", val_bytes)[0]
            elif tag == 257:  # ImageLength
                if typ == 3:
                    height = struct.unpack(endian + "H", val_bytes[:2])[0]
                elif typ == 4:
                    height = struct.unpack(endian + "I", val_bytes)[0]

        if width and height:
            header["Dim_1"] = str(width)
            header["Dim_2"] = str(height)
            return (height, width), header

    return None, None


def _probe_edf_header(path: str) -> Tuple[Optional[tuple], Optional[dict]]:
    """
    Parse EDF plain-text header for image dimensions WITHOUT loading pixel data.
    解析 EDF 纯文本头信息获取图像尺寸，不加载像素数据。

    Reads only up to the closing '}' character of the header block.
    仅读取到头信息块的 '}' 结束符。
    """
    header: Dict[str, str] = {}
    dim_1 = dim_2 = None

    with open(path, "rb") as f:
        # Read chunks until we find the header end marker '}'
        # 分块读取直到找到头结束标记 '}'
        buf = b""
        while True:
            chunk = f.read(8192)
            if not chunk:
                break
            buf += chunk
            end = buf.find(b"}")
            if end >= 0:
                buf = buf[:end]
                break

    text = buf.lstrip(b"{").decode("ascii", errors="replace")
    for line in text.split("\n"):
        line = line.strip()
        if "=" not in line:
            continue
        key, _, val = line.partition("=")
        key = key.strip()
        val = val.strip().rstrip(";").strip()
        if not key:
            continue
        header[key] = val
        if key == "Dim_1":
            try:
                dim_1 = int(val.split()[0])
            except (ValueError, IndexError):
                pass
        elif key == "Dim_2":
            try:
                dim_2 = int(val.split()[0])
            except (ValueError, IndexError):
                pass

    if dim_1 is not None and dim_2 is not None:
        # EDF convention: Dim_1 = columns, Dim_2 = rows
        # EDF 约定：Dim_1 = 列数，Dim_2 = 行数
        return (dim_2, dim_1), header

    return None, None


def probe_image_file(path: str) -> Tuple[Optional[tuple], Optional[dict]]:
    """
    Read image dimensions and header metadata WITHOUT loading pixel data.
    仅读取图像尺寸和头信息/元数据，不加载像素数据。

    Uses lightweight format-specific header parsers (TIFF IFD / EDF text)
    instead of fabio, avoiding full pixel array decompression.
    使用轻量级格式专用头解析器（TIFF IFD / EDF 文本），
    而非 fabio，避免完整像素数组解压。

    Parameters
    ----------
    path : str
        Path to the image file / 图像文件路径

    Returns
    -------
    shape : tuple or None
        Image shape as (rows, cols) / 图像形状（行，列）
    header : dict or None
        Header key-value metadata / 头信息键值对
    """
    ext = os.path.splitext(path)[1].lower()
    try:
        if ext == ".edf":
            return _probe_edf_header(path)
        elif ext in (".tif", ".tiff"):
            return _probe_tiff_header(path)
    except Exception:
        pass
    return None, None


def probe_h5_datasets(path: str) -> Optional[Dict[str, Any]]:
    """
    Scan HDF5 file for the best image dataset using metadata only.
    仅使用元数据扫描 HDF5 文件中的最佳图像数据集。

    Reads shape, ndim, and path from h5py.Dataset objects.
    Does NOT execute ``[:]`` or read any array data.
    读取 h5py.Dataset 对象的 shape、ndim 和路径。
    不执行 ``[:]`` 或读取任何数组数据。

    Parameters
    ----------
    path : str
        Path to HDF5 file / HDF5 文件路径

    Returns
    -------
    dict or None
        Metadata for the best dataset:
        {
            "dataset_path": str,
            "shape": tuple,
            "ndim": int,          # raw ndim (2/3/4)
            "effective_ndim": int, # normalized (3 for 2D input)
            "n_frames": int,
            "n_channels": int or None,
            "filename": str,
            "has_transmissions": bool,
        }
        Returns None if no suitable dataset found.
    """
    candidates: Dict[str, Dict[str, Any]] = {}
    has_transmissions = False

    try:
        with h5py.File(path, "r") as fh:

            def _visitor(name: str, obj) -> None:
                if isinstance(obj, h5py.Dataset) and obj.ndim >= 2:
                    candidates[name] = {
                        "shape": obj.shape,
                        "ndim": obj.ndim,
                        "size": obj.size,
                    }

            fh.visititems(_visitor)

            # Check for transmission data existence (no array reads)
            # 检查透过率数据是否存在（不读取数组）
            keys_lower = {k.lower() for k in H5_TRANSMISSION_KEYS}

            for key in H5_TRANSMISSION_KEYS:
                if key in fh and isinstance(fh[key], h5py.Dataset):
                    has_transmissions = True
                    break

            if not has_transmissions:
                found = [False]

                def _check_trans(name: str, obj) -> None:
                    if found[0]:
                        return
                    leaf = name.split("/")[-1].lower()
                    if isinstance(obj, h5py.Dataset) and leaf in keys_lower:
                        found[0] = True

                fh.visititems(_check_trans)
                has_transmissions = found[0]

            if not has_transmissions:
                for key in H5_TRANSMISSION_KEYS:
                    if key in fh.attrs:
                        has_transmissions = True
                        break
    except Exception:
        return None

    if not candidates:
        return None

    best_path = max(candidates, key=lambda k: (candidates[k]["ndim"], candidates[k]["size"]))

    meta = candidates[best_path]
    ndim = meta["ndim"]
    shape = meta["shape"]

    # Normalize ndim: 2D → 3D (single frame), same as load_h5_stack
    # 标准化 ndim：2D → 3D（单帧），与 load_h5_stack 一致
    effective_ndim = 3 if ndim == 2 else ndim
    if ndim == 2:
        n_frames, n_channels = 1, None
    elif ndim == 3:
        n_frames, n_channels = shape[0], None
    elif ndim == 4:
        n_frames, n_channels = shape[0], shape[1]
    else:
        return None

    return {
        "dataset_path": best_path,
        "shape": shape,
        "ndim": ndim,
        "effective_ndim": effective_ndim,
        "n_frames": n_frames,
        "n_channels": n_channels,
        "filename": os.path.basename(path),
        "has_transmissions": has_transmissions,
    }


# ---------------------------------------------------------------------------
# TIFF-to-H5 export / TIFF 转 H5 导出
# ---------------------------------------------------------------------------


def _safe_h5_name(name: str) -> str:
    """Sanitize a string for use as an HDF5 dataset name.
    安全化字符串用于 HDF5 数据集名称。"""
    return re.sub(r"[^A-Za-z0-9_\-]", "_", name)


def export_tiff_to_h5(
    processed: list,
    trans_dict: Dict[str, float],
    bg_name: str,
    stacked: bool = True,
) -> bytes:
    """
    Export processed TIFF results into a single HDF5 file.
    将处理后的 TIFF 结果导出为单个 HDF5 文件。

    Parameters
    ----------
    processed : list of dict
        Each dict has ``"name"`` and ``"data"`` (np.ndarray) keys.
        每个字典包含 ``"name"`` 和 ``"data"`` 键。
    trans_dict : dict
        Mapping from sample name to transmission percent.
        样品名到透过率百分比的映射。
    bg_name : str
        Background filename / 背景文件名
    stacked : bool
        If True, store as (N, H, W); if False, store as separate datasets.
        如果为 True，存储为 (N, H, W)；如果为 False，存储为独立数据集。

    Returns
    -------
    bytes
        HDF5 file content / HDF5 文件内容
    """
    from io import BytesIO

    buf = BytesIO()
    orig = [pf["name"] for pf in processed]
    T = np.array(
        [trans_dict.get(pf["name"].replace("processed_", "", 1), 100.0) for pf in processed],
        dtype=np.float32,
    )
    with h5py.File(buf, "w") as fh:
        m = fh.create_group("metadata")
        m.attrs.update(
            {
                "created": datetime.now().isoformat(),
                "background_file": bg_name,
                "n_files": len(processed),
                "processing_formula": "processed = sample / T - background",
                "transmission_unit": "percent",
                "h5_mode": "stacked" if stacked else "split",
            }
        )
        g = fh.require_group("entry/sub_bg")
        dt = g.create_dataset("transmission", data=T, compression="gzip")
        dt.attrs["unit"] = "percent"
        dt.attrs["filenames"] = json.dumps(orig, ensure_ascii=False)
        if stacked:
            stack = np.stack([pf["data"].astype(np.float32) for pf in processed])
            ds = g.create_dataset(
                "data",
                data=stack,
                compression="gzip",
                compression_opts=4,
            )
            ds.attrs["shape_description"] = "(N_frames, Height, Width)"
            ds.attrs["filenames"] = json.dumps(orig, ensure_ascii=False)
        else:
            for pf, o, t in zip(processed, orig, T):
                ds = g.create_dataset(
                    _safe_h5_name(o),
                    data=pf["data"].astype(np.float32),
                    compression="gzip",
                    compression_opts=4,
                )
                ds.attrs["original_name"] = pf["name"]
                ds.attrs["transmission_percent"] = float(t)
    buf.seek(0)
    return buf.read()


path: lib/task_pipeline.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
task_pipeline.py — Batch task model and streaming pipeline engine
批任务模型与流式管线引擎

Memory-friendly design:
  - TaskItem stores paths/config, NOT large arrays
  - PipelineEngine yields results one at a time (generator-based)
  - Data is loaded lazily and released after each task completes
"""

from __future__ import annotations

import os
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import ClassVar, Optional, List, Dict, Any, Callable, Generator, Tuple

import numpy as np

from .curve_data import Curve1D, CurveMetadata, ProcessMode
from .curve_processor import CurveProcessor, CurveProcessorConfig


class TaskStatus(Enum):
    PENDING = "pending"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"
    SKIPPED = "skipped"


@dataclass
class TaskItem:
    """
    Describes a single processing task without holding large arrays.
    描述一个处理任务，不持有大数组。
    """

    task_id: str = ""
    source_path: str = ""
    background_path: Optional[str] = None
    transmission: Optional[float] = None
    process_mode: ProcessMode = ProcessMode.MORPH_1D
    status: TaskStatus = TaskStatus.PENDING
    result: Optional[Curve1D] = None
    error_msg: Optional[str] = None
    x_column: int = 0
    y_column: int = 1
    skip_header: int = 0
    delimiter: Optional[str] = None
    comment: str = "#"
    extra: Dict[str, Any] = field(default_factory=dict)

    _counter: ClassVar[int] = 0

    def __post_init__(self):
        if not self.task_id:
            TaskItem._counter += 1
            self.task_id = f"task_{TaskItem._counter:06d}"


@dataclass
class TaskResult:
    """Lightweight result for one completed task."""

    task_id: str
    source_path: str
    status: TaskStatus
    curve: Optional[Curve1D] = None
    error_msg: Optional[str] = None
    elapsed_seconds: float = 0.0


@dataclass
class PipelineContext:
    """
    Global settings for the pipeline engine.
    管线引擎的全局设置。
    """

    processor_config: CurveProcessorConfig = field(default_factory=CurveProcessorConfig)
    output_dir: str = ""
    output_format: str = "xy"
    save_results: bool = False
    output_mode: str = "per_file"
    merged_output_path: str = ""
    export_raw: bool = True
    export_bg: bool = False
    export_sub: bool = True
    max_memory_bytes: int = 0
    on_progress: Optional[Callable] = None
    on_task_complete: Optional[Callable] = None
    stop_on_error: bool = False


class PipelineEngine:
    """
    Generator-based streaming pipeline that processes TaskItems one at a time.
    基于生成器的流式管线，逐个处理 TaskItem。

    Memory-friendly: only one task's data is in memory at a time.
    Results are yielded as TaskResult objects.
    """

    def __init__(self, context: Optional[PipelineContext] = None):
        self.ctx = context or PipelineContext()
        self._results: List[TaskResult] = []

    def _make_processor(self, task: TaskItem) -> CurveProcessor:
        cfg = self.ctx.processor_config
        overrides: Dict[str, Any] = {}
        if task.transmission is not None:
            overrides["transmission"] = task.transmission
        if overrides:
            import copy

            new_cfg = copy.deepcopy(cfg)
            for k, v in overrides.items():
                if hasattr(new_cfg, k):
                    setattr(new_cfg, k, v)
            new_cfg.process_mode = task.process_mode
            return CurveProcessor(new_cfg)
        cfg_copy = CurveProcessorConfig(**{k: v for k, v in cfg.__dict__.items()})
        cfg_copy.process_mode = task.process_mode
        return CurveProcessor(cfg_copy)

    def _load_input(self, task: TaskItem):
        """Load input as 1D curve file. / 将输入加载为一维曲线文件。"""
        from .curve_io import load_curve_file

        return load_curve_file(
            task.source_path,
            x_column=task.x_column,
            y_column=task.y_column,
            skip_header=task.skip_header,
            comment=task.comment,
            delimiter=task.delimiter,
        )

    def _save_result(self, task: TaskItem, curve: Curve1D) -> Optional[str]:
        if not self.ctx.save_results or not self.ctx.output_dir:
            return None
        from .curve_io import save_curve_file

        os.makedirs(self.ctx.output_dir, exist_ok=True)
        base = os.path.splitext(os.path.basename(task.source_path))[0]
        suffix = str(task.extra.get("output_suffix", "")).strip()
        if suffix:
            base = f"{base}_{suffix}"
        ext_map = {
            "xy": ".xy",
            "csv": ".csv",
            "txt": ".txt",
            "gr": ".gr",
            "npy": ".npy",
            "h5": ".h5",
        }
        ext = ext_map.get(self.ctx.output_format, f".{self.ctx.output_format}")
        saved_paths = []

        # Save raw curve / 保存原始曲线
        if self.ctx.export_raw:
            raw_path = os.path.join(self.ctx.output_dir, f"{base}_raw{ext}")
            if save_curve_file(curve, raw_path, fmt=self.ctx.output_format, data_type="raw"):
                saved_paths.append(raw_path)

        # Save background / 保存背景
        if self.ctx.export_bg and curve.background is not None:
            bg_path = os.path.join(self.ctx.output_dir, f"{base}_bg{ext}")
            if save_curve_file(curve, bg_path, fmt=self.ctx.output_format, data_type="background"):
                saved_paths.append(bg_path)

        # Save subtracted / 保存扣除后曲线
        if self.ctx.export_sub and curve.subtracted is not None:
            sub_path = os.path.join(self.ctx.output_dir, f"{base}_sub{ext}")
            if save_curve_file(curve, sub_path, fmt=self.ctx.output_format, data_type="subtracted"):
                saved_paths.append(sub_path)

        # Fallback for cases where no exports were enabled / 无导出项时的降级处理
        if not saved_paths:
            default_path = os.path.join(self.ctx.output_dir, f"{base}{ext}")
            save_curve_file(curve, default_path, fmt=self.ctx.output_format, data_type="raw")
            saved_paths.append(default_path)

        return "; ".join(saved_paths) if saved_paths else None

    def _process_one(self, task: TaskItem) -> TaskResult:
        t0 = time.monotonic()
        task.status = TaskStatus.RUNNING

        if self.ctx.on_progress:
            self.ctx.on_progress(0.0, task.task_id, "running")

        if not os.path.isfile(task.source_path):
            task.status = TaskStatus.FAILED
            task.error_msg = f"File not found: {task.source_path}"
            return TaskResult(
                task_id=task.task_id,
                source_path=task.source_path,
                status=TaskStatus.FAILED,
                error_msg=task.error_msg,
                elapsed_seconds=time.monotonic() - t0,
            )

        try:
            processor = self._make_processor(task)
            if task.process_mode == ProcessMode.T_BG_SUBTRACT:
                if not task.background_path:
                    raise ValueError("T_BG_SUBTRACT requires background_path")
                if not os.path.isfile(task.background_path):
                    raise FileNotFoundError(f"Background file not found: {task.background_path}")
                if task.transmission is None:
                    raise ValueError("T_BG_SUBTRACT requires transmission")

                sample_curve = self._load_input(task)
                background_task = TaskItem(
                    source_path=task.background_path,
                    x_column=task.x_column,
                    y_column=task.y_column,
                    skip_header=task.skip_header,
                    delimiter=task.delimiter,
                    comment=task.comment,
                )
                background_curve = self._load_input(background_task)

                if sample_curve is None:
                    task.status = TaskStatus.FAILED
                    task.error_msg = f"Failed to load: {task.source_path}"
                    return TaskResult(
                        task_id=task.task_id,
                        source_path=task.source_path,
                        status=TaskStatus.FAILED,
                        error_msg=task.error_msg,
                        elapsed_seconds=time.monotonic() - t0,
                    )

                if background_curve is None:
                    task.status = TaskStatus.FAILED
                    task.error_msg = f"Failed to load background: {task.background_path}"
                    return TaskResult(
                        task_id=task.task_id,
                        source_path=task.source_path,
                        status=TaskStatus.FAILED,
                        error_msg=task.error_msg,
                        elapsed_seconds=time.monotonic() - t0,
                    )

                # 若 x 轴不一致，则将背景插值到样品 x 轴。
                # If x axes differ, interpolate background onto sample x axis.
                if sample_curve.n_points != background_curve.n_points or not np.allclose(
                    sample_curve.x,
                    background_curve.x,
                    rtol=1e-7,
                    atol=1e-12,
                ):
                    interp_y = np.interp(sample_curve.x, background_curve.x, background_curve.y)
                    background_curve = Curve1D(
                        x=sample_curve.x.copy(),
                        y=interp_y,
                        metadata=CurveMetadata(
                            source_file=background_curve.metadata.source_file,
                            source_type=background_curve.metadata.source_type,
                            x_label=sample_curve.metadata.x_label,
                            y_label=background_curve.metadata.y_label,
                            x_unit=sample_curve.metadata.x_unit,
                            process_mode=background_curve.metadata.process_mode,
                            extra={
                                **background_curve.metadata.extra,
                                "interpolated_to": task.source_path,
                            },
                        ),
                    )

                result_curve = processor.process_t_bg_subtract(
                    sample_curve,
                    background_curve,
                    task.transmission,
                )
            else:
                input_data = self._load_input(task)

                if input_data is None:
                    task.status = TaskStatus.FAILED
                    task.error_msg = f"Failed to load: {task.source_path}"
                    return TaskResult(
                        task_id=task.task_id,
                        source_path=task.source_path,
                        status=TaskStatus.FAILED,
                        error_msg=task.error_msg,
                        elapsed_seconds=time.monotonic() - t0,
                    )

                result_curve = processor.process(input_data)

            if result_curve is None:
                task.status = TaskStatus.FAILED
                task.error_msg = "Processing returned None"
                return TaskResult(
                    task_id=task.task_id,
                    source_path=task.source_path,
                    status=TaskStatus.FAILED,
                    error_msg=task.error_msg,
                    elapsed_seconds=time.monotonic() - t0,
                )

            if not result_curve.metadata.source_file:
                result_curve.metadata.source_file = task.source_path

            saved_path = self._save_result(task, result_curve)
            if saved_path and result_curve.metadata.extra is not None:
                result_curve.metadata.extra["saved_to"] = saved_path

            task.status = TaskStatus.COMPLETED
            task.result = result_curve
            elapsed = time.monotonic() - t0

            if self.ctx.on_progress:
                self.ctx.on_progress(1.0, task.task_id, "completed")

            return TaskResult(
                task_id=task.task_id,
                source_path=task.source_path,
                status=TaskStatus.COMPLETED,
                curve=result_curve,
                elapsed_seconds=elapsed,
            )

        except Exception as exc:
            task.status = TaskStatus.FAILED
            task.error_msg = str(exc)
            elapsed = time.monotonic() - t0
            return TaskResult(
                task_id=task.task_id,
                source_path=task.source_path,
                status=TaskStatus.FAILED,
                error_msg=str(exc),
                elapsed_seconds=elapsed,
            )

    def run(self, tasks: List[TaskItem]) -> Generator[TaskResult, None, None]:
        """
        Execute tasks one at a time, yielding results as they complete.
        逐个执行任务，完成即产出结果。

        Memory-friendly: each task's data is loaded, processed, and
        optionally saved before the next task begins.
        """
        total = len(tasks)
        for i, task in enumerate(tasks):
            pct = i / total if total > 0 else 0.0
            if self.ctx.on_progress:
                self.ctx.on_progress(pct, task.task_id, "starting")

            result = self._process_one(task)
            self._results.append(result)

            if self.ctx.on_task_complete:
                self.ctx.on_task_complete(result)

            yield result

            if self.ctx.stop_on_error and result.status == TaskStatus.FAILED:
                for remaining_task in tasks[i + 1 :]:
                    remaining_task.status = TaskStatus.SKIPPED
                    skip_result = TaskResult(
                        task_id=remaining_task.task_id,
                        source_path=remaining_task.source_path,
                        status=TaskStatus.SKIPPED,
                    )
                    self._results.append(skip_result)
                    yield skip_result
                break

    def run_all(self, tasks: List[TaskItem]) -> List[TaskResult]:
        """
        Run all tasks and collect results into a list.
        WARNING: This loads all results into memory.
        For large batches, use ``run()`` instead.
        """
        return list(self.run(tasks))

    @staticmethod
    def build_tasks(
        paths: List[str],
        process_mode: Optional[ProcessMode] = None,
        background_path: Optional[str] = None,
        transmission: Optional[float] = None,
        transmissions: Optional[Dict[str, float]] = None,
        x_column: int = 0,
        y_column: int = 1,
        skip_header: int = 0,
        delimiter: Optional[str] = None,
        comment: str = "#",
    ) -> List[TaskItem]:
        """
        Build TaskItem list from file paths.
        从文件路径构建 TaskItem 列表。
        """
        from .curve_io import is_1d_curve_file

        tasks = []
        for p in paths:
            mode = process_mode
            if mode is None:
                if is_1d_curve_file(p):
                    mode = ProcessMode.MORPH_1D
                else:
                    mode = ProcessMode.MORPH_1D

            tasks.append(
                TaskItem(
                    source_path=p,
                    background_path=background_path,
                    transmission=(
                        transmissions.get(p)
                        if transmissions is not None and p in transmissions
                        else transmission
                    ),
                    process_mode=mode,
                    x_column=x_column,
                    y_column=y_column,
                    skip_header=skip_header,
                    delimiter=delimiter,
                    comment=comment,
                )
            )
        return tasks

    def summary(self) -> Dict[str, Any]:
        """Get pipeline execution summary."""
        completed = sum(1 for r in self._results if r.status == TaskStatus.COMPLETED)
        failed = sum(1 for r in self._results if r.status == TaskStatus.FAILED)
        skipped = sum(1 for r in self._results if r.status == TaskStatus.SKIPPED)
        pending = sum(1 for r in self._results if r.status == TaskStatus.PENDING)
        total = len(self._results)
        total_time = sum(r.elapsed_seconds for r in self._results)

        return {
            "total": total,
            "completed": completed,
            "failed": failed,
            "skipped": skipped,
            "pending": pending,
            "total_seconds": round(total_time, 3),
            "avg_seconds": round(total_time / total, 3) if total > 0 else 0.0,
        }


path: lib/__init__.py
content: # BGsub skills self-contained library / BGsub skills 自包含库
#
# This package contains all dependencies needed by BGsub_skills scripts.
# Scripts import from this package using relative imports.
# 本包包含 BGsub_skills 脚本所需的全部依赖。


path: README.md
content: # BGsub Skills — 2D / 1D Background Subtraction Toolkit

BGsub Skills is the self-contained CLI layer of BGsub for X-ray diffraction data processing.
BGsub Skills 是 BGsub 的自包含命令行工具层，用于 X 射线衍射数据处理。

It covers 2D detector images (TIFF / EDF / H5) and 1D curves (XY / DAT / CSV / GR), and includes SSRF ionchamber-based transmission correction.
它覆盖 2D 探测器图像（TIFF / EDF / H5）和 1D 曲线（XY / DAT / CSV / GR），并集成基于 SSRF 电离室的透射率校正。

All scripts run directly from this bundle and import from `lib/`; no external BGsub package installation is required.
所有脚本都可直接运行并从 `lib/` 导入；无需额外安装外部 BGsub 包。

Standard scientific Python dependencies are still required, such as `numpy`, `scipy`, `fabio`, `h5py`, and `pandas`.
仍然需要标准科学计算 Python 依赖，例如 `numpy`、`scipy`、`fabio`、`h5py`、`pandas`。

---

## When to Use / 适用场景

Use BGsub Skills when you need any of the following:
当你有以下需求时，可使用 BGsub Skills：

- 2D image background subtraction / 2D 图像背景扣除
- 1D curve background estimation and subtraction / 1D 曲线背景估计与扣除
- T-corrected reference subtraction / T 修正参考背景扣除
- SSRF ionchamber parsing and transmission calculation / SSRF 电离室解析与透射率计算
- Batch processing for TIFF / EDF / H5 or XY / DAT / CSV files / TIFF / EDF / H5 或 XY / DAT / CSV 文件的批量处理
- Format conversion among TIFF / EDF / H5 / TIFF / EDF / H5 格式互转
- Quick visual comparison of two diffraction images / 两张衍射图像的快速可视化对比

Typical trigger phrases include:
常见触发词包括：

- 背景扣除 / background subtraction
- 基线校正 / baseline correction
- SAXS/WAXS/XRD 背景相关处理 / SAXS/WAXS/XRD background-related processing
- 电离室 / ion chamber / ionchamber
- 透射率修正 / transmission correction / transmission
- 基线扣除 / baseline subtraction
- 批量背景扣除 / batch background subtraction
- 图像格式转换 / file format conversion
- TIFF 转 EDF / convert TIFF to EDF

---

## Core Capabilities / 核心能力

| Script / 脚本 | Purpose / 功能 |
|---|---|
| `scripts/bg_subtract.py` | Single 2D image background subtraction / 单文件 2D 背景扣除 |
| `scripts/bg_batch.py` | Batch 2D image processing / 批量 2D 背景扣除 |
| `scripts/ionchamber.py` | Ionchamber statistics and transmission calculation / 电离室统计与透射率计算 |
| `scripts/curve_process_1d.py` | Single 1D curve processing / 单条 1D 曲线处理 |
| `scripts/curve_batch_1d.py` | Batch 1D curve processing / 批量 1D 曲线处理 |
| `scripts/format_convert.py` | TIFF / EDF / H5 format conversion / TIFF / EDF / H5 格式转换 |
| `scripts/compare_images.py` | silx popup-based image comparison / 基于 silx 弹窗的图像对比 |

---

## Quick Start / 快速开始

Run from the project root directory.
请在项目根目录下运行。

```bash
# 2D: single image subtraction / 单张图像背景扣除
python BGsub_skills/scripts/bg_subtract.py sample.tif background.tif --ion-dir ./ionchamber -o result.tif

# 2D: batch subtraction / 批量图像背景扣除
python BGsub_skills/scripts/bg_batch.py ./data background.tif --ion-dir ./ionchamber -o ./output

# 1D: morphological subtraction / 1D 形态学背景扣除
python BGsub_skills/scripts/curve_process_1d.py curve.xy --method morph --radius 50 -o result.xy

# 1D: reference background + manual transmission / 1D 参考背景 + 手动透射率
python BGsub_skills/scripts/curve_process_1d.py curve.xy --background bg.xy --transmission 87.5 -o result.xy

# 1D: batch processing with transmission map / 带透射率映射的 1D 批量处理
python BGsub_skills/scripts/curve_batch_1d.py ./curves --background bg.xy --transmission-map transmissions.csv -o ./output
```

For full CLI options, use:
完整命令行参数请使用：

```bash
python BGsub_skills/scripts/<script>.py --help
```

---

## Key Processing Notes / 关键处理说明

### 2D Reference Subtraction / 2D 参考背景扣除

```text
result = sample / T - background
```

### 1D Reference Subtraction / 1D 参考背景扣除

```text
result = sample / (T/100) - background
```

### Ionchamber Transmission / 电离室透射率

```text
T(%) = I_sample / I_background × 100
```

Files are matched by numeric suffix when possible.
在可自动匹配时，文件通过编号后缀进行关联。

```text
PBS-sd1-1s_00046.tif  ←→  PBS-sd1-1s_046.Ionchamber
```

---

## Important Constraints / 重要约束

- Multi-column 1D layouts (`xyxy` / `xyyy`) are supported.
  支持多列 1D 结构（`xyxy` / `xyyy`）。
- When `--background` is used in multi-column mode, `--ion-dir` auto ionchamber T is currently not supported.
  多列模式下若同时使用 `--background`，当前不支持 `--ion-dir` 自动电离室 T。
- Merged export is supported for text / CSV / HDF5, but exact workflow details should be checked in the reference docs.
  支持文本 / CSV / HDF5 合并导出，具体工作流请查看参考文档。
- `compare_images.py` depends on a GUI-capable environment because it opens a silx popup.
  `compare_images.py` 需要可用的图形界面环境，因为它会打开 silx 弹窗。

---

## Reference Documents / 参考文档

Read the detailed docs in `references/` when the quick commands above are not enough.
如果上方快速命令不够，请阅读 `references/` 中的详细文档。

| File / 文件 | Purpose / 用途 |
|---|---|
| `references/quickstart.md` | 5-minute onboarding with real scenarios / 5 分钟入门与真实场景 |
| `references/cli_commands.md` | Full CLI reference for all scripts / 全部脚本的完整 CLI 参数参考 |
| `references/examples.md` | End-to-end SSRF PBS examples / SSRF PBS 端到端处理示例 |

---

## Directory Layout / 目录结构

```text
BGsub_skills/
├── README.md
├── SKILL.md
├── lib/
├── scripts/
└── references/
```

---

## Acknowledgements / 致谢

We sincerely thank the **fabio** project for providing the reliable image I/O foundation used throughout BGsub Skills.
我们真诚感谢 **fabio** 项目，它为 BGsub Skills 提供了稳定可靠的图像读写基础。

Fabio makes it practical to handle key diffraction image formats such as EDF and TIFF in a unified workflow, which is essential for these 2D workflows.
Fabio 让 EDF、TIFF 等关键衍射图像格式能够在统一工作流中处理，这对这里的 2D 工作流非常关键。

Recommended citation / 推荐引用：

> Knudsen, E. B., Sørensen, H. O., Wright, J. P., Goret, G. & Kieffer, J. (2013). **fabio: Processing of 2D X-ray diffraction data**. *J. Appl. Cryst.* 46, 537–539. http://dx.doi.org/10.1107/S0021889813000150


path: references/cli_commands.md
content: # CLI 命令参考 / CLI Commands Reference

## Table of Contents / 目录

- [通用约定 / General Conventions](#通用约定--general-conventions)
- [bg_subtract.py — 单文件 2D 背景扣除](#bg_subtractpy--单文件-2d-背景扣除--single-2d-background-subtraction)
- [bg_batch.py — 批量 2D 处理](#bg_batchpy--批量-2d-处理--batch-2d-processing)
- [ionchamber.py — 电离室分析](#ionchamberpy--电离室分析--ionchamber-analysis)
- [curve_process_1d.py — 单曲线 1D 处理](#curve_process_1dpy--单曲线-1d-处理--single-1d-curve-processing)
- [curve_batch_1d.py — 批量 1D 处理](#curve_batch_1dpy--批量-1d-处理--batch-1d-curve-processing)
- [format_convert.py — 格式转换](#format_convertpy--格式转换--format-conversion)
- [compare_images.py — 图像对比](#compare_imagespy--图像对比--image-comparison)

---

## 通用约定 / General Conventions

- 所有脚本从项目根目录运行 / All scripts run from project root
- 脚本路径前缀: `BGsub_skills/scripts/` / Script path prefix: `BGsub_skills/scripts/`
- 查看帮助: `python <script>.py --help` / View help: `python <script>.py --help`

---

## bg_subtract.py — 单文件 2D 背景扣除 / Single 2D Background Subtraction

```bash
python BGsub_skills/scripts/bg_subtract.py SAMPLE BACKGROUND [OPTIONS]
```

### 参数 / Arguments

| 参数 / Arg | 必需 / Required | 说明 / Description |
|---|---|---|
| SAMPLE | 是 / Yes | 样品图像路径 / Sample image path |
| BACKGROUND | 是 / Yes | 背景图像路径 / Background image path |

### 选项 / Options

| 选项 / Option | 类型 / Type | 默认值 / Default | 说明 / Description |
|---|---|---|---|
| `-o, --output` | PATH | — | 输出文件路径 / Output file path |
| `-t, --transmission` | FLOAT | None | 透射率 (0-1)，覆盖电离室计算 / Transmission (0-1), override ionchamber |
| `--ion-dir` | PATH | None | 电离室文件目录 / Ionchamber file directory |

### 示例 / Examples

```bash
# 基本扣除 / Basic subtraction (T=1.0)
python BGsub_skills/scripts/bg_subtract.py sample.tif background.tif -o result.tif

# 带电离室校正 / With ionchamber correction
python BGsub_skills/scripts/bg_subtract.py sample.tif background.tif --ion-dir ./ionchamber -o result.tif

# 手动透射率 / Manual transmission
python BGsub_skills/scripts/bg_subtract.py sample.tif background.tif -t 0.99 -o result.tif
```

---

## bg_batch.py — 批量 2D 处理 / Batch 2D Processing

```bash
python BGsub_skills/scripts/bg_batch.py SAMPLE_DIR BACKGROUND [OPTIONS]
```

### 参数 / Arguments

| 参数 / Arg | 必需 / Required | 说明 / Description |
|---|---|---|
| SAMPLE_DIR | 是 / Yes | 样品图像目录 / Sample image directory |
| BACKGROUND | 是 / Yes | 背景图像路径 / Background image path |

### 选项 / Options

| 选项 / Option | 类型 / Type | 默认值 / Default | 说明 / Description |
|---|---|---|---|
| `-o, --output-dir` | PATH | `./output` | 输出目录 / Output directory |
| `--pattern` | STRING | `*.tif` | 文件匹配模式 / File glob pattern |
| `--ion-dir` | PATH | SAMPLE_DIR | 电离室文件目录 / Ionchamber directory |
| `-t, --transmission` | FLOAT | None | 统一透射率 / Uniform transmission override |

### 示例 / Examples

```bash
# 批量处理所有 TIFF / Batch all TIFFs
python BGsub_skills/scripts/bg_batch.py ./samples background.tif -o ./results

# 指定模式匹配 / Specify pattern
python BGsub_skills/scripts/bg_batch.py ./samples background.tif --pattern "*.edf" -o ./results

# 带电离室 / With ionchamber
python BGsub_skills/scripts/bg_batch.py ./samples background.tif --ion-dir ./ionchamber -o ./results
```

---

## ionchamber.py — 电离室分析 / Ionchamber Analysis

```bash
python BGsub_skills/scripts/ionchamber.py IONCHAMBER_FILE [OPTIONS]
```

### 参数 / Arguments

| 参数 / Arg | 必需 / Required | 说明 / Description |
|---|---|---|
| IONCHAMBER_FILE | 是 / Yes | 电离室文件路径 / Ionchamber file path |

### 选项 / Options

| 选项 / Option | 类型 / Type | 默认值 / Default | 说明 / Description |
|---|---|---|---|
| `--background-ion` | PATH | None | 背景电离室文件，用于计算样品/背景透过率 / Background ionchamber file |
| `-c, --channel` | CHOICE | `Ionchamber2` | 显示通道 / Channel to display |
| `--method` | CHOICE | `median` | 单文件摘要统计方法 / Single-file summary method |
| `--sample-channel` | CHOICE | `Ionchamber1` | 样品透过率通道 / Sample transmission channel |
| `--background-channel` | CHOICE | `Ionchamber1` | 背景透过率通道 / Background transmission channel |
| `--sample-method` | CHOICE | `median` | 样品统计方法 / Sample summary method |
| `--background-method` | CHOICE | `median` | 背景统计方法 / Background summary method |

### 示例 / Examples

```bash
# Ionchamber2 统计（默认）/ Ionchamber2 stats (default)
python BGsub_skills/scripts/ionchamber.py sample.Ionchamber

# 所有通道 / All channels
python BGsub_skills/scripts/ionchamber.py sample.Ionchamber --channel all

# 样品/背景透过率 / Sample-vs-background transmission
python BGsub_skills/scripts/ionchamber.py sample.Ionchamber --background-ion background.Ionchamber
```

---

## curve_process_1d.py — 单曲线 1D 处理 / Single 1D Curve Processing

```bash
python BGsub_skills/scripts/curve_process_1d.py INPUT [OPTIONS]
```

### 参数 / Arguments

| 参数 / Arg | 必需 / Required | 说明 / Description |
|---|---|---|
| INPUT | 是 / Yes | 输入曲线文件路径 / Input curve file path |

### 选项 / Options

| 选项 / Option | 类型 / Type | 默认值 / Default | 说明 / Description |
|---|---|---|---|
| `--background` | PATH | None | 参考背景曲线；提供后进入 T 修正参考扣除模式 / Reference background curve |
| `--parse-mode` | CHOICE | `single` | 列结构: single/xyxy/xyyy / Column layout |
| `--skip-header` | INT | `0` | 跳过头部行数 / Skip header rows |
| `--delimiter` | CHOICE | `auto` | 分隔符: auto/comma/tab/space / Delimiter |
| `--method` | CHOICE | `morph` | 算法背景估计方法: morph/poly/rolling_ball / Algorithmic method |
| `--radius` | INT | 50 | 形态学/滚球半径 / Morph/RB radius |
| `--iterations` | INT | 1 | 形态学迭代次数 / Morph iterations |
| `--degree` | INT | 4 | 多项式阶数 / Polynomial degree |
| `--quantile` | FLOAT | `0.3` | 多项式分位数 / Polynomial quantile |
| `-o, --output` | PATH | — | 输出文件路径 / Output path |
| `--format` | CHOICE | `auto` | 输出格式: xy/csv/txt/gr/npy/h5 / Output format |
| `--output-mode` | CHOICE | `merged` | 输出模式: merged/per-file / Output mode |
| `--preview` | FLAG | False | 显示 matplotlib 预览 / Show matplotlib preview |
| `-t, --transmission` | FLOAT | None | 手动透过率(%) / Manual transmission percent |
| `--ion-dir` | PATH | None | 电离室目录，自动匹配样品/背景 / Ionchamber directory |
| `--sample-ion` | PATH | None | 显式样品电离室文件 / Explicit sample ionchamber file |
| `--background-ion` | PATH | None | 显式背景电离室文件 / Explicit background ionchamber file |
| `--sample-channel` | CHOICE | `Ionchamber1` | 样品通道 / Sample channel |
| `--background-channel` | CHOICE | `Ionchamber1` | 背景通道 / Background channel |
| `--sample-method` | CHOICE | `median` | 样品统计方法 / Sample summary method |
| `--background-method` | CHOICE | `median` | 背景统计方法 / Background summary method |
| `--match-regex` | STRING | `""` | 自定义匹配正则 / Custom matching regex |

### 示例 / Examples

```bash
# 形态学 / Morphological
python BGsub_skills/scripts/curve_process_1d.py curve.xy --method morph --radius 50 -o result.xy

# 多项式 / Polynomial
python BGsub_skills/scripts/curve_process_1d.py curve.xy --method poly --degree 4 --quantile 0.3 -o result.xy

# 滚球 + 预览 / Rolling ball + preview
python BGsub_skills/scripts/curve_process_1d.py curve.xy --method rolling_ball --radius 80 --preview

# CSV 输入 / CSV input
python BGsub_skills/scripts/curve_process_1d.py curve.csv -o result.xy

# 多列 XYYY 合并 HDF5 / Multi-column XYYY merged HDF5
python BGsub_skills/scripts/curve_process_1d.py curve.txt --parse-mode xyyy --format h5 --output-mode merged -o merged_curves.h5

# 参考背景 + 手动 T / Reference background + manual T
python BGsub_skills/scripts/curve_process_1d.py curve.xy --background bg.xy --transmission 87.5 -o result.xy

# 参考背景 + 电离室 / Reference background + ionchamber
python BGsub_skills/scripts/curve_process_1d.py curve.xy --background bg.xy --ion-dir ./ionchamber -o result.xy
```

---

## curve_batch_1d.py — 批量 1D 处理 / Batch 1D Curve Processing

```bash
python BGsub_skills/scripts/curve_batch_1d.py INPUT_DIR [OPTIONS]
```

### 参数 / Arguments

| 参数 / Arg | 必需 / Required | 说明 / Description |
|---|---|---|
| INPUT_DIR | 是 / Yes | 曲线文件目录 / Curve file directory |

### 选项 / Options

| 选项 / Option | 类型 / Type | 默认值 / Default | 说明 / Description |
|---|---|---|---|
| `--background` | PATH | None | 参考背景曲线 / Reference background curve |
| `--parse-mode` | CHOICE | `single` | 列结构: single/xyxy/xyyy / Column layout |
| `--skip-header` | INT | `0` | 跳过头部行数 / Skip header rows |
| `--delimiter` | CHOICE | `auto` | 分隔符: auto/comma/tab/space / Delimiter |
| `--method` | CHOICE | `morph` | 背景估计方法 / Background estimation method |
| `--radius` | INT | 50 | 半径参数 / Radius parameter |
| `--iterations` | INT | 1 | 形态学迭代次数 / Morph iterations |
| `--degree` | INT | 4 | 多项式阶数 / Polynomial degree |
| `--quantile` | FLOAT | None | 多项式分位数 / Polynomial quantile |
| `-o, --output-dir` | PATH | `./output` | 输出目录 / Output directory |
| `--pattern` | STRING | `*.xy` | 文件匹配模式 / File glob pattern |
| `--format` | CHOICE | `xy` | 输出格式: xy/csv/txt/gr/npy/h5 / Output format |
| `--output-mode` | CHOICE | `per-file` | 输出模式: per-file/merged / Output mode |
| `--merged-output` | STRING | `merged_curves.h5` | 合并输出文件名 / Merged output filename |
| `--save-bg` | FLAG | False | 保存背景 / Save background |
| `--save-sub` | FLAG | True | 保存扣除结果 / Save subtracted |
| `-t, --transmission` | FLOAT | None | 手动统一透过率(%) / Manual unified transmission percent |
| `--transmission-map` | PATH | None | 分别设置透过率 CSV/JSON / Per-file transmission CSV/JSON |
| `--ion-dir` | PATH | None | 电离室目录，自动计算逐文件 T / Ionchamber directory |
| `--sample-channel` | CHOICE | `Ionchamber1` | 样品通道 / Sample channel |
| `--background-channel` | CHOICE | `Ionchamber1` | 背景通道 / Background channel |
| `--sample-method` | CHOICE | `median` | 样品统计方法 / Sample summary method |
| `--background-method` | CHOICE | `median` | 背景统计方法 / Background summary method |
| `--match-regex` | STRING | `""` | 自定义匹配正则 / Custom matching regex |

### 示例 / Examples

```bash
# 批量形态学 / Batch morphological
python BGsub_skills/scripts/curve_batch_1d.py ./curves --method morph --radius 50 -o ./output

# 批量多项式 + 保存背景 / Batch polynomial + save background
python BGsub_skills/scripts/curve_batch_1d.py ./curves --method poly --degree 4 --save-bg -o ./output

# CSV 批量 / CSV batch
python BGsub_skills/scripts/curve_batch_1d.py ./csv_data --pattern "*.csv" -o ./output

# 多列 XYXY + 合并输出 / Multi-column XYXY + merged output
python BGsub_skills/scripts/curve_batch_1d.py ./curves --parse-mode xyxy --output-mode merged --merged-output merged_xyxy.h5 --pattern "*.txt" -o ./output

# 参考背景 + 分别设置 T / Reference background + per-file T
python BGsub_skills/scripts/curve_batch_1d.py ./curves --background bg.xy --transmission-map transmissions.csv -o ./output

# 参考背景 + 电离室 / Reference background + ionchamber
python BGsub_skills/scripts/curve_batch_1d.py ./curves --background bg.xy --ion-dir ./ionchamber -o ./output
```

多列说明 / Multi-column note:
- `single`: 默认两列 XY
- `xyxy`: `(x1,y1)(x2,y2)...`
- `xyyy`: `x,y1,y2,...`
- 多列模式下当前不支持 `--ion-dir` 自动电离室 T

---

## format_convert.py — 格式转换 / Format Conversion

```bash
python BGsub_skills/scripts/format_convert.py INPUT -o OUTPUT [OPTIONS]
```

### 参数 / Arguments

| 参数 / Arg | 必需 / Required | 说明 / Description |
|---|---|---|
| INPUT | 是 / Yes | 输入文件路径 / Input file path |

### 选项 / Options

| 选项 / Option | 类型 / Type | 默认值 / Default | 说明 / Description |
|---|---|---|---|
| `-o, --output` | PATH | 必需 / Required | 输出文件路径 / Output file path |
| `--format` | CHOICE | auto-detect | 输出格式: edf/tif/tiff / Output format |
| `--frame` | INT | 0 | H5 帧索引 / H5 frame index |

### 示例 / Examples

```bash
# TIFF → EDF
python BGsub_skills/scripts/format_convert.py input.tif -o output.edf --format edf

# EDF → TIFF
python BGsub_skills/scripts/format_convert.py input.edf -o output.tif

# H5 → TIFF（提取第3帧 / Extract frame 3）
python BGsub_skills/scripts/format_convert.py stack.h5 -o frame3.tif --frame 3
```

---

## compare_images.py — 图像对比 / Image Comparison

```bash
python BGsub_skills/scripts/compare_images.py IMAGE1 IMAGE2
```

### 参数 / Arguments

| 参数 / Arg | 必需 / Required | 说明 / Description |
|---|---|---|
| IMAGE1 | 是 / Yes | 第一张图像 / First image |
| IMAGE2 | 是 / Yes | 第二张图像 / Second image |

### 依赖 / Dependencies

需要 silx (含 GUI 支持) / Requires silx (with GUI support)

### 示例 / Examples

```bash
python BGsub_skills/scripts/compare_images.py sample.tif background.tif
python BGsub_skills/scripts/compare_images.py result.tif sample.tif
```


path: references/examples.md
content: # Examples — Real-world Processing Workflows / 实际处理示例

## Table of Contents / 目录

- [Example 1: SSRF PBS Sample with Ionchamber / SSRF PBS 样品 + 电离室](#example-1-ssrf-pbs-sample-with-ionchamber--ssrf-pbs-样品--电离室)
- [Example 2: Manual Transmission Value / 手动指定透射率](#example-2-manual-transmission-value--手动指定透射率)
- [Example 3: Format Conversion / 格式转换](#example-3-format-conversion--格式转换)
- [Example 4: 1D Curve Background Estimation / 1D曲线背景估计](#example-4-1d-curve-background-estimation--1d曲线背景估计)
- [Example 5: Batch 1D Curve Processing / 批量1D曲线处理](#example-5-batch-1d-curve-processing--批量1d曲线处理)
- [Example 6: 1D Curve with Reference Background / 1D曲线 + 参考背景](#example-6-1d-curve-with-reference-background--1d曲线--参考背景)
- [Example 7: 1D Curve with Ionchamber Transmission / 1D曲线 + 电离室透过率](#example-7-1d-curve-with-ionchamber-transmission--1d曲线--电离室透过率)
- [Example 8: Batch 1D Curves with Per-file Transmission Map / 批量1D曲线 + 分别设置透过率](#example-8-batch-1d-curves-with-per-file-transmission-map--批量1d曲线--分别设置透过率)
- [Example 9: Image Comparison / 图像对比](#example-9-image-comparison--图像对比)

---

## Example 1: SSRF PBS Sample with Ionchamber / SSRF PBS 样品 + 电离室

Scenario: Process PBS biological SAXS data from SSRF BL19U2 with ionchamber transmission correction.
场景：处理来自 SSRF BL19U2 的 PBS 生物 SAXS 数据，使用电离室透射率校正。

### Data Layout / 数据目录结构

```
project/
├── data/
│   ├── PBS-sd1-1s_00001.tif       # 样品图像 / Sample image
│   ├── PBS-sd1-1s_00002.tif
│   ├── PBS-sd1-1s_00003.tif
│   └── ...
├── bg/
│   └── PBS-bg-5s_00001.tif        # 背景图像 / Background image
├── ionchamber/
│   ├── PBS-sd1-1s_001.Ionchamber  # 样品电离室 / Sample ionchamber
│   ├── PBS-sd1-1s_002.Ionchamber
│   └── PBS-bg-005.Ionchamber      # 背景电离室 / Background ionchamber
└── output/
```

### Step 1: Analyze Ionchamber / 分析电离室

```bash
python BGsub_skills/scripts/ionchamber.py ionchamber/PBS-sd1-1s_001.Ionchamber
# Output: channel means, file name, suggested transmission
# 输出：各通道均值、文件名、建议透射率
```

### Step 2: Batch Background Subtraction / 批量背景扣除

```bash
python BGsub_skills/scripts/bg_batch.py ./data ./bg/PBS-bg-5s_00001.tif \
    --ion-dir ./ionchamber \
    --channel 2 \
    -o ./output \
    --format edf
```

Result / 结果: Each sample image is divided by its transmission T, then the background is subtracted.
每个样品图像除以其透射率 T，再减去背景。

---

## Example 2: Manual Transmission Value / 手动指定透射率

Scenario: You know the transmission is 85% from beamline metadata.
场景：从线站元数据得知透射率为 85%。

```bash
python BGsub_skills/scripts/bg_subtract.py sample.tif background.tif \
    --transmission 85 \
    -o result.tif
```

Formula: `result = sample / 0.85 - background`

---

## Example 3: Format Conversion / 格式转换

Scenario: Convert a directory of TIFF images to EDF format for downstream tools.
场景：将目录下的 TIFF 图像转为 EDF 格式。

```bash
python BGsub_skills/scripts/format_convert.py ./tiff_data \
    -o ./edf_data \
    --output-format edf \
    --pattern "*.tif"
```

---

## Example 4: 1D Curve Background Estimation / 1D曲线背景估计

Scenario: You have integrated 1D curves (q-I format) and need to remove the amorphous background.
场景：已有积分后的 1D 曲线（q-I 格式），需要去除非晶背景。

### Morphological Method / 形态学方法

```bash
python BGsub_skills/scripts/curve_process_1d.py sample.xy \
    --method morph \
    --radius 80 \
    --iterations 2 \
    -o sample_processed.xy \
    --preview
```

### Polynomial Method / 多项式方法

```bash
python BGsub_skills/scripts/curve_process_1d.py sample.xy \
    --method poly \
    --degree 6 \
    --quantile 0.25 \
    -o sample_processed.xy
```

### Rolling Ball Method / 滚球方法

```bash
python BGsub_skills/scripts/curve_process_1d.py sample.xy \
    --method rolling_ball \
    --radius 100 \
    -o sample_processed.xy
```

---

## Example 5: Batch 1D Curve Processing / 批量1D曲线处理

Scenario: A directory of 100 XY curves from a temperature series experiment.
场景：一个包含 100 条 XY 曲线的目录，来自温度系列实验。

```bash
python BGsub_skills/scripts/curve_batch_1d.py ./curves \
    --method morph \
    --radius 60 \
    --iterations 1 \
    --pattern "*.xy" \
    --save-bg \
    -o ./processed \
    --format xy
```

Output naming / 输出命名:
```
curves/sample_001.xy → processed/sample_001_raw.xy
                                   sample_001_bg.xy
                                   sample_001_sub.xy
```

---

## Example 6: 1D Curve with Reference Background / 1D曲线 + 参考背景

Scenario: You have a separate background curve file and want to subtract it from all sample curves.
场景：有一个独立的背景曲线文件，需要从所有样品曲线中扣除。

```bash
python BGsub_skills/scripts/curve_batch_1d.py ./sample_curves \
    --background ./bg_curve.xy \
    --method morph \
    --radius 50 \
    -o ./results
```

公式 / Formula:
```text
result = sample / (T/100) - background
```

---

## Example 7: 1D Curve with Ionchamber Transmission / 1D曲线 + 电离室透过率

Scenario: You have a sample curve, a background curve, and SSRF ionchamber files in one directory.
场景：你有样品曲线、背景曲线，以及同目录下的 SSRF 电离室文件。

```bash
python BGsub_skills/scripts/curve_process_1d.py sample.xy \
    --background background.xy \
    --ion-dir ./ionchamber \
    --sample-channel Ionchamber1 \
    --background-channel Ionchamber1 \
    -o sample_tsub.xy
```

---

## Example 8: Batch 1D Curves with Per-file Transmission Map / 批量1D曲线 + 分别设置透过率

Scenario: Each sample has a different measured transmission and you want deterministic batch processing.
场景：每个样品都有不同透过率，需要可重复的批量处理。

`transmissions.csv`:

```csv
file,transmission
sample_001.xy,88.5
sample_002.xy,91.2
sample_003.xy,94.0
```

```bash
python BGsub_skills/scripts/curve_batch_1d.py ./sample_curves \
    --background ./bg_curve.xy \
    --transmission-map ./transmissions.csv \
    -o ./results
```

---

## Example 9: Image Comparison / 图像对比

Scenario: Compare the processed result with the original to verify quality.
场景：对比处理结果与原始图像以验证质量。

```bash
python BGsub_skills/scripts/compare_images.py original.tif processed.tif
```

Opens a silx CompareImages popup window for interactive visual comparison.
打开 silx CompareImages 弹窗进行交互式视觉对比。


path: references/quickstart.md
content: # 快速入门 / Quick Start

## 5分钟上手 BGsub / 5-Minute BGsub Guide

### 场景 A: 2D 图像背景扣除 / Scenario A: 2D Image Background Subtraction

你有一批 PBS 样品的 SAXS 数据，需要用 69 号背景图像扣除背景，并用电离室文件进行透射率校正。
You have PBS sample SAXS data and need to subtract background using image #69, with ionchamber transmission correction.

#### 数据结构 / Data Structure

```
test/
├── PBS-BG-sd1-20s_00069.tif      # 背景图像 / Background image (20s exposure)
├── PBS-BG-sd1-20s_069.Ionchamber # 背景电离室 / Background ionchamber
├── PBS-sd1-1s_00046.tif          # 样品 46 / Sample #46
├── PBS-sd1-1s_046.Ionchamber     # 样品电离室 / Sample ionchamber
└── ...更多样品 / more samples
```

#### 单文件处理 / Single File Processing

```bash
python BGsub_skills/scripts/bg_subtract.py \
    test/PBS-sd1-1s_00046.tif \
    test/PBS-BG-sd1-20s_00069.tif \
    --ion-dir test/ \
    -o test/output/PBS-sd1-1s_00046_bgsub.tif
```

#### 批量处理 / Batch Processing

```bash
python BGsub_skills/scripts/bg_batch.py \
    test/ \
    test/PBS-BG-sd1-20s_00069.tif \
    --ion-dir test/ \
    --pattern "PBS-sd1-1s_*.tif" \
    -o test/output
```

#### 预期输出 / Expected Output

```
test/output/
├── PBS-sd1-1s_00046_bgsub.tif
├── PBS-sd1-1s_00047_bgsub.tif
└── ...
```

正常透射率应在 **99% ~ 101%** 范围内。
Normal transmission should be in the **99% ~ 101%** range.

---

### 场景 B: 1D 曲线背景扣除 / Scenario B: 1D Curve Background Subtraction

你有多个 1D 衍射曲线文件（XY 格式），需要用形态学方法估计并扣除背景。
You have 1D diffraction curve files (XY format) and need to estimate and subtract background using morphological method.

#### 数据结构 / Data Structure

```
curves/
├── sample_001.xy    # q-I 两列 / two-column q-I
├── sample_002.xy
└── ...
```

#### 单曲线处理 / Single Curve Processing

```bash
# 形态学方法 / Morphological method
python BGsub_skills/scripts/curve_process_1d.py \
    curves/sample_001.xy \
    --method morph --radius 50 \
    -o output/sample_001_processed.xy

# 多项式拟合 / Polynomial fitting
python BGsub_skills/scripts/curve_process_1d.py \
    curves/sample_001.xy \
    --method poly --degree 4 --quantile 0.3 \
    -o output/sample_001_sub.xy

# 滚球算法 / Rolling ball
python BGsub_skills/scripts/curve_process_1d.py \
    curves/sample_001.xy \
    --method rolling_ball --radius 80 \
    -o output/sample_001_sub.xy

# 多列 XYYY 合并导出 / Multi-column XYYY merged export
python BGsub_skills/scripts/curve_process_1d.py \
    curves/multi_curve.txt \
    --parse-mode xyyy \
    --output-mode merged \
    -o output/multi_curve_merged.h5
```

#### 批量曲线处理 / Batch Curve Processing

```bash
python BGsub_skills/scripts/curve_batch_1d.py \
    curves/ \
    --method morph --radius 50 \
    --pattern "*.xy" \
    -o output/

# 批量多列 XYXY，逐文件导出 / Batch XYXY, per-file export
python BGsub_skills/scripts/curve_batch_1d.py \
    curves/ \
    --parse-mode xyxy \
    --output-mode per-file \
    --pattern "*.txt" \
    -o output/
```

#### 参考背景 + 手动统一 T / Reference Background + Manual Unified T

```bash
python BGsub_skills/scripts/curve_process_1d.py \
    curves/sample_001.xy \
    --background curves/background.xy \
    --transmission 88.5 \
    -o output/sample_001_tsub.xy
```

#### 参考背景 + 电离室自动 T / Reference Background + Ionchamber T

```bash
python BGsub_skills/scripts/curve_process_1d.py \
    curves/sample_001.xy \
    --background curves/background.xy \
    --ion-dir ionchamber/ \
    --sample-channel Ionchamber1 \
    --background-channel Ionchamber1 \
    -o output/sample_001_tsub.xy
```

#### 批量参考背景 + 分别设置 T / Batch Reference Background + Per-file T

```bash
python BGsub_skills/scripts/curve_batch_1d.py \
    curves/ \
    --background curves/background.xy \
    --transmission-map transmissions.csv \
    --pattern "*.xy" \
    -o output/
```

其中 `transmissions.csv` 示例 / Example `transmissions.csv`:

```csv
file,transmission
sample_001.xy,88.5
sample_002.xy,91.2
```

#### 预期输出 / Expected Output

```
output/
├── sample_001_processed.xy   # 单文件脚本输出：多列(raw/bg/sub) / Single-file output: multi-column raw/bg/sub
├── sample_001_raw.xy         # 批处理原始数据 / Batch raw data
├── sample_001_bg.xy          # 批处理背景 / Batch background (if --save-bg)
├── sample_001_sub.xy         # 批处理扣除结果 / Batch subtracted result
├── multi_curve_merged.h5     # 多曲线合并输出 / Merged multi-curve output
└── ...
```

说明 / Notes:
- `--parse-mode single`：默认两列 XY
- `--parse-mode xyxy`：按 `(x1,y1)(x2,y2)...` 拆分
- `--parse-mode xyyy`：按 `x,y1,y2,...` 拆分
- 多列模式下暂不支持 `--ion-dir` 自动电离室 T

---

### 场景 C: 格式转换 / Scenario C: Format Conversion

```bash
# TIFF → EDF
python BGsub_skills/scripts/format_convert.py input.tif -o output.edf --format edf

# EDF → TIFF
python BGsub_skills/scripts/format_convert.py input.edf -o output.tif --format tif

# H5 → TIFF (提取第一帧 / Extract first frame)
python BGsub_skills/scripts/format_convert.py stack.h5 -o frame0.tif --frame 0
```

---

### 场景 D: 图像对比 / Scenario D: Image Comparison

```bash
# 使用 silx CompareImages 弹窗对比 / Compare with silx popup
python BGsub_skills/scripts/compare_images.py sample.tif background.tif
```

---

## 透射率计算公式 / Transmission Formula

```text
T(%) = I_sample / I_background × 100
result = sample / (T/100) - background
```

其中 `I` 可由 `Ionchamber0/1/2` 任一通道的 `mean / median / trimmed_mean` 给出。
Here `I` can come from any `Ionchamber0/1/2` channel using `mean / median / trimmed_mean`.

## 常见问题 / FAQ

**Q: 电离室文件不在同一目录？ / Ionchamber files in different directory?**
```bash
--ion-dir /path/to/ionchamber/files
```

**Q: 不想用电离室校正？ / Skip ionchamber correction?**
```bash
python BGsub_skills/scripts/bg_subtract.py sample.tif background.tif -o result.tif
# 使用 T=1.0 / Uses T=1.0
```

**Q: 1D 曲线格式是 CSV？ / 1D curve is CSV format?**
```bash
python BGsub_skills/scripts/curve_process_1d.py curve.csv -o result.xy
# 自动识别 CSV / CSV is auto-detected
```

**Q: 我想给每条 1D 曲线分别设置 T？ / I need per-file T for 1D curves?**
```bash
python BGsub_skills/scripts/curve_batch_1d.py ./curves \
    --background ./bg.xy \
    --transmission-map transmissions.csv \
    -o ./output
```


path: scripts/bg_batch.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
bg_batch.py — 批量二维图像背景扣除
Batch 2D image background subtraction.

命令行脚本：对目录中的样品图像批量执行有参考背景扣除，可使用统一透射率，
或通过电离室文件自动匹配并计算每个样品的透射率。
Command-line script: perform reference-based background subtraction for all sample
images in a directory, using either a fixed transmission value or per-sample
transmission computed from matched ionchamber files.
"""

import argparse
import glob
import os
import re
import sys

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from lib.image_io import load_image_file, save_image


def load_ionchamber(path: str):
    """加载电离室文件 / Load ionchamber file"""
    values = {"Ionchamber0": [], "Ionchamber1": [], "Ionchamber2": []}
    try:
        with open(path, "r", encoding="utf-8") as handle:
            for raw_line in handle:
                line = raw_line.strip()
                if not line or line.startswith("#"):
                    continue
                parts = line.split()
                if len(parts) < 5:
                    continue
                try:
                    floats = [float(parts[-3]), float(parts[-2]), float(parts[-1])]
                except ValueError:
                    continue
                values["Ionchamber0"].append(floats[0])
                values["Ionchamber1"].append(floats[1])
                values["Ionchamber2"].append(floats[2])
    except OSError:
        return None

    if not values["Ionchamber0"]:
        return None

    return {key: float(np.mean(channel_values)) for key, channel_values in values.items()}


def match_ionchamber(filename: str, ion_dir: str) -> str:
    """匹配电离室文件 / Match ionchamber file to image filename"""
    base_name = os.path.basename(filename)
    stem, _ = os.path.splitext(base_name)
    candidates = []

    match = re.search(r"(\d+)(?!.*\d)", stem)
    if match:
        number = match.group(1)
        stripped = str(int(number))
        three_digit = f"{int(number):03d}"
        candidates.extend(
            [
                f"_{number}",
                f"_{three_digit}",
                f"_{stripped}",
                number,
                three_digit,
                stripped,
            ]
        )

    try:
        ion_files = [
            os.path.join(ion_dir, name)
            for name in os.listdir(ion_dir)
            if name.lower().endswith(".ionchamber")
        ]
    except OSError:
        return None

    stem_lower = stem.lower()
    for path in ion_files:
        ion_stem = os.path.splitext(os.path.basename(path))[0].lower()
        if ion_stem == stem_lower:
            return path
        for candidate in candidates:
            candidate_lower = candidate.lower()
            if ion_stem.endswith(candidate_lower) or candidate_lower in ion_stem:
                return path
    return None


def crop_to_common_shape(sample: np.ndarray, background: np.ndarray):
    """裁剪到共同尺寸 / Crop to common shape"""
    height = min(sample.shape[0], background.shape[0])
    width = min(sample.shape[1], background.shape[1])
    return sample[:height, :width], background[:height, :width]


def resolve_transmission(sample_path: str, args, background_ion_data):
    """解析单个样品透射率 / Resolve transmission for one sample"""
    if args.transmission is not None:
        return float(args.transmission)

    if not args.ion_dir:
        return 1.0

    if background_ion_data is None:
        raise ValueError("背景电离室文件不可用 / Background ionchamber file is unavailable")

    sample_ion = match_ionchamber(sample_path, args.ion_dir)
    if not sample_ion:
        raise ValueError("未匹配到样品电离室文件 / No matching sample ionchamber file found")

    sample_ion_data = load_ionchamber(sample_ion)
    if sample_ion_data is None:
        raise ValueError("样品电离室文件解析失败 / Failed to parse sample ionchamber file")

    denominator = background_ion_data["Ionchamber2"]
    if denominator == 0:
        raise ValueError(
            "背景电离室均值为 0，无法计算透射率 / Background ionchamber mean is 0, cannot compute transmission"
        )

    return float(sample_ion_data["Ionchamber2"] / denominator)


def parse_args():
    """解析命令行参数 / Parse command-line arguments"""
    parser = argparse.ArgumentParser(
        description="批量二维图像背景扣除 / Batch 2D image background subtraction"
    )
    parser.add_argument("sample_dir", help="样品图像目录 / sample directory")
    parser.add_argument("background", help="背景图像路径 / background image path")
    parser.add_argument(
        "-o",
        "--output-dir",
        default="./output",
        help="输出目录 / output directory",
    )
    parser.add_argument(
        "--pattern",
        default="*.tif",
        help="文件匹配模式 / file matching pattern",
    )
    parser.add_argument("--ion-dir", help="电离室文件目录 / ionchamber directory")
    parser.add_argument(
        "-t",
        "--transmission",
        type=float,
        help="统一透射率覆盖值 / transmission override",
    )
    return parser.parse_args()


def main():
    """主函数 / Main entry point"""
    args = parse_args()

    try:
        sample_files = sorted(glob.glob(os.path.join(args.sample_dir, args.pattern)))
        if not sample_files:
            print(
                f"未找到样品文件: {args.sample_dir} ({args.pattern}) / "
                f"No sample files found: {args.sample_dir} ({args.pattern})"
            )
            return 1

        os.makedirs(args.output_dir, exist_ok=True)

        background_data, _ = load_image_file(args.background)
        if background_data is None:
            print(f"加载失败: {args.background} / Failed to load: {args.background}")
            return 1

        background_array = np.asarray(background_data, dtype=np.float64)
        background_ion_data = None
        if args.ion_dir and args.transmission is None:
            background_ion = match_ionchamber(args.background, args.ion_dir)
            if not background_ion:
                print("未匹配到背景电离室文件 / No matching background ionchamber file found")
                return 1
            background_ion_data = load_ionchamber(background_ion)
            if background_ion_data is None:
                print("背景电离室文件解析失败 / Failed to parse background ionchamber file")
                return 1

        success = 0
        total = len(sample_files)
        for sample_path in sample_files:
            name = os.path.basename(sample_path)
            try:
                sample_data, _ = load_image_file(sample_path)
                if sample_data is None:
                    raise ValueError("图像加载失败 / Image loading failed")

                transmission = resolve_transmission(sample_path, args, background_ion_data)
                if transmission <= 0:
                    raise ValueError("透射率必须大于 0 / Transmission must be greater than 0")

                sample_array = np.asarray(sample_data, dtype=np.float64)
                sample_array, cropped_background = crop_to_common_shape(
                    sample_array, background_array
                )
                result = sample_array / transmission - cropped_background

                stem, _ = os.path.splitext(name)
                output_path = os.path.join(args.output_dir, f"{stem}_bgsub.tif")
                if not save_image(result, output_path, format="auto", header=None):
                    raise ValueError("结果保存失败 / Failed to save result")

                success += 1
                print(
                    f"[OK] {name} -> T={transmission * 100:.2f}% / [OK] {name} -> T={transmission * 100:.2f}%"
                )
            except Exception as exc:
                print(f"[FAIL] {name} - {exc} / [FAIL] {name} - {exc}")

        print(f"完成: {success}/{total} 成功 / Complete: {success}/{total} succeeded")
        return 0 if success == total else 1
    except Exception as exc:
        print(f"批量处理失败: {exc} / Batch processing failed: {exc}")
        return 1


if __name__ == "__main__":
    sys.exit(main())


path: scripts/bg_subtract.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
bg_subtract.py — 有参考背景扣除
Background subtraction with reference image.

命令行脚本：加载样品图像与背景图像，按给定透射率或电离室文件自动计算透射率，
执行二维图像背景扣除，并保存输出结果。
Command-line script: load sample and background images, compute transmission from a
provided value or matching ionchamber files, perform 2D image background subtraction,
and save the output result.
"""

import argparse
import os
import re
import sys

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from lib.image_io import load_image_file, save_image


def load_ionchamber(path: str):
    """加载电离室文件 / Load ionchamber file"""
    values = {"Ionchamber0": [], "Ionchamber1": [], "Ionchamber2": []}
    try:
        with open(path, "r", encoding="utf-8") as handle:
            for raw_line in handle:
                line = raw_line.strip()
                if not line or line.startswith("#"):
                    continue
                parts = line.split()
                if len(parts) < 5:
                    continue
                try:
                    floats = [float(parts[-3]), float(parts[-2]), float(parts[-1])]
                except ValueError:
                    continue
                values["Ionchamber0"].append(floats[0])
                values["Ionchamber1"].append(floats[1])
                values["Ionchamber2"].append(floats[2])
    except OSError:
        return None

    if not values["Ionchamber0"]:
        return None

    return {key: float(np.mean(channel_values)) for key, channel_values in values.items()}


def match_ionchamber(filename: str, ion_dir: str) -> str:
    """匹配电离室文件 / Match ionchamber file to image filename"""
    base_name = os.path.basename(filename)
    stem, _ = os.path.splitext(base_name)
    candidates = []

    match = re.search(r"(\d+)(?!.*\d)", stem)
    if match:
        number = match.group(1)
        stripped = str(int(number))
        three_digit = f"{int(number):03d}"
        candidates.extend(
            [
                f"_{number}",
                f"_{three_digit}",
                f"_{stripped}",
                number,
                three_digit,
                stripped,
            ]
        )

    try:
        ion_files = [
            os.path.join(ion_dir, name)
            for name in os.listdir(ion_dir)
            if name.lower().endswith(".ionchamber")
        ]
    except OSError:
        return None

    stem_lower = stem.lower()
    for path in ion_files:
        ion_stem = os.path.splitext(os.path.basename(path))[0].lower()
        if ion_stem == stem_lower:
            return path
        for candidate in candidates:
            candidate_lower = candidate.lower()
            if ion_stem.endswith(candidate_lower) or candidate_lower in ion_stem:
                return path
    return None


def format_shape(data: np.ndarray) -> str:
    """格式化图像尺寸 / Format image shape"""
    if data.ndim < 2:
        return "unknown"
    return f"{data.shape[0]}x{data.shape[1]}"


def crop_to_common_shape(sample: np.ndarray, background: np.ndarray):
    """裁剪到共同尺寸 / Crop to common shape"""
    height = min(sample.shape[0], background.shape[0])
    width = min(sample.shape[1], background.shape[1])
    return sample[:height, :width], background[:height, :width]


def resolve_transmission(args, sample_path: str, background_path: str):
    """解析透射率 / Resolve transmission"""
    if args.transmission is not None:
        return float(args.transmission)

    if not args.ion_dir:
        return 1.0

    sample_ion = match_ionchamber(sample_path, args.ion_dir)
    background_ion = match_ionchamber(background_path, args.ion_dir)
    if not sample_ion or not background_ion:
        raise ValueError(
            "无法匹配样品或背景的电离室文件 / Could not match ionchamber file for sample or background"
        )

    sample_data = load_ionchamber(sample_ion)
    background_data = load_ionchamber(background_ion)
    if sample_data is None or background_data is None:
        raise ValueError("电离室文件解析失败 / Failed to parse ionchamber file")

    denominator = background_data["Ionchamber2"]
    if denominator == 0:
        raise ValueError(
            "背景电离室均值为 0，无法计算透射率 / Background ionchamber mean is 0, cannot compute transmission"
        )

    return float(sample_data["Ionchamber2"] / denominator)


def parse_args():
    """解析命令行参数 / Parse command-line arguments"""
    parser = argparse.ArgumentParser(
        description="有参考背景扣除 / Background subtraction with reference image"
    )
    parser.add_argument("sample", help="样品图像路径 / sample image path")
    parser.add_argument("background", help="背景图像路径 / background image path")
    parser.add_argument("-o", "--output", help="输出文件路径 / output file path")
    parser.add_argument(
        "-t",
        "--transmission",
        type=float,
        help="透射率（0-1） / transmission (0-1)",
    )
    parser.add_argument("--ion-dir", help="电离室文件目录 / ionchamber directory")
    return parser.parse_args()


def main():
    """主函数 / Main entry point"""
    args = parse_args()

    try:
        sample_data, _ = load_image_file(args.sample)
        if sample_data is None:
            print(f"加载失败: {args.sample} / Failed to load: {args.sample}")
            return 1

        background_data, _ = load_image_file(args.background)
        if background_data is None:
            print(f"加载失败: {args.background} / Failed to load: {args.background}")
            return 1

        sample_array = np.asarray(sample_data, dtype=np.float64)
        background_array = np.asarray(background_data, dtype=np.float64)

        print(
            f"加载成功: {os.path.basename(args.sample)} ({format_shape(sample_array)}) / "
            f"Loaded: {os.path.basename(args.sample)} ({format_shape(sample_array)})"
        )
        print(
            f"加载成功: {os.path.basename(args.background)} ({format_shape(background_array)}) / "
            f"Loaded: {os.path.basename(args.background)} ({format_shape(background_array)})"
        )

        transmission = resolve_transmission(args, args.sample, args.background)
        if transmission <= 0:
            print("透射率必须大于 0 / Transmission must be greater than 0")
            return 1

        print(
            f"透射率: T={transmission:.4f} ({transmission * 100:.2f}%) / "
            f"Transmission: T={transmission:.4f} ({transmission * 100:.2f}%)"
        )

        sample_array, background_array = crop_to_common_shape(sample_array, background_array)
        result = sample_array / transmission - background_array

        if args.output:
            ok = save_image(result, args.output, format="auto", header=None)
            if not ok:
                print(f"保存失败: {args.output} / Failed to save: {args.output}")
                return 1
            print(f"已保存: {args.output} / Saved: {args.output}")
        else:
            print(
                f"结果统计: min={result.min():.6g}, max={result.max():.6g}, mean={result.mean():.6g} / "
                f"Result statistics: min={result.min():.6g}, max={result.max():.6g}, mean={result.mean():.6g}"
            )

        return 0
    except Exception as exc:
        print(f"处理失败: {exc} / Processing failed: {exc}")
        return 1


if __name__ == "__main__":
    sys.exit(main())


path: scripts/compare_images.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
compare_images.py — 使用 silx CompareImages 对比两张图像
Compare two images with silx CompareImages.

命令行脚本：加载两张二维图像，并使用 silx 的 CompareImages 组件弹出交互式对比窗口。
Command-line script: load two 2D images and open an interactive comparison window
using silx CompareImages.
"""

import argparse
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from lib.image_io import load_image_file


def parse_args():
    """解析命令行参数 / Parse command-line arguments"""
    parser = argparse.ArgumentParser(
        description="使用 silx CompareImages 对比两张图像 / Compare two images with silx CompareImages"
    )
    parser.add_argument("image1", help="第一张图像 / first image")
    parser.add_argument("image2", help="第二张图像 / second image")
    return parser.parse_args()


def main():
    """主函数 / Main entry point"""
    args = parse_args()

    try:
        try:
            from silx.gui.compare import CompareImages
            from silx.gui import qt
        except ImportError:
            print("错误: silx 未安装 / Error: silx is not installed")
            print("安装: pip install silx / Install: pip install silx")
            return 1

        data1, _ = load_image_file(args.image1)
        if data1 is None:
            print(f"加载失败: {args.image1} / Failed to load: {args.image1}")
            return 1

        data2, _ = load_image_file(args.image2)
        if data2 is None:
            print(f"加载失败: {args.image2} / Failed to load: {args.image2}")
            return 1

        app = qt.QApplication([])
        widget = CompareImages()
        widget.setData(data1, data2)
        widget.show()
        app.exec()
        return 0
    except Exception as exc:
        print(f"对比失败: {exc} / Comparison failed: {exc}")
        return 1


if __name__ == "__main__":
    sys.exit(main())


path: scripts/curve_batch_1d.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
curve_batch_1d.py — 1D曲线批量处理脚本
Batch 1D curve processing script.

支持三种 transmission 来源：
1. 统一手动 T
2. 分别设置 T（CSV/JSON 映射）
3. 电离室自动计算 T

Supports three transmission sources:
1. Unified manual T
2. Per-file T (CSV/JSON mapping)
3. Ionchamber-derived T
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from lib.curve_data import ProcessMode  # noqa: E402
from lib.curve_processor import CurveProcessorConfig  # noqa: E402
from lib.task_pipeline import PipelineContext, PipelineEngine, TaskItem  # noqa: E402
from lib.curve_io import inspect_curve_layout, save_curve_collection  # noqa: E402
from lib.common import build_transmission_map  # noqa: E402


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="批量处理 1D 曲线：算法背景估计或参考背景 T 修正扣除 / Batch-process 1D curves with algorithmic estimation or T-corrected reference subtraction."
    )
    parser.add_argument("input_dir", help="输入目录 / Input directory")
    parser.add_argument("--background", help="参考背景曲线 / Reference background curve")
    parser.add_argument("--method", choices=["morph", "poly", "rolling_ball"], default="morph")
    parser.add_argument("--radius", type=int, default=50)
    parser.add_argument("--iterations", type=int, default=1)
    parser.add_argument("--degree", type=int, default=4)
    parser.add_argument("--quantile", type=float, default=0.3)
    parser.add_argument("--pattern", default="*.xy")
    parser.add_argument(
        "--parse-mode",
        choices=["single", "xyxy", "xyyy"],
        default="single",
        help="列结构 / Column layout",
    )
    parser.add_argument("--skip-header", type=int, default=0)
    parser.add_argument(
        "--delimiter",
        choices=["auto", "comma", "tab", "space"],
        default="auto",
    )
    parser.add_argument("-o", "--output-dir", default="./output")
    parser.add_argument("--format", choices=["xy", "csv", "txt", "gr", "npy", "h5"], default="xy")
    parser.add_argument(
        "--output-mode",
        choices=["per-file", "merged"],
        default="per-file",
        help="输出模式 / Output mode",
    )
    parser.add_argument(
        "--merged-output",
        default="merged_curves.h5",
        help="合并输出文件名 / Merged output filename",
    )
    parser.add_argument("--save-bg", action="store_true")
    parser.add_argument("--save-sub", action="store_true", default=True)
    parser.add_argument(
        "-t",
        "--transmission",
        type=float,
        help="统一透过率(%%) / Unified transmission percent",
    )
    parser.add_argument(
        "--transmission-map",
        help="分别设置透过率映射文件(CSV/JSON) / Per-file transmission map CSV/JSON",
    )
    parser.add_argument("--ion-dir", help="电离室目录 / Ionchamber directory")
    parser.add_argument(
        "--sample-channel",
        default="Ionchamber1",
        choices=["Ionchamber0", "Ionchamber1", "Ionchamber2"],
    )
    parser.add_argument(
        "--background-channel",
        default="Ionchamber1",
        choices=["Ionchamber0", "Ionchamber1", "Ionchamber2"],
    )
    parser.add_argument(
        "--sample-method", default="median", choices=["mean", "median", "trimmed_mean"]
    )
    parser.add_argument(
        "--background-method", default="median", choices=["mean", "median", "trimmed_mean"]
    )
    parser.add_argument("--match-regex", default="")
    return parser


def _iter_curve_files(input_dir: Path, pattern: str) -> List[str]:
    return [str(path) for path in sorted(input_dir.glob(pattern)) if path.is_file()]


def _build_config(args: argparse.Namespace) -> CurveProcessorConfig:
    if args.background:
        return CurveProcessorConfig(
            process_mode=ProcessMode.T_BG_SUBTRACT, transmission=args.transmission or 100.0
        )
    if args.method == "morph":
        return CurveProcessorConfig(
            process_mode=ProcessMode.MORPH_1D,
            bg_method_1d="morph",
            morph_radius=args.radius,
            morph_iterations=args.iterations,
        )
    if args.method == "poly":
        return CurveProcessorConfig(
            process_mode=ProcessMode.FIT_1D,
            bg_method_1d="poly",
            poly_degree=args.degree,
            poly_quantile=args.quantile,
        )
    return CurveProcessorConfig(
        process_mode=ProcessMode.MORPH_1D,
        bg_method_1d="rolling_ball",
        rolling_ball_radius=float(args.radius),
    )


def _load_transmission_map(path: str, sample_paths: List[str]) -> Dict[str, float]:
    transmission_by_name: Dict[str, float] = {}
    file_path = Path(path)
    if file_path.suffix.lower() == ".json":
        data = json.loads(file_path.read_text(encoding="utf-8"))
        transmission_by_name = {str(key): float(value) for key, value in data.items()}
    else:
        with open(file_path, "r", encoding="utf-8", newline="") as handle:
            reader = csv.DictReader(handle)
            for row in reader:
                name = row.get("file") or row.get("filename") or row.get("path")
                value = row.get("transmission") or row.get("transmission_percent")
                if name and value:
                    transmission_by_name[str(name)] = float(value)

    resolved: Dict[str, float] = {}
    missing: List[str] = []
    for sample_path in sample_paths:
        basename = Path(sample_path).name
        if sample_path in transmission_by_name:
            resolved[sample_path] = transmission_by_name[sample_path]
        elif basename in transmission_by_name:
            resolved[sample_path] = transmission_by_name[basename]
        else:
            missing.append(basename)
    if missing:
        raise RuntimeError(
            f"透过率映射缺少条目 / Missing transmission entries: {', '.join(missing)}"
        )
    return resolved


def _collect_ion_paths(ion_dir: Optional[str]) -> List[str]:
    if not ion_dir:
        return []
    base = Path(ion_dir)
    if not base.is_dir():
        raise RuntimeError(f"电离室目录不存在 / Ionchamber directory not found: {ion_dir}")
    return [
        str(path)
        for path in sorted(base.rglob("*"))
        if path.is_file() and path.suffix.lower() in {".ionchamber", ".txt"}
    ]


def _resolve_transmissions(
    args: argparse.Namespace, sample_paths: List[str]
) -> Tuple[Optional[float], Optional[Dict[str, float]]]:
    if not args.background:
        return None, None
    if args.transmission_map:
        return None, _load_transmission_map(args.transmission_map, sample_paths)
    if args.ion_dir:
        ion_paths = _collect_ion_paths(args.ion_dir)
        transmissions, match_results, background_result = build_transmission_map(
            sample_paths=sample_paths,
            background_path=args.background,
            ion_paths=ion_paths,
            background_channel=args.background_channel,
            background_method=args.background_method,
            sample_channel=args.sample_channel,
            sample_method=args.sample_method,
            user_regex=args.match_regex or None,
        )
        if background_result is None or not background_result.success:
            raise RuntimeError(
                background_result.error_message if background_result else "背景电离室匹配失败"
            )
        for result in match_results:
            if result.success and result.transmission_percent is not None:
                print(f"[ION] {result.sample_name}: T={result.transmission_percent:.2f}%")
            else:
                print(f"[SKIP] {result.sample_name}: {result.error_message}")
        if not transmissions:
            raise RuntimeError("没有样品获得有效透过率 / No valid ionchamber transmission found")
        return None, transmissions
    return args.transmission or 100.0, None


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()
    try:
        input_dir = Path(args.input_dir)
        if not input_dir.is_dir():
            raise RuntimeError(f"输入目录不存在 / Input directory not found: {input_dir}")

        output_dir = Path(args.output_dir)
        output_dir.mkdir(parents=True, exist_ok=True)

        sample_paths = _iter_curve_files(input_dir, args.pattern)
        if args.background:
            background_resolved = str(Path(args.background).resolve())
            sample_paths = [
                path for path in sample_paths if str(Path(path).resolve()) != background_resolved
            ]

        if args.parse_mode != "single" and args.ion_dir:
            raise RuntimeError("多列 XYXY/XYYY 模式暂不支持 T-背景扣除中的电离室自动匹配")

        if not sample_paths:
            print("未找到可处理样品文件 / No sample files found")
            return 0

        config = _build_config(args)
        transmission, transmissions = _resolve_transmissions(args, sample_paths)
        if args.background and transmissions is not None:
            sample_paths = [path for path in sample_paths if path in transmissions]
        context = PipelineContext(
            processor_config=config,
            output_dir=str(output_dir),
            output_format=args.format,
            save_results=args.output_mode == "per-file",
            export_raw=True,
            export_bg=args.save_bg,
            export_sub=args.save_sub,
        )
        tasks: List[TaskItem] = []
        delimiter = None if args.delimiter == "auto" else args.delimiter
        for sample_path in sample_paths:
            layout = inspect_curve_layout(
                path=sample_path,
                parse_mode=args.parse_mode,
                skip_header=args.skip_header,
                delimiter=delimiter,
            )
            for spec in layout["curve_specs"]:
                task = TaskItem(
                    source_path=sample_path,
                    background_path=args.background,
                    transmission=(
                        transmissions.get(sample_path)
                        if transmissions is not None and sample_path in transmissions
                        else transmission
                    ),
                    process_mode=config.process_mode,
                    x_column=spec.x_column,
                    y_column=spec.y_column,
                    skip_header=args.skip_header,
                    delimiter=delimiter,
                    extra={
                        "curve_label": spec.label,
                        "output_suffix": spec.label if args.parse_mode != "single" else "",
                    },
                )
                tasks.append(task)
        engine = PipelineEngine(context)
        success = 0
        collected_curves = []
        for result in engine.run(tasks):
            name = Path(result.source_path).name
            if result.curve is not None:
                success += 1
                collected_curves.append(result.curve)
                transmission_note = result.curve.metadata.extra.get("transmission_percent")
                if transmission_note is not None:
                    print(f"[OK] {name} | T={float(transmission_note):.2f}%")
                else:
                    print(f"[OK] {name}")
            else:
                print(f"[FAIL] {name}: {result.error_msg}")

        if args.output_mode == "merged" and collected_curves:
            merged_path = output_dir / args.merged_output
            if not save_curve_collection(
                curves=collected_curves,
                path=str(merged_path),
                fmt=args.format,
                include_bg=args.save_bg,
                include_subtracted=args.save_sub,
            ):
                raise RuntimeError(f"合并保存失败 / Failed to save merged output: {merged_path}")
            print(f"[MERGED] {merged_path}")

        print(f"完成 / Complete: {success}/{len(tasks)}")
        return 0 if success == len(tasks) else 1
    except Exception as exc:
        print(f"批处理失败: {exc} / Batch processing failed: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())


path: scripts/curve_process_1d.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
curve_process_1d.py — 1D曲线处理脚本
Single-file 1D curve processing script.

支持两类工作流：
1. 算法背景估计：morph / poly / rolling_ball
2. 传统参考背景扣除：sample / (T/100) - background

Supports two workflows:
1. Algorithmic background estimation: morph / poly / rolling_ball
2. Traditional reference subtraction: sample / (T/100) - background
"""

from __future__ import annotations

import argparse
import os
import sys
from pathlib import Path
from typing import List, Optional

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from lib.curve_data import Curve1D, CurveMetadata, ProcessMode  # noqa: E402
from lib.curve_processor import CurveProcessorConfig  # noqa: E402
from lib.task_pipeline import PipelineContext, PipelineEngine  # noqa: E402
from lib.curve_io import (  # noqa: E402
    inspect_curve_layout,
    save_curve_collection,
    save_curve_with_background,
)
from lib.common import (  # noqa: E402
    build_transmission_map,
    calc_ion_intensity,
    load_ionchamber_table,
)


METHOD_LABELS = {
    "morph": "形态学 / Morphological",
    "poly": "多项式 / Polynomial",
    "rolling_ball": "滚球 / Rolling Ball",
}


def build_parser() -> argparse.ArgumentParser:
    """构建参数解析器。Build the argument parser."""
    parser = argparse.ArgumentParser(
        description="单条 1D 曲线处理：算法背景估计或参考背景 T 修正扣除 / Process one 1D curve with algorithmic background estimation or T-corrected reference subtraction."
    )
    parser.add_argument("input", help="输入曲线文件 / Input curve file")
    parser.add_argument("--background", help="参考背景曲线文件 / Reference background curve")
    parser.add_argument(
        "--parse-mode",
        choices=["single", "xyxy", "xyyy"],
        default="single",
        help="列结构 / Column layout",
    )
    parser.add_argument("--skip-header", type=int, default=0, help="跳过头部行数 / Skip header")
    parser.add_argument(
        "--delimiter",
        choices=["auto", "comma", "tab", "space"],
        default="auto",
        help="分隔符 / Delimiter",
    )
    parser.add_argument(
        "--method",
        choices=["morph", "poly", "rolling_ball"],
        default="morph",
        help="算法背景模式下的方法 / Method for algorithmic background mode",
    )
    parser.add_argument("--radius", type=int, default=50, help="半径参数 / Radius parameter")
    parser.add_argument(
        "--iterations", type=int, default=1, help="形态学迭代次数 / Morph iterations"
    )
    parser.add_argument("--degree", type=int, default=4, help="多项式阶数 / Polynomial degree")
    parser.add_argument(
        "--quantile", type=float, default=0.3, help="多项式分位数 / Polynomial quantile"
    )
    parser.add_argument(
        "-t",
        "--transmission",
        type=float,
        help="手动透过率(%%) / Manual transmission percent",
    )
    parser.add_argument("--ion-dir", help="电离室文件目录 / Ionchamber directory")
    parser.add_argument(
        "--ion-files",
        nargs="+",
        help="额外指定电离室文件列表 / Additional explicit ionchamber files",
    )
    parser.add_argument(
        "--sample-ion", help="显式指定样品电离室文件 / Explicit sample ionchamber file"
    )
    parser.add_argument(
        "--background-ion", help="显式指定背景电离室文件 / Explicit background ionchamber file"
    )
    parser.add_argument(
        "--sample-channel",
        default="Ionchamber1",
        choices=["Ionchamber0", "Ionchamber1", "Ionchamber2"],
        help="样品通道 / Sample channel",
    )
    parser.add_argument(
        "--background-channel",
        default="Ionchamber1",
        choices=["Ionchamber0", "Ionchamber1", "Ionchamber2"],
        help="背景通道 / Background channel",
    )
    parser.add_argument(
        "--sample-method",
        default="median",
        choices=["mean", "median", "trimmed_mean"],
        help="样品统计方法 / Sample summary method",
    )
    parser.add_argument(
        "--background-method",
        default="median",
        choices=["mean", "median", "trimmed_mean"],
        help="背景统计方法 / Background summary method",
    )
    parser.add_argument(
        "--match-regex", default="", help="可选自定义匹配正则 / Optional custom matching regex"
    )
    parser.add_argument("-o", "--output", help="输出文件路径 / Output file path")
    parser.add_argument(
        "--format",
        choices=["auto", "xy", "csv", "txt", "gr", "npy", "h5"],
        default="auto",
        help="输出格式 / Output format",
    )
    parser.add_argument(
        "--output-mode",
        choices=["merged", "per-file"],
        default="merged",
        help="输出模式 / Output mode",
    )
    parser.add_argument(
        "--preview", action="store_true", help="显示 matplotlib 预览 / Show matplotlib preview"
    )
    return parser


def _detect_format(output_path: str, requested_format: str) -> str:
    if requested_format != "auto":
        return requested_format
    suffix = Path(output_path).suffix.lower().lstrip(".")
    return suffix if suffix in {"xy", "csv", "txt", "gr", "npy"} else "xy"


def _build_config(args: argparse.Namespace) -> CurveProcessorConfig:
    if args.background:
        return CurveProcessorConfig(
            process_mode=ProcessMode.T_BG_SUBTRACT, transmission=args.transmission or 100.0
        )
    if args.method == "morph":
        return CurveProcessorConfig(
            process_mode=ProcessMode.MORPH_1D,
            bg_method_1d="morph",
            morph_radius=args.radius,
            morph_iterations=args.iterations,
        )
    if args.method == "poly":
        return CurveProcessorConfig(
            process_mode=ProcessMode.FIT_1D,
            bg_method_1d="poly",
            poly_degree=args.degree,
            poly_quantile=args.quantile,
        )
    return CurveProcessorConfig(
        process_mode=ProcessMode.MORPH_1D,
        bg_method_1d="rolling_ball",
        rolling_ball_radius=float(args.radius),
    )


def _collect_ion_paths(args: argparse.Namespace) -> List[str]:
    ion_paths: List[str] = []
    if args.ion_dir:
        ion_dir = Path(args.ion_dir)
        if ion_dir.is_dir():
            for path in sorted(ion_dir.rglob("*")):
                if path.is_file() and path.suffix.lower() in {".ionchamber", ".txt"}:
                    ion_paths.append(str(path))
    if args.ion_files:
        for path in args.ion_files:
            if path not in ion_paths:
                ion_paths.append(path)
    return ion_paths


def _compute_explicit_transmission(args: argparse.Namespace) -> Optional[float]:
    if not args.sample_ion or not args.background_ion:
        return None
    sample_table = load_ionchamber_table(args.sample_ion)
    background_table = load_ionchamber_table(args.background_ion)
    if sample_table is None or background_table is None:
        raise RuntimeError("显式电离室文件读取失败 / Failed to read explicit ionchamber files")
    sample_intensity = calc_ion_intensity(sample_table, args.sample_channel, args.sample_method)
    background_intensity = calc_ion_intensity(
        background_table, args.background_channel, args.background_method
    )
    if sample_intensity is None or background_intensity in (None, 0):
        raise RuntimeError(
            "显式电离室强度计算失败 / Failed to calculate explicit ionchamber intensity"
        )
    return sample_intensity / background_intensity * 100.0


def _resolve_transmission(args: argparse.Namespace) -> Optional[float]:
    if not args.background:
        return None
    if args.transmission is not None:
        return args.transmission

    explicit_transmission = _compute_explicit_transmission(args)
    if explicit_transmission is not None:
        return explicit_transmission

    ion_paths = _collect_ion_paths(args)
    if not ion_paths:
        return 100.0

    transmissions, match_results, background_result = build_transmission_map(
        sample_paths=[args.input],
        background_path=args.background,
        ion_paths=ion_paths,
        background_channel=args.background_channel,
        background_method=args.background_method,
        sample_channel=args.sample_channel,
        sample_method=args.sample_method,
        user_regex=args.match_regex or None,
    )
    if background_result is None or not background_result.success:
        raise RuntimeError(
            background_result.error_message if background_result else "背景电离室匹配失败"
        )
    if args.input not in transmissions:
        reason = match_results[0].error_message if match_results else "样品电离室匹配失败"
        raise RuntimeError(reason)
    return transmissions[args.input]


def _run_pipeline(
    args: argparse.Namespace,
    config: CurveProcessorConfig,
) -> List[Curve1D]:
    transmission = _resolve_transmission(args)
    layout = inspect_curve_layout(
        path=args.input,
        parse_mode=args.parse_mode,
        skip_header=args.skip_header,
        delimiter=None if args.delimiter == "auto" else args.delimiter,
    )
    ctx = PipelineContext(
        processor_config=config,
        output_dir="",
        save_results=False,
        export_raw=True,
        export_bg=True,
        export_sub=True,
    )
    engine = PipelineEngine(ctx)
    tasks = []
    for spec in layout["curve_specs"]:
        tasks.extend(
            PipelineEngine.build_tasks(
                paths=[args.input],
                process_mode=config.process_mode,
                background_path=args.background,
                transmission=transmission,
                x_column=spec.x_column,
                y_column=spec.y_column,
                skip_header=args.skip_header,
                delimiter=None if args.delimiter == "auto" else args.delimiter,
            )
        )
        tasks[-1].extra["curve_label"] = spec.label
        tasks[-1].extra["output_suffix"] = spec.label if args.parse_mode != "single" else ""
    results = engine.run_all(tasks)
    curves: List[Curve1D] = []
    for result in results:
        if result.curve is None:
            raise RuntimeError(result.error_msg or "处理失败 / Processing failed")
        if transmission is not None:
            result.curve.metadata.extra["transmission_source"] = (
                "manual" if args.transmission is not None else "ionchamber"
            )
        curves.append(result.curve)
    return curves


def _show_preview(curve: Curve1D) -> None:
    try:
        import matplotlib.pyplot as plt
    except Exception as exc:  # pragma: no cover
        raise RuntimeError(f"无法导入 matplotlib / Failed to import matplotlib: {exc}") from exc

    fig, axes = plt.subplots(1, 3, figsize=(15, 4), constrained_layout=True)
    axes[0].plot(curve.x, curve.y, color="tab:blue", lw=1.2)
    axes[0].set_title("原始曲线 / Raw")
    axes[1].plot(curve.x, curve.y, color="0.75", lw=1.0, label="Raw")
    if curve.background is not None:
        axes[1].plot(curve.x, curve.background, color="tab:orange", lw=1.2, label="Background")
    axes[1].set_title("背景 / Background")
    axes[1].legend()
    if curve.subtracted is not None:
        axes[2].plot(curve.x, curve.subtracted, color="tab:green", lw=1.2)
    axes[2].set_title("扣除后 / Subtracted")
    plt.show()


def _save_outputs(curves: List[Curve1D], output_path: str, fmt: str, output_mode: str) -> None:
    if output_mode == "merged" or len(curves) == 1:
        target_curve = curves[0] if len(curves) == 1 and output_mode != "merged" else None
        if target_curve is not None:
            save_curve_with_background(
                curve=target_curve,
                path=output_path,
                include_bg=target_curve.background is not None,
                include_subtracted=target_curve.subtracted is not None,
                fmt=fmt,
            )
            return
        if not save_curve_collection(
            curves=curves,
            path=output_path,
            fmt=fmt,
            include_bg=True,
            include_subtracted=True,
        ):
            raise RuntimeError("合并保存失败 / Failed to save merged output")
        return

    output_dir = Path(output_path)
    output_dir.mkdir(parents=True, exist_ok=True)
    for curve in curves:
        label = curve.metadata.extra.get("curve_label", "curve")
        base_name = f"{Path(curve.metadata.source_file).stem}_{label}"
        ext = ".h5" if fmt == "h5" else f".{fmt if fmt != 'auto' else 'xy'}"
        path = output_dir / f"{base_name}{ext}"
        save_curve_with_background(
            curve=curve,
            path=str(path),
            include_bg=curve.background is not None,
            include_subtracted=curve.subtracted is not None,
            fmt=fmt,
        )


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()
    try:
        config = _build_config(args)
        if args.parse_mode != "single" and args.background and (args.ion_dir or args.sample_ion):
            raise RuntimeError("多列 XYXY/XYYY 模式暂不支持 T-背景扣除中的电离室自动匹配")
        results = _run_pipeline(args, config)
        result = results[0]

        if args.background:
            transmission = result.metadata.extra.get(
                "transmission_percent", args.transmission or 100.0
            )
            print(
                f"参考背景模式 / Reference mode: T = {float(transmission):.4f}% | 背景文件 / Background = {Path(args.background).name}"
            )
        else:
            print(f"算法背景模式 / Algorithmic mode: {METHOD_LABELS[args.method]}")

        if args.output:
            fmt = _detect_format(args.output, args.format)
            _save_outputs(results, args.output, fmt, args.output_mode)
            print(f"已保存结果 / Saved result: {args.output}")
        else:
            print(f"曲线数量 / Curves: {len(results)}")
            print(f"点数 / Points: {result.n_points}")
            if result.subtracted is not None:
                print(
                    f"扣除后最小/最大值 / Subtracted min/max: {result.subtracted.min():.6g} / {result.subtracted.max():.6g}"
                )

        if args.preview:
            _show_preview(result)
        return 0
    except Exception as exc:
        print(f"处理失败: {exc} / Processing failed: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())


path: scripts/format_convert.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
format_convert.py — 二维图像格式转换
2D image format conversion.

命令行脚本：探测输入文件类型，加载 TIFF、EDF 或 H5 图像数据，并导出为目标格式。
对于 H5 多帧数据，可选择指定帧进行导出。
Command-line script: probe the input file type, load TIFF, EDF, or H5 image data,
and export it to the target format. For H5 multi-frame data, a specific frame can
be selected for export.
"""

import argparse
import os
import sys

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from lib.image_io import load_h5_stack, load_image_file, probe_image_file, save_image


def parse_args():
    """解析命令行参数 / Parse command-line arguments"""
    parser = argparse.ArgumentParser(description="二维图像格式转换 / 2D image format conversion")
    parser.add_argument("input", help="输入文件路径 / input file path")
    parser.add_argument(
        "-o",
        "--output",
        required=True,
        help="输出文件路径 / output file path",
    )
    parser.add_argument(
        "--format",
        choices=["edf", "tif", "tiff"],
        help="输出格式 / output format",
    )
    parser.add_argument(
        "--frame",
        type=int,
        default=0,
        help="H5 导出帧索引 / frame index for H5 export",
    )
    return parser.parse_args()


def detect_output_format(args) -> str:
    """解析输出格式 / Resolve output format"""
    if args.format:
        return args.format
    ext = os.path.splitext(args.output)[1].lower()
    if ext in {".edf", ".tif", ".tiff"}:
        return ext.lstrip(".")
    return "auto"


def extract_h5_frame(stack_info, frame_index: int):
    """从 H5 结构中提取帧 / Extract frame from H5 structure"""
    if not isinstance(stack_info, dict):
        raise ValueError("H5 数据结构无效 / Invalid H5 data structure")

    arrays = []
    for value in stack_info.values():
        if isinstance(value, np.ndarray) and value.ndim >= 2:
            arrays.append(value)

    if not arrays:
        raise ValueError("未在 H5 文件中找到图像数据 / No image data found in H5 file")

    data = arrays[0]
    if data.ndim == 2:
        if frame_index != 0:
            raise ValueError("二维 H5 数据仅支持 frame=0 / 2D H5 data only supports frame=0")
        return np.asarray(data), None

    if frame_index < 0 or frame_index >= data.shape[0]:
        raise ValueError(f"帧索引超出范围: {frame_index} / Frame index out of range: {frame_index}")
    return np.asarray(data[frame_index]), None


def main():
    """主函数 / Main entry point"""
    args = parse_args()

    try:
        probe, probe_meta = probe_image_file(args.input)
        if probe is None and probe_meta is None:
            print(f"无法识别输入文件: {args.input} / Could not probe input file: {args.input}")
            return 1

        lower_path = args.input.lower()
        if lower_path.endswith(".h5") or lower_path.endswith(".hdf5"):
            stack_info = load_h5_stack(args.input)
            if stack_info is None:
                print(f"H5 加载失败: {args.input} / Failed to load H5: {args.input}")
                return 1
            data, header = extract_h5_frame(stack_info, args.frame)
        else:
            data, header = load_image_file(args.input)
            if data is None:
                print(f"图像加载失败: {args.input} / Failed to load image: {args.input}")
                return 1

        output_format = detect_output_format(args)
        ok = save_image(np.asarray(data), args.output, format=output_format, header=header)
        if not ok:
            print(f"保存失败: {args.output} / Failed to save: {args.output}")
            return 1

        print(
            f"转换成功: {args.input} -> {args.output} / "
            f"Conversion succeeded: {args.input} -> {args.output}"
        )
        return 0
    except Exception as exc:
        print(f"转换失败: {exc} / Conversion failed: {exc}")
        return 1


if __name__ == "__main__":
    sys.exit(main())


path: scripts/ionchamber.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ionchamber.py — 电离室数据分析工具
Ionchamber data analysis utility.

支持两种模式：
1. 单文件统计
2. 样品/背景双文件透过率计算

Supports two modes:
1. Single-file statistics
2. Sample/background transmission calculation
"""

from __future__ import annotations

import argparse
import os
import sys

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from lib.common import calc_ion_intensity, load_ionchamber_table  # noqa: E402


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="电离室分析 / Ionchamber analysis")
    parser.add_argument("ionchamber", help="样品电离室文件 / Sample ionchamber file")
    parser.add_argument("--background-ion", help="背景电离室文件 / Background ionchamber file")
    parser.add_argument(
        "-c",
        "--channel",
        choices=["Ionchamber0", "Ionchamber1", "Ionchamber2", "all"],
        default="Ionchamber2",
    )
    parser.add_argument("--method", choices=["mean", "median", "trimmed_mean"], default="median")
    parser.add_argument(
        "--background-channel",
        choices=["Ionchamber0", "Ionchamber1", "Ionchamber2"],
        default="Ionchamber1",
    )
    parser.add_argument(
        "--sample-channel",
        choices=["Ionchamber0", "Ionchamber1", "Ionchamber2"],
        default="Ionchamber1",
    )
    parser.add_argument(
        "--background-method", choices=["mean", "median", "trimmed_mean"], default="median"
    )
    parser.add_argument(
        "--sample-method", choices=["mean", "median", "trimmed_mean"], default="median"
    )
    return parser.parse_args()


def print_stats(name: str, values: np.ndarray) -> None:
    print(f"\n{name} 统计 / Statistics:")
    print(f"  mean   = {np.mean(values):.6e}")
    print(f"  median = {np.median(values):.6e}")
    print(f"  std    = {np.std(values):.6e}")
    print(f"  min    = {np.min(values):.6e}")
    print(f"  max    = {np.max(values):.6e}")


def main() -> int:
    args = parse_args()
    try:
        data = load_ionchamber_table(args.ionchamber)
        if data is None:
            raise RuntimeError("电离室文件中没有有效数据 / No valid ionchamber data found")

        print(f"文件 / File: {args.ionchamber}")
        print(f"数据行数 / Rows: {len(data)}")

        if args.channel == "all":
            for name in ["Ionchamber0", "Ionchamber1", "Ionchamber2"]:
                print_stats(name, data[name].to_numpy(dtype=float))
        else:
            print_stats(args.channel, data[args.channel].to_numpy(dtype=float))

        summary_channel = args.channel if args.channel != "all" else args.sample_channel
        summary_intensity = calc_ion_intensity(
            data, summary_channel, args.method if args.channel != "all" else args.sample_method
        )
        if summary_intensity is not None:
            print(
                f"\n摘要强度 / Summary intensity ({summary_channel}, {args.method}): {summary_intensity:.6e}"
            )

        if args.background_ion:
            background_data = load_ionchamber_table(args.background_ion)
            if background_data is None:
                raise RuntimeError(
                    "背景电离室文件中没有有效数据 / No valid background ionchamber data found"
                )

            sample_intensity = calc_ion_intensity(data, args.sample_channel, args.sample_method)
            background_intensity = calc_ion_intensity(
                background_data,
                args.background_channel,
                args.background_method,
            )
            if sample_intensity is None or background_intensity in (None, 0):
                raise RuntimeError("透过率计算失败 / Failed to calculate transmission")
            transmission = sample_intensity / background_intensity * 100.0
            print(f"\n样品/背景透过率 / Sample-vs-background transmission: {transmission:.4f}%")
            print(
                f"公式 / Formula: T = {args.sample_channel}_{args.sample_method}(sample) / {args.background_channel}_{args.background_method}(background) × 100%"
            )
        return 0
    except Exception as exc:
        print(f"处理失败: {exc} / Processing failed: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)