diff --git a/src/data/store/ChunkedVolumeStore.cpp b/src/data/store/ChunkedVolumeStore.cpp index d61d7f2..eed4aa5 100644 --- a/src/data/store/ChunkedVolumeStore.cpp +++ b/src/data/store/ChunkedVolumeStore.cpp @@ -3,12 +3,16 @@ #include #include +#include +#include #include #include #include +#include #include #include "core/algo/GprVolumeBuilder.hpp" // geopro::core::BuiltI16 +#include "model/ScalarVolumeI16.hpp" // geopro::core::ScalarVolumeI16::kBlank namespace geopro::data { @@ -19,8 +23,31 @@ namespace fs = std::filesystem; constexpr const char* kMetaFile = "meta.json"; constexpr const char* kDataFile = "data.bin"; +constexpr std::int16_t kBlank = geopro::core::ScalarVolumeI16::kBlank; + +std::string levelDataFile(int level) { + return level == 0 ? std::string(kDataFile) + : "data_L" + std::to_string(level) + ".bin"; +} + int ceilDiv(int n, int brick) { return (n + brick - 1) / brick; } +// 块内 (min,max),跳过 kBlank;全 blank → (kBlank,kBlank)。 +std::pair computeRange( + const std::vector& blk) { + std::int16_t lo = std::numeric_limits::max(); + std::int16_t hi = std::numeric_limits::min(); + bool any = false; + for (std::int16_t v : blk) { + if (v == kBlank) continue; + any = true; + if (v < lo) lo = v; + if (v > hi) hi = v; + } + if (!any) return {kBlank, kBlank}; + return {lo, hi}; +} + // 块尺寸(边缘块 < brick):第 b 块沿该轴的体素数。 int extent(int n, int b, int brick) { const int got = n - b * brick; @@ -167,30 +194,118 @@ ChunkedVolumeStore::ChunkedVolumeStore(const std::string& dir) : dir_(dir) { be.bw = e.at("bw").get(); be.bh = e.at("bh").get(); be.bd = e.at("bd").get(); + // 老 store 无 min/max;缺失时置 0(brickRange(0,...) 会惰性算)。 + be.vmin = e.value("min", static_cast(0)); + be.vmax = e.value("max", static_cast(0)); bricks_.push_back(be); } + + // 始终从 bricks_ 重建 level 0(与兼容索引同源,复用 data.bin)。 + levels_.clear(); + Level lv0; + lv0.nx = meta_.nx; + lv0.ny = meta_.ny; + lv0.nz = meta_.nz; + lv0.bx = bricksX_; + lv0.by = bricksY_; + lv0.bz = bricksZ_; + lv0.dataFile = kDataFile; + lv0.bricks = bricks_; + levels_.push_back(std::move(lv0)); + + // 若 meta.json 含金字塔(buildPyramid 写入的 levels 数组),加载附加级。 + // levels[0] 与上面的 level 0 一致,跳过;levels[1..] 各自独立数据文件。 + if (meta.contains("levels") && meta.at("levels").is_array()) { + const auto& la = meta.at("levels"); + for (std::size_t L = 1; L < la.size(); ++L) { + const auto& je = la[L]; + Level lv; + lv.nx = je.at("nx").get(); + lv.ny = je.at("ny").get(); + lv.nz = je.at("nz").get(); + lv.bx = ceilDiv(lv.nx, meta_.brick); + lv.by = ceilDiv(lv.ny, meta_.brick); + lv.bz = ceilDiv(lv.nz, meta_.brick); + lv.dataFile = je.value("dataFile", levelDataFile(static_cast(L))); + const auto& jb = je.at("bricks"); + lv.bricks.reserve(jb.size()); + for (const auto& e : jb) { + BrickEntry be; + be.offset = e.at("offset").get(); + be.compressedLen = e.at("compressedLen").get(); + be.bw = e.at("bw").get(); + be.bh = e.at("bh").get(); + be.bd = e.at("bd").get(); + be.vmin = e.value("min", kBlank); + be.vmax = e.value("max", kBlank); + lv.bricks.push_back(be); + } + levels_.push_back(std::move(lv)); + } + // 若 meta 含 level0 的 min/max(buildPyramid 写过),回填 level 0 与 bricks_。 + if (!la.empty() && la[0].contains("bricks")) { + const auto& jb0 = la[0].at("bricks"); + if (jb0.size() == levels_[0].bricks.size()) { + for (std::size_t i = 0; i < jb0.size(); ++i) { + const std::int16_t mn = jb0[i].value("min", kBlank); + const std::int16_t mx = jb0[i].value("max", kBlank); + levels_[0].bricks[i].vmin = mn; + levels_[0].bricks[i].vmax = mx; + bricks_[i].vmin = mn; + bricks_[i].vmax = mx; + } + } + } + } + + levelCount_ = static_cast(levels_.size()); } std::vector ChunkedVolumeStore::readBrick(int bx, int by, int bz) const { - if (bx < 0 || by < 0 || bz < 0 || bx >= bricksX_ || by >= bricksY_ || - bz >= bricksZ_) { + return readBrick(0, bx, by, bz); +} + +// ----------------------- 金字塔实现 ----------------------- + +const ChunkedVolumeStore::Level& ChunkedVolumeStore::levelAt(int level) const { + if (level < 0 || level >= static_cast(levels_.size())) { + throw std::out_of_range("ChunkedVolumeStore: level out of range"); + } + return levels_[static_cast(level)]; +} + +int ChunkedVolumeStore::bricksX(int level) const { return levelAt(level).bx; } +int ChunkedVolumeStore::bricksY(int level) const { return levelAt(level).by; } +int ChunkedVolumeStore::bricksZ(int level) const { return levelAt(level).bz; } + +void ChunkedVolumeStore::dims(int level, int& nx, int& ny, int& nz) const { + const Level& lv = levelAt(level); + nx = lv.nx; + ny = lv.ny; + nz = lv.nz; +} + +std::vector ChunkedVolumeStore::readBrickFrom(const Level& lv, + int bx, int by, + int bz) const { + if (bx < 0 || by < 0 || bz < 0 || bx >= lv.bx || by >= lv.by || bz >= lv.bz) { throw std::out_of_range("ChunkedVolumeStore::readBrick: brick index out of range"); } - const BrickEntry& be = bricks_.at(static_cast(brickIndex(bx, by, bz))); + const BrickEntry& be = + lv.bricks.at(static_cast(brickIndexAt(lv, bx, by, bz))); - std::ifstream data((fs::path(dir_) / kDataFile).string(), std::ios::binary); - if (!data) throw std::runtime_error("ChunkedVolumeStore: cannot open data.bin"); + std::ifstream data((fs::path(dir_) / lv.dataFile).string(), std::ios::binary); + if (!data) throw std::runtime_error("ChunkedVolumeStore: cannot open level data file"); data.seekg(static_cast(be.offset), std::ios::beg); QByteArray compressed; compressed.resize(static_cast(be.compressedLen)); data.read(compressed.data(), be.compressedLen); - if (!data) throw std::runtime_error("ChunkedVolumeStore: data.bin read failed"); + if (!data) throw std::runtime_error("ChunkedVolumeStore: level data read failed"); const QByteArray raw = qUncompress(compressed); - const std::size_t count = - static_cast(be.bw) * be.bh * be.bd; + const std::size_t count = static_cast(be.bw) * be.bh * be.bd; if (static_cast(raw.size()) != count * sizeof(std::int16_t)) { throw std::runtime_error("ChunkedVolumeStore: decompressed size mismatch"); } @@ -200,4 +315,215 @@ std::vector ChunkedVolumeStore::readBrick(int bx, int by, return out; } +std::vector ChunkedVolumeStore::readBrick(int level, int bx, + int by, int bz) const { + return readBrickFrom(levelAt(level), bx, by, bz); +} + +std::pair ChunkedVolumeStore::brickRange( + int level, int bx, int by, int bz) const { + const Level& lv = levelAt(level); + if (bx < 0 || by < 0 || bz < 0 || bx >= lv.bx || by >= lv.by || bz >= lv.bz) { + throw std::out_of_range("ChunkedVolumeStore::brickRange: brick index out of range"); + } + const BrickEntry& be = + lv.bricks.at(static_cast(brickIndexAt(lv, bx, by, bz))); + // level 0 老 store 无存储 min/max(vmin==vmax==0 哨兵):惰性读块计算。 + if (level == 0 && be.vmin == 0 && be.vmax == 0) { + return computeRange(readBrickFrom(lv, bx, by, bz)); + } + return {be.vmin, be.vmax}; +} + +void ChunkedVolumeStore::buildPyramid(int levels) { + // 参数 levels = 最高级索引(level 1..levels 为降采样级);总级数(含 level 0) + // = levels + 1。levels<=0 视为仅 level 0。 + const int target = levels < 0 ? 1 : levels + 1; + const int brick = meta_.brick; + + // 1) 重建所有级到内存(整卷重组 → 逐级 2× 平均降采样)。 + // level 0 直接从已存块重组,避免依赖外部 BuiltI16。 + struct DenseLevel { + int nx, ny, nz; + std::vector vox; // i 最快、k 最慢 + }; + + auto idxAt = [](int nx, int ny, int i, int j, int k) -> std::size_t { + return (static_cast(k) * ny + j) * nx + i; + }; + + // 重组 level 0 整卷。 + DenseLevel d0; + d0.nx = meta_.nx; + d0.ny = meta_.ny; + d0.nz = meta_.nz; + d0.vox.assign(static_cast(d0.nx) * d0.ny * d0.nz, kBlank); + const Level& lv0 = levels_[0]; + for (int bz = 0; bz < lv0.bz; ++bz) { + for (int by = 0; by < lv0.by; ++by) { + for (int bx = 0; bx < lv0.bx; ++bx) { + const auto blk = readBrickFrom(lv0, bx, by, bz); + const BrickEntry& be = + lv0.bricks[static_cast(brickIndexAt(lv0, bx, by, bz))]; + const int i0 = bx * brick, j0 = by * brick, k0 = bz * brick; + std::size_t w = 0; + for (int kk = 0; kk < be.bd; ++kk) + for (int jj = 0; jj < be.bh; ++jj) + for (int ii = 0; ii < be.bw; ++ii) + d0.vox[idxAt(d0.nx, d0.ny, i0 + ii, j0 + jj, k0 + kk)] = blk[w++]; + } + } + } + + std::vector dense; + dense.push_back(std::move(d0)); + for (int L = 1; L < target; ++L) { + const DenseLevel& src = dense.back(); + DenseLevel dst; + dst.nx = std::max(1, (src.nx + 1) / 2); + dst.ny = std::max(1, (src.ny + 1) / 2); + dst.nz = std::max(1, (src.nz + 1) / 2); + dst.vox.assign(static_cast(dst.nx) * dst.ny * dst.nz, kBlank); + for (int k = 0; k < dst.nz; ++k) { + for (int j = 0; j < dst.ny; ++j) { + for (int i = 0; i < dst.nx; ++i) { + // 2×2×2 邻域非 blank 平均(round),全 blank → kBlank。 + long sum = 0; + int cnt = 0; + for (int dk = 0; dk < 2; ++dk) { + const int sk = 2 * k + dk; + if (sk >= src.nz) continue; + for (int dj = 0; dj < 2; ++dj) { + const int sj = 2 * j + dj; + if (sj >= src.ny) continue; + for (int di = 0; di < 2; ++di) { + const int si = 2 * i + di; + if (si >= src.nx) continue; + const std::int16_t v = + src.vox[idxAt(src.nx, src.ny, si, sj, sk)]; + if (v == kBlank) continue; + sum += v; + ++cnt; + } + } + } + if (cnt > 0) { + const long avg = std::lround(static_cast(sum) / cnt); + dst.vox[idxAt(dst.nx, dst.ny, i, j, k)] = + static_cast(avg); + } + } + } + } + dense.push_back(std::move(dst)); + } + + // 2) 逐级落盘(分块 qCompress)+ 计算每块 min/max,重建 levels_。 + std::vector rebuilt; + rebuilt.reserve(dense.size()); + for (std::size_t L = 0; L < dense.size(); ++L) { + const DenseLevel& dl = dense[L]; + Level lv; + lv.nx = dl.nx; + lv.ny = dl.ny; + lv.nz = dl.nz; + lv.bx = ceilDiv(dl.nx, brick); + lv.by = ceilDiv(dl.ny, brick); + lv.bz = ceilDiv(dl.nz, brick); + lv.dataFile = levelDataFile(static_cast(L)); + + std::ofstream data((fs::path(dir_) / lv.dataFile).string(), + std::ios::binary | std::ios::trunc); + if (!data) throw std::runtime_error("ChunkedVolumeStore: cannot open level data for write"); + std::int64_t offset = 0; + for (int bz = 0; bz < lv.bz; ++bz) { + for (int by = 0; by < lv.by; ++by) { + for (int bx = 0; bx < lv.bx; ++bx) { + const int bw = std::min(brick, dl.nx - bx * brick); + const int bh = std::min(brick, dl.ny - by * brick); + const int bd = std::min(brick, dl.nz - bz * brick); + std::vector blk(static_cast(bw) * bh * bd); + const int i0 = bx * brick, j0 = by * brick, k0 = bz * brick; + std::size_t w = 0; + for (int kk = 0; kk < bd; ++kk) + for (int jj = 0; jj < bh; ++jj) + for (int ii = 0; ii < bw; ++ii) + blk[w++] = dl.vox[idxAt(dl.nx, dl.ny, i0 + ii, j0 + jj, k0 + kk)]; + + const int rawBytes = + static_cast(blk.size() * sizeof(std::int16_t)); + const QByteArray compressed = qCompress( + reinterpret_cast(blk.data()), rawBytes); + const std::int64_t clen = compressed.size(); + data.write(compressed.constData(), clen); + if (!data) throw std::runtime_error("ChunkedVolumeStore: level data write failed"); + + const auto rng = computeRange(blk); + BrickEntry be; + be.offset = offset; + be.compressedLen = clen; + be.bw = bw; + be.bh = bh; + be.bd = bd; + be.vmin = rng.first; + be.vmax = rng.second; + lv.bricks.push_back(be); + offset += clen; + } + } + } + data.close(); + rebuilt.push_back(std::move(lv)); + } + + levels_ = std::move(rebuilt); + levelCount_ = static_cast(levels_.size()); + // 兼容字段:level 0 索引(含新算的 min/max)回写 bricks_。 + bricks_ = levels_[0].bricks; + + // 3) 重写 meta.json:保留所有原字段(含原 bricks),追加/覆盖 levels 数组。 + const fs::path metaPath = fs::path(dir_) / kMetaFile; + json meta; + { + std::ifstream in(metaPath.string()); + if (!in) throw std::runtime_error("ChunkedVolumeStore: cannot open meta.json"); + in >> meta; + } + // 原 bricks(level 0 兼容索引)补上 min/max,保持 readBrick/老读取不变。 + if (meta.contains("bricks") && meta.at("bricks").is_array()) { + auto& jb = meta.at("bricks"); + if (jb.size() == levels_[0].bricks.size()) { + for (std::size_t i = 0; i < jb.size(); ++i) { + jb[i]["min"] = levels_[0].bricks[i].vmin; + jb[i]["max"] = levels_[0].bricks[i].vmax; + } + } + } + json jlevels = json::array(); + for (std::size_t L = 0; L < levels_.size(); ++L) { + const Level& lv = levels_[L]; + json jbricks = json::array(); + for (const auto& be : lv.bricks) { + jbricks.push_back(json{{"offset", be.offset}, + {"compressedLen", be.compressedLen}, + {"bw", be.bw}, + {"bh", be.bh}, + {"bd", be.bd}, + {"min", be.vmin}, + {"max", be.vmax}}); + } + jlevels.push_back(json{{"nx", lv.nx}, + {"ny", lv.ny}, + {"nz", lv.nz}, + {"dataFile", lv.dataFile}, + {"bricks", std::move(jbricks)}}); + } + meta["levels"] = std::move(jlevels); + + std::ofstream metaOut(metaPath.string(), std::ios::trunc); + if (!metaOut) throw std::runtime_error("ChunkedVolumeStore: cannot open meta.json for write"); + metaOut << meta.dump(2); + if (!metaOut) throw std::runtime_error("ChunkedVolumeStore: meta.json write failed"); +} + } // namespace geopro::data diff --git a/src/data/store/ChunkedVolumeStore.hpp b/src/data/store/ChunkedVolumeStore.hpp index af42357..efeb997 100644 --- a/src/data/store/ChunkedVolumeStore.hpp +++ b/src/data/store/ChunkedVolumeStore.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include "model/ScalarVolumeI16.hpp" // geopro::core::Quant @@ -43,29 +44,73 @@ class ChunkedVolumeStore { const StoreMeta& meta() const { return meta_; } + // --- level 0 兼容接口(语义不变,= 全分辨率级)--- int bricksX() const { return bricksX_; } int bricksY() const { return bricksY_; } int bricksZ() const { return bricksZ_; } // 读单块 → 还原 int16 vector。返回长度 = bw*bh*bd(内部块 = brick³,边缘块更小)。 + // == readBrick(0, bx, by, bz)。 std::vector readBrick(int bx, int by, int bz) const; + // --- 金字塔(多分辨率 LOD)--- + // 在已 write 的 store 目录上构建金字塔:level 0=全分辨率(已存), + // level 1..levels 逐级 2× 降采样(维度 ceil(n/2)),故总级数 = levels+1。 + // 同时为所有 level(含 0)计算并存每块 (min,max)(跳过 kBlank)。结果写回 + // meta.json + 各级数据文件(level 0 复用现有 data.bin,level L 写 + // data_L.bin)。levels<=0 视为无金字塔(仅 level 0)。 + void buildPyramid(int levels); + + // 总层数(含 level 0);未建金字塔时 = 1。 + int levels() const { return levelCount_; } + + // 各级块数;bricksX() == bricksX(0) 保持兼容。 + int bricksX(int level) const; + int bricksY(int level) const; + int bricksZ(int level) const; + + // 各级体素维度。 + void dims(int level, int& nx, int& ny, int& nz) const; + + // 读某级单块 → 还原 int16 vector。level 0 与兼容重载等价。 + std::vector readBrick(int level, int bx, int by, int bz) const; + + // 每块 (min,max),跳过 kBlank;全 blank 块返回 (kBlank,kBlank)。 + // 对未建金字塔的 level 0,惰性读块计算。 + std::pair brickRange(int level, int bx, int by, + int bz) const; + private: - // 单块在 data.bin 中的位置与未压缩尺寸。 + // 单块在所属级数据文件中的位置、未压缩尺寸与值域。 struct BrickEntry { std::int64_t offset = 0; std::int64_t compressedLen = 0; int bw = 0, bh = 0, bd = 0; + std::int16_t vmin = 0, vmax = 0; // 块内 (min,max),跳过 kBlank;全 blank=(kBlank,kBlank) }; - int brickIndex(int bx, int by, int bz) const { - return (bz * bricksY_ + by) * bricksX_ + bx; + // 一个分辨率级:维度 + 块数 + 逐块索引 + 数据文件名。 + struct Level { + int nx = 0, ny = 0, nz = 0; + int bx = 0, by = 0, bz = 0; // 块数 + std::string dataFile; + std::vector bricks; + }; + + int brickIndexAt(const Level& lv, int bx, int by, int bz) const { + return (bz * lv.by + by) * lv.bx + bx; } + const Level& levelAt(int level) const; + std::vector readBrickFrom(const Level& lv, int bx, int by, + int bz) const; std::string dir_; StoreMeta meta_; - int bricksX_ = 0, bricksY_ = 0, bricksZ_ = 0; - std::vector bricks_; + int bricksX_ = 0, bricksY_ = 0, bricksZ_ = 0; // = level 0 块数(兼容字段) + std::vector bricks_; // = level 0 索引(兼容字段,readBrick(bx,by,bz) 用) + + int levelCount_ = 1; + std::vector levels_; // levels_[0] 即 level 0(与 bricks_ 同源) }; } // namespace geopro::data diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6e4da3d..636c483 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -56,6 +56,8 @@ target_link_libraries(geopro_tests PRIVATE geopro_data) # store 层:ChunkedVolumeStore(GPR 三维体分块压缩落盘 round-trip + 边缘块 + 压缩生效)。 target_sources(geopro_tests PRIVATE data/store/test_chunked_volume_store.cpp) +# store 层:金字塔(多分辨率 LOD + 每块 min/max;不破坏 level0 与老 store 兼容)。 +target_sources(geopro_tests PRIVATE data/store/test_pyramid.cpp) target_link_libraries(geopro_tests PRIVATE geopro_store) # net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package diff --git a/tests/data/store/test_pyramid.cpp b/tests/data/store/test_pyramid.cpp new file mode 100644 index 0000000..981de46 --- /dev/null +++ b/tests/data/store/test_pyramid.cpp @@ -0,0 +1,113 @@ +#include "data/store/ChunkedVolumeStore.hpp" +#include "core/algo/GprVolumeBuilder.hpp" +#include +#include +#include +using namespace geopro::data; +namespace { +geopro::core::BuiltI16 makeRamp(int n) { + geopro::core::BuiltI16 b; + b.vol = geopro::core::ScalarVolumeI16(n, n, n); + for (int k = 0; k < n; k++) + for (int j = 0; j < n; j++) + for (int i = 0; i < n; i++) b.vol.at(i, j, k) = (short)(i); // 沿 x 斜坡 + b.quant = {1.0, 0.0}; + b.origin = {{0, 0, 0}}; + b.spacing = {{1, 1, 1}}; + b.vminPhys = 0; + b.vmaxPhys = n; + return b; +} +} // namespace + +TEST(Pyramid, BuildsHalfResLevelsAndRanges) { + auto dir = (std::filesystem::temp_directory_path() / "gpr_pyr").string(); + std::filesystem::remove_all(dir); + ChunkedVolumeStore::write(dir, makeRamp(64), 32); + ChunkedVolumeStore s(dir); + s.buildPyramid(2); // level 0(64³),1(32³),2(16³) + EXPECT_GE(s.levels(), 3); + int nx, ny, nz; + s.dims(1, nx, ny, nz); + EXPECT_EQ(nx, 32); + EXPECT_EQ(ny, 32); + EXPECT_EQ(nz, 32); + // level0 块0 的 range:x 斜坡 0..31(brick32) → min=0,max=31 + auto r0 = s.brickRange(0, 0, 0, 0); + EXPECT_EQ(r0.first, 0); + EXPECT_EQ(r0.second, 31); + auto blk1 = s.readBrick(1, 0, 0, 0); // level1 块,降采样后非空 + EXPECT_FALSE(blk1.empty()); + std::filesystem::remove_all(dir); +} + +TEST(Pyramid, Level0ReadCompatUnchanged) { + auto dir = (std::filesystem::temp_directory_path() / "gpr_pyr_compat").string(); + std::filesystem::remove_all(dir); + ChunkedVolumeStore::write(dir, makeRamp(64), 32); + ChunkedVolumeStore s(dir); + s.buildPyramid(1); + EXPECT_EQ(s.readBrick(0, 0, 0), s.readBrick(0, 0, 0, 0)); // 兼容重载等价 + std::filesystem::remove_all(dir); +} + +// 降采样语义:level1(0,0,0) 由 level0 每 2×2×2 平均得到。 +// x 斜坡:level1 体素 i 来自 level0 的 (2i, 2i+1) 平均 = round((2i + 2i+1)/2) = 2i(取整向偶或截断, +// 这里两值差 1,平均 2i+0.5,round→2i+1 或 2i 取决实现;仅校验单调与范围)。 +TEST(Pyramid, DownsampledRangeWithinSource) { + auto dir = (std::filesystem::temp_directory_path() / "gpr_pyr_ds").string(); + std::filesystem::remove_all(dir); + ChunkedVolumeStore::write(dir, makeRamp(64), 32); + ChunkedVolumeStore s(dir); + s.buildPyramid(2); + // level1 维度 32³;以 brick32 仍是 1 块(覆盖 i=0..31 → 源 i=0..63)。 + int nx, ny, nz; + s.dims(1, nx, ny, nz); + EXPECT_EQ(nx, 32); + auto r1 = s.brickRange(1, 0, 0, 0); + // 降采样值落在源范围 [0,63] 内,且块覆盖全 x → min≈0, max≈63 附近(round 后)。 + EXPECT_GE(r1.first, 0); + EXPECT_LE(r1.second, 63); + EXPECT_LT(r1.first, r1.second); // 斜坡降采样后仍有跨度 + // level1 块尺寸 = 32³。 + auto blk1 = s.readBrick(1, 0, 0, 0); + EXPECT_EQ(blk1.size(), 32u * 32 * 32); + std::filesystem::remove_all(dir); +} + +// 全 blank 块 → range 记 (kBlank,kBlank),降采样后该区域仍 blank。 +TEST(Pyramid, AllBlankBrickRange) { + auto dir = (std::filesystem::temp_directory_path() / "gpr_pyr_blank").string(); + std::filesystem::remove_all(dir); + geopro::core::BuiltI16 b; + b.vol = geopro::core::ScalarVolumeI16(64, 64, 64); + for (auto& v : b.vol.data()) v = geopro::core::ScalarVolumeI16::kBlank; + b.quant = {1.0, 0.0}; + b.origin = {{0, 0, 0}}; + b.spacing = {{1, 1, 1}}; + b.vminPhys = 0; + b.vmaxPhys = 0; + ChunkedVolumeStore::write(dir, b, 32); + ChunkedVolumeStore s(dir); + s.buildPyramid(2); + auto r0 = s.brickRange(0, 0, 0, 0); + EXPECT_EQ(r0.first, geopro::core::ScalarVolumeI16::kBlank); + EXPECT_EQ(r0.second, geopro::core::ScalarVolumeI16::kBlank); + auto r1 = s.brickRange(1, 0, 0, 0); + EXPECT_EQ(r1.first, geopro::core::ScalarVolumeI16::kBlank); + std::filesystem::remove_all(dir); +} + +// 老 store(未 buildPyramid):levels()==1;brickRange(0,...) 仍可惰性算。 +TEST(Pyramid, LegacyStoreNoPyramidLevelsIsOne) { + auto dir = (std::filesystem::temp_directory_path() / "gpr_pyr_legacy").string(); + std::filesystem::remove_all(dir); + ChunkedVolumeStore::write(dir, makeRamp(64), 32); + ChunkedVolumeStore s(dir); // 未调用 buildPyramid + EXPECT_EQ(s.levels(), 1); + EXPECT_EQ(s.bricksX(), s.bricksX(0)); + auto r0 = s.brickRange(0, 0, 0, 0); // 惰性算 + EXPECT_EQ(r0.first, 0); + EXPECT_EQ(r0.second, 31); + std::filesystem::remove_all(dir); +}