現在 ScanDataPy/model/file_io.py には TsmFileIo / TbnFileIo
/ DaFileIo / HekaFileIO の 4 クラスがあり、いずれも
コンストラクタ内でファイル I/O を実行し、結果を多数のインスタンス変数に
書き込む典型的な OO スタイルで書かれています。
本ドキュメントは、これを Rust の関数型プログラミング (FP) 強寄りのスタイルに 書き換えるためのプランを、両方の構造を図示しながら整理します。最終的には Python 側で AI による画像解析を行う構想があるため、境界 (PyO3) を意識した分離を 最初から設計に組み込みます。
std::fs::read 1 箇所だけScanRecord を全フォーマットで統一し、ファイル形式の非対称性を呼び出し側から隠蔽※ Rust 言語仕様の詳細は別資料を参照: pub キーワード / derive マクロ
4 つのクラスが並び、TsmFileIo は内部で TbnFileIo をインスタンス化する
凝集度の高い構造になっています。
# コンストラクタを呼んだ瞬間にファイル I/O が走り、
# 30 個近いインスタンス変数が mutate される
tsm = TsmFileIo(filename_obj, num_fluo_ch=2)
full, ch0, ch1 = tsm.get_3d()
elec = tsm.get_1d() # 内部で elec_data_obj.get_data() を呼ぶ
| # | 問題 | 影響 |
|---|---|---|
| P1 | コンストラクタが副作用を持つ (__init__ 内で open(), np.fromfile, 例外) |
テスト時にファイルなしでオブジェクトを作れない。失敗が constructor で起きる |
| P2 | インスタンス変数の遅延初期化 (self.dark_frame = np.array([0,]) 後で上書き) |
「初期化済みなのか未読込なのか」が型から読み取れない |
| P3 | TsmFileIo が内部で TbnFileIo をインスタンス化 | TBN だけ独立テストできない。TSM なしでは TBN を読めない設計 |
| P4 | print() をログ代わりに使用 |
ログレベル制御不能、テスト出力が汚れる |
| P5 | TsmFileIo と DaFileIo で split_frames, dark frame 減算が重複 |
仕様変更時に 2 箇所修正が必要 |
| P6 | 例外を握りつぶして文字列 print → 再 raise | 呼び出し側がエラー種別で分岐できない |
| P7 | HekaFileIO が NameError | そもそも動作しない (現状 try/except で import 時にスキップ) |
| P8 | getter が大量 (get_3d, get_1d, get_header, ...) |
不変なデータなのに「メソッド経由でしか取れない」OO 形式に縛られる |
| P9 | TSM+TBN ペアと DA 単一ファイルで API がバラバラ | get_1d() の戻り値や電気生理データの取得経路が形式ごとに違う |
本リファクタの全体方針は次の 3 つの原則に集約されます。これらは独立ではなく、互いに支え合う関係にあります。
OO 版では self.full_frames = ... のように、生成後に値が書き換わるフィールドが多数ありました。
Rust FP 版では struct のフィールドは生成後に mutate しないことを徹底します。
#[derive(Debug, Clone)]
pub struct TsmHeader {
pub num_x: usize,
pub num_y: usize,
pub num_frames: u32,
pub frame_interval_ms: f64,
}
全フィールドを pub にすれば getter メソッドを書く必要がなく、不変なので外部から読まれても問題ありません。
#[derive] マクロで Debug / Clone を自動実装するのが定石です。
OO 版の tsm.read_infor() のように self を mutate するメソッドではなく、
入力を取って出力を返すだけの関数として書きます。
// バイト列を渡すと、ヘッダ構造体を返す。それだけ。
// self も io も触らない、再現性 100%。
pub fn parse_tsm_header(bytes: &[u8; 2880]) -> Result<TsmHeader, ScanIoError> {
// FITS 形式ヘッダから NAXIS1, NAXIS2, NAXIS3, EXPOSURE を抽出
...
}
同じ入力に対して常に同じ出力を返すため、ファイルなしでテストできるのが大きな利点です:
// テスト例: 実ファイルなしで小さなバイト列を渡して検証
#[test]
fn parses_minimal_header() {
let bytes = make_test_header(64, 64, 100, 0.005);
let header = parse_tsm_header(&bytes).unwrap();
assert_eq!(header.num_x, 64);
}
ファイル I/O は open_tsm / open_tsm_pair / open_da 等の
Adapter 層関数 1 つにだけ閉じ込めます。それ以外の関数は全て純粋。
pub fn open_tsm(path: impl AsRef<Path>) -> Result<ScanRecord, ScanIoError> {
// ① 副作用: ファイル読み込み (この 1 行だけが I/O)
let bytes = std::fs::read(path.as_ref())?;
// ② 以下は全て純粋関数の合成
let header = parse_tsm_header(&bytes[..2880].try_into()?)?;
let raw = raw_i16_from_bytes(&bytes[2880..], &header)?;
let arr = reshape_fortran_3d(raw, header.shape_with_dark())?;
let arr = axis_transform(arr);
let (full, dark) = split_dark(arr);
let full = subtract_dark_frame(full.view(), dark.view());
Ok(ScanRecord {
source: SourceFormat::Tsm,
fluo: FluoData { full_frames: full, dark_frame: dark, num_fluo_ch: 2, ... },
elec: None,
})
}
実際の研究データは 3 種類のファイル形式があり、それぞれ「画像データ」と「電気生理データ」の持ち方が違います。 この非対称性を低レベルで露呈させると、呼び出し側に形式ごとの分岐が漏れて API が汚れます。
| 形式 | 画像 | 電気生理 | Dark frame | ファイル数 |
|---|---|---|---|---|
.tsm + .tbn (Redshirt) | .tsm 内 | .tbn 内 (別ファイル) | .tsm の最終フレーム | 2 ファイル でペア |
.da (NeuroPlex) | .da 内 | .da 内 (8 ch 固定) | .da 内 (末尾) | 1 ファイル に全部 |
.heka (HEKA) ※対象外 | — | .heka 内 | — | 1 ファイル |
解決策は、高レベル API が返す型を 1 つに統一することです。形式の違いは Adapter 層で吸収し、呼び出し側からは見えなくします。
record.fluo.full_frames や record.elec.as_ref() で統一的に扱えるFluoData をリテラルで組み立ててダウンストリーム処理だけテストできるscandata-io が公開する関数は明確な役割分担を持ちます。利用者は通常、高レベル API だけを使えば済みます。
| 関数 | レベル | 用途 | 戻り値 |
|---|---|---|---|
open_tsm(path) |
高レベル | TSM 単体を読む (TBN なし)。電気生理データなしの想定 | Result<ScanRecord, _> (elec: None) |
open_tsm_pair(tsm_path) |
高レベル | TSM + 隣の TBN を一括で読む (実用上はこちらが基本) | Result<ScanRecord, _> (elec: Some) |
open_da(path) |
高レベル | DA 単一ファイルを読む | Result<ScanRecord, _> (elec: Some) |
open(path) (将来) |
高レベル | 拡張子で振り分けて上の 3 つにディスパッチ | Result<ScanRecord, _> |
parse_tsm_header(bytes) |
低レベル (純粋) | FITS ヘッダ解析 | Result<TsmHeader, _> |
parse_tbn_header(bytes) |
低レベル (純粋) | TBN ヘッダ解析 (num_ch, bnc_ratio) | Result<TbnHeader, _> |
parse_tbn_elec(bytes, hint) |
低レベル (純粋) | TBN 電気生理トレース解析 + ゲイン適用 | Result<ElecData, _> |
parse_da_header(bytes) |
低レベル (純粋) | DA 5120 byte ヘッダ解析 | Result<DaHeader, _> |
parse_da_imaging_section |
低レベル (純粋) | DA imaging section の int16 → Array3 変換 | Result<Array3<i16>, _> |
parse_da_elec_section |
低レベル (純粋) | DA elec section (8 ch 固定) の AtoD 変換 | Result<Array2<f64>, _> |
open_tbn という単独関数は公開しない:
TBN は単独で読んでも意味がない (TSM のフレーム数や frame_interval が必要なため)。
TBN を扱いたい場合は open_tsm_pair を使うか、低レベルの parse_tbn_* 純粋関数を直接呼びます。
公開される struct と enum の定義を types.rs に集約します。
全て不変 (生成後 mutate なし) で、フィールドは pub 公開します。
// ===== 出力型 (公開) =====
#[derive(Debug, Clone)]
pub enum SourceFormat {
Tsm, // .tsm + .tbn ペア
Da, // .da 単一ファイル
}
#[derive(Debug, Clone)]
pub struct ScanRecord {
pub source: SourceFormat,
pub fluo: FluoData, // 画像は必ずある
pub elec: Option<ElecData>, // 電気は無い場合もある
}
#[derive(Debug, Clone)]
pub struct FluoData {
pub full_frames: Array3<i32>, // dark 減算済み (Y, X, N)
pub dark_frame: Array2<i16>, // (Y, X)
pub num_fluo_ch: usize, // チャネル数 (通常 2)
pub frame_interval_ms: f64,
}
// 注: channels (チャネル別 4D 配列) は保持しない。
// チャネル分離は domain 層 (builder) で split_channels() を呼び出して行う。
#[derive(Debug, Clone)]
pub struct ElecData {
pub trace: Array2<f64>, // (sample, ch)
pub num_ch: usize,
pub interval_ms: f64,
pub gain_table: Vec<f64>, // チャネル別ゲイン
}
// ===== 内部ヘッダ型 (公開だが主に内部使用) =====
#[derive(Debug, Clone)]
pub struct TsmHeader {
pub num_x: usize,
pub num_y: usize,
pub num_full_frames: u32,
pub frame_interval_ms: f64,
}
#[derive(Debug, Clone)]
pub struct TbnHeader {
pub num_elec_ch: usize,
pub bnc_ratio: f64,
}
#[derive(Debug, Clone)]
pub struct DaHeader {
pub num_x: usize,
pub num_y: usize,
pub num_frames: u32,
pub bnc_ratio: f64,
pub interval_ms: f64,
}
// ===== エラー型 (sum type) =====
#[derive(Debug, thiserror::Error)]
pub enum ScanIoError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("missing header key: {0}")]
MissingHeaderKey(&'static str),
#[error("invalid header value for {key}: {value}")]
InvalidHeaderValue { key: &'static str, value: String },
#[error("file too small: expected {expected} bytes, got {actual}")]
FileTooSmall { expected: usize, actual: usize },
}
クレートは scandata-io として ScanDataNet プロジェクトの workspace に置きます。
Adapter 層 1 段 + Domain 層 1 段の構造。
ScanDataNet/server/crates/scandata-io/
├── Cargo.toml
├── src/
│ ├── lib.rs // 公開 API の再エクスポート
│ ├── io.rs // 副作用層: open_tsm / open_tsm_pair / open_da
│ ├── header.rs // 純粋: 3 形式のヘッダ解析
│ ├── frames.rs // 純粋: reshape / axis_transform / subtract_dark
│ ├── elec.rs // 純粋: TBN 電気生理解析
│ ├── da_sections.rs // 純粋: DA の 3 section 分割
│ ├── types.rs // データ struct / enum
│ └── error.rs // ScanIoError
├── tests/
│ └── compat.rs // .npy との bit-perfect 一致テスト
└── examples/
└── tsm_info.rs // CLI
open_tsm() は「ファイルから生バイトを読む」副作用 1 ステップと、その後の純粋関数の合成として表現できます。
最終出力は ScanRecord。
pub fn open_tsm(path: impl AsRef<Path>) -> Result<ScanRecord, ScanIoError> {
let bytes = std::fs::read(path.as_ref())?;
let fluo = build_fluo_from_tsm_bytes(&bytes)?;
Ok(ScanRecord {
source: SourceFormat::Tsm,
fluo,
elec: None,
})
}
fn build_fluo_from_tsm_bytes(bytes: &[u8]) -> Result<FluoData, ScanIoError> {
let header_bytes: &[u8; 2880] = bytes.get(..2880)
.ok_or(ScanIoError::FileTooSmall { expected: 2880, actual: bytes.len() })?
.try_into()?;
let header = parse_tsm_header(header_bytes)?;
let raw = raw_i16_from_bytes(&bytes[2880..], &header)?;
let arr = reshape_fortran_3d(raw, header.shape_with_dark())?;
let arr = axis_transform(arr);
let (full, dark) = split_dark(arr);
let full = subtract_dark_frame(full.view(), dark.view());
Ok(FluoData {
full_frames: full,
dark_frame: dark,
num_fluo_ch: 2,
frame_interval_ms: header.frame_interval_ms,
})
}
// open_tsm_pair は同じ関数を再利用しつつ TBN も読む
pub fn open_tsm_pair(tsm_path: impl AsRef<Path>) -> Result<ScanRecord, ScanIoError> {
let tsm_path = tsm_path.as_ref();
let tbn_path = tsm_path.with_extension("tbn");
let tsm_bytes = std::fs::read(tsm_path)?;
let tbn_bytes = std::fs::read(&tbn_path)?;
let fluo = build_fluo_from_tsm_bytes(&tsm_bytes)?;
let elec = build_elec_from_tbn_bytes(&tbn_bytes, fluo.frame_interval_ms)?;
Ok(ScanRecord {
source: SourceFormat::Tsm,
fluo,
elec: Some(elec),
})
}
? 演算子で Result<_, ScanIoError> が自然に伝播するopen_tsm と open_tsm_pair は build_fluo_from_tsm_bytes を共有 (重複なし)
DA は .tsm + .tbn と違い、imaging / elec / dark frame が 1 つのバイト列の中で連続配置されています。
これを副作用 1 ステップ + 純粋関数による section 分割で処理します。
pub fn open_da(path: impl AsRef<Path>) -> Result<ScanRecord, ScanIoError> {
let bytes = std::fs::read(path.as_ref())?;
let header_bytes: &[u8; 5120] = bytes.get(..5120)
.ok_or(ScanIoError::FileTooSmall { expected: 5120, actual: bytes.len() })?
.try_into()?;
let header = parse_da_header(header_bytes)?;
let ranges = section_offsets(&header);
let img = parse_da_imaging_section(&bytes[ranges.img.clone()], &header)?;
let elec = parse_da_elec_section (&bytes[ranges.elec.clone()], &header)?;
let dark = parse_da_dark_section (&bytes[ranges.dark.clone()], &header)?;
let full = subtract_dark_frame(img.view(), dark.view());
Ok(ScanRecord {
source: SourceFormat::Da,
fluo: FluoData {
full_frames: full,
dark_frame: dark,
num_fluo_ch: 2,
frame_interval_ms: header.interval_ms,
},
elec: Some(ElecData {
trace: elec,
num_ch: 8,
interval_ms: header.interval_ms / header.bnc_ratio,
gain_table: header.gain_table(),
}),
})
}
| 層 | 関数 | 性質 | 役割 |
|---|---|---|---|
| 高レベル API (副作用) | open_tsm(path) |
副作用 | .tsm 単体を読んで ScanRecord (elec: None) を返す |
open_tsm_pair(tsm_path) |
副作用 | .tsm + .tbn を読んで ScanRecord (elec: Some) を返す | |
open_da(path) |
副作用 | .da 1 ファイルから 3 section を読んで ScanRecord を返す | |
| 低レベル (TSM 系) | parse_tsm_header(&[u8; 2880]) | 純粋 | FITS ヘッダ解析 |
parse_tbn_header(&[u8; 4]) | 純粋 | TBN ヘッダ (num_ch, bnc_ratio) | |
parse_tbn_elec(&[u8], hint) | 純粋 | float64 トレース解析 + ゲイン適用 (hint = TsmHeader 由来) | |
| 低レベル (DA 系) | parse_da_header(&[u8; 5120]) | 純粋 | 5120 byte ヘッダ解析 |
parse_da_imaging_section(...) | 純粋 | imaging section の int16 → Array3 | |
parse_da_elec_section(...) | 純粋 | elec section (8 ch 固定) の int16 → Array2 + AtoD | |
| 低レベル (共通) | frames::subtract_dark_frame |
純粋 | 形式に依存しないので TSM/DA で共有 |
| domain 層 (builder で使用) | split_channels(full_frames, num_ch) |
純粋 | scandata-app/usecase の builder が必要時に呼び出す。file_io 層は持たない |
scandata-io ではなく domain 層で行う:
Python OO 版の builder.py は、TSM/DA から読んだ後で各チャネル (FluoFramesCh0, FluoFramesCh1, ...) を
対等な FramesData ValueObject として Repository に登録していました。
Rust 版もこの哲学を踏襲し、scandata-io は素材 (full_frames, dark_frame) だけ返し、
チャネル分離は builder が split_channels を呼んで行います。これにより file_io 層に派生データを持たせず、
メモリ消費も最小になります (channels 524 MB 分の冗長保持が不要)。
| 用途 | 選択 | Stage | 理由 |
|---|---|---|---|
| エラー | enum ScanIoError | Stage 1 | 「これらのうち一つが起きる」という閉じた集合 |
| データ保持 | struct ScanRecord 等 | Stage 1 | 単なるデータ束。enum でも trait でもない |
| ソース形式判別 | enum SourceFormat | Stage 1 | Tsm / Da の閉じた集合。match record.source { ... } |
| ファイル種別判定 (path → format) | enum FileFormat + detect_format(path) | Stage 4+ | 拡張子から振り分ける用途。Stage 1 では不要 |
| 複数フォーマット抽象化 | trait FileReader | Stage 4+ | TBN/DA を実装してから共通点を見て切り出す (premature abstraction を避ける) |
| Modifier chain (将来) | trait Modifier | Stage 4+ | BlComp / DfOverF 等を後から増やせる方が嬉しい |
| 観点 | 現状 (Python OO) | 提案 (Rust FP) |
|---|---|---|
| データ作成 | TsmFileIo(filename) がコンストラクタ内で I/O |
open_tsm_pair(path) -> Result<ScanRecord, _> 自由関数 |
| 状態 | 30 個近いインスタンス変数を mutate | 不変な struct を組み立てて 1 回返すだけ |
| エラー | raise Exception + print で文字列 |
enum ScanIoError + thiserror + ? 演算子 |
| テスト容易性 | ファイルがないとオブジェクトを作れない | 各純粋関数を小さなバイト列で単独テストできる |
| TBN の扱い | TsmFileIo の中で TbnFileIo() をインスタンス化 |
open_tsm_pair が 2 ファイル読み込みを 1 関数に集約。下層は parse_tbn_* として独立純粋関数 |
| ファイル形式の非対称性 | TSM+TBN ペアと DA で API がバラバラ (get_3d()/get_1d() の戻り値が違う、電気生理データの取得経路が形式ごとに別) |
共通出力型 ScanRecord で吸収。open_tsm_pair も open_da も同じ型を返す。呼び出し側は record.fluo.full_frames / record.elec.as_ref() で形式に依存しない |
| DA 単一ファイルの section 分割 | 1 メソッド (約 100 行の DaFileIo.read_data) で imaging / elec / dark を全部 inline 処理 |
3 個の純粋関数 (parse_da_imaging_section / parse_da_elec_section / parse_da_dark_section) に分割。各 section 単独でテスト可能 |
| ロジック重複 | Tsm / Da で split_frames と dark 減算が重複 |
subtract_dark_frame は TSM/DA で共有。split_channels は builder が呼び出す純粋関数として 1 箇所に集約 |
| 派生データの保持 | ch_frames (4D 配列、約 524 MB) を full_frames と並行して持つ |
file_io 層は持たない。builder が必要時に split_channels を呼んで Ch ごとの FluoFrames を生成 |
| ログ | print() 連発 |
tracing または呼び出し側で制御 |
| HEKA | HekaFileIO が NameError |
Stage 4+ で仕様確定後に open_heka を実装 |
| 不変性 | 後から self.full_frames = ... で上書き可能 |
struct のフィールドは生成後 mutate しない |
| API 形式 | getter メソッド (get_3d, get_1d, ...) |
pub フィールドを直接読む |
既存 PHASE_L1_TSM_PARSER.md のステップを本 FP 設計に整合させ、Stage 1〜3 で実装する順序です。
| # | ステップ | 純粋? | 学習対象 |
|---|---|---|---|
| S1 | workspace + クレート scandata-io 初期化 | — | Cargo workspace |
| S2 | error.rs: enum ScanIoError 定義 | — | thiserror, #[from] |
| S3 | types.rs: ScanRecord, FluoData, ElecData, SourceFormat 定義 | — | イミュータブル struct, Option, 列挙型 |
| S4 | header.rs: parse_tsm_header(&[u8; 2880]) | ✓ | 純粋関数, 文字列解析 |
| S5 | frames.rs: raw_i16_from_bytes, reshape_fortran_3d | ✓ | byteorder, ndarray Fortran order |
| S6 | frames.rs: axis_transform, split_dark, subtract_dark_frame | ✓ | ndarray view, 型昇格 |
| S7 | channels.rs: split_channels 純粋関数 | ✓ | iterator chain, step_by |
| S8 | io.rs: open_tsm 実装 (TSM 単独、ScanRecord { elec: None }) | ✗(I/O) | ? 演算子, Result 合成 |
| S9 | テスト: 既存 Python が出力した .npy と bit-perfect 一致 | — | ndarray-npy, 検証戦略 |
| S10 | CLI examples/tsm_info.rs | — | anyhow (bin 側) |
| S11 | Stage 2: parse_tbn_header / parse_tbn_elec (純粋関数) | ✓ | パターン再利用 |
| S12 | Stage 2: open_tsm_pair で open_tsm + TBN 統合 (elec: Some(.)) | ✗(I/O) | 2 ファイル読み + 副作用集約 |
| S13 | Stage 3: parse_da_header + section_offsets (純粋関数) | ✓ | section 分割の数学 |
| S14 | Stage 3: parse_da_imaging_section / parse_da_elec_section / parse_da_dark_section | ✓ | 巨大関数の分解 |
| S15 | Stage 3: open_da 実装 (subtract_dark_frame を再利用して ScanRecord 組み立て) | ✗(I/O) | 共通関数の再利用 |
| S16 | (Stage 4+) detect_format(path) + open(path) ディスパッチ | ✓ (pure) | 拡張子→FileFormat enum |
cargo test が高速に回ります。
S8 / S12 / S15 で副作用が登場し、S9 で実ファイル相手の統合テストが入る、という二段構えです。
本ドキュメントは既存の実装プラン (ScanDataNet/PHASE_L1_TSM_PARSER.md および
ScanDataNet/LEARNING_WEB_PROJECT_PLAN.md) を以下のように拡張・更新します:
| 観点 | 実装プランでの状態 | 本ドキュメントでの提案 |
|---|---|---|
| TSM / TBN / DA の Stage 順序 | ✓ Stage 1 / 2 / 3 と明記済み (PHASE_L1) | 変更なし |
| エラー型名 | ✓ PHASE_L1 §5 で TsmError として雛形あり |
ScanIoError に統一 (TBN/DA を扱うため) |
共通出力型 ScanRecord |
✗ 言及なし。PHASE_L1 は TsmFile 単体を返す前提 |
本ドキュメントで新規提案 (§4) |
TSM+TBN ペアの統合 API (open_tsm_pair) |
✗ 未設計 | 本ドキュメントで新規提案 (§5, S12) |
| DA の 3 section 分割 | ✗ 未設計 | 本ドキュメントで新規提案 (§9, S13〜S15) |
| PyO3 / numpy 連携 (将来 AI 解析用) | △ LEARNING_WEB_PROJECT_PLAN §6 で「移行期の保険」と一言のみ | 本ドキュメント §14 で「ScanRecord ベースなら数行でラップ可能」と明示 |
ScanDataNet/PHASE_L1_TSM_PARSER.md の §5 公開 API 草案を更新するのが望ましいです。
PHASE_L1 の現行案では TsmFile という型を返すことになっているため、
Stage 2 で TBN を追加するときに型を変えるか別関数を生やすかで迷うことになります。
ScanRecord を最初から想定しておけば、Stage 1 完了時点で型が安定します。
最終的に Python 側で AI による画像解析を行う構想があるため、Rust と Python の境界を 最初から見据えて 設計します。 ただし Stage 1〜3 では PyO3 連携は導入せず、純粋 Rust で完結させます。
maturin ビルドが必須になり、純粋 Rust より複雑度が上がるScanRecord ↔ numpy.ndarray) を書く前に Rust 側ロジックを確定させたいscandata-io-py のようなクレートを追加するだけで済む構造にするScanDataNet/server/crates/
├── scandata-io/ // 純粋 Rust (現在のターゲット)
└── scandata-io-py/ // 将来追加: scandata-io をラップする PyO3 クレート
└── src/lib.rs:
use pyo3::prelude::*;
use numpy::{IntoPyArray, PyArray3};
// scandata-io の open_tsm_pair を Python から呼べる薄いラッパ
#[pyfunction]
fn open_tsm_pair_py(py: Python, path: &str) -> PyResult<Py<PyArray3<i32>>> {
let record = scandata_io::open_tsm_pair(path)?;
Ok(record.fluo.full_frames.into_pyarray(py).to_owned())
}
open_tsm_pair を 純粋関数の合成として書いておけば、
PyO3 ラッパは scandata_io::open_tsm_pair(path)? を呼ぶだけの数行で済む。
共通出力型 ScanRecord のおかげで Python 側も「形式を問わず同じ型」で受け取れる。
作成 2026-05-17 · 最終更新 2026-05-18 (全面リライト) · ScanDataPy → ScanDataNet 移行計画の一部