From 1d17b5e20dd864f378d21568813fc63000c5d418 Mon Sep 17 00:00:00 2001 From: obroccolio Date: Sun, 26 Jul 2026 18:32:25 +0800 Subject: [PATCH] Rework layout engine around exact-glyph collision, add tests and docs sync Replace the old bbox/heuristic placement (scale search rounds, large-font capping, stratified sampling, fill-retry ladders) with an area-model font sizing pass feeding a C++ exact-glyph collision engine (centroid-biased spiral + random probing, HD clearance refinement, density/hole optimization). Simplify the frontend advanced-params panel and JobParams type to match the surviving config surface, add a layout-constraints test suite and a repeatable benchmark tool, and bring docs/*.md back in sync with current code (plus new TESTING.md and DEPLOYMENT.md). Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + backend/EfficientWordCloud/docs/usage.md | 37 +- .../efficient_wordcloud/src/ewc_core.cpp | 413 +++++++++-- .../efficient_wordcloud/wordcloud.py | 63 +- backend/core/config.py | 99 +-- backend/core/layout.py | 539 ++++++++------ backend/core/mask.py | 18 +- backend/core/pipeline.py | 676 ++++++++++++------ backend/core/render.py | 190 ++++- backend/core/weights.py | 58 +- backend/requirements.txt | 1 + backend/tests/test_layout_constraints.py | 209 ++++++ backend/tools/benchmark_layout.py | 219 ++++++ docs/ALGORITHM.md | 183 +++-- docs/API.md | 77 +- docs/CANVAS_STUDIO.md | 109 ++- docs/CONFIG.md | 126 +++- docs/DEPLOYMENT.md | 132 ++++ docs/PROJECT_STANDARD.md | 222 ++++-- docs/README.md | 33 +- docs/TESTING.md | 109 +++ frontend/src/components/AdvancedPanel.tsx | 254 +------ frontend/src/pages/TestWorkbench.tsx | 76 +- frontend/src/types.ts | 39 - 24 files changed, 2600 insertions(+), 1283 deletions(-) create mode 100644 backend/tests/test_layout_constraints.py create mode 100644 backend/tools/benchmark_layout.py create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/TESTING.md diff --git a/.gitignore b/.gitignore index a25fd25..7298c82 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ backend/service_projects/ backend/service_fonts/ backend/output/ backend/output_cli_test/ +backend/benchmark_outputs/ backend/ref/ backend/.runtime/ diff --git a/backend/EfficientWordCloud/docs/usage.md b/backend/EfficientWordCloud/docs/usage.md index 5d90f9d..bbfa6ab 100644 --- a/backend/EfficientWordCloud/docs/usage.md +++ b/backend/EfficientWordCloud/docs/usage.md @@ -23,7 +23,8 @@ wc = EfficientWordCloud( height=600, font_path="/path/to/font.ttf", max_words=200, - min_font_size=8, + min_font_size=8, + max_font_size=32, prefer_horizontal=0.9 ) @@ -36,26 +37,18 @@ img.show() - `width` / `height`:画布尺寸。 - `font_path`:字体路径。 - `max_words`:最大词数。 -- `min_font_size`:最小字体。 -- `prefer_horizontal`:水平排版概率。 -- `use_spiral_search`:是否启用中心优先排序搜索。 - -## 4. 并行优化的使用说明 -### 4.1 Python bbox 预取 -- 自动启用,无需额外配置。 -- 内部使用 `ProcessPoolExecutor`,将未来词语的 bbox 计算并行化。 -- 运行 `generate` 时会输出预取/等待日志,便于观察并行效果。 - -### 4.2 C++ 并行搜索 -- 当 `use_spiral_search=True` 时启用。 -- 在 C++ 内部自动进行分块并行搜索,并保持中心优先排序的结果一致性。 +- `min_font_size` / `max_font_size`:本次整批布局可使用的硬字号边界。 +- `prefer_horizontal`:水平排版概率。 +- `relative_scaling`:权重对目标字号的影响比例。 +- `margin`:真实字形之间的最小工作网格间距。 + +## 4. 放置语义 + +- 每个词只使用权重映射得到的目标字号。 +- 放不下时只尝试同字号的另一方向,不会逐词缩字号。 +- 调用者需要检查 `layout_` 的数量;若不完整,应整批调整字号或扩大画布后创建新实例重排。 +- 项目正式流水线使用 C++ `place_glyph_exact()` 做真实字形碰撞;底层兼容类保留矩形搜索 API。 ## 5. 常见问题 -### 5.1 为什么字体缩小时没有并行? -缩小字体后 bbox 依赖当前失败状态,需要同步确认以确保正确性。 - -### 5.2 多进程是否会导致额外内存开销? -是的,但任务仅用于 bbox 预取,且窗口大小有限,避免过度占用。 - -### 5.3 若没有字体文件怎么办? -会回退到 PIL 默认字体,但测量与渲染效果可能不同。 +### 5.1 若没有字体文件怎么办? +会回退到 PIL 默认字体,但测量与渲染效果可能不同。 diff --git a/backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp b/backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp index 0833aa4..ffcbbe3 100644 --- a/backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp +++ b/backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp @@ -22,8 +22,9 @@ #include #include #include -#include -#include +#include +#include +#include // ========================================== // Thread Pool (avoid per-query thread creation) @@ -114,8 +115,13 @@ public: int w; }; - // valid coordinates sorted by distance to center (for sorted/spiral search) - std::vector> valid_coords; + // Original mask-free coordinates. Sorting is lazy because the active + // query_direct/query_near_center paths do not need the O(A log A) order. + std::vector> valid_coords; + bool valid_coords_center_sorted = false; + double free_center_y = 0.0; + double free_center_x = 0.0; + int spiral_cursor = 1; // Lazy update buffers std::vector diff; @@ -133,9 +139,9 @@ public: } void init_from_buffer(unsigned char* raw_mask, int h, int w) { - int center_y = h / 2; - int center_x = w / 2; - valid_coords.reserve(h * w / 2); + valid_coords.reserve(h * w / 2); + uint64_t free_y_sum = 0; + uint64_t free_x_sum = 0; // Initialize canvas from mask ensure_canvas(); @@ -151,28 +157,48 @@ public: if (is_blocked) { canvas[i * width + j] = 1; } - if (!is_blocked) { - valid_coords.push_back({i, j}); - } - } - } - - std::sort(valid_coords.begin(), valid_coords.end(), - [center_y, center_x](const std::pair& a, const std::pair& b) { - long da = (long)(a.first - center_y)*(a.first - center_y) + (long)(a.second - center_x)*(a.second - center_x); - long db = (long)(b.first - center_y)*(b.first - center_y) + (long)(b.second - center_x)*(b.second - center_x); - return da < db; - } - ); + if (!is_blocked) { + valid_coords.push_back({i, j}); + free_y_sum += (uint64_t)i; + free_x_sum += (uint64_t)j; + } + } + } + if (!valid_coords.empty()) { + free_center_y = (double)free_y_sum / (double)valid_coords.size(); + free_center_x = (double)free_x_sum / (double)valid_coords.size(); + } else { + free_center_y = (double)h * 0.5; + free_center_x = (double)w * 0.5; + } + valid_coords_center_sorted = false; + spiral_cursor = 1; std::fill(diff.begin(), diff.end(), 0); recent_rects.clear(); - dirty_count = 0; - } - - void reorder_stratified(int bands) { - std::unique_lock lock(mutex_); - const size_t len = valid_coords.size(); + dirty_count = 0; + } + + void ensure_valid_coords_center_sorted() { + if (valid_coords_center_sorted) return; + const double center_y = free_center_y; + const double center_x = free_center_x; + std::sort(valid_coords.begin(), valid_coords.end(), + [center_y, center_x](const std::pair& a, const std::pair& b) { + double ay = (double)a.first - center_y; + double ax = (double)a.second - center_x; + double by = (double)b.first - center_y; + double bx = (double)b.second - center_x; + return ay * ay + ax * ax < by * by + bx * bx; + } + ); + valid_coords_center_sorted = true; + } + // Legacy coordinate ordering remains available to compatibility callers. + void reorder_stratified(int bands) { + std::unique_lock lock(mutex_); + ensure_valid_coords_center_sorted(); + const size_t len = valid_coords.size(); if (bands <= 1 || len == 0) return; if ((size_t)bands > len) bands = (int)len; @@ -279,8 +305,7 @@ public: std::fill(diff.begin(), diff.end(), 0); recent_rects.clear(); dirty_count = 0; - - for (int i = 0; i < height; ++i) { + for (int i = 0; i < height; ++i) { uint32_t row_sum = 0; for (int j = 0; j < width; ++j) { uint32_t val = (raw_pixels[i * width + j] > 0) ? 1 : 0; @@ -318,10 +343,10 @@ public: } // Rebuild integral from the internal canvas (partial from pos_r, pos_c) - void rebuild_from_canvas(int pos_r, int pos_c) { - if (canvas.empty()) return; - rebuild_from_bitmap_partial(canvas.data(), pos_r, pos_c); - } + void rebuild_from_canvas(int pos_r, int pos_c) { + if (canvas.empty()) return; + rebuild_from_bitmap_partial(canvas.data(), pos_r, pos_c); + } // v4: Partial integral rebuild from position (pos_r, pos_c) downward // Optimized: use row-sum approach (like rebuild_from_bitmap) for the partial region @@ -425,8 +450,9 @@ public: return {-1, -1}; } - DirectResult query_direct(int box_h, int box_w, uint32_t seed) { - // Always flush before scanning + DirectResult query_direct(int box_h, int box_w, uint32_t seed) { + // Apply pending rectangle updates before reading the integral image. + // Exact-glyph placement uses its own canvas-only path. flush(); int max_row = height - box_h; @@ -447,11 +473,12 @@ public: return {true, y, x}; } - // Quick random probe: try a few random positions first - // If the canvas is mostly empty, one of these will hit quickly - // This avoids the full O(H*W) scan for early words - { - int n_probes = std::min(16, (int)total_positions); + // Random probes avoid an O(H*W) scan during early and middle packing. + // Increase the budget as occupancy rises. + { + const double occ_ratio = (double)total_occupied / (double)(width * height); + const int probe_budget = (occ_ratio < 0.15) ? 32 : (occ_ratio < 0.40) ? 96 : 192; + const int n_probes = (int)std::min(probe_budget, total_positions); for (int p = 0; p < n_probes; ++p) { int y = std::uniform_int_distribution(0, max_row)(rng); int x = std::uniform_int_distribution(0, max_col)(rng); @@ -469,27 +496,28 @@ public: } if (nt <= 1) { - // Single-thread: two-pass (count then pick) is faster than - // reservoir sampling because it avoids per-position RNG calls - uint64_t total_valid = 0; - for (int i = 0; i <= max_row; ++i) { - for (int j = 0; j <= max_col; ++j) { - if (get_area_sum_fast(i, j, box_h, box_w) == 0) - ++total_valid; - } - } - if (total_valid == 0) return {false, -1, -1}; - uint64_t target = std::uniform_int_distribution(0, total_valid - 1)(rng); - uint64_t count = 0; + // Single-pass reservoir halves the work of count-then-pick. A + // small LCG avoids invoking mt19937 for every valid position. + uint64_t count = 0; + int best_y = -1, best_x = -1; + uint32_t state = seed ? seed : 1u; for (int i = 0; i <= max_row; ++i) { for (int j = 0; j <= max_col; ++j) { if (get_area_sum_fast(i, j, box_h, box_w) == 0) { - if (count == target) return {true, i, j}; ++count; + // Replace the current result with probability 1/count. + state = state * 1664525u + 1013904223u; + const bool replace = (state % count) == 0; + if (replace) { + best_y = i; + best_x = j; + } } } } - return {false, -1, -1}; + return count == 0 + ? DirectResult{false, -1, -1} + : DirectResult{true, best_y, best_x}; } // Multi-thread path: parallel count then pick @@ -531,10 +559,193 @@ public: } cum += chunk_counts[t]; } - return {false, -1, -1}; - } - - // ========================================================= + return {false, -1, -1}; + } + + // Sample legal positions and prefer the one whose box center is closest + // to the free-mask centroid. This gives the visually important large + // words a stable focal region without paying for a full spiral scan. + DirectResult query_near_center(int box_h, int box_w, uint32_t seed, int probes) { + flush(); + + int max_row = height - box_h; + int max_col = width - box_w; + if (max_row < 0 || max_col < 0) return {false, -1, -1}; + + std::mt19937 rng(seed); + probes = std::max(16, std::min(probes, 1024)); + + int best_y = -1; + int best_x = -1; + double best_score = std::numeric_limits::infinity(); + std::uniform_int_distribution row_dist(0, max_row); + std::uniform_int_distribution col_dist(0, max_col); + std::uniform_real_distribution jitter(0.0, 1e-4); + + // Test the centroid-aligned position first. + int center_y = std::max(0, std::min(max_row, (int)std::lround(free_center_y - box_h * 0.5))); + int center_x = std::max(0, std::min(max_col, (int)std::lround(free_center_x - box_w * 0.5))); + if (get_area_sum_fast(center_y, center_x, box_h, box_w) == 0) { + return {true, center_y, center_x}; + } + + const double norm_y = std::max(1.0, (double)height); + const double norm_x = std::max(1.0, (double)width); + for (int p = 0; p < probes; ++p) { + int y = row_dist(rng); + int x = col_dist(rng); + if (get_area_sum_fast(y, x, box_h, box_w) != 0) continue; + double cy = (double)y + box_h * 0.5; + double cx = (double)x + box_w * 0.5; + double dy = (cy - free_center_y) / norm_y; + double dx = (cx - free_center_x) / norm_x; + double score = dy * dy + dx * dx + jitter(rng); + if (score < best_score) { + best_score = score; + best_y = y; + best_x = x; + } + } + + if (best_y >= 0) return {true, best_y, best_x}; + return query_direct(box_h, box_w, seed ^ 0x9E3779B9u); + } + + inline bool glyph_fits_exact( + const unsigned char* glyph, int glyph_h, int glyph_w, int y, int x + ) const { + for (int row = 0; row < glyph_h; ++row) { + const unsigned char* glyph_row = glyph + row * glyph_w; + const uint8_t* canvas_row = canvas.data() + (y + row) * width + x; + for (int col = 0; col < glyph_w; ++col) { + if (glyph_row[col] > 0 && canvas_row[col] != 0) return false; + } + } + return true; + } + + inline void reserve_glyph_exact( + const unsigned char* glyph, int glyph_h, int glyph_w, int y, int x + ) { + stamp_glyph(glyph, glyph_h, glyph_w, y, x); + } + + // Find and reserve a position using the actual (optionally dilated) glyph + // bitmap. Unlike rectangle queries, transparent corners and gaps between + // strokes may overlap safely. Search and reservation stay in one C++ call, + // so no integral-image rebuild is needed between words. + DirectResult place_glyph_exact( + const unsigned char* collision_glyph, + const unsigned char* stamp_glyph_data, + int glyph_h, + int glyph_w, + uint32_t seed, + int probes, + int placement_mode + ) { + ensure_canvas(); + const int max_row = height - glyph_h; + const int max_col = width - glyph_w; + if (max_row < 0 || max_col < 0) return {false, -1, -1}; + + std::mt19937 rng(seed); + probes = std::max(32, std::min(probes, 2048)); + std::uniform_int_distribution row_dist(0, max_row); + std::uniform_int_distribution col_dist(0, max_col); + + int best_y = -1; + int best_x = -1; + double best_score = std::numeric_limits::infinity(); + + if (placement_mode != 0) { + const int center_y = std::max( + 0, std::min(max_row, (int)std::lround(free_center_y - glyph_h * 0.5)) + ); + const int center_x = std::max( + 0, std::min(max_col, (int)std::lround(free_center_x - glyph_w * 0.5)) + ); + if (glyph_fits_exact(collision_glyph, glyph_h, glyph_w, center_y, center_x)) { + reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, center_y, center_x); + return {true, center_y, center_x}; + } + } + + const double norm_y = std::max(1.0, (double)height); + const double norm_x = std::max(1.0, (double)width); + + // Mode 1 is a persistent centre-out Fermat spiral. Mode 2 skips the + // spiral and selects a random legal candidate biased toward the centre. + if (placement_mode == 1) { + constexpr double golden_angle = 2.39996322972865332; + constexpr double sample_spacing = 1.25; + const int start_step = spiral_cursor; + const int end_step = std::min(200000, start_step + 60000); + int last_y = std::numeric_limits::min(); + int last_x = std::numeric_limits::min(); + for (int step = start_step; step <= end_step; ++step) { + const double radius = sample_spacing * std::sqrt((double)step); + const double theta = golden_angle * (double)step; + const int y = (int)std::lround( + free_center_y + radius * std::sin(theta) - glyph_h * 0.5 + ); + const int x = (int)std::lround( + free_center_x + radius * std::cos(theta) - glyph_w * 0.5 + ); + if (y == last_y && x == last_x) continue; + last_y = y; + last_x = x; + if (y < 0 || x < 0 || y > max_row || x > max_col) continue; + if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue; + reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, y, x); + spiral_cursor = step; + return {true, y, x}; + } + } + + for (int probe = 0; probe < probes; ++probe) { + const int y = row_dist(rng); + const int x = col_dist(rng); + if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue; + if (placement_mode == 0) { + stamp_glyph(stamp_glyph_data, glyph_h, glyph_w, y, x); + return {true, y, x}; + } + const double cy = (double)y + glyph_h * 0.5; + const double cx = (double)x + glyph_w * 0.5; + const double dy = (cy - free_center_y) / norm_y; + const double dx = (cx - free_center_x) / norm_x; + const double score = dy * dy + dx * dx; + if (score < best_score) { + best_score = score; + best_y = y; + best_x = x; + } + } + if (best_y >= 0) { + reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, best_y, best_x); + return {true, best_y, best_x}; + } + + // Dense late-stage fallback. Start at a seeded offset to avoid a + // top-left bias, but visit every possible origin so completeness is + // deterministic whenever a legal position exists. + const int n_rows = max_row + 1; + const int n_cols = max_col + 1; + const int row_start = row_dist(rng); + const int col_start = col_dist(rng); + for (int row_offset = 0; row_offset < n_rows; ++row_offset) { + const int y = (row_start + row_offset) % n_rows; + for (int col_offset = 0; col_offset < n_cols; ++col_offset) { + const int x = (col_start + col_offset) % n_cols; + if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue; + reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, y, x); + return {true, y, x}; + } + } + return {false, -1, -1}; + } + + // ========================================================= // v3: Batch query — process multiple (box_h, box_w) in one call // Returns vector of {found, y, x} for each query // ========================================================= @@ -575,9 +786,13 @@ public: return {false, -1, -1, end_idx}; } - std::pair> find_spot_parallel(int box_h, int box_w, int step) { - { - std::shared_lock lock(mutex_); + std::pair> find_spot_parallel(int box_h, int box_w, int step) { + { + std::unique_lock lock(mutex_); + ensure_valid_coords_center_sorted(); + } + { + std::shared_lock lock(mutex_); if (dirty_count > 0 && dirty_count >= rebuild_interval) { lock.unlock(); std::unique_lock write_lock(mutex_); @@ -679,19 +894,67 @@ static PyObject* Grid_query_reservoir(PyIntegralGrid* self, PyObject* args) { } // v3: Direct pixel-grid scan with parallel counting -static PyObject* Grid_query_direct(PyIntegralGrid* self, PyObject* args) { +static PyObject* Grid_query_direct(PyIntegralGrid* self, PyObject* args) { int box_h, box_w; unsigned int seed = 0; if (!PyArg_ParseTuple(args, "ii|I", &box_h, &box_w, &seed)) return NULL; - Py_BEGIN_ALLOW_THREADS - // query_direct is GIL-free safe (no Python objects touched) - Py_END_ALLOW_THREADS - - auto r = self->grid->query_direct(box_h, box_w, seed); + auto r = self->grid->query_direct(box_h, box_w, seed); if (r.found) return Py_BuildValue("ii", r.y, r.x); - Py_RETURN_NONE; -} + Py_RETURN_NONE; +} + +static PyObject* Grid_query_near_center(PyIntegralGrid* self, PyObject* args) { + int box_h, box_w; + unsigned int seed = 0; + int probes = 160; + if (!PyArg_ParseTuple(args, "ii|Ii", &box_h, &box_w, &seed, &probes)) return NULL; + + auto r = self->grid->query_near_center(box_h, box_w, seed, probes); + if (r.found) return Py_BuildValue("ii", r.y, r.x); + Py_RETURN_NONE; +} + +static PyObject* Grid_place_glyph_exact(PyIntegralGrid* self, PyObject* args) { + PyObject* collision_obj; + PyObject* stamp_obj; + int glyph_h, glyph_w; + unsigned int seed = 0; + int probes = 256; + int placement_mode = 0; + if (!PyArg_ParseTuple( + args, "OOiiIii", &collision_obj, &stamp_obj, &glyph_h, &glyph_w, &seed, &probes, &placement_mode + )) return NULL; + + Py_buffer collision_view; + Py_buffer stamp_view; + if (PyObject_GetBuffer(collision_obj, &collision_view, PyBUF_SIMPLE) < 0) return NULL; + if (PyObject_GetBuffer(stamp_obj, &stamp_view, PyBUF_SIMPLE) < 0) { + PyBuffer_Release(&collision_view); + return NULL; + } + const Py_ssize_t required = (Py_ssize_t)glyph_h * (Py_ssize_t)glyph_w; + if (glyph_h <= 0 || glyph_w <= 0 || collision_view.len < required || stamp_view.len < required) { + PyBuffer_Release(&collision_view); + PyBuffer_Release(&stamp_view); + PyErr_SetString(PyExc_ValueError, "glyph buffer is smaller than glyph_h * glyph_w"); + return NULL; + } + + auto result = self->grid->place_glyph_exact( + (const unsigned char*)collision_view.buf, + (const unsigned char*)stamp_view.buf, + glyph_h, + glyph_w, + seed, + probes, + placement_mode + ); + PyBuffer_Release(&collision_view); + PyBuffer_Release(&stamp_view); + if (result.found) return Py_BuildValue("ii", result.y, result.x); + Py_RETURN_NONE; +} // v3: Batch query — process multiple placements in one C++ call // Input: list of (box_h, box_w, seed) tuples @@ -768,16 +1031,16 @@ static PyObject* Grid_rebuild_from_bitmap_partial(PyIntegralGrid* self, PyObject Py_RETURN_NONE; } -// v4: Stamp glyph bitmap onto C++ canvas and rebuild integral -static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) { +// Stamp glyph bitmap onto C++ canvas and rebuild the affected integral region. +static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) { PyObject* glyph_obj; int gh, gw, pos_r, pos_c; if (!PyArg_ParseTuple(args, "Oiiii", &glyph_obj, &gh, &gw, &pos_r, &pos_c)) return NULL; Py_buffer view; if (PyObject_GetBuffer(glyph_obj, &view, PyBUF_SIMPLE) < 0) return NULL; - self->grid->stamp_glyph((const unsigned char*)view.buf, gh, gw, pos_r, pos_c); - self->grid->rebuild_from_canvas(pos_r, pos_c); + self->grid->stamp_glyph((const unsigned char*)view.buf, gh, gw, pos_r, pos_c); + self->grid->rebuild_from_canvas(pos_r, pos_c); PyBuffer_Release(&view); Py_RETURN_NONE; @@ -786,8 +1049,10 @@ static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) { static PyMethodDef Grid_methods[] = { {"reorder_stratified", (PyCFunction)Grid_reorder_stratified, METH_VARARGS, "Reorder valid coords with stratified interleaving."}, {"query_sorted", (PyCFunction)Grid_query_sorted, METH_VARARGS, "Find position using sorted coordinate list (center-out, parallel)."}, - {"query_reservoir", (PyCFunction)Grid_query_reservoir, METH_VARARGS, "Find position using parallel direct-scan reservoir sampling."}, - {"query_direct", (PyCFunction)Grid_query_direct, METH_VARARGS, "Direct pixel-grid scan with parallel reservoir sampling."}, + {"query_reservoir", (PyCFunction)Grid_query_reservoir, METH_VARARGS, "Find position using parallel direct-scan reservoir sampling."}, + {"query_direct", (PyCFunction)Grid_query_direct, METH_VARARGS, "Direct pixel-grid scan with parallel reservoir sampling."}, + {"query_near_center", (PyCFunction)Grid_query_near_center, METH_VARARGS, "Sample legal positions and prefer the free-mask centroid."}, + {"place_glyph_exact", (PyCFunction)Grid_place_glyph_exact, METH_VARARGS, "Place and reserve an exact glyph bitmap without changing its size."}, {"batch_query", (PyCFunction)Grid_batch_query, METH_VARARGS, "Batch placement: list of (bh,bw,seed) -> list of (y,x)|None."}, {"update", (PyCFunction)Grid_update, METH_VARARGS, "Update grid with placed rectangle."}, {"flush", (PyCFunction)Grid_flush, METH_NOARGS, "Force rebuild integral image."}, diff --git a/backend/EfficientWordCloud/efficient_wordcloud/wordcloud.py b/backend/EfficientWordCloud/efficient_wordcloud/wordcloud.py index 368232d..c97aece 100644 --- a/backend/EfficientWordCloud/efficient_wordcloud/wordcloud.py +++ b/backend/EfficientWordCloud/efficient_wordcloud/wordcloud.py @@ -101,8 +101,6 @@ class EfficientWordCloud: Probability a word is placed horizontally (0–1). mode : str PIL image mode ('RGB', 'RGBA', …). - use_spiral_search : bool - Use center-out sorted search (True) or reservoir sampling (False). scale : float Scaling factor between layout computation and final rendering. ``scale=2`` means the output image is 2× the canvas size in each @@ -142,8 +140,6 @@ class EfficientWordCloud: How much word frequency (vs rank) influences font size. 0 = rank only, 1 = fully frequency-driven. When *repeat* is True, defaults to 0. - font_step : int - Step size when reducing font size to find a fit. """ def __init__(self, @@ -156,16 +152,14 @@ class EfficientWordCloud: background_color="black", prefer_horizontal=0.9, mode="RGB", - use_spiral_search=True, - scale=1, + scale=1, contour_width=0, contour_color="black", margin=2, color_func=None, colormap=None, random_state=None, - relative_scaling="auto", - font_step=1, + relative_scaling="auto", repeat=False, stopwords=None, regexp=None, @@ -185,7 +179,6 @@ class EfficientWordCloud: self.background_color = background_color self.prefer_horizontal = prefer_horizontal self.mode = mode - self.use_spiral_search = use_spiral_search self.scale = scale self.contour_width = contour_width self.contour_color = contour_color @@ -197,7 +190,6 @@ class EfficientWordCloud: else: self.relative_scaling = relative_scaling self.margin = margin - self.font_step = font_step self.stopwords = stopwords if stopwords is not None else STOPWORDS self.regexp = regexp self.collocations = collocations @@ -319,10 +311,9 @@ class EfficientWordCloud: def _query(qh, qw): return self.grid.query_direct(qh, qw, rs.randint(0, 2**31)) - # v4: Ref-like linear step-down placement with bitmap occupancy - # After each word placement, stamp glyph bitmap into C++ canvas - # and rebuild integral for pixel-accurate collision detection. - # No PIL image drawn during placement — to_image() renders later. + # Each word is tried at exactly its weight-derived target size. A + # failed word may change orientation, but never receives a private + # fallback size. Whole-cloud scaling belongs to the caller. # Dummy draw for textbbox measurement _measure_img = Image.new("L", (1, 1)) @@ -344,36 +335,26 @@ class EfficientWordCloud: else: orientation = Image.ROTATE_90 - tried_other_orientation = False - - while True: - if font_size < self.min_font_size: - break - - font = _get_font(font_size) - transposed = ImageFont.TransposedFont(font, orientation=orientation) - bbox = _measure_draw.textbbox((0, 0), word, font=transposed) - tw = bbox[2] - bbox[0] - th = bbox[3] - bbox[1] + pos = None + orientations = [orientation] + if self.prefer_horizontal < 1: + orientations.append(Image.ROTATE_90 if orientation is None else None) + for candidate_orientation in orientations: + font = _get_font(font_size) + transposed = ImageFont.TransposedFont(font, orientation=candidate_orientation) + bbox = _measure_draw.textbbox((0, 0), word, font=transposed) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] qh = th + self.margin qw = tw + self.margin - pos = _query(qh, qw) - if pos is not None: - break - - # No position found — try alternate orientation, then reduce size - if not tried_other_orientation and self.prefer_horizontal < 1: - orientation = Image.ROTATE_90 if orientation is None else None - tried_other_orientation = True - else: - font_size -= self.font_step - orientation = None - tried_other_orientation = False - - if font_size < self.min_font_size: - # Canvas full — no more words can fit - break + pos = _query(qh, qw) + if pos is not None: + orientation = candidate_orientation + break + + if pos is None: + continue y, x = pos # Adjust position for margin (like ref: x,y += margin // 2) diff --git a/backend/core/config.py b/backend/core/config.py index 2b9f5e0..737a9f5 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -31,8 +31,6 @@ MODE = "IMAGE" # --- Image Mode --- MASK_IMAGE_PATH = "7887.png" IMAGE_CANVAS_MODE = "WIDTH" -EXPAND_FOR_SPIRAL = True # 放大画布使螺旋填充覆盖边角 -EXPAND_RATIO = 2.5 # 更大倍率确保覆盖边缘 FILL_CORNERS = False CORNER_FILL_RATIO = 0.15 @@ -42,11 +40,12 @@ MASK_FONT_PATH = str(PROJECT_DEFAULT_FONT) MASK_FONT_SIZE = 3000 # --- 自动画幅与清晰度 --- -AUTO_EXPAND_CANVAS = True -BASE_HD_WIDTH = 8000 +# 默认 4k 级画布:打印/激光足够清晰,比 8k 渲染快约 4× +BASE_HD_WIDTH = 4000 BASE_HD_HEIGHT = 4000 -MIN_READABLE_HEIGHT_PX = 25 -WORK_SCALE = 0.25 +MIN_READABLE_HEIGHT_PX = 22 +# 运算网格缩放:0.18 在速度/质量之间更均衡 +WORK_SCALE = 0.18 # --- 阴阳刻 --- FILL_ON = "BLACK" @@ -68,53 +67,17 @@ FONT_FALLBACK_PATHS = ( # --- 填充策略 --- N_REPETITIONS = 1 -TARGET_FILL_RATIO = 0.0 # 关闭填充率检测 +# 面积模型目标填充率:中文实心笔画像素占比约 0.35–0.55。 +# 略偏保守以保证 scale=1.0 首次就能放满,减少多轮重试。 +TARGET_FILL_RATIO = 0.45 SIZE_RATIO = 2.0 -PACKING_EFFICIENCY = 0.85 - -# --- 分层采样(边缘覆盖) --- -ENABLE_STRATIFIED_SAMPLING = True -STRATIFIED_BANDS = 3 # Mix Center, Middle, and Edge - -# --- 填充率补偿(低填充时略增字号) --- -GROW_FONT_ON_LOW_FILL = False # 关闭 -GROW_FONT_STEP = 1.05 - -# --- 填充率检测 --- -MIN_ACCEPT_FILL_RATIO = 0.75 -FILL_RETRY_RELAX_LARGE_CAP = True -FILL_RETRY_MAX_ROUNDS = 3 -FILL_RETRY_MAX_SCALE = 1.5 +PACKING_EFFICIENCY = 0.9 # --- 智能字号搜索 --- -REQUIRE_ALL_WORDS = True MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE) USER_MIN_FONT_SIZE = None USER_MAX_FONT_SIZE = None MIN_FONT_FLOOR = 2 -FONT_SCALE_MIN = 0.5 -FONT_SCALE_MAX = 1.2 -SCALE_SEARCH_STEPS = 7 -SCALE_SEARCH_ROUNDS = 5 -SCALE_DECAY = 0.85 -SCALE_FLOOR = 0.25 -AUTO_SHRINK_ROUNDS = 4 -LOG_WEIGHT_RATIO = 0.72 -RANK_WEIGHT_RATIO = 0.28 - -# --- 大字号智能降级 --- -# 开启后,如果填不满,会自动尝试减少大字号的数量,给小词腾空间 -ENABLE_SMART_LARGE_FONT_REDUCTION = True -LIMIT_LARGE_FONTS = True -LARGE_FONT_LIMIT_RATIO = 0.2 # 初始允许 20% 的词是大字 -LARGE_FONT_THRESHOLD_RATIO = 0.8 # 超过最大字号 80% 算大字 -LARGE_FONT_CAP_RATIO = 0.6 # 被限制时,缩小到阈值的 60% - -# --- 点阵补偿 --- -ENABLE_DOT_MATRIX = False -DOT_SPACING = 15 -DOT_RADIUS = 0 -DOT_SAFETY_BUFFER = 12 # --- 画布重试 --- CANVAS_RETRY_MAX_ROUNDS = 1 @@ -138,51 +101,36 @@ LIGHT_COLOR_PALETTE = ( FONT_COLOR = "#000000" # 统一字体颜色,None 则使用调色板 # --- 输出 --- -MAX_ATTEMPTS = 5 OUTPUT_DIR = "." OUTPUT_PREFIX = "" OUTPUT_PNG = "Efficient_Result_HD_AutoResize.png" OUTPUT_SVG = "Efficient_Result_HD_AutoResize.svg" DB_PATH = "wordcloud_hd.db" METRICS_FILE = "metrics.json" -SAVE_DEBUG_IMAGES = True +SAVE_DEBUG_IMAGES = False DEBUG_OUTPUT_DIR = "output" # --- 可复现性 --- SEED = None -LAYOUT_ORDER_MODE_SORTED = "SORTED" -LAYOUT_ORDER_MODE_INTERLEAVED_RANDOM = "INTERLEAVED_RANDOM" -VALID_LAYOUT_ORDER_MODES = ( - LAYOUT_ORDER_MODE_SORTED, - LAYOUT_ORDER_MODE_INTERLEAVED_RANDOM, -) -LAYOUT_ORDER_MODE = LAYOUT_ORDER_MODE_SORTED LAYOUT_SEED = None KNOWN_CONFIG_KEYS = { - 'MODE', 'MASK_IMAGE_PATH', 'IMAGE_CANVAS_MODE', 'EXPAND_FOR_SPIRAL', 'EXPAND_RATIO', 'FILL_CORNERS', - 'CORNER_FILL_RATIO', 'MASK_TEXT', 'MASK_FONT_PATH', 'MASK_FONT_SIZE', 'AUTO_EXPAND_CANVAS', + 'MODE', 'MASK_IMAGE_PATH', 'IMAGE_CANVAS_MODE', 'FILL_CORNERS', + 'CORNER_FILL_RATIO', 'MASK_TEXT', 'MASK_FONT_PATH', 'MASK_FONT_SIZE', 'BASE_HD_WIDTH', 'BASE_HD_HEIGHT', 'MIN_READABLE_HEIGHT_PX', 'WORK_SCALE', 'FILL_ON', 'EXCEL_PATH', 'DATA_COL_INDEX', 'WEIGHT_COL_INDEX', 'WEIGHT_COL_NAME', 'REMOVE_DUPLICATES', 'ENABLE_STROKE_WEIGHTS', 'WC_FONT_PATH', 'FONT_FALLBACK_PATHS', - 'N_REPETITIONS', 'TARGET_FILL_RATIO', 'SIZE_RATIO', 'PACKING_EFFICIENCY', 'ENABLE_STRATIFIED_SAMPLING', - 'STRATIFIED_BANDS', 'GROW_FONT_ON_LOW_FILL', 'GROW_FONT_STEP', 'MIN_ACCEPT_FILL_RATIO', - 'FILL_RETRY_RELAX_LARGE_CAP', 'FILL_RETRY_MAX_ROUNDS', 'FILL_RETRY_MAX_SCALE', 'REQUIRE_ALL_WORDS', - 'MIN_FONT_SIZE', 'USER_MIN_FONT_SIZE', 'USER_MAX_FONT_SIZE', 'MIN_FONT_FLOOR', 'FONT_SCALE_MIN', - 'FONT_SCALE_MAX', 'SCALE_SEARCH_STEPS', 'SCALE_SEARCH_ROUNDS', 'SCALE_DECAY', 'SCALE_FLOOR', - 'LOG_WEIGHT_RATIO', 'RANK_WEIGHT_RATIO', - 'AUTO_SHRINK_ROUNDS', 'ENABLE_SMART_LARGE_FONT_REDUCTION', 'LIMIT_LARGE_FONTS', - 'LARGE_FONT_LIMIT_RATIO', 'LARGE_FONT_THRESHOLD_RATIO', 'LARGE_FONT_CAP_RATIO', 'ENABLE_DOT_MATRIX', - 'DOT_SPACING', 'DOT_RADIUS', 'DOT_SAFETY_BUFFER', 'CANVAS_RETRY_MAX_ROUNDS', 'CANVAS_RETRY_GROWTH', - 'DARK_COLOR_PALETTE', 'LIGHT_COLOR_PALETTE', 'FONT_COLOR', 'MAX_ATTEMPTS', 'OUTPUT_DIR', 'OUTPUT_PREFIX', + 'N_REPETITIONS', 'TARGET_FILL_RATIO', 'SIZE_RATIO', 'PACKING_EFFICIENCY', + 'USER_MIN_FONT_SIZE', 'USER_MAX_FONT_SIZE', + 'CANVAS_RETRY_MAX_ROUNDS', 'CANVAS_RETRY_GROWTH', + 'DARK_COLOR_PALETTE', 'LIGHT_COLOR_PALETTE', 'FONT_COLOR', 'OUTPUT_DIR', 'OUTPUT_PREFIX', 'OUTPUT_PNG', 'OUTPUT_SVG', 'DB_PATH', 'METRICS_FILE', 'SAVE_DEBUG_IMAGES', 'DEBUG_OUTPUT_DIR', 'SEED', - 'LAYOUT_ORDER_MODE', 'LAYOUT_SEED' + 'LAYOUT_SEED' } CONFIG_ALIASES = { 'seed': 'SEED', - 'layout_order_mode': 'LAYOUT_ORDER_MODE', 'layout_seed': 'LAYOUT_SEED', 'excel_path': 'EXCEL_PATH', 'mask_image_path': 'MASK_IMAGE_PATH', @@ -207,16 +155,12 @@ CRITICAL_TYPE_CHECKS = { 'FONT_FALLBACK_PATHS': (list, tuple), 'USER_MIN_FONT_SIZE': (int, float, type(None)), 'USER_MAX_FONT_SIZE': (int, float, type(None)), - 'MAX_ATTEMPTS': int, 'SAVE_DEBUG_IMAGES': bool, 'REMOVE_DUPLICATES': bool, 'ENABLE_STROKE_WEIGHTS': bool, 'CANVAS_RETRY_MAX_ROUNDS': int, 'CANVAS_RETRY_GROWTH': (int, float), - 'LOG_WEIGHT_RATIO': (int, float), - 'RANK_WEIGHT_RATIO': (int, float), 'SEED': (int, type(None)), - 'LAYOUT_ORDER_MODE': str, 'LAYOUT_SEED': (int, type(None)), } @@ -227,7 +171,6 @@ def parse_args(): parser = argparse.ArgumentParser(description="Efficient WordCloud generator") parser.add_argument("--config", type=str, help="JSON 配置文件路径") parser.add_argument("--seed", type=int, help="随机种子(可复现)") - parser.add_argument("--layout-order-mode", type=lambda s: s.upper(), choices=VALID_LAYOUT_ORDER_MODES, help="布局顺序模式") parser.add_argument("--layout-seed", type=int, help="布局顺序随机种子") parser.add_argument("--excel-path", type=str, help="Excel 输入路径") parser.add_argument("--mask-image-path", type=str, help="掩膜图片路径(IMAGE 模式)") @@ -298,7 +241,6 @@ def apply_json_config(config_path): def apply_cli_overrides(args): mapping = { 'seed': 'SEED', - 'layout_order_mode': 'LAYOUT_ORDER_MODE', 'layout_seed': 'LAYOUT_SEED', 'excel_path': 'EXCEL_PATH', 'mask_image_path': 'MASK_IMAGE_PATH', @@ -342,7 +284,7 @@ def _resolve_font_path(configured_path, fallback_paths, *, role): def finalize_runtime_config(): global EXCEL_PATH, MASK_IMAGE_PATH, MASK_FONT_PATH, WC_FONT_PATH global OUTPUT_DIR, OUTPUT_PNG, OUTPUT_SVG, DB_PATH, METRICS_FILE, DEBUG_OUTPUT_DIR, MIN_FONT_SIZE - global LAYOUT_ORDER_MODE, LAYOUT_SEED + global LAYOUT_SEED # 运行时派生字段 MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE) @@ -350,11 +292,6 @@ def finalize_runtime_config(): if LAYOUT_SEED is None: LAYOUT_SEED = SEED - LAYOUT_ORDER_MODE = str(LAYOUT_ORDER_MODE).upper() - if LAYOUT_ORDER_MODE not in VALID_LAYOUT_ORDER_MODES: - print(f"错误: 不支持的 LAYOUT_ORDER_MODE: {LAYOUT_ORDER_MODE}") - sys.exit(1) - EXCEL_PATH = str(_resolve_path(EXCEL_PATH)) MASK_IMAGE_PATH = str(_resolve_path(MASK_IMAGE_PATH)) diff --git a/backend/core/layout.py b/backend/core/layout.py index 0fa40e4..9addd03 100644 --- a/backend/core/layout.py +++ b/backend/core/layout.py @@ -1,16 +1,159 @@ import math import random +from pathlib import Path import numpy as np -from PIL import Image, ImageDraw, ImageFont +from PIL import Image, ImageDraw, ImageFilter, ImageFont from matplotlib.path import Path as MplPath -from matplotlib.textpath import TextPath from matplotlib.transforms import Affine2D from . import config from .ewc import EfficientWordCloud from .fonts import get_cached_font, get_font_properties +# ── Fast SVG path cache (fontTools outlines, unscaled per-char) ────────────── +# font_path -> (glyph_set, cmap, units_per_em) +_FT_FONT_CACHE = {} +# (font_path, char) -> (svg_path_d_in_font_units, advance_width) +_FT_CHAR_PATH_CACHE = {} +# (font_path, word, size, orient) -> (path_d_scaled, tx0, ty0) +_SVG_SHAPE_CACHE = {} + + +def _load_ft_font(font_path): + cached = _FT_FONT_CACHE.get(font_path) + if cached is not None: + return cached + from fontTools.ttLib import TTFont + + # .ttc collections: try face 0 first + try: + tt = TTFont(font_path, fontNumber=0) + except TypeError: + tt = TTFont(font_path) + glyph_set = tt.getGlyphSet() + cmap = tt.getBestCmap() or {} + units = tt["head"].unitsPerEm + cached = (tt, glyph_set, cmap, units) + _FT_FONT_CACHE[font_path] = cached + return cached + + + + +def build_svg_text_path_cached(word, size, x, y, font_path, orient): + """Return (path_d, tx, ty, None). Geometry is cached for identical glyphs. + + Path is in y-up font space (same as matplotlib TextPath). Caller applies + translate(tx, ty) scale(1, -1) to place it on the canvas. + """ + key = (font_path, word, int(size), bool(orient)) + cached = _SVG_SHAPE_CACHE.get(key) + if cached is None: + try: + cached = _build_shape_fonttools(word, size, font_path, orient) + except Exception: + cached = _build_shape_matplotlib(word, size, font_path, orient) + _SVG_SHAPE_CACHE[key] = cached + if len(_SVG_SHAPE_CACHE) > 20000: + for i, k in enumerate(list(_SVG_SHAPE_CACHE.keys())): + if i % 2 == 0: + _SVG_SHAPE_CACHE.pop(k, None) + path_d, tx0, ty0 = cached + return path_d, tx0 + x, ty0 + y, None + + +def _build_shape_fonttools(word, size, font_path, orient): + from fontTools.pens.svgPathPen import SVGPathPen + from fontTools.pens.transformPen import TransformPen + from fontTools.misc.transform import Transform + + _tt, glyph_set, cmap, units = _load_ft_font(font_path) + scale = float(size) / float(units) + pen = SVGPathPen(glyph_set) + cursor = 0.0 + for ch in word: + gname = cmap.get(ord(ch)) + if not gname or gname not in glyph_set: + continue + glyph = glyph_set[gname] + if orient: + # Horizontal layout then rotate -90° around origin: + # point (px, py) in string space -> after scale: (s*px, s*py) + # rotate -90: (s*py, -s*px). Compose with glyph origin at cursor: + # glyph local (gx,gy) -> (scale*gx + cursor, scale*gy) + # -> rotate -90: (scale*gy, -(scale*gx + cursor)) = (scale*gy, -scale*gx - cursor) + # matrix: x' = 0*gx + scale*gy + 0; y' = -scale*gx + 0*gy - cursor + # Transform(xx, xy, yx, yy, dx, dy): x' = xx*x + xy*y + dx; y' = yx*x + yy*y + dy + # xx=0, xy=scale, yx=-scale, yy=0, dx=0, dy=-cursor + tp = TransformPen(pen, Transform(0, -scale, scale, 0, 0, -cursor)) + else: + tp = TransformPen(pen, Transform(scale, 0, 0, scale, cursor, 0)) + glyph.draw(tp) + cursor += float(glyph.width) * scale + + path_d = pen.getCommands() + if not path_d: + return "", 0.0, 0.0 + + xmin, ymin, xmax, ymax = _path_bbox(path_d) + # Offsets placing glyph top-left at (0,0) under translate(tx,ty) scale(1,-1) + tx0 = -xmin + ty0 = ymax + return path_d, tx0, ty0 + + +def _path_bbox(path_d): + import re + nums = [float(n) for n in re.findall(r"[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d)] + # This is approximate (includes arc radii etc.) but good enough for placement offsets + # Better: parse properly. For font outlines, commands are mostly M/L/Q/C/Z with coords. + xs, ys = [], [] + tokens = re.findall(r"[A-Za-z]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d) + i = 0 + while i < len(tokens): + t = tokens[i] + if t.isalpha(): + cmd = t + i += 1 + if cmd in "Zz": + continue + if cmd in "Hh": + while i < len(tokens) and not tokens[i].isalpha(): + xs.append(float(tokens[i])); i += 1 + elif cmd in "Vv": + while i < len(tokens) and not tokens[i].isalpha(): + ys.append(float(tokens[i])); i += 1 + elif cmd in "Aa": + while i + 6 < len(tokens) and not tokens[i].isalpha(): + xs.append(float(tokens[i + 5])); ys.append(float(tokens[i + 6])); i += 7 + else: + while i + 1 < len(tokens) and not tokens[i].isalpha(): + xs.append(float(tokens[i])); ys.append(float(tokens[i + 1])); i += 2 + else: + i += 1 + if not xs or not ys: + return 0.0, 0.0, 0.0, 0.0 + return min(xs), min(ys), max(xs), max(ys) + + +def _build_shape_matplotlib(word, size, font_path, orient): + from matplotlib.textpath import TextPath + + path = TextPath((0, 0), word, prop=get_font_properties(font_path, size), size=size) + if orient: + path = path.transformed(Affine2D().rotate_deg(-90)) + bbox = path.get_extents() + tx0 = -bbox.xmin + ty0 = bbox.ymax + path_d = mpl_path_to_svg_d(path) + return path_d, tx0, ty0 + + +def build_svg_text_path(word, size, x, y, font_path, orient): + path_d, tx, ty, _ = build_svg_text_path_cached(word, size, x, y, font_path, orient) + return path_d, tx, ty, None + def normalize_relative_scores(values): if not values: @@ -18,7 +161,10 @@ def normalize_relative_scores(values): v_min = min(values) v_max = max(values) if math.isclose(v_min, v_max): - return [1.0 for _ in values] + # Equal weights should produce a neutral, equal hierarchy. Returning + # 1.0 made every word request the maximum size and later words were + # arbitrarily shrunk by placement order. + return [0.5 for _ in values] scale = v_max - v_min return [(value - v_min) / scale for value in values] @@ -35,28 +181,17 @@ def build_log_rank_scores(freq_list, *, per_word=False): word_weights[word] = f unique_weights = sorted(set(word_weights.values()), reverse=True) if len(unique_weights) <= 1: - word_scores = {w: 1.0 for w in word_weights} + word_scores = {w: 0.5 for w in word_weights} else: log_vals = [math.log1p(w) for w in unique_weights] normed = normalize_relative_scores(log_vals) weight_to_score = dict(zip(unique_weights, normed)) word_scores = {w: weight_to_score[weight] for w, weight in word_weights.items()} - return [word_scores.get(w, 1.0) for w, _ in freq_list] + return [word_scores.get(w, 0.5) for w, _ in freq_list] safe_freqs = [max(float(freq), 1e-6) for _word, freq in freq_list] log_scores = normalize_relative_scores([math.log1p(freq) for freq in safe_freqs]) - rank_scores = [1.0 - (idx / max(1, len(freq_list) - 1)) for idx in range(len(freq_list))] - - total_ratio = config.LOG_WEIGHT_RATIO + config.RANK_WEIGHT_RATIO - if total_ratio <= 0: - return log_scores - - log_ratio = config.LOG_WEIGHT_RATIO / total_ratio - rank_ratio = config.RANK_WEIGHT_RATIO / total_ratio - return [ - max(0.0, min(1.0, log_score * log_ratio + rank_score * rank_ratio)) - for log_score, rank_score in zip(log_scores, rank_scores) - ] + return log_scores def pick_palette_color(relative_score): @@ -69,76 +204,34 @@ def pick_palette_color(relative_score): return palette[idx] -def _build_layout_sequence(sorted_freq, max_words, layout_order_mode, layout_seed): +def _build_layout_sequence(sorted_freq, max_words, layout_seed): if max_words <= 0 or not sorted_freq: return [] - expanded_freq = list(sorted_freq) - if len(expanded_freq) < max_words: - base_words = expanded_freq[:] - if not base_words: - return [] - while len(expanded_freq) < max_words: - for item in base_words: - if len(expanded_freq) >= max_words: - break - expanded_freq.append(item) - - expanded_freq = expanded_freq[:max_words] - if layout_order_mode == config.LAYOUT_ORDER_MODE_SORTED or len(expanded_freq) <= 1: - return expanded_freq - - band_count = min(3, len(expanded_freq)) - band_size = math.ceil(len(expanded_freq) / band_count) - bands = [] rng = random.Random(layout_seed) - for band_idx in range(band_count): - start = band_idx * band_size - end = min(len(expanded_freq), start + band_size) - band = expanded_freq[start:end] - rng.shuffle(band) - if band: - bands.append(band) - - interleave_pattern = [0, 1, 0, 2] - band_positions = [0] * len(bands) + base_words = list(sorted_freq) sequence = [] - - while len(sequence) < len(expanded_freq): - appended = False - for pattern_idx in interleave_pattern: - if pattern_idx >= len(bands): - continue - pos = band_positions[pattern_idx] - if pos >= len(bands[pattern_idx]): - continue - sequence.append(bands[pattern_idx][pos]) - band_positions[pattern_idx] += 1 - appended = True - if len(sequence) >= len(expanded_freq): - break - if appended: - continue - for band_idx, band in enumerate(bands): - pos = band_positions[band_idx] - if pos < len(band): - sequence.append(band[pos]) - band_positions[band_idx] += 1 - appended = True - if len(sequence) >= len(expanded_freq): - break - if not appended: - break - + while len(sequence) < max_words: + round_items = [] + start = 0 + while start < len(base_words): + end = start + 1 + weight = float(base_words[start][1]) + while end < len(base_words) and math.isclose(float(base_words[end][1]), weight): + end += 1 + # Start every equal-weight group from a canonical order before + # shuffling. A fixed seed must therefore give the same layout + # regardless of the row order in the uploaded workbook. + group = sorted(base_words[start:end], key=lambda item: str(item[0])) + rng.shuffle(group) + round_items.extend(group) + start = end + remaining = max_words - len(sequence) + sequence.extend(round_items[:remaining]) return sequence class OptimizedEfficientWordCloud(EfficientWordCloud): - def __init__(self, *args, large_font_ratio=config.LARGE_FONT_LIMIT_RATIO, size_scale=1.0, **kwargs): - super().__init__(*args, **kwargs) - self.large_font_ratio = large_font_ratio - self.size_scale = size_scale - def generate_from_frequencies(self, frequencies): if isinstance(frequencies, dict): freq_list = list(frequencies.items()) @@ -147,12 +240,12 @@ class OptimizedEfficientWordCloud(EfficientWordCloud): else: raise ValueError("frequencies 必须是字典或 (word, freq) 列表") - sorted_freq = sorted(freq_list, key=lambda x: x[1], reverse=True) + sorted_freq = sorted(freq_list, key=lambda item: (-float(item[1]), str(item[0]))) + layout_seed = getattr(self, "layout_seed", config.LAYOUT_SEED) layout_sequence = _build_layout_sequence( sorted_freq, self.max_words, - config.LAYOUT_ORDER_MODE, - config.LAYOUT_SEED, + layout_seed, ) if not layout_sequence: @@ -164,116 +257,120 @@ class OptimizedEfficientWordCloud(EfficientWordCloud): for (w, _f), s in zip(freq_list, per_word_scores): if w not in word_to_score or s > word_to_score[w]: word_to_score[w] = s - score_by_index = [word_to_score.get(w, 1.0) for w, _ in layout_sequence] - effective_max_font = max(self.min_font_size + 1, int(self.max_font_size * self.size_scale)) - large_threshold = int(effective_max_font * config.LARGE_FONT_THRESHOLD_RATIO) if config.LIMIT_LARGE_FONTS else None - large_limit = int(self.max_words * self.large_font_ratio) if config.LIMIT_LARGE_FONTS else None - large_count = 0 - - rotation_flags = [np.random.random() > self.prefer_horizontal for _ in layout_sequence] + score_by_index = [word_to_score.get(w, 0.5) for w, _ in layout_sequence] + seed = layout_seed if layout_seed is not None else config.SEED + rng = np.random.default_rng(seed) + rotation_flags = [bool(rng.random() > self.prefer_horizontal) for _ in layout_sequence] # Dummy draw for textbbox measurement (no actual PIL image needed during placement) _measure_img = Image.new("L", (1, 1)) _measure_draw = ImageDraw.Draw(_measure_img) - base_span = max(1, self.max_font_size - self.min_font_size) + # (word, size, rotate) -> exact collision and drawing geometry. + # The C++ canvas stores the same tight glyph bitmap that PIL renders; + # bbox bearings are carried separately so HD output cannot drift away + # from the collision map. + glyph_cache = {} + + def measure_and_mask(word, size, rotate): + key = (word, size, rotate) + cached = glyph_cache.get(key) + if cached is not None: + return cached + font = get_cached_font(self.font_path, size) + orientation = Image.ROTATE_90 if rotate else None + transposed = ImageFont.TransposedFont(font, orientation=orientation) if orientation else font + bbox = _measure_draw.textbbox((0, 0), word, font=transposed) + glyph_mask = transposed.getmask(word, mode="L") + gw, gh = glyph_mask.size + if gw <= 0 or gh <= 0: + return None + # np.array avoids the intermediate bytes() copy that + # frombuffer(bytes(...)) would incur. + glyph_arr = np.array(glyph_mask, dtype=np.uint8).reshape(gh, gw) + pad = max(0, int(self.margin)) + if pad: + padded = np.zeros((gh + 2 * pad, gw + 2 * pad), dtype=np.uint8) + padded[pad:pad + gh, pad:pad + gw] = glyph_arr + # Reserve a true inter-glyph margin while still allowing + # transparent corners and stroke gaps to interlock. + collision_arr = np.asarray( + Image.fromarray(padded).filter(ImageFilter.MaxFilter(2 * pad + 1)), + dtype=np.uint8, + ) + stamp_arr = padded + else: + collision_arr = glyph_arr + stamp_arr = glyph_arr + result = ( + collision_arr.shape[0], + collision_arr.shape[1], + collision_arr, + stamp_arr, + orientation, + int(bbox[0]), + int(bbox[1]), + pad, + ) + glyph_cache[key] = result + return result + + min_font = max(config.MIN_FONT_FLOOR, int(self.min_font_size)) + max_font = max(min_font, int(self.max_font_size)) + base_span = max_font - min_font target_font_sizes = [] for score in score_by_index: - raw_size = self.min_font_size + base_span * score - f_size = max(config.MIN_FONT_FLOOR, int(round(raw_size * self.size_scale))) + raw_size = min_font + base_span * score + f_size = min(max_font, max(min_font, int(round(raw_size)))) target_font_sizes.append(f_size) - gap_fill_list = [] # 收集未成功放置的词,用于第二轮填充 - + random_large_prefix = max(1, int(math.ceil(len(layout_sequence) * 0.08))) for idx, (word, _freq) in enumerate(layout_sequence): font_size = target_font_sizes[idx] - if config.LIMIT_LARGE_FONTS and large_threshold is not None and large_limit is not None: - if font_size >= large_threshold and large_count >= large_limit: - font_size = max(self.min_font_size, int(large_threshold * config.LARGE_FONT_CAP_RATIO)) - - current_size = font_size - min_attempt_size = max(self.min_font_size, int(current_size * 0.4)) placed = False + rotate = rotation_flags[idx] + for try_rotate in (rotate, not rotate): + measured = measure_and_mask(word, font_size, try_rotate) + if measured is None: + continue + ( + query_h, + query_w, + collision_arr, + stamp_arr, + orientation, + bbox_left, + bbox_top, + pad, + ) = measured + query_seed = int(rng.integers(0, 2**31)) + large_word = idx < random_large_prefix or score_by_index[idx] >= 0.80 + placement_mode = 2 if large_word else 1 + pos = self.grid.place_glyph_exact( + collision_arr, + stamp_arr, + query_h, + query_w, + query_seed, + 256, + placement_mode, + ) - while current_size >= min_attempt_size: - orientation = None - rotate = rotation_flags[idx] - if rotate: - orientation = Image.ROTATE_90 + if pos is None: + continue + y, x = pos + ink_y = y + pad + ink_x = x + pad + draw_y = ink_y - bbox_top + draw_x = ink_x - bbox_left + color = pick_palette_color(score_by_index[idx]) + self.layout_.append((word, font_size, (draw_y, draw_x), orientation, color)) + placed = True + break - font = get_cached_font(self.font_path, current_size) - if orientation: - transposed = ImageFont.TransposedFont(font, orientation=orientation) - else: - transposed = font - bbox = _measure_draw.textbbox((0, 0), word, font=transposed) - w_text = bbox[2] - bbox[0] - h_text = bbox[3] - bbox[1] - - query_w = w_text + self.margin - query_h = h_text + self.margin - - pos = self.grid.query_direct(query_h, query_w, np.random.randint(0, 2**31)) - - if pos is not None: - y, x = pos - draw_y = y + self.margin // 2 - draw_x = x + self.margin // 2 - - # Stamp glyph bitmap into C++ canvas for pixel-accurate collision - font = get_cached_font(self.font_path, current_size) - if orientation: - transposed = ImageFont.TransposedFont(font, orientation=orientation) - else: - transposed = font - glyph_mask = transposed.getmask(word, mode="L") - gw, gh = glyph_mask.size - glyph_arr = np.frombuffer(bytes(glyph_mask), dtype=np.uint8).reshape(gh, gw) - self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x) - - color = pick_palette_color(score_by_index[idx]) - self.layout_.append((word, current_size, (draw_y, draw_x), orientation, color)) - - if config.LIMIT_LARGE_FONTS and large_threshold is not None and current_size >= large_threshold: - large_count += 1 - placed = True - break - - current_size -= 2 - - if not placed: - gap_fill_list.append((word, score_by_index[idx])) - - # ── Gap-filling pass: 用更小的字号填充剩余空隙 ────────────── - if gap_fill_list: - gap_font_size = max(config.MIN_FONT_FLOOR, int(self.min_font_size * 0.8)) - if gap_font_size >= config.MIN_FONT_FLOOR: - placed_gap = 0 - for word, score in gap_fill_list: - font = get_cached_font(self.font_path, gap_font_size) - bbox = _measure_draw.textbbox((0, 0), word, font=font) - w_text = bbox[2] - bbox[0] - h_text = bbox[3] - bbox[1] - query_w = w_text + self.margin - query_h = h_text + self.margin - - pos = self.grid.query_direct(query_h, query_w, np.random.randint(0, 2**31)) - if pos is not None: - y, x = pos - draw_y = y + self.margin // 2 - draw_x = x + self.margin // 2 - - glyph_mask = font.getmask(word, mode="L") - gw, gh = glyph_mask.size - glyph_arr = np.frombuffer(bytes(glyph_mask), dtype=np.uint8).reshape(gh, gw) - self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x) - - color = pick_palette_color(score) - self.layout_.append((word, gap_font_size, (draw_y, draw_x), None, color)) - placed_gap += 1 - - if placed_gap > 0: - config._warn(f"Gap-filling: 用小字号 {gap_font_size} 额外放置了 {placed_gap}/{len(gap_fill_list)} 个词") + # Deliberately do not shrink an individual word. The pipeline + # treats a short layout as a failed batch and retries every word + # at one uniformly scaled size range. return self @@ -287,6 +384,57 @@ class OptimizedEfficientWordCloud(EfficientWordCloud): draw.text((x, y), word, font=font, fill=color) return img + def _iter_svg_paths(self): + """Build SVG path data once per layout entry, with glyph-shape cache.""" + # Cache by (word, size, orient): path geometry is identical; only translate differs. + shape_cache = {} + for word, size, (y, x), orient, color in self.layout_: + key = (word, int(size), bool(orient)) + cached = shape_cache.get(key) + if cached is None: + try: + path_d, origin_tx, origin_ty, bbox = build_svg_text_path_cached( + word, size, 0, 0, self.font_path, orient + ) + except Exception as exc: + config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}") + continue + # origin_tx/ty place the glyph so its top-left is at (0,0) + shape_cache[key] = (path_d, origin_tx, origin_ty) + cached = shape_cache[key] + path_d, origin_tx, origin_ty = cached + # Shift from (0,0) origin to actual layout position + tx = origin_tx + x + ty = origin_ty + y + yield path_d, tx, ty, color + + def export_svgs(self, fill_filename, stroke_color="#000000", stroke_width=1.0): + """Write fill + stroke SVG in one pass (path geometry built once).""" + stroke_filename = str( + Path(fill_filename).with_name(Path(fill_filename).stem + "_stroke" + Path(fill_filename).suffix) + ) + background = self.background_color + header = ( + f'\n' + ) + with open(fill_filename, "w", encoding="utf-8") as ff, open(stroke_filename, "w", encoding="utf-8") as sf: + ff.write(header) + sf.write(header) + ff.write(f'\n') + sf.write('\n') + for path_d, tx, ty, color in self._iter_svg_paths(): + transform = f'translate({tx:.3f} {ty:.3f}) scale(1 -1)' + ff.write(f'\n') + sf.write( + f'\n' + ) + ff.write("\n") + sf.write("\n") + return stroke_filename + def to_svg(self, filename): background = self.background_color with open(filename, "w", encoding="utf-8") as f: @@ -295,15 +443,8 @@ class OptimizedEfficientWordCloud(EfficientWordCloud): f'xmlns="http://www.w3.org/2000/svg">\n' ) f.write(f'\n') - - for word, size, (y, x), orient, color in self.layout_: - try: - path, tx, ty, _ = build_svg_text_path(word, size, x, y, self.font_path, orient) - except Exception as exc: - config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}") - continue - f.write(f'\n') - + for path_d, tx, ty, color in self._iter_svg_paths(): + f.write(f'\n') f.write("\n") def to_svg_stroke(self, filename, stroke_color="#000000", stroke_width=1.0): @@ -313,20 +454,13 @@ class OptimizedEfficientWordCloud(EfficientWordCloud): f'\n' ) - f.write(f'\n') - - for word, size, (y, x), orient, _color in self.layout_: - try: - path, tx, ty, _ = build_svg_text_path(word, size, x, y, self.font_path, orient) - except Exception as exc: - config._warn(f"SVG stroke path 导出失败,跳过词条: {word}, error={exc}") - continue + f.write('\n') + for path_d, tx, ty, _color in self._iter_svg_paths(): f.write( - f'\n' ) - f.write("\n") def to_svg_dotfill(self, filename, dot_spacing=10, dot_radius=2, dot_color="#000000"): @@ -471,18 +605,6 @@ class OptimizedEfficientWordCloud(EfficientWordCloud): f.write(' \n') -def build_svg_text_path(word, size, x, y, font_path, orient): - path = TextPath((0, 0), word, prop=get_font_properties(font_path, size), size=size) - if orient: - path = path.transformed(Affine2D().rotate_deg(-90)) - bbox = path.get_extents() - tx = x - bbox.xmin - ty = y + bbox.ymax - # 返回变换后的 Path(已定位到画布坐标)以及 SVG 用的偏移量 - transformed = path.transformed(Affine2D().scale(1, -1).translate(tx, ty)) - return mpl_path_to_svg_d(path), tx, ty, transformed - - def mpl_path_to_svg_d(path): parts = [] for vertices, code in path.iter_segments(): @@ -557,4 +679,3 @@ def render_path_occupancy(layout_data, canvas_shape, font_path): # 最终蒙版:文字笔画=1,外部和字内空洞=0 return (occ_raw & (~outside).astype(np.uint8)).astype(np.uint8) - diff --git a/backend/core/mask.py b/backend/core/mask.py index 475443e..92465c8 100644 --- a/backend/core/mask.py +++ b/backend/core/mask.py @@ -40,15 +40,12 @@ def normalize_mask_for_fill(mask): def calculate_dynamic_dimensions(base_w, base_h, num_words, avg_len=3, mask_stats=None): - if not config.AUTO_EXPAND_CANVAS: - return base_w, base_h - - effective_fill = config.TARGET_FILL_RATIO if config.TARGET_FILL_RATIO > 0 else max(config.MIN_ACCEPT_FILL_RATIO, 0.82) + effective_fill = config.TARGET_FILL_RATIO if config.TARGET_FILL_RATIO > 0 else 0.45 mask_fill_ratio = 0.5 if mask_stats is not None: mask_fill_ratio = max(0.05, mask_stats["free_ratio"]) - area_per_word = (config.MIN_READABLE_HEIGHT_PX ** 2) * max(1.0, avg_len) * 1.2 + area_per_word = (config.MIN_READABLE_HEIGHT_PX ** 2) * max(1.0, avg_len) * 1.05 required_fillable_area = (num_words * area_per_word * max(1, config.N_REPETITIONS)) / max(effective_fill, 0.1) required_canvas_area = required_fillable_area / mask_fill_ratio current_area = base_w * base_h @@ -56,8 +53,15 @@ def calculate_dynamic_dimensions(base_w, base_h, num_words, avg_len=3, mask_stat scale_factor = (required_canvas_area / current_area) ** 0.5 new_w = int(base_w * scale_factor) new_h = int(base_h * scale_factor) - new_w = ((new_w // 100) + 1) * 100 - new_h = ((new_h // 100) + 1) * 100 + # Cap HD canvas to keep render/export under the speed budget. + # 6000px 边长对激光/打印足够,再大收益很小但 SVG/PNG 成本陡增。 + max_edge = 6000 + if max(new_w, new_h) > max_edge: + s = max_edge / max(new_w, new_h) + new_w = int(new_w * s) + new_h = int(new_h * s) + new_w = max(100, ((new_w // 100) + 1) * 100) + new_h = max(100, ((new_h // 100) + 1) * 100) print(f"[Auto-Size] 扩展画布: {base_w}x{base_h} -> {new_w}x{new_h}") return new_w, new_h return base_w, base_h diff --git a/backend/core/pipeline.py b/backend/core/pipeline.py index 5d51eae..8e57882 100644 --- a/backend/core/pipeline.py +++ b/backend/core/pipeline.py @@ -1,4 +1,5 @@ import logging +import math import os import sqlite3 import sys @@ -13,13 +14,32 @@ from . import config from .fonts import get_cached_font from .layout import OptimizedEfficientWordCloud from .mask import analyze_mask, apply_safe_padding, calculate_dynamic_dimensions, prepare_mask -from .render import apply_dot_matrix, compute_fill_ratio_fast -from .weights import calculate_font_by_area_model, extract_weights_from_df, get_stroke_complexity_batch +from .render import ( + compute_fill_ratio_fast, + count_layout_overlap_pixels, + largest_empty_square_size, + refine_layout_with_hd_clearance, + scale_layout_for_hd, +) +from .weights import ( + calculate_font_by_area_model, + extract_weights_from_df, + get_stroke_complexity_batch, + merge_weight_maps, +) log = logging.getLogger("core.pipeline") -def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real_hd_w, real_hd_h): +def run_generation_pass( + names, + frequencies_data, + name_weights_map, + mask_hd, + real_hd_w, + real_hd_h, + _collision_margin=0, +): log.info("[run_generation_pass] 开始") log.info(" 输入: %d 词 | HD尺寸: %dx%d", len(names), real_hd_w, real_hd_h) @@ -42,44 +62,73 @@ def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real print(f"最终输出: {real_hd_w}x{real_hd_h} | 运算网格: {w_small}x{h_small}") - current_min_font = max(config.MIN_FONT_FLOOR, int(config.MIN_READABLE_HEIGHT_PX * config.WORK_SCALE)) + readable_min_font = max( + config.MIN_FONT_FLOOR, + int(math.ceil(config.MIN_READABLE_HEIGHT_PX * config.WORK_SCALE)), + ) total_target = len(names) * config.N_REPETITIONS - current_packing_eff = config.PACKING_EFFICIENCY - grow_step = config.GROW_FONT_STEP if config.GROW_FONT_ON_LOW_FILL else 1.0 final_wc = None final_scale = 1.0 - base_min_font = current_min_font - base_max_font = current_min_font + 1 + base_layout_seed = config.LAYOUT_SEED if config.LAYOUT_SEED is not None else config.SEED + final_layout_seed = base_layout_seed + base_min_font, base_max_font = calculate_font_by_area_model( + mask_small, + names, + name_weights_map, + config.TARGET_FILL_RATIO, + config.SIZE_RATIO, + config.PACKING_EFFICIENCY, + config.N_REPETITIONS, + ) - def compute_font_bounds(packing_eff): - min_font, max_font = calculate_font_by_area_model( - mask_small, names, name_weights_map, config.TARGET_FILL_RATIO, config.SIZE_RATIO, packing_eff, config.N_REPETITIONS - ) - min_font = max(current_min_font, min_font) + explicit_min_font = config.USER_MIN_FONT_SIZE is not None + hard_min_font = readable_min_font + if explicit_min_font: + hard_min_font = max(config.MIN_FONT_FLOOR, int(round(config.USER_MIN_FONT_SIZE))) - if config.USER_MIN_FONT_SIZE is not None: - user_min = int(config.USER_MIN_FONT_SIZE) - if user_min < config.MIN_FONT_FLOOR: - config._warn(f"USER_MIN_FONT_SIZE={config.USER_MIN_FONT_SIZE} 过小,提升到 {config.MIN_FONT_FLOOR}") - user_min = config.MIN_FONT_FLOOR - min_font = user_min + hard_max_font = None + if config.USER_MAX_FONT_SIZE is not None: + hard_max_font = max(config.MIN_FONT_FLOOR, int(round(config.USER_MAX_FONT_SIZE))) + if explicit_min_font and hard_max_font < hard_min_font: + raise ValueError( + f"字号硬约束冲突: USER_MAX_FONT_SIZE={hard_max_font} " + f"小于最小允许字号 {hard_min_font}" + ) + if not explicit_min_font: + # A user-specified maximum outranks the automatic readability + # suggestion. It remains an exact ceiling rather than causing an + # artificial conflict with a value the user never requested. + hard_min_font = min(hard_min_font, hard_max_font) - if config.USER_MAX_FONT_SIZE is not None: - user_max = int(config.USER_MAX_FONT_SIZE) - if user_max < config.MIN_FONT_FLOOR: - config._warn(f"USER_MAX_FONT_SIZE={config.USER_MAX_FONT_SIZE} 过小,提升到 {config.MIN_FONT_FLOOR}") - user_max = config.MIN_FONT_FLOOR - max_font = user_max + equal_size_mode = math.isclose(float(config.SIZE_RATIO), 1.0, rel_tol=0.0, abs_tol=1e-9) + base_min_font = max(hard_min_font, int(base_min_font)) + base_max_font = max(base_min_font, int(base_max_font)) + if hard_max_font is not None: + base_min_font = min(base_min_font, hard_max_font) + base_max_font = min(base_max_font, hard_max_font) + if equal_size_mode: + # Keep one scalar throughout every retry. This is what makes + # SIZE_RATIO=1 exact even after automatic batch scaling. + equal_font = min(base_min_font, base_max_font) + base_min_font = equal_font + base_max_font = equal_font - if max_font <= min_font: - config._warn(f"字号区间无效: min={min_font}, max={max_font},自动修正 max=min+1") - max_font = min_font + 1 + def scaled_bounds(scale): + if equal_size_mode: + size = max(hard_min_font, int(round(base_min_font * scale))) + if hard_max_font is not None: + size = min(size, hard_max_font) + return size, size - return min_font, max_font + min_font = max(hard_min_font, int(round(base_min_font * scale))) + max_font = max(min_font, int(round(base_max_font * scale))) + if hard_max_font is not None: + min_font = min(min_font, hard_max_font) + max_font = min(max_font, hard_max_font) + return min_font, max(min_font, max_font) - def try_place(min_font, max_font, large_ratio=config.LARGE_FONT_LIMIT_RATIO, size_scale=1.0): - min_font = max(config.MIN_FONT_FLOOR, int(min_font)) - max_font = max(min_font + 1, int(max_font)) + def try_place(scale, layout_seed=base_layout_seed): + min_font, max_font = scaled_bounds(scale) wc = OptimizedEfficientWordCloud( width=w_small, height=h_small, @@ -89,108 +138,77 @@ def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real min_font_size=min_font, max_font_size=max_font, background_color=config.get_output_background(), - use_spiral_search=True, - large_font_ratio=large_ratio, - size_scale=size_scale, + prefer_horizontal=0.82, + # HD clearance is applied after scaling. Keeping the coarse-grid + # margin at zero avoids turning 1 HD pixel into 5-6 output pixels. + margin=_collision_margin, ) - if config.ENABLE_STRATIFIED_SAMPLING: - wc.grid.reorder_stratified(config.STRATIFIED_BANDS) + wc.layout_seed = layout_seed wc.generate_from_frequencies(frequencies_data) - return wc, len(wc.layout_) + return wc, len(wc.layout_), min_font, max_font print(f"--- 5. 启动生成 (目标: {total_target} 词) ---") log.info("--- 5. 启动生成 ---") log.info(" 目标词数: %d (names=%d * N_REPETITIONS=%d)", total_target, len(names), config.N_REPETITIONS) - log.info(" 当前最小字号: %d, 效率: %.2f", current_min_font, current_packing_eff) - for attempt in range(1, config.MAX_ATTEMPTS + 1): - base_min_font, base_max_font = compute_font_bounds(current_packing_eff) - print(f"尝试 #{attempt}: 基准字号 [{base_min_font}, {base_max_font}], 效率: {current_packing_eff:.2f}") - log.info("[尝试 #%d] 字号区间: [%d, %d], 效率: %.2f, 大字率: %.2f", - attempt, base_min_font, base_max_font, current_packing_eff, config.LARGE_FONT_LIMIT_RATIO) + log.info( + " 基准字号: [%d, %d], 硬边界: [%d, %s], 等字号=%s", + base_min_font, + base_max_font, + hard_min_font, + hard_max_font if hard_max_font is not None else "∞", + equal_size_mode, + ) - best_wc = None - best_count = 0 - best_scale = config.FONT_SCALE_MIN - best_success_wc = None - best_success_scale = None - current_large_ratio = config.LARGE_FONT_LIMIT_RATIO - low_scale = max(config.SCALE_FLOOR, config.FONT_SCALE_MIN) - high_scale = max(low_scale + 0.01, config.FONT_SCALE_MAX) - - for _ in range(max(1, config.SCALE_SEARCH_ROUNDS)): - mid_scale = ((low_scale + high_scale) / 2) * grow_step - wc, placed_count = try_place(base_min_font, base_max_font, current_large_ratio, mid_scale) - print(f" 尺度 {mid_scale:.3f} (字号 {base_min_font}-{base_max_font}) -> 成功: {placed_count}/{total_target}") - log.info(" 尺度 %.3f -> 放置 %d/%d", mid_scale, placed_count, total_target) - - if placed_count > best_count: - best_wc = wc - best_count = placed_count - best_scale = mid_scale - - if config.REQUIRE_ALL_WORDS: - if placed_count >= total_target: - best_success_wc = wc - best_success_scale = mid_scale - low_scale = max(low_scale, mid_scale / max(grow_step, 1e-6)) - else: - high_scale = min(high_scale, mid_scale / max(grow_step, 1e-6)) - else: - if placed_count >= best_count: - low_scale = max(low_scale, mid_scale / max(grow_step, 1e-6)) - else: - high_scale = min(high_scale, mid_scale / max(grow_step, 1e-6)) - - if abs(high_scale - low_scale) < 0.02: - break - - if best_success_wc is not None: - final_wc = best_success_wc - final_scale = best_success_scale if best_success_scale is not None else best_scale + # Every attempt is a fresh, whole-cloud layout. No word may silently + # receive a smaller fallback size. Usually the area model succeeds on + # attempt one; two adaptive retries cover fragmentation-heavy masks. + best_wc = None + best_count = 0 + best_scale = 1.0 + scale = 1.0 + tried_layouts = set() + failed_scales = [] + for attempt in range(1, 4): + bounds = scaled_bounds(scale) + layout_key = (bounds, base_layout_seed) + if layout_key in tried_layouts: + break + tried_layouts.add(layout_key) + wc, placed_count, min_font, max_font = try_place(scale) + print( + f" 整批布局 #{attempt}: scale={scale:.3f}, " + f"字号=[{min_font}, {max_font}] -> {placed_count}/{total_target}" + ) + log.info( + " 整批布局 #%d scale=%.3f 字号=[%d,%d] -> %d/%d", + attempt, + scale, + min_font, + max_font, + placed_count, + total_target, + ) + if placed_count > best_count: + best_wc = wc + best_count = placed_count + best_scale = scale + if placed_count >= total_target: + final_wc = wc + final_scale = scale + final_layout_seed = base_layout_seed break - if best_wc is not None: - shrink_min = current_min_font - for _ in range(config.AUTO_SHRINK_ROUNDS): - shrink_min = max(config.MIN_FONT_FLOOR, int(shrink_min * 0.8)) - if shrink_min >= base_min_font: - continue + failed_scales.append(scale) - print(f" [降级:缩小字号] {shrink_min}...") - wc, placed_count = try_place(shrink_min, base_max_font, current_large_ratio, best_scale) - if placed_count > best_count: - best_wc = wc - best_count = placed_count - best_scale = best_scale - if config.REQUIRE_ALL_WORDS and placed_count >= total_target: - final_wc = wc - final_scale = best_scale - break - if final_wc is not None: - break + placed_ratio = placed_count / max(1, total_target) + # Required box area is roughly proportional to size². The extra + # safety margin absorbs fragmentation without wasting a binary search. + shrink = 0.62 if placed_ratio <= 0 else min(0.92, max(0.58, math.sqrt(placed_ratio) * 0.92)) + scale *= shrink - if config.ENABLE_SMART_LARGE_FONT_REDUCTION: - print(" [降级:牺牲大字] 仍然放不下,尝试减少大字数量...") - strict_large_ratio = 0.05 - retry_min = shrink_min if 'shrink_min' in locals() else current_min_font - wc, placed_count = try_place(retry_min, base_max_font, strict_large_ratio, best_scale) - print(f" [严格模式] 大字率 {strict_large_ratio} -> 成功: {placed_count}/{total_target}") - - if placed_count > best_count: - best_wc = wc - best_count = placed_count - if config.REQUIRE_ALL_WORDS and placed_count >= total_target: - final_wc = wc - final_scale = best_scale - break - - if attempt == config.MAX_ATTEMPTS: - final_wc = best_wc - final_scale = best_scale - break - - shrink_ratio = (best_count / total_target) if best_count else 0.5 - current_packing_eff *= min(0.95, max(0.5, shrink_ratio)) + if final_wc is None: + final_wc = best_wc + final_scale = best_scale if final_wc is None: return { @@ -203,61 +221,263 @@ def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real "base_max_font": base_max_font, "mask_small": mask_small, "size_scale": final_scale, + "layout_seed": final_layout_seed, + "collision_margin": _collision_margin, + "hd_overlap_pixels": 0, + "hd_layout": [], + "hd_clearance": None, } fill_ratio, occ_fast = compute_fill_ratio_fast(final_wc.layout_, mask_small, config.WC_FONT_PATH) + largest_empty_square = largest_empty_square_size(occ_fast, mask_small) + + def has_character_sized_hole(wc, hole_size): + if not equal_size_mode or wc is None or not wc.layout_: + return False + font_size = int(wc.layout_[0][1]) + return hole_size >= max(2, int(math.ceil(font_size * 1.25))) + + complete_candidates = [] + if len(final_wc.layout_) >= total_target: + complete_candidates.append( + (final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast) + ) print(f"填充率: {fill_ratio:.3f}") - log.info("[填充率] 初始填充率: %.4f (最低要求: %.4f)", fill_ratio, config.MIN_ACCEPT_FILL_RATIO) + log.info("[填充率] 初始填充率: %.4f", fill_ratio) log.info(" layout_ 词数: %d", len(final_wc.layout_)) if config.SAVE_DEBUG_IMAGES and occ_fast is not None: debug_dir = Path(config.DEBUG_OUTPUT_DIR) debug_dir.mkdir(parents=True, exist_ok=True) Image.fromarray((occ_fast * 255).astype(np.uint8)).save(str(debug_dir / "occ_fast.png")) - if fill_ratio < config.MIN_ACCEPT_FILL_RATIO: - print(f"[填充率不足] {fill_ratio:.3f} < {config.MIN_ACCEPT_FILL_RATIO:.2f},启动二分放大字号重试...") - low_scale = max(final_scale, 1.0) - high_scale = max(low_scale, config.FILL_RETRY_MAX_SCALE) - retry_round = 0 - best_wc = final_wc - best_fill = fill_ratio + # Probe larger whole-cloud layouts and keep the largest complete one. If + # an earlier batch was too large, search the discrete interval between the + # complete and failed scales instead of accepting an over-aggressive + # shrink. Every probe rebuilds the entire cloud with one shared scale. + if ( + len(final_wc.layout_) >= total_target + and fill_ratio > 0 + and ( + fill_ratio < config.TARGET_FILL_RATIO * 0.98 + or has_character_sized_hole(final_wc, largest_empty_square) + ) + ): + upper_scale = min( + (failed for failed in failed_scales if failed > final_scale), + default=None, + ) + for density_attempt in range(1, 5): + if upper_scale is not None: + grow_scale = (final_scale + upper_scale) / 2.0 + elif equal_size_mode: + current_size, _ = scaled_bounds(final_scale) + grow_scale = (current_size + 1) / max(1, base_min_font) + else: + desired_growth = min( + 1.12, + math.sqrt(config.TARGET_FILL_RATIO / fill_ratio) * 0.98, + ) + if desired_growth <= 1.005: + break + grow_scale = final_scale * desired_growth - while retry_round < config.FILL_RETRY_MAX_ROUNDS: - mid_scale = (low_scale + high_scale) / 2 - retry_large_ratio = 1.0 if config.FILL_RETRY_RELAX_LARGE_CAP else config.LARGE_FONT_LIMIT_RATIO + grow_bounds = scaled_bounds(grow_scale) + if grow_bounds == scaled_bounds(final_scale): + break + grow_min, grow_max = grow_bounds + layout_key = (grow_bounds, base_layout_seed) + attempted_layout = False + if layout_key not in tried_layouts: + tried_layouts.add(layout_key) + wc, placed_count, _, _ = try_place(grow_scale) + attempted_layout = True + print( + f" 密度优化 #{density_attempt}: scale={grow_scale:.3f}, " + f"字号=[{grow_min}, {grow_max}] -> {placed_count}/{total_target}" + ) + else: + wc, placed_count = None, -1 - wc, _placed_count = try_place(base_min_font, base_max_font, retry_large_ratio, mid_scale) - new_fill, occ_fast = compute_fill_ratio_fast(wc.layout_, mask_small, config.WC_FONT_PATH) - print(f" [二分重试#{retry_round + 1}] scale={mid_scale:.3f} 填充率={new_fill:.3f}") - - if new_fill > best_fill: - best_fill = new_fill - best_wc = wc - - if new_fill >= config.MIN_ACCEPT_FILL_RATIO: - final_wc = wc - final_scale = mid_scale - fill_ratio = new_fill - if config.SAVE_DEBUG_IMAGES and occ_fast is not None: - debug_dir = Path(config.DEBUG_OUTPUT_DIR) - debug_dir.mkdir(parents=True, exist_ok=True) - Image.fromarray((occ_fast * 255).astype(np.uint8)).save( - str(debug_dir / f"occ_fast_retry_{retry_round + 1}.png") + selected_seed = base_layout_seed + if placed_count < total_target and base_layout_seed is not None: + # The reference library samples a fresh legal position order. + # One bounded whole-cloud re-layout recovers dense solutions + # without per-word shrinking or an unbounded random search. + candidate_seed = (int(base_layout_seed) * 3 + 3) % (2**31 - 1) + retry_key = (grow_bounds, candidate_seed) + if retry_key not in tried_layouts: + tried_layouts.add(retry_key) + retry_wc, retry_count, _, _ = try_place(grow_scale, candidate_seed) + attempted_layout = True + print( + f" 密度优化 #{density_attempt} 整批重排: " + f"seed={candidate_seed}, 字号=[{grow_min}, {grow_max}] -> " + f"{retry_count}/{total_target}" ) + if retry_count > placed_count: + wc = retry_wc + placed_count = retry_count + selected_seed = candidate_seed + if not attempted_layout: + break + if placed_count < total_target: + upper_scale = grow_scale + continue + + new_fill, new_occ = compute_fill_ratio_fast(wc.layout_, mask_small, config.WC_FONT_PATH) + if new_fill <= fill_ratio: + break + final_wc = wc + final_scale = grow_scale + final_layout_seed = selected_seed + fill_ratio = new_fill + occ_fast = new_occ + largest_empty_square = largest_empty_square_size(occ_fast, mask_small) + complete_candidates.append( + (final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast) + ) + if ( + fill_ratio >= config.TARGET_FILL_RATIO * 0.98 + and not has_character_sized_hole(final_wc, largest_empty_square) + ): break - if new_fill > fill_ratio: - low_scale = mid_scale - else: - high_scale = mid_scale + # At the largest complete equal-size tier, compare a small deterministic + # set of whole-cloud reorderings and retain the one with the smallest + # character-scale void. This never changes an individual font size. + if ( + equal_size_mode + and len(final_wc.layout_) >= total_target + and has_character_sized_hole(final_wc, largest_empty_square) + and base_layout_seed is not None + ): + if total_target < 100: + hole_attempt_budget = 3 + elif total_target <= 300: + hole_attempt_budget = 2 + else: + hole_attempt_budget = 1 + modulus = 2**31 - 1 + for hole_attempt in range(1, hole_attempt_budget + 1): + candidate_seed = ( + int(base_layout_seed) ^ ((0x9E3779B9 * hole_attempt) & 0x7FFFFFFF) + ) % modulus + bounds = scaled_bounds(final_scale) + layout_key = (bounds, candidate_seed) + if layout_key in tried_layouts: + continue + tried_layouts.add(layout_key) + candidate_wc, placed_count, _, _ = try_place(final_scale, candidate_seed) + if placed_count < total_target: + continue + candidate_fill, candidate_occ = compute_fill_ratio_fast( + candidate_wc.layout_, mask_small, config.WC_FONT_PATH + ) + candidate_hole = largest_empty_square_size(candidate_occ, mask_small) + print( + f" 空洞优化 #{hole_attempt}: seed={candidate_seed}, " + f"最大空白={candidate_hole}px -> {placed_count}/{total_target}" + ) + if candidate_hole >= largest_empty_square: + continue + final_wc = candidate_wc + final_layout_seed = candidate_seed + fill_ratio = candidate_fill + occ_fast = candidate_occ + largest_empty_square = candidate_hole + complete_candidates.append( + (final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast) + ) - retry_round += 1 + hd_layout = None + hd_clearance = None + raw_hd_layout = [] + for candidate_index, candidate in enumerate(reversed(complete_candidates), start=1): + candidate_wc, candidate_scale, candidate_seed, candidate_fill, candidate_occ = candidate + raw_hd_layout = scale_layout_for_hd(candidate_wc.layout_, config.WORK_SCALE) + refined_layout = None + clearance_stats = None + clearance_modes = (1, 0) + if total_target >= 100 and candidate_index > 1: + clearance_modes = (0,) + for clearance_px in clearance_modes: + priority_layout = raw_hd_layout + priority_restarts = 0 + max_priority_attempts = 2 if clearance_px == 0 or total_target < 100 else 1 + for priority_attempt in range(max_priority_attempts): + refined_layout, clearance_stats = refine_layout_with_hd_clearance( + priority_layout, + mask_hd, + config.WC_FONT_PATH, + clearance=clearance_px, + ) + if refined_layout is not None: + break + failed_word = clearance_stats["failed_word"] + failed_index = next( + (index for index, item in enumerate(priority_layout) if item[0] == failed_word), + None, + ) + if ( + failed_index is None + or failed_index == 0 + or priority_attempt + 1 >= max_priority_attempts + ): + break + failed_item = priority_layout[failed_index] + priority_layout = [failed_item, *priority_layout[:failed_index], *priority_layout[failed_index + 1:]] + priority_restarts += 1 - if fill_ratio < config.MIN_ACCEPT_FILL_RATIO: - final_wc = best_wc - fill_ratio = best_fill + if refined_layout is not None: + clearance_stats["clearance_px"] = clearance_px + clearance_stats["priority_restarts"] = priority_restarts + break + clearance_stats["clearance_px"] = clearance_px + clearance_stats["priority_restarts"] = priority_restarts + if refined_layout is None: + log.warning( + "高清候选 #%d 隔离精修失败: word=%s, shifted=%d, max_shift=%d", + candidate_index, + clearance_stats["failed_word"], + clearance_stats["shifted_words"], + clearance_stats["max_shift"], + ) + hd_clearance = clearance_stats + continue - print(f"最终填充率: {fill_ratio:.3f}") + final_wc = candidate_wc + final_scale = candidate_scale + final_layout_seed = candidate_seed + fill_ratio = candidate_fill + occ_fast = candidate_occ + largest_empty_square = largest_empty_square_size(occ_fast, mask_small) + hd_layout = refined_layout + hd_clearance = clearance_stats + break + + if hd_layout is None: + hd_overlap_pixels = -1 + hd_layout = raw_hd_layout + if hd_clearance is None: + hd_clearance = { + "shifted_words": 0, + "max_shift": 0, + "clearance_px": None, + "priority_restarts": 0, + "failed_word": None, + } + else: + hd_overlap_pixels = count_layout_overlap_pixels( + hd_layout, + (real_hd_h, real_hd_w), + config.WC_FONT_PATH, + ) + + print( + f"最终填充率: {fill_ratio:.3f} | 高清重叠像素: {hd_overlap_pixels} | " + f"精修位移: {hd_clearance['shifted_words']} 词, 最大 {hd_clearance['max_shift']}px | " + f"隔离带: {hd_clearance['clearance_px']}px" + ) return { "wc": final_wc, "fill_ratio": fill_ratio, @@ -268,6 +488,17 @@ def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real "base_max_font": base_max_font, "mask_small": mask_small, "size_scale": final_scale, + "layout_seed": final_layout_seed, + "collision_margin": _collision_margin, + "hd_overlap_pixels": hd_overlap_pixels, + "hd_layout": hd_layout, + "hd_clearance": hd_clearance, + "largest_empty_square_work_px": largest_empty_square, + "largest_empty_square_font_ratio": ( + largest_empty_square / max(1, int(final_wc.layout_[0][1])) + if final_wc.layout_ and equal_size_mode + else None + ), } @@ -317,6 +548,7 @@ def main(): log.info(" 平均名字长度: %.2f 字符", avg_len) log.info(" BASE_HD: %dx%d", config.BASE_HD_WIDTH, config.BASE_HD_HEIGHT) + # 只生成一次掩膜:先 probe 尺寸,再按需扩展后复用(避免二次 LANCZOS) probe_mask_hd, (probe_w, probe_h), _ = prepare_mask(config.BASE_HD_WIDTH, config.BASE_HD_HEIGHT) probe_stats = analyze_mask(probe_mask_hd) log.info(" Probe mask: %dx%d, free_ratio=%.4f, bbox_fill_ratio=%.4f", @@ -330,8 +562,12 @@ def main(): print("--- 3. 生成掩膜 (High Quality & Edge Fix) ---") log.info("--- 阶段3: 生成掩膜 ---") - mask_hd, (real_hd_w, real_hd_h), _ = prepare_mask(hd_w, hd_h) - mask_stats = analyze_mask(mask_hd) + if (hd_w, hd_h) == (probe_w, probe_h): + mask_hd, real_hd_w, real_hd_h = probe_mask_hd, probe_w, probe_h + mask_stats = probe_stats + else: + mask_hd, (real_hd_w, real_hd_h), _ = prepare_mask(hd_w, hd_h) + mask_stats = analyze_mask(mask_hd) log.info(" mask_hd: %dx%d", real_hd_w, real_hd_h) log.info(" free_area=%d, free_ratio=%.6f", mask_stats['free_area'], mask_stats['free_ratio']) log.info(" bbox_fill_ratio=%.6f", mask_stats['bbox_fill_ratio']) @@ -371,8 +607,11 @@ def main(): print(f"Excel 权重不可用,已回退{fallback}") log.info(" Excel 权重不可用,已回退%s", fallback) - name_weights_map = dict(stroke_weights_map) - name_weights_map.update(excel_weights_map) + name_weights_map = merge_weight_maps( + names, + stroke_weights_map if config.ENABLE_STROKE_WEIGHTS else {}, + excel_weights_map, + ) frequencies_data = name_weights_map if config.REMOVE_DUPLICATES else [(name, name_weights_map.get(name, 10)) for name in names] canvas_retry_round = 0 @@ -389,21 +628,35 @@ def main(): real_hd_h, ) - if generation_result["wc"] is None: - if canvas_retry_round >= config.CANVAS_RETRY_MAX_ROUNDS: - print("生成失败:未找到合适布局") - log.error("生成失败:未找到合适布局 (已重试 %d 轮)", canvas_retry_round) + wc = generation_result.get("wc") + placed = len(wc.layout_) if wc is not None else 0 + target = len(names) * config.N_REPETITIONS + # 名单完整性是硬约束。填充率优化不得以漏掉姓名为代价。 + hd_overlap_pixels = int(generation_result.get("hd_overlap_pixels", 0)) + placement_ok = wc is not None and placed >= target and hd_overlap_pixels == 0 + + if placement_ok or canvas_retry_round >= config.CANVAS_RETRY_MAX_ROUNDS: + if not placement_ok: + print( + f"生成失败:放置 {placed}/{target},高清重叠像素 {hd_overlap_pixels}," + "未满足完整名单与零碰撞约束" + ) + log.error( + "生成失败:放置 %d/%d, 高清重叠像素=%d (已重试 %d 轮)", + placed, + target, + hd_overlap_pixels, + canvas_retry_round, + ) sys.exit(1) - log.warning(" 本轮生成失败 (wc=None), 将重试") - elif generation_result["fill_ratio"] >= config.MIN_ACCEPT_FILL_RATIO or canvas_retry_round >= config.CANVAS_RETRY_MAX_ROUNDS: - log.info(" 生成成功! fill_ratio=%.4f (要求>=%.4f), 重试轮次=%d", - generation_result['fill_ratio'], config.MIN_ACCEPT_FILL_RATIO, canvas_retry_round) + log.info(" 生成完成 placed=%d/%d fill=%.4f retry=%d", + placed, target, generation_result["fill_ratio"], canvas_retry_round) break canvas_retry_round += 1 next_w = int(real_hd_w * config.CANVAS_RETRY_GROWTH) next_h = int(real_hd_h * config.CANVAS_RETRY_GROWTH) - print(f"[画布重试#{canvas_retry_round}] {real_hd_w}x{real_hd_h} -> {next_w}x{next_h}") + print(f"[画布重试#{canvas_retry_round}] {real_hd_w}x{real_hd_h} -> {next_w}x{next_h} (放置 {placed}/{target})") log.info("[画布重试#%d] %dx%d -> %dx%d (growth=%.2f)", canvas_retry_round, real_hd_w, real_hd_h, next_w, next_h, config.CANVAS_RETRY_GROWTH) mask_hd, (real_hd_w, real_hd_h), _ = prepare_mask(next_w, next_h) @@ -419,12 +672,7 @@ def main(): print("--- 6. 高清渲染 ---") log.info("--- 阶段6: 高清渲染 ---") t_render = time.time() - hd_layout = [] - for text, size, (y, x), orient, color in final_wc.layout_: - hd_size = int(size / config.WORK_SCALE) - hd_y = int(y / config.WORK_SCALE) - hd_x = int(x / config.WORK_SCALE) - hd_layout.append((text, hd_size, (hd_y, hd_x), orient, color)) + hd_layout = generation_result["hd_layout"] log.info(" HD layout 词数: %d", len(hd_layout)) log.info(" HD 画布: %dx%d", real_hd_w, real_hd_h) @@ -439,26 +687,17 @@ def main(): final_wc.height = real_hd_h base_img = final_wc.to_image().convert("RGB") - if config.ENABLE_DOT_MATRIX: - base_img = apply_dot_matrix(base_img, mask_hd) - - base_img.save(config.OUTPUT_PNG) + # 更快的 PNG 写出(压缩等级 1,视觉无损) + base_img.save(config.OUTPUT_PNG, compress_level=1) print(f"已保存: {config.OUTPUT_PNG}") log.info(" PNG 已保存: %s (%.2f MB)", config.OUTPUT_PNG, Path(config.OUTPUT_PNG).stat().st_size / 1024 / 1024 if Path(config.OUTPUT_PNG).exists() else 0) - final_wc.to_svg(config.OUTPUT_SVG) + # 一次构建路径,同时写出 fill / stroke 两份 SVG + stroke_svg = final_wc.export_svgs(config.OUTPUT_SVG) print(f"已保存: {config.OUTPUT_SVG}") - log.info(" SVG 已保存: %s (%.2f MB)", config.OUTPUT_SVG, - Path(config.OUTPUT_SVG).stat().st_size / 1024 / 1024 if Path(config.OUTPUT_SVG).exists() else 0) - - # 描边版 SVG(激光雕刻用) - stroke_svg = str(Path(config.OUTPUT_SVG).with_name( - Path(config.OUTPUT_SVG).stem + "_stroke" + Path(config.OUTPUT_SVG).suffix - )) - final_wc.to_svg_stroke(stroke_svg) print(f"已保存: {stroke_svg}") - log.info(" SVG(stroke) 已保存: %s (%.2f MB)", stroke_svg, - Path(stroke_svg).stat().st_size / 1024 / 1024 if Path(stroke_svg).exists() else 0) + log.info(" SVG 已保存: %s", config.OUTPUT_SVG) + log.info(" SVG(stroke) 已保存: %s", stroke_svg) log.info(" 渲染耗时: %.2fs", time.time() - t_render) log.info("--- 阶段7: 写入数据库 ---") @@ -482,27 +721,35 @@ def main(): box_height INTEGER ) """) - bbox_canvas = Image.new("L", (1, 1), 0) - bbox_draw = ImageDraw.Draw(bbox_canvas) + # Cache full font bearings as well as dimensions so search highlights + # match the actual PIL-rendered glyph position. + bbox_cache = {} + _measure = ImageDraw.Draw(Image.new("L", (1, 1))) db_data = [] for name, font_size, (y, x), orient, color in final_wc.layout_: - font = get_cached_font(config.WC_FONT_PATH, max(1, int(font_size))) orientation = "vertical" if orient else "horizontal" - if orient: - font = ImageFont.TransposedFont(font, orientation=orient) - bbox = bbox_draw.textbbox((x, y), name, font=font) + key = (name, int(font_size), bool(orient)) + box = bbox_cache.get(key) + if box is None: + font = get_cached_font(config.WC_FONT_PATH, max(1, int(font_size))) + if orient: + font = ImageFont.TransposedFont(font, orientation=orient) + bb = _measure.textbbox((0, 0), name, font=font) + box = (bb[0], bb[1], bb[2] - bb[0], bb[3] - bb[1]) + bbox_cache[key] = box + bx, by, bw, bh = box db_data.append( ( name, - x, - y, + x + bx, + y + by, font_size, color, orientation, - bbox[0], - bbox[1], - bbox[2] - bbox[0], - bbox[3] - bbox[1], + x, + y, + bw, + bh, ) ) cursor.executemany( @@ -525,11 +772,18 @@ def main(): placed_count = len(final_wc.layout_) metrics = { "seed": config.SEED, - "layout_order_mode": config.LAYOUT_ORDER_MODE, - "layout_seed": config.LAYOUT_SEED, + "layout_seed": generation_result.get("layout_seed", config.LAYOUT_SEED), "input_count": input_count, "placed_count": placed_count, "fill_ratio": fill_ratio, + "largest_empty_square_work_px": int( + generation_result.get("largest_empty_square_work_px", 0) + ), + "largest_empty_square_font_ratio": generation_result.get( + "largest_empty_square_font_ratio" + ), + "hd_overlap_pixels": int(generation_result.get("hd_overlap_pixels", -1)), + "hd_clearance": generation_result.get("hd_clearance"), "elapsed_seconds": round(elapsed, 4), "font_info": { "layout_font_path": config.WC_FONT_PATH, @@ -563,12 +817,8 @@ def main(): "output_dir": config.OUTPUT_DIR, "output_prefix": config.OUTPUT_PREFIX, "min_font_size": config.MIN_FONT_SIZE, - "max_attempts": config.MAX_ATTEMPTS, "fill_on": config.FILL_ON, - "min_accept_fill_ratio": config.MIN_ACCEPT_FILL_RATIO, - "require_all_words": config.REQUIRE_ALL_WORDS, - "layout_order_mode": config.LAYOUT_ORDER_MODE, - "layout_seed": config.LAYOUT_SEED, + "layout_seed": generation_result.get("layout_seed", config.LAYOUT_SEED), } } config.write_metrics(metrics) diff --git a/backend/core/render.py b/backend/core/render.py index 1dd4d8a..4a03f9d 100644 --- a/backend/core/render.py +++ b/backend/core/render.py @@ -1,10 +1,157 @@ import numpy as np from PIL import Image, ImageDraw, ImageFilter, ImageFont -from . import config from .fonts import get_cached_font +def scale_layout_for_hd(layout, work_scale): + if work_scale <= 0: + raise ValueError("WORK_SCALE must be greater than zero") + return [ + ( + text, + max(1, int(size / work_scale)), + (int(y / work_scale), int(x / work_scale)), + orient, + color, + ) + for text, size, (y, x), orient, color in layout + ] + + +def count_layout_overlap_pixels(layout, mask_shape, font_path, alpha_threshold=0): + """Count final rendered pixels occupied by more than one layout entry.""" + height, width = mask_shape + occupied = np.zeros((height, width), dtype=bool) + overlaps = np.zeros((height, width), dtype=bool) + measure = ImageDraw.Draw(Image.new("L", (1, 1))) + + for word, size, (y, x), orient, _color in layout: + font = get_cached_font(font_path, size) + if orient: + font = ImageFont.TransposedFont(font, orientation=orient) + bbox = measure.textbbox((0, 0), word, font=font) + glyph = font.getmask(word, mode="L") + glyph_width, glyph_height = glyph.size + if glyph_width <= 0 or glyph_height <= 0: + continue + ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width) > alpha_threshold + + ink_x = int(x) + int(bbox[0]) + ink_y = int(y) + int(bbox[1]) + x0 = max(0, ink_x) + y0 = max(0, ink_y) + x1 = min(width, ink_x + glyph_width) + y1 = min(height, ink_y + glyph_height) + if x0 >= x1 or y0 >= y1: + continue + + visible_ink = ink[y0 - ink_y:y1 - ink_y, x0 - ink_x:x1 - ink_x] + occupied_region = occupied[y0:y1, x0:x1] + overlaps[y0:y1, x0:x1] |= occupied_region & visible_ink + occupied_region |= visible_ink + + return int(overlaps.sum()) + + +def refine_layout_with_hd_clearance( + layout, + mask, + font_path, + clearance=1, + max_shift=24, +): + """Validate the whole HD batch and minimally move rasterization collisions.""" + height, width = mask.shape + blocked = np.asarray(mask) != 0 + occupied = np.zeros((height, width), dtype=bool) + measure = ImageDraw.Draw(Image.new("L", (1, 1))) + refined = [] + shifted_words = 0 + max_applied_shift = 0 + + offset_rings = [[(0, 0)]] + for radius in range(1, max_shift + 1): + ring = [ + (dy, dx) + for dy in range(-radius, radius + 1) + for dx in range(-radius, radius + 1) + if max(abs(dy), abs(dx)) == radius + ] + ring.sort(key=lambda item: (item[0] * item[0] + item[1] * item[1], item[0], item[1])) + offset_rings.append(ring) + + for word, size, (draw_y, draw_x), orient, color in layout: + font = get_cached_font(font_path, size) + if orient: + font = ImageFont.TransposedFont(font, orientation=orient) + bbox = measure.textbbox((0, 0), word, font=font) + glyph = font.getmask(word, mode="L") + glyph_width, glyph_height = glyph.size + if glyph_width <= 0 or glyph_height <= 0: + refined.append((word, size, (draw_y, draw_x), orient, color)) + continue + + glyph_ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width) + pad = max(0, int(clearance)) + padded = np.zeros((glyph_height + 2 * pad, glyph_width + 2 * pad), dtype=np.uint8) + padded[pad:pad + glyph_height, pad:pad + glyph_width] = glyph_ink + if pad: + collision = np.asarray( + Image.fromarray(padded).filter(ImageFilter.MaxFilter(2 * pad + 1)), + dtype=np.uint8, + ) > 0 + else: + collision = padded > 0 + stamp = padded > 0 + + base_y = int(draw_y) + int(bbox[1]) - pad + base_x = int(draw_x) + int(bbox[0]) - pad + + def fits(y0, x0): + y1 = y0 + collision.shape[0] + x1 = x0 + collision.shape[1] + if y0 < 0 or x0 < 0 or y1 > height or x1 > width: + return False + if np.any(blocked[y0:y1, x0:x1] & stamp): + return False + return not np.any(occupied[y0:y1, x0:x1] & collision) + + placed_offset = None + for ring in offset_rings: + for dy, dx in ring: + y0 = base_y + dy + x0 = base_x + dx + if not fits(y0, x0): + continue + placed_offset = (dy, dx) + break + if placed_offset is not None: + break + + if placed_offset is None: + return None, { + "shifted_words": shifted_words, + "max_shift": max_applied_shift, + "failed_word": word, + } + + dy, dx = placed_offset + y0 = base_y + dy + x0 = base_x + dx + occupied[y0:y0 + stamp.shape[0], x0:x0 + stamp.shape[1]] |= stamp + if dy or dx: + shifted_words += 1 + max_applied_shift = max(max_applied_shift, abs(dy), abs(dx)) + refined.append((word, size, (int(draw_y) + dy, int(draw_x) + dx), orient, color)) + + return refined, { + "shifted_words": shifted_words, + "max_shift": max_applied_shift, + "failed_word": None, + } + + def render_layout_occupancy(layout, mask_shape, font_path): h, w = mask_shape canvas = Image.new("L", (w, h), 0) @@ -28,20 +175,27 @@ def compute_fill_ratio_fast(layout, mask, font_path): return filled_area / free_area, occ -def apply_dot_matrix(base_img, mask_hd): - text_mask = base_img.convert("L").point(lambda x: 0 if x < 200 else 255) - filter_size = max(3, (config.DOT_SAFETY_BUFFER // 2) * 2 + 1) - safe_zone_mask = text_mask.filter(ImageFilter.MinFilter(size=filter_size)) - safe_zone_array = np.array(safe_zone_mask) - unfilled_zone = (mask_hd == 0) & (safe_zone_array > 200) - draw = ImageDraw.Draw(base_img) - h, w = mask_hd.shape - dot_color = "white" if config.FILL_ON == "WHITE" else "black" - for y in range(0, h, config.DOT_SPACING): - for x in range(0, w, config.DOT_SPACING): - if unfilled_zone[y, x]: - if config.DOT_RADIUS > 0: - draw.ellipse([x - config.DOT_RADIUS, y - config.DOT_RADIUS, x + config.DOT_RADIUS, y + config.DOT_RADIUS], fill=dot_color) - else: - draw.point((x, y), fill=dot_color) - return base_img +def largest_empty_square_size(occupancy, mask): + """Return the largest fully empty square inside the fillable mask.""" + blocked = (np.asarray(mask) != 0) | (np.asarray(occupancy) != 0) + if blocked.ndim != 2 or blocked.size == 0: + return 0 + + integral = np.pad(blocked.astype(np.uint32), ((1, 0), (1, 0))) + integral = integral.cumsum(axis=0).cumsum(axis=1) + + low = 0 + high = min(blocked.shape) + while low < high: + size = (low + high + 1) // 2 + window_sums = ( + integral[size:, size:] + - integral[:-size, size:] + - integral[size:, :-size] + + integral[:-size, :-size] + ) + if np.any(window_sums == 0): + low = size + else: + high = size - 1 + return int(low) diff --git a/backend/core/weights.py b/backend/core/weights.py index e75d9e1..5b376f2 100644 --- a/backend/core/weights.py +++ b/backend/core/weights.py @@ -73,13 +73,55 @@ def extract_weights_from_df(df, names): return dict(zip(grouped["name"], grouped["weight"])) +def merge_weight_maps(names, stroke_weights=None, excel_weights=None): + """Combine optional manual weights with normalized stroke complexity. + + Manual Excel weights remain the base signal. When both sources exist, + stroke complexity is normalized around the median and applied as a + multiplicative factor, so an all-ones Excel column still enables visibly + different stroke-driven font sizes while meaningful manual ratios remain. + """ + stroke_weights = stroke_weights or {} + excel_weights = excel_weights or {} + if not stroke_weights: + return dict(excel_weights) + if not excel_weights: + return dict(stroke_weights) + + positive_strokes = sorted( + max(float(stroke_weights[name]), 1.0) + for name in names + if name in stroke_weights + ) + if not positive_strokes: + return dict(excel_weights) + midpoint = positive_strokes[len(positive_strokes) // 2] + + merged = {} + for name in names: + manual = excel_weights.get(name) + stroke = stroke_weights.get(name) + if manual is None: + if stroke is not None: + merged[name] = float(stroke) + continue + if stroke is None: + merged[name] = float(manual) + continue + merged[name] = max(float(manual), 1e-6) * max(float(stroke), 1.0) / midpoint + return merged + + def calculate_font_by_area_model(mask, names, weights_map, fill_ratio, size_ratio, packing_efficiency, n_rep): free_area = int(np.sum(mask == 0)) if free_area <= 0: free_area = int(mask.size) - effective_fill = fill_ratio if fill_ratio > 0 else max(config.MIN_ACCEPT_FILL_RATIO, 0.82) - target_area = free_area * effective_fill * packing_efficiency + # 中文实心笔画约占字形包围盒 35–55%;目标取 0.42 附近,略保守以便一次放满。 + effective_fill = fill_ratio if fill_ratio > 0 else 0.42 + # 包围盒面积 vs 实际笔画:面积模型用包围盒估算,需额外 ink_factor 校正 + ink_factor = 0.42 + target_area = free_area * effective_fill * packing_efficiency / ink_factor weights = [max(float(weights_map.get(name, 10)), 1.0) for name in names] if not weights: @@ -89,13 +131,19 @@ def calculate_font_by_area_model(mask, names, weights_map, fill_ratio, size_rati char_mass = 0.0 for name, score in zip(names, log_scores): length = max(1, len(name)) - char_mass += length * (0.9 + 0.9 * score) + char_mass += length * (0.85 + 0.7 * score) char_mass *= max(1, n_rep) if char_mass <= 0: return max(config.MIN_FONT_SIZE, 10), max(config.MIN_FONT_SIZE + 4, 20) nominal_size = math.sqrt(target_area / char_mass) - min_f = max(config.MIN_FONT_SIZE, int(nominal_size * 0.72)) - max_f = max(min_f + 1, int(min_f * max(1.4, size_ratio))) + min_f = max(config.MIN_FONT_SIZE, int(round(nominal_size * 0.78))) + ratio = max(1.0, float(size_ratio)) + if math.isclose(ratio, 1.0, rel_tol=0.0, abs_tol=1e-9): + # A ratio of exactly one is a hard semantic guarantee: every word + # receives the same target size. Do not inject an artificial span. + max_f = min_f + else: + max_f = max(min_f, int(round(min_f * ratio))) return min_f, max_f diff --git a/backend/requirements.txt b/backend/requirements.txt index 79ed405..9c9dabc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,3 +8,4 @@ scipy>=1.11.0 matplotlib>=3.10.0 pandas>=2.0.0 openpyxl>=3.1.0 +fonttools>=4.50.0 diff --git a/backend/tests/test_layout_constraints.py b/backend/tests/test_layout_constraints.py new file mode 100644 index 0000000..b7bd4cb --- /dev/null +++ b/backend/tests/test_layout_constraints.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + + +BACKEND_DIR = Path(__file__).resolve().parents[1] +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +from core import config # noqa: E402 +from core.ewc import EfficientWordCloud # noqa: E402 +from core.fonts import get_cached_font # noqa: E402 +from core.pipeline import run_generation_pass # noqa: E402 +from core.render import count_layout_overlap_pixels, largest_empty_square_size # noqa: E402 +from core.weights import merge_weight_maps # noqa: E402 + + +def chinese_names(count: int) -> list[str]: + surnames = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜" + given = "子涵宇轩梓萱浩然欣怡雨桐诗涵俊杰思远若曦嘉怡明哲一诺安然沐阳" + return [ + surnames[index % len(surnames)] + + given[(index * 3) % len(given)] + + given[(index * 7 + 1) % len(given)] + for index in range(count) + ] + + +def circle_mask(size: int) -> np.ndarray: + image = Image.new("L", (size, size), 255) + ImageDraw.Draw(image).ellipse((20, 20, size - 21, size - 21), fill=0) + return np.asarray(image) + + +class LayoutConstraintTests(unittest.TestCase): + def setUp(self) -> None: + self.saved = { + key: getattr(config, key) + for key in ( + "SIZE_RATIO", + "WORK_SCALE", + "MIN_READABLE_HEIGHT_PX", + "MIN_FONT_SIZE", + "USER_MIN_FONT_SIZE", + "USER_MAX_FONT_SIZE", + "N_REPETITIONS", + "LAYOUT_SEED", + "SEED", + "TARGET_FILL_RATIO", + "WC_FONT_PATH", + ) + } + config.WC_FONT_PATH = str(config.PROJECT_DEFAULT_FONT) + config.SIZE_RATIO = 1.0 + config.WORK_SCALE = 0.18 + config.MIN_READABLE_HEIGHT_PX = 22 + config.MIN_FONT_SIZE = 3 + config.USER_MIN_FONT_SIZE = None + config.USER_MAX_FONT_SIZE = None + config.N_REPETITIONS = 1 + config.LAYOUT_SEED = 20260718 + config.SEED = 20260718 + config.TARGET_FILL_RATIO = 0.42 + + def tearDown(self) -> None: + for key, value in self.saved.items(): + setattr(config, key, value) + + def generate(self, count: int, canvas: int = 1600): + names = chinese_names(count) + frequencies = [(name, 10.0) for name in names] + weights = dict(frequencies) + result = run_generation_pass( + names, + frequencies, + weights, + circle_mask(canvas), + canvas, + canvas, + ) + return names, result + + def test_size_ratio_one_keeps_every_equal_weight_size_identical(self) -> None: + names, result = self.generate(40) + layout = result["wc"].layout_ + self.assertEqual(len(layout), len(names)) + self.assertEqual(len({font_size for _, font_size, *_ in layout}), 1) + + def test_explicit_equal_min_max_is_exact(self) -> None: + config.USER_MIN_FONT_SIZE = 12 + config.USER_MAX_FONT_SIZE = 12 + names, result = self.generate(20) + layout = result["wc"].layout_ + self.assertEqual(len(layout), len(names)) + self.assertEqual({font_size for _, font_size, *_ in layout}, {12}) + + def test_same_weight_groups_receive_the_same_size(self) -> None: + config.SIZE_RATIO = 2.0 + names = chinese_names(24) + weights = { + name: (100.0 if index < 8 else 30.0 if index < 16 else 10.0) + for index, name in enumerate(names) + } + frequencies = [(name, weights[name]) for name in names] + result = run_generation_pass( + names, + frequencies, + weights, + circle_mask(1800), + 1800, + 1800, + ) + sizes_by_weight: dict[float, set[int]] = {} + for name, font_size, *_ in result["wc"].layout_: + sizes_by_weight.setdefault(weights[name], set()).add(font_size) + self.assertEqual(len(result["wc"].layout_), len(names)) + self.assertTrue(all(len(sizes) == 1 for sizes in sizes_by_weight.values())) + self.assertGreater( + max(sizes_by_weight[100.0]), + max(sizes_by_weight[10.0]), + ) + + def test_stroke_weight_is_applied_when_excel_weights_are_flat(self) -> None: + merged = merge_weight_maps( + ["甲", "乙", "丙"], + {"甲": 100.0, "乙": 200.0, "丙": 300.0}, + {"甲": 1.0, "乙": 1.0, "丙": 1.0}, + ) + self.assertLess(merged["甲"], merged["乙"]) + self.assertLess(merged["乙"], merged["丙"]) + + def test_largest_empty_square_ignores_space_outside_mask(self) -> None: + mask = np.full((7, 7), 255, dtype=np.uint8) + mask[1:6, 1:6] = 0 + occupancy = np.zeros((7, 7), dtype=np.uint8) + occupancy[1:3, 1:6] = 1 + self.assertEqual(largest_empty_square_size(occupancy, mask), 3) + + def test_conflicting_explicit_font_bounds_fail(self) -> None: + config.USER_MIN_FONT_SIZE = 13 + config.USER_MAX_FONT_SIZE = 12 + with self.assertRaisesRegex(ValueError, "字号硬约束冲突"): + self.generate(10) + + def test_explicit_max_overrides_automatic_readability_floor(self) -> None: + config.USER_MAX_FONT_SIZE = 2 + names, result = self.generate(12) + layout = result["wc"].layout_ + self.assertEqual(len(layout), len(names)) + self.assertEqual({font_size for _, font_size, *_ in layout}, {2}) + + def test_base_class_never_uses_a_private_fallback_size(self) -> None: + words = {name: 1.0 for name in chinese_names(12)} + wc = EfficientWordCloud( + width=150, + height=150, + font_path=config.WC_FONT_PATH, + max_words=len(words), + min_font_size=5, + max_font_size=30, + prefer_horizontal=1.0, + random_state=7, + margin=1, + ) + wc.generate_from_frequencies(words) + self.assertGreater(len(wc.layout_), 0) + self.assertEqual({font_size for _, font_size, *_ in wc.layout_}, {30}) + + def test_rendered_ink_stays_inside_mask_and_does_not_overlap(self) -> None: + _names, result = self.generate(50) + layout = result["wc"].layout_ + mask = result["mask_small"] + height, width = mask.shape + coverage = np.zeros((height, width), dtype=np.uint16) + for word, size, (y, x), orient, _color in layout: + image = Image.new("L", (width, height), 0) + draw = ImageDraw.Draw(image) + font = get_cached_font(config.WC_FONT_PATH, size) + if orient: + font = ImageFont.TransposedFont(font, orientation=orient) + draw.text((x, y), word, font=font, fill=255) + coverage += (np.asarray(image) > 0).astype(np.uint16) + + self.assertFalse(np.any((coverage > 0) & (mask != 0))) + self.assertLessEqual(int(coverage.max()), 1) + + def test_hd_rendered_ink_does_not_overlap_after_scaling(self) -> None: + names, result = self.generate(50) + self.assertEqual(len(result["wc"].layout_), len(names)) + hd_layout = result["hd_layout"] + overlap_pixels = count_layout_overlap_pixels( + hd_layout, + (1600, 1600), + config.WC_FONT_PATH, + ) + self.assertEqual(result["hd_overlap_pixels"], 0) + self.assertEqual(overlap_pixels, 0) + self.assertEqual(result["collision_margin"], 0) + self.assertEqual(result["hd_clearance"]["failed_word"], None) + self.assertIn(result["hd_clearance"]["clearance_px"], (0, 1)) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tools/benchmark_layout.py b/backend/tools/benchmark_layout.py new file mode 100644 index 0000000..03c4443 --- /dev/null +++ b/backend/tools/benchmark_layout.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""可重复的中文词云性能与视觉质量基准。""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +import time +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + + +BACKEND_DIR = Path(__file__).resolve().parents[1] +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +from core import config # noqa: E402 +from core.fonts import get_cached_font # noqa: E402 +from core.pipeline import run_generation_pass # noqa: E402 +from core.render import count_layout_overlap_pixels, scale_layout_for_hd # noqa: E402 + + +SURNAMES = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜戚谢邹喻柏水窦章云苏潘葛奚范彭郎鲁韦昌马苗凤花方俞任袁柳唐罗薛雷贺倪汤滕殷罗毕郝邬安常乐于时傅皮卞齐康伍余元卜顾孟平黄和穆萧尹姚邵湛汪祁毛禹狄米贝明臧计伏成戴谈宋茅庞熊纪舒屈项祝董梁杜阮蓝闵席季麻强贾路娄危江童颜郭梅盛林刁钟徐邱骆高夏蔡田樊胡凌霍虞万支柯昝管卢莫经房裘缪干解应宗宣丁贲邓郁单杭洪包诸左石崔吉龚程嵇邢裴陆荣翁荀羊甄魏家封芮羿储靳汲邴糜松井段富巫乌焦巴弓牧隗山谷车侯宓蓬全郗班仰秋仲伊宫宁仇栾暴甘钭厉戎祖武符刘景詹束龙叶幸司韶郜黎蓟薄印宿白怀蒲台从鄂索咸籍赖卓蔺屠蒙池乔阴胥能苍双闻莘党翟谭贡劳逄姬申扶堵冉宰郦雍却璩桑桂濮牛寿通边扈燕冀郏浦尚农温别庄晏柴瞿阎充慕连茹习宦艾鱼容向古易慎戈廖庾终暨居衡步都耿满弘匡国文寇广禄阙东欧殳沃利蔚越夔隆师巩厍聂晁勾敖融冷訾辛阚那简饶空曾毋沙乜养鞠须丰巢关蒯相查后荆红游竺权逯盖益桓公" +GIVEN = "子涵宇轩梓萱浩然欣怡雨桐诗涵俊杰思远若曦嘉怡明哲一诺安然沐阳可馨奕辰语嫣皓轩晨曦梦瑶佳宁天佑书瑶瑞泽景行星辰清越知夏亦航舒雅嘉禾锦程乐言思齐云舟清欢予安望舒嘉树怀瑾景明青禾昭阳念初令仪时安向晚星野云舒允和知许南乔初晴清晏如松修远云起长风映雪听澜" + + +def make_names(count: int) -> list[str]: + names = [] + for index in range(count): + surname = SURNAMES[index % len(SURNAMES)] + a = GIVEN[(index * 7) % len(GIVEN)] + b = GIVEN[(index * 17 + index // len(GIVEN)) % len(GIVEN)] + names.append(surname + a + b) + return names + + +def make_round_mask(size: int) -> np.ndarray: + image = Image.new("L", (size, size), 255) + draw = ImageDraw.Draw(image) + inset = max(8, size // 50) + draw.ellipse((inset, inset, size - inset - 1, size - inset - 1), fill=0) + return np.array(image) + + +def render_hd(layout, size: int, work_scale: float, output: Path) -> np.ndarray: + image = Image.new("RGB", (size, size), "white") + draw = ImageDraw.Draw(image) + hd_layout = scale_layout_for_hd(layout, work_scale) + for word, font_size, (y, x), orient, color in hd_layout: + font = get_cached_font(config.WC_FONT_PATH, font_size) + if orient: + font = ImageFont.TransposedFont(font, orientation=orient) + draw.text((x, y), word, font=font, fill=color) + image.save(output, compress_level=1) + return np.any(np.asarray(image) < 245, axis=2) + + +def visual_metrics(ink: np.ndarray, mask: np.ndarray) -> dict[str, float]: + free = mask == 0 + true_ink = ink & free + free_area = int(free.sum()) + density = float(true_ink.sum() / free_area) if free_area else 0.0 + + rows, cols = np.where(true_ink) + free_rows, free_cols = np.where(free) + bbox_coverage = 0.0 + if rows.size and free_rows.size: + ink_h = int(rows.max() - rows.min() + 1) + ink_w = int(cols.max() - cols.min() + 1) + free_h = int(free_rows.max() - free_rows.min() + 1) + free_w = int(free_cols.max() - free_cols.min() + 1) + bbox_coverage = (ink_h * ink_w) / max(1, free_h * free_w) + + # 轮廓覆盖:掩膜内 8×8 有效区域中,被真实墨迹触达的区域比例。 + grid_hit = 0 + grid_free = 0 + height, width = free.shape + for gy in range(8): + y0, y1 = gy * height // 8, (gy + 1) * height // 8 + for gx in range(8): + x0, x1 = gx * width // 8, (gx + 1) * width // 8 + cell_free = free[y0:y1, x0:x1] + if not cell_free.any(): + continue + grid_free += 1 + if true_ink[y0:y1, x0:x1].any(): + grid_hit += 1 + + return { + "hd_true_density": density, + "ink_bbox_coverage": float(bbox_coverage), + "contour_grid_coverage": float(grid_hit / grid_free) if grid_free else 0.0, + } + + +def run_case(count: int, canvas: int, output_dir: Path, max_growth_rounds: int) -> dict: + names = make_names(count) + weights = {name: 10.0 for name in names} + frequencies = [(name, 10.0) for name in names] + + started = time.perf_counter() + current_canvas = canvas + growth_rounds = 0 + while True: + mask = make_round_mask(current_canvas) + result = run_generation_pass( + names, + frequencies, + weights, + mask, + current_canvas, + current_canvas, + ) + wc = result["wc"] + placed = len(wc.layout_) if wc is not None else 0 + collision_ok = int(result.get("hd_overlap_pixels", -1)) == 0 + if (placed >= count and collision_ok) or growth_rounds >= max_growth_rounds: + break + growth_rounds += 1 + current_canvas = int(math.ceil(current_canvas * config.CANVAS_RETRY_GROWTH)) + layout_seconds = time.perf_counter() - started + layout = wc.layout_ if wc is not None else [] + + png_path = output_dir / f"chinese_{count}.png" + render_started = time.perf_counter() + ink = render_hd(layout, current_canvas, config.WORK_SCALE, png_path) + render_seconds = time.perf_counter() - render_started + + font_sizes = [int(item[1]) for item in layout] + hd_layout = result["hd_layout"] + metrics = { + "count": count, + "canvas": current_canvas, + "canvas_growth_rounds": growth_rounds, + "placed": len(layout), + "completeness": len(layout) / count if count else 1.0, + "layout_seconds": layout_seconds, + "render_seconds": render_seconds, + "total_seconds": layout_seconds + render_seconds, + "work_fill_ratio": float(result["fill_ratio"]), + "font_size_min": min(font_sizes) if font_sizes else 0, + "font_size_max": max(font_sizes) if font_sizes else 0, + "equal_weight_font_consistent": len(set(font_sizes)) <= 1, + "collision_margin": int(result["collision_margin"]), + "hd_clearance_shifted_words": int(result["hd_clearance"]["shifted_words"]), + "hd_clearance_max_shift": int(result["hd_clearance"]["max_shift"]), + "hd_clearance_px": int(result["hd_clearance"]["clearance_px"]), + "hd_clearance_priority_restarts": int(result["hd_clearance"]["priority_restarts"]), + "hd_overlap_pixels": count_layout_overlap_pixels( + hd_layout, + (current_canvas, current_canvas), + config.WC_FONT_PATH, + ), + "largest_empty_square_work_px": int(result["largest_empty_square_work_px"]), + "largest_empty_square_font_ratio": result["largest_empty_square_font_ratio"], + "png": str(png_path), + } + metrics.update(visual_metrics(ink, mask)) + return metrics + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--counts", nargs="+", type=int, default=[80, 800]) + parser.add_argument("--canvas", type=int, default=2000) + parser.add_argument("--max-growth-rounds", type=int, default=1) + parser.add_argument("--assert-targets", action="store_true") + parser.add_argument("--output-dir", type=Path, default=BACKEND_DIR / "benchmark_outputs") + args = parser.parse_args() + args.output_dir.mkdir(parents=True, exist_ok=True) + + config.WC_FONT_PATH = str(config.PROJECT_DEFAULT_FONT) + config.SIZE_RATIO = 1.0 + config.N_REPETITIONS = 1 + config.WORK_SCALE = 0.18 + config.MIN_READABLE_HEIGHT_PX = 22 + config.MIN_FONT_SIZE = max(1, int(config.MIN_READABLE_HEIGHT_PX * config.WORK_SCALE)) + config.USER_MIN_FONT_SIZE = None + config.USER_MAX_FONT_SIZE = None + config.LAYOUT_SEED = 20260718 + config.SEED = 20260718 + config.TARGET_FILL_RATIO = 0.45 + config.FONT_COLOR = "#102A43" + + results = [ + run_case(count, args.canvas, args.output_dir, args.max_growth_rounds) + for count in args.counts + ] + if args.assert_targets: + failures = [] + for item in results: + if item["completeness"] != 1.0: + failures.append(f'{item["count"]}: completeness={item["completeness"]:.4f}') + if not item["equal_weight_font_consistent"]: + failures.append(f'{item["count"]}: equal-weight font sizes differ') + if item["hd_overlap_pixels"] != 0: + failures.append(f'{item["count"]}: HD overlap pixels={item["hd_overlap_pixels"]}') + if item["contour_grid_coverage"] < 0.80: + failures.append(f'{item["count"]}: contour coverage below 0.80') + if item["hd_true_density"] < 0.10: + failures.append(f'{item["count"]}: HD true density below 0.10') + if item["count"] < 100 and item["total_seconds"] >= 1.0: + failures.append(f'{item["count"]}: total time >= 1.0s') + if item["count"] < 1000 and item["total_seconds"] >= 5.0: + failures.append(f'{item["count"]}: total time >= 5.0s') + if failures: + raise SystemExit("基准门禁失败: " + "; ".join(failures)) + report = args.output_dir / "benchmark.json" + report.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(results, ensure_ascii=False, indent=2)) + print(f"报告: {report}") + + +if __name__ == "__main__": + main() diff --git a/docs/ALGORITHM.md b/docs/ALGORITHM.md index 88721c0..95aa5e2 100644 --- a/docs/ALGORITHM.md +++ b/docs/ALGORITHM.md @@ -4,132 +4,175 @@ ## 总流程 -1. 读取 Excel 名单:`pipeline.main()` -2. 计算自动画幅:`mask.calculate_dynamic_dimensions()` -3. 生成并归一化掩膜:`mask.prepare_mask()` -4. 计算权重:`weights.extract_weights_from_df()` 和 `weights.get_stroke_complexity_batch()` -5. 估算字号范围:`weights.calculate_font_by_area_model()` -6. 小画布布局:`layout.OptimizedEfficientWordCloud.generate_from_frequencies()` -7. C++ 找可放位置:`IntegralGrid.query_direct()` -8. C++ 写入字形占用:`IntegralGrid.stamp_and_rebuild()` -9. 计算填充率并必要时重试放大 -10. 将小画布 layout 放大到高清画布并输出 PNG/SVG/DB/metrics +``` +1. 读取 Excel 名单 → pipeline.main() +2. 智能画幅计算 → mask.calculate_dynamic_dimensions() +3. 生成并归一化掩膜 → mask.prepare_mask() +4. 计算权重 → weights.extract_weights_from_df() + weights.get_stroke_complexity_batch() +5. 估算字号范围 → weights.calculate_font_by_area_model() +6. 小画布布局 → layout.OptimizedEfficientWordCloud.generate_from_frequencies() +7. C++ 按真实字形找位置并原子写入 → IntegralGrid.place_glyph_exact() +8. 整批未完整放入 → 统一缩放字号或扩大画布后重排 +9. 密度优化 → 探测更大字号并保留完整率不下降的候选 +10. 高清精修 → render.refine_layout_with_hd_clearance() 加入隔离带局部微调 +11. 输出 PNG / SVG / DB / metrics +``` -## 名单和重复填充 +## 名单与重复填充 `N_REPETITIONS` 决定目标词数: -```text +``` total_target = len(names) * N_REPETITIONS ``` -布局序列由 `_build_layout_sequence()` 生成。当前行为是按原名单循环追加: +布局序列由 `_build_layout_sequence()` 生成。行为:**按原名单循环追加**,不是把同一个名字所有副本先放完。 -```text +``` [A, B, C], N_REPETITIONS=4 -=> [A, B, C, A, B, C, A, B, C, A, B, C] +→ [A, B, C, A, B, C, A, B, C, A, B, C] ``` -所以当前队列顺序是“先填一轮名单,再填下一轮”,不是先放完同一个名字所有副本。 - -需要注意:队列顺序公平不等于最终字号完全一致。放置阶段如果某个词以目标字号找不到位置,会单独降字号继续尝试。因此后几轮词语通常比前几轮小。 +同名副本在同一批布局中使用相同目标字号。放置失败不会触发逐词缩字号。 ## 权重逻辑 ### Excel 权重 -`WEIGHT_COL_NAME` 优先于 `WEIGHT_COL_INDEX`。有效权重必须是可转数字且大于 0。 - -`REMOVE_DUPLICATES = True` 时,同名权重取最大值。`REMOVE_DUPLICATES = False` 时,仍会按名字聚合权重映射,所以同名不同权重不会保留为不同权重实例。 +- `WEIGHT_COL_NAME` 优先于 `WEIGHT_COL_INDEX` +- 有效权重必须是可转数字且大于 `0` +- `REMOVE_DUPLICATES=True` 时,同名权重取最大值 +- `REMOVE_DUPLICATES=False` 时,仍按名字聚合成权重映射 ### 笔画权重 -`ENABLE_STROKE_WEIGHTS = True` 时,系统渲染每个字符到 64x64 灰度图,用像素占用量估算复杂度。一个名字的笔画权重取其中最复杂字符的值。 - -`ENABLE_STROKE_WEIGHTS = False` 时跳过笔画权重。若没有 Excel 权重,所有名字权重默认为 `10`。 +- `ENABLE_STROKE_WEIGHTS=True` 时,系统渲染每个字符到 `64×64` 灰度图,用像素占用量估算复杂度 +- 一个名字的笔画权重取其中最复杂字符的值 +- 若同时存在 Excel 权重:Excel 值作为基础权重,笔画复杂度除以全体中位数后作为乘数 +- 这样手动权重比例仍保留,且 Excel 权重全为 `1` 时笔画开关也不会失效 +- `ENABLE_STROKE_WEIGHTS=False` 且无 Excel 权重时,所有名字权重默认为 `10` ## 字号范围估算 -`calculate_font_by_area_model()` 使用可填充面积、目标填充率、packing efficiency、重复次数和名字长度估算 `min_font` / `max_font`。 +`calculate_font_by_area_model()` 使用以下输入估算 `min_font` / `max_font`: + +- 可填充面积(掩膜中 `0` 的像素数) +- 目标填充率 `TARGET_FILL_RATIO` +- 打包效率 `PACKING_EFFICIENCY` +- 重复次数 `N_REPETITIONS` +- 名字长度、各名字权重 公式思想: - 可填区域越大,字号越大 - 名字越多、重复次数越高,字号越小 -- 字符越多,总占用质量越高,字号越小 +- 字符越多,总字符质量越高,字号越小 - 权重越高,在 `log1p(weight)` 归一化后获得更高面积质量 -最终 `max_font` 基于 `min_font * SIZE_RATIO` 计算。 +最终 `max_font = min_font × SIZE_RATIO`。 ## 字号打分 -当前 `build_log_rank_scores(..., per_word=True)` 会按姓名权重计算固定分数,然后映射到展开后的重复序列。 +`build_log_rank_scores(..., per_word=True)` 按姓名权重计算固定分数,映射到展开后的重复序列。结果是: -这意味着: - -- 同名副本的目标分数相同 -- 同名副本的初始目标字号相同 +- 同名副本目标分数相同 +- 同名副本初始目标字号相同 - 权重相同时,所有姓名初始目标字号相同 -但最终放置字号仍可能变小,因为放置失败时会逐词降字号。 +`SIZE_RATIO=1` 时,`min_font == max_font`,所有同权重姓名在重试前后都保持完全相同字号。 + +## 等字号模式 + +`SIZE_RATIO=1` 时,面积估算阶段直接令 `min_font = max_font`。所有字号重试也只改变单一字号值。 + +等字号批次的特殊优化: + +1. 视作整批同字号布局,使用整批字号打分(`per_word=False`) +2. 密度优化阶段在完整候选中比较字符级最大空洞(`largest_empty_square_size`),并尝试不同布局种子降低空洞 +3. 高清精修阶段允许优先级回溯(把失败词提前到队列最前) ## 放置策略 每个词的放置流程: -1. 根据目标分数得到目标字号 -2. 随机决定横排或竖排 -3. 用 PIL 测量文字包围盒 -4. 调用 C++ `query_direct(query_h, query_w, seed)` 找位置 -5. 如果找不到,字号减 2 后重试,最低到目标字号的 40% 或 `min_font_size` -6. 放置成功后,取真实字形 bitmap 并调用 `stamp_and_rebuild()` -7. 主循环放不下的词进入 gap filling,用更小字号再尝试一次 +1. 根据目标分数得到目标字号(线性插值于 `min_font` 到 `max_font`) +2. 随机决定横排或竖排(`prefer_horizontal` 控制概率) +3. 用 PIL 渲染真实字形 bitmap,施加单侧安全边距(margin)生成碰撞 mask +4. 调用 C++ `place_glyph_exact()` 搜索合法位置并原子写入 +5. 当前方向找不到时,只尝试同字号的另一方向 +6. 任意姓名失败即视为本次整批布局不完整 +7. **不逐词缩字号**。Python 统一缩放整批字号范围后重新布局 +8. 触及字号下限仍失败时,按 `CANVAS_RETRY_GROWTH` 扩大画布并整批重排 +9. 将完整布局映射到高清字号和坐标 +10. 高清画布进行逐词隔离带精修,局部无合法位置时扩大画布并整批重排 -## C++ 积分图搜索 +## C++ 真实字形搜索 -C++ `IntegralGrid` 维护两个核心结构: +`IntegralGrid` 维护两个核心结构: -- `canvas`:真实占用像素,`1` 表示已占用或掩膜阻挡 -- `data`:`canvas` 的积分图,用于 O(1) 判断矩形区域是否为空 +- `canvas`:真实占用像素(`1` 表示已占用或掩膜阻挡) +- `data`:兼容旧矩形查询的积分图;当前主路径不依赖逐词重建 -`query_direct()` 的行为: +`place_glyph_exact()` 行为: -1. 如果画布完全空,随机返回一个位置 -2. 先随机探测最多 16 个位置 -3. 如果未命中,扫描所有可能位置 -4. 对每个候选位置用积分图判断包围盒是否为空 -5. 从所有可放位置中随机选一个 +1. 等字号批次前 `70%` 姓名优先选择靠近可填区域质心的合法位置,避免少量姓名接受第一个随机空位而形成大块空洞 +2. 多字号批次只对前 `25%` 以及高权重(`score ≥ 0.80`)姓名启用中心偏好 +3. 少于 `100` 人的等字号批次中心候选数提高到 `768`;`100–300` 人提高到 `512`;其余保持 `256` +4. 随机探测失败后,从种子决定的偏移开始完整扫描 +5. 对每个候选位置逐像素比较碰撞 mask 与 C++ `canvas` +6. 命中后只写入真实字形,占位查询和写入在同一次 C++ 调用中完成 -`stamp_and_rebuild()` 的行为: +真实字形搜索允许透明角落和笔画间空隙安全交错,比外接矩形碰撞更密。安全边距只参与候选检查,不会被双侧累计放大。 -1. 把真实字形像素写入 C++ `canvas` -2. 从字形左上角开始局部重建积分图 +## 高清精修(隔离带) -当前碰撞检测是“矩形找位置 + 字形像素落图”。找位置阶段要求文字包围盒矩形完全空;实际占用阶段只写入字形像素。 +正式流水线不在低清工作网格增加边距,因为 `1` 个工作像素会被放大成约 `5–6` 个高清像素并显著损失容量。 + +隔离带优先作用在最终高清画布,宽度为 `1px`,不是用户参数。精修流程: + +1. 小画布完整布局放大到高清 +2. 逐词在高清画布上验证,加入 `1px` 隔离带 +3. 碰撞时只允许最大 `max_shift=24px` 的局部微位移 +4. 精修失败时,最多进行 `1` 次确定性整批优先级回溯(把失败词移到队列最前) +5. 若轮廓过窄无法容纳额外隔离带,降为精确零间隙碰撞(`clearance=0`) +6. 全部候选无解时,由外层扩大画布后整批重新布局,不进行远距离单词搬移 + +密度搜索会保留各档完整整批候选。最高密度候选若无法在有限位置修正范围内通过高清隔离验收,则改用上一档完整整批候选;不会对碰撞姓名单独缩字号,也不会接受带重叠的高密度结果。 ## 填充率重试 一次布局完成后,`compute_fill_ratio_fast()` 重新渲染 layout 并计算填充率。 -如果填充率低于 `MIN_ACCEPT_FILL_RATIO`,管线会尝试二分放大 `size_scale`,并可通过 `FILL_RETRY_RELAX_LARGE_CAP` 放宽大字号限制。 +- 面积模型首次完整放入但明显低于 `TARGET_FILL_RATIO` 时,只允许一次整批等比例增字号尝试。新布局必须仍然完整且真实填充率更高才会采用 +- 整批未完整放入时,流水线不会输出半成品:先整批等比例调整字号;触及最小字号仍失败时按 `CANVAS_RETRY_GROWTH` 扩大画布并重新生成 -如果仍无法达到目标,会保留填充率最好的 layout。 +## 字号硬约束 -## 已知算法限制 +- 不存在逐词缩字号、gap filling 或大字号自动压缩路径 +- `USER_MIN_FONT_SIZE` 和 `USER_MAX_FONT_SIZE` 是不可越过的边界;冲突时直接报错 +- `SIZE_RATIO=1` 在面积估算、整批重试和高清放大阶段始终保持单一字号 +- 完整名单不可关闭;放不下时只能整批重排、整批等比例调整或扩大画布 -- 当前没有真正的“整轮统一降字号”机制。重复填充虽然按轮展开,但每个词可以独立降字号。 -- `LIMIT_LARGE_FONTS` 是全局计数,不区分姓名和轮次。 -- `LAYOUT_ORDER_MODE_INTERLEAVED_RANDOM` 只改变展开序列的顺序,不改变 C++ 的空间采样策略。 -- `ENABLE_STRATIFIED_SAMPLING` 调用的 `reorder_stratified()` 对 `query_direct()` 主路径无效。 -- C++ `batch_query()` 会用矩形 `update_rect_add()` 更新,不走真实字形 `stamp_and_rebuild()`;当前 Python 主路径没有使用它。 +## 空洞优化(等字号模式) -## 后续公平重复填充建议 +等字号模式下,字符级最大空洞(`largest_empty_square_size`)用于判断是否存在"可放字符级空洞": -如果目标是“每一轮名单整体公平变小”,建议新增独立模式,而不是继续微调当前逐词降字号: +- 当前 `largest_empty_square ≥ ceil(font_size * 1.25)` 时视为存在字符级空洞 +- 流水线会在同一字号档位尝试最多 `3` 种不同布局种子(按人数递减预算) +- 目标是最小化最大空洞尺寸,不改变任何字号 -- `REPEAT_FILL_MODE = "ROUND_ROBIN_FAIR"` -- 以轮为单位生成任务 -- 同一轮使用统一字号或统一权重映射 -- 某一轮放不下时,整轮降低字号重试 -- 失败词统一进入下一档补位队列 -- 大字号限制按姓名或轮次计数 +## 自动画幅 + +`calculate_dynamic_dimensions()` 先对 `BASE_HD_WIDTH × BASE_HD_HEIGHT` 做一次 probe 掩膜,计算可填比例 `free_ratio`,再用以下公式扩展: + +``` +area_per_word = (MIN_READABLE_HEIGHT_PX²) × avg_len × 1.05 +required_area = num_words × area_per_word × N_REPETITIONS / TARGET_FILL_RATIO +required_area /= free_ratio +scale_factor = √(required_area / current_area) +new_w = clamp(base_w × scale_factor, max_edge=6000) +``` + +- 扩展后宽高向上取整到最近的 `100` +- 最大边长限制为 `6000px`,避免 SVG/PNG 过度膨胀 +- 掩膜只生成一次,扩展后复用 diff --git a/docs/API.md b/docs/API.md index b0fbce6..ceee4eb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -7,13 +7,14 @@ - 默认后端地址:`http://localhost:8000` - 请求体中上传文件使用 `multipart/form-data` - `params` 字段是 JSON 字符串,顶层必须是对象 -- 任务状态存在内存中,服务重启后状态会丢失 +- 任务状态存在内存中(`JobManager`),服务重启后状态会丢失;文件仍保留在 `service_workspace` +- CORS 已开启,允许所有来源 ## Jobs ### GET `/api/health` -返回: +健康检查。 ```json {"ok": true} @@ -30,15 +31,13 @@ Form 字段: | 字段 | 必填 | 说明 | -| --- | --- | --- | +|------|------|------| | `name_list` | 是 | `.xlsx` 名单文件 | | `mask_image` | IMAGE 模式必填 | `.png` / `.jpg` / `.jpeg` 掩膜 | -| `font_file` | 否 | 临时上传字体,支持后端 `_FONT_EXTENSIONS` 中的格式 | +| `font_file` | 否 | 临时上传字体(`.ttf` / `.ttc` / `.otf`) | | `font_id` | 否 | 使用已上传字体 | | `params` | 否 | JSON 字符串,合并到任务配置 | -字体格式当前支持 `.ttf`、`.ttc`、`.otf`。 - `params` 示例: ```json @@ -129,10 +128,12 @@ SSE 事件流。事件数据模型: ### GET `/api/jobs/{job_id}/locations` -查询词语位置。查询参数: +查询词语位置。 + +查询参数: | 参数 | 说明 | -| --- | --- | +|------|------| | `name` | 可选;为空返回全部,非空精确匹配 | 返回: @@ -173,7 +174,7 @@ SSE 事件流。事件数据模型: 查询参数: | 参数 | 默认 | 说明 | -| --- | --- | --- | +|------|------|------| | `fill` | `fill` | `fill` / `dot` / `line` / `ring` | | `stroke` | `0` | 是否描边 | | `spacing` | `10` | 点阵间距 | @@ -192,11 +193,11 @@ SSE 事件流。事件数据模型: 返回后端硬编码模板列表: -- `poster_1x2` -- `poster_4x5` -- `poster_1x1` -- `poster_3x4` -- `poster_16x9` +- `poster_1x2`(竖版手机海报,1080×2160) +- `poster_4x5`(社交媒体图,1080×1350) +- `poster_1x1`(方形封面,1080×1080) +- `poster_3x4`(竖版广告,1080×1440) +- `poster_16x9`(横版电商,1920×1080) ## Assets @@ -205,7 +206,7 @@ SSE 事件流。事件数据模型: 上传素材。Form 字段: | 字段 | 必填 | 说明 | -| --- | --- | --- | +|------|------|------| | `file` | 是 | 素材文件 | | `name` | 否 | 名称 | | `type` | 否 | 默认 `upload` | @@ -215,7 +216,7 @@ SSE 事件流。事件数据模型: 从任务产物导入素材。Form 字段: | 字段 | 默认 | 说明 | -| --- | --- | --- | +|------|------|------| | `kind` | `png` | 产物类型 | | `name` | 空 | 素材名称 | | `type` | `wordcloud` | 素材类型 | @@ -243,10 +244,10 @@ SSE 事件流。事件数据模型: ### POST `/api/projects` -Form 字段: +创建工程。Form 字段: | 字段 | 必填 | 说明 | -| --- | --- | --- | +|------|------|------| | `name` | 是 | 工程名 | | `template_id` | 是 | 模板 ID | | `background_color` | 是 | `#RRGGBB` | @@ -272,29 +273,61 @@ Form 字段: ### GET `/api/fonts` -返回字体列表,包含默认字体项。 +返回字体列表,包含默认字体项(`__default__`)。 ### POST `/api/fonts` 上传字体。Form 字段: | 字段 | 必填 | 说明 | -| --- | --- | --- | -| `file` | 是 | 字体文件 | +|------|------|------| +| `file` | 是 | 字体文件(`.ttf` / `.ttc` / `.otf`) | | `name` | 否 | 字体名 | ### DELETE `/api/fonts/{font_id}` 删除已上传字体。默认字体不能删除。 +## Line Spacing Analysis + +### POST `/api/jobs/{job_id}/analyze-line-spacing` + +分析 SVG 词云路径的线距,返回 `LineSpacingAnalysisSummary`。 + +请求体(JSON): + +```json +{ + "percentile": 3, + "elementWidth": 100.0, + "elementHeight": 100.0, + "sampleStep": 2.0 +} +``` + +响应字段: + +| 字段 | 说明 | +|------|------| +| `percentile` | 线距百分位 | +| `spacingPx` | 采样线距(像素) | +| `spacingMm` | 采样线距(毫米) | +| `minSpacingPx` | 最小线距(像素) | +| `minSpacingMm` | 最小线距(毫米) | +| `curveCount` | 曲线数量 | +| `segmentCount` | 线段数量 | +| `sourceWidth` / `sourceHeight` | SVG 原始尺寸 | +| `elementWidth` / `elementHeight` | 目标元素尺寸 | + ## 常见错误 | 场景 | 状态码 | detail | -| --- | --- | --- | +|------|--------|--------| | `params` 不是合法 JSON | 400 | `params must be valid JSON` | | `params` 不是对象 | 400 | `params must be JSON object` | | `name_list` 非 `.xlsx` | 400 | `name_list must be xlsx` | | IMAGE 模式缺少掩膜 | 400 | `mask_image is required when MODE=IMAGE` | +| 掩膜文件不存在 | 400 | `mask_image must be png/jpg/jpeg` | | job 不存在 | 404 | `job not found` | | 产物未就绪 | 404 | `artifact not ready` | | 文件类型未知 | 404 | `unknown artifact kind` | diff --git a/docs/CANVAS_STUDIO.md b/docs/CANVAS_STUDIO.md index 33f8c2d..40df0cd 100644 --- a/docs/CANVAS_STUDIO.md +++ b/docs/CANVAS_STUDIO.md @@ -1,23 +1,30 @@ # 画布与贴纸功能 -本文档描述当前前端代码中已经实现的画布设计功能。事实来源是 `frontend/src/App.tsx`、`frontend/src/pages/CanvasStudio.tsx`、`frontend/src/components/ExportPanel.tsx`、`frontend/src/lib/stickerLibrary.ts` 和 `frontend/src/types.ts`。 +本文档描述当前前端代码中已实现的画布设计功能。事实来源是 `frontend/src/pages/CanvasStudio.tsx`、`frontend/src/App.tsx`、`frontend/src/components/ExportPanel.tsx`、`frontend/src/lib/stickerLibrary.ts`、`frontend/src/lib/canvasDocument.ts` 和 `frontend/src/types.ts`。 ## 页面关系 -- 应用默认进入画布设计页。 -- 画布页顶部的“添加词云”会切换到原有词云生成页。 -- 词云生成页顶部的“返回画布”会回到画布设计页。 -- 词云生成页的导出面板保留下载 SVG/位图功能,并新增“作为贴纸导入贴纸库”。 +``` +TemplateHome(首页) + ├── CanvasStudio(画布设计页)─ 添加词云 ─→ TestWorkbench(词云生成页) + ├── TestWorkbench(词云生成页)─ 返回画布 ─→ CanvasStudio + └── HelpPage(帮助页) +``` + +- 应用默认进入首页,展示模板列表 +- 画布页顶部的"添加词云"会切换到词云生成页 +- 词云生成页顶部的"返回画布"会回到画布设计页 +- 词云生成页导出面板保留下载 SVG/位图功能,并新增"作为贴纸导入贴纸库" ## 贴纸库 贴纸库是前端本地能力,不依赖后端接口: -- 存储位置:`localStorage` 的 `wordcloud-sticker-library`。 -- 数据类型:`StickerAsset`,当前支持 `svg` 和 `image` 两类,已实现入口主要使用 `svg`。 -- 用户导入 SVG:画布页左侧“贴纸”面板读取 `.svg` 文件文本,写入贴纸库,并立即插入画布。 -- 词云作为贴纸:导出面板按当前 SVG 导出参数请求 `/api/jobs/{job_id}/custom.svg`,读取返回的 SVG 文本后写入贴纸库。 -- 贴纸删除只删除本地贴纸库记录,不会删除已经导出的总图文件。 +- 存储位置:`localStorage` 的 `wordcloud-sticker-library` +- 数据类型:`StickerAsset`,当前支持 `svg` 和 `image` 两类,已实现入口主要使用 `svg` +- 用户导入 SVG:画布页左侧"贴纸"面板读取 `.svg` 文件文本,写入贴纸库,并立即插入画布 +- 词云作为贴纸:导出面板按当前 SVG 导出参数请求 `/api/jobs/{job_id}/custom.svg`,读取返回的 SVG 文本后写入贴纸库 +- 贴纸删除只删除本地贴纸库记录,不会删除已经导出的总图文件 ## 画布模型 @@ -25,43 +32,71 @@ 当前模型字段: -- `width`:画布宽度,默认 `1600`。 -- `height`:画布高度,默认 `1000`。 -- `background`:画布背景色,默认 `#ffffff`。 -- `elements`:画布元素数组。 +- `width`:画布宽度,默认 `1600` +- `height`:画布高度,默认 `1000` +- `background`:画布背景色,默认 `#ffffff`,支持透明 `#00000000` +- `elements`:画布元素数组 +- `layers`:图层数组(可选,支持可见性、锁定、文件夹分组) +- `layerFolders`:图层文件夹数组(可选) 当前元素类型: -- `sticker`:引用贴纸库中的 SVG 或图片。 -- `text`:普通文字,支持内容、颜色、字号、字体、字重、位置、尺寸、旋转、透明度。 -- `rect`:矩形,支持填充、描边、描边宽度、位置、尺寸、旋转、透明度。 -- `ellipse`:椭圆,支持填充、描边、描边宽度、位置、尺寸、旋转、透明度。 -- `line`:线条,支持描边、描边宽度、位置、尺寸、旋转、透明度。 +- `sticker`:引用贴纸库中的 SVG 或图片(`assetId`) +- `text`:普通文字(内容、颜色、字号、字体、字重、位置、尺寸、旋转、透明度) +- `rect`:矩形(填充、描边、描边宽度、位置、尺寸、旋转、透明度) +- `ellipse`:椭圆(同上) +- `line`:线条(描边、描边宽度、位置、尺寸、旋转、透明度) ## 编辑行为 -- 点击贴纸库中的贴纸会把该贴纸插入画布中央区域。 -- 画布元素可拖拽移动。 -- 选中元素后可通过右下角手柄调整大小。 -- 右侧属性面板可以精确编辑位置、尺寸、旋转、透明度和元素特有属性。 -- 右侧属性面板提供上移、下移和删除。 -- 画布面板支持修改画布宽高、背景色、导出 SVG、清空画布。 +- 点击贴纸库中的贴纸会把该贴纸插入画布中央区域 +- 画布元素支持: + - **拖拽移动**:鼠标/触摸拖拽 + - **大小调整**:选中后通过右下角手柄调整 + - **精确编辑**:右侧面板可修改位置、尺寸、旋转、透明度、元素特有属性 + - **层级调整**:上移、下移 + - **删除**:删除元素 +- 右侧面板支持修改画布宽高、背景色 +- 支持导出总图 SVG、清空画布 +- **图层管理**:支持图层可见性、锁定、文件夹分组 +- **吸附对齐**:元素拖拽时自动吸附到附近元素边缘 +- **缩放**:编辑视图可缩放(不影响导出尺寸) ## SVG 导出 -“导出总图 SVG”由前端序列化当前画布模型完成: +"导出总图 SVG"由前端序列化当前画布模型完成: -- 导出文件名:`canvas-design.svg`。 -- 背景输出为一个覆盖全画布的 ``。 -- 贴纸输出为 ``,SVG 贴纸会以内联 `data:image/svg+xml` 的形式嵌入。 -- 文字输出为 ``。 -- 基础形状输出为原生 SVG 的 ``、``、``。 -- 元素的位移和旋转写入 SVG `transform`,透明度写入 `opacity`。 +- 导出文件名:`canvas-design.svg` +- 背景输出为一个覆盖全画布的 ``(透明背景时 fill="none") +- 贴纸输出为 ``,SVG 贴纸会以内联 `data:image/svg+xml` 的形式嵌入 +- 文字输出为 `` +- 基础形状输出为原生 SVG 的 ``、``、`` +- 元素的位移和旋转写入 SVG `transform`,透明度写入 `opacity` + +## ZIP 导出 + +支持导出含以下内容的 ZIP 包: + +- `canvas.svg`:总图 SVG +- `sticker_{id}.svg`:画布中所有独立 SVG 贴纸 +- `manifest.json`:元素元数据清单 + +## 同底图换名单(Replace Session) + +画布页支持"替换词云名单"功能: + +1. 用户右键点击画布上的词云贴纸,选择"同底图换名单" +2. 生成一个 `WordcloudReplaceSession`,包含: + - `mask`:原词云的底图遮罩(SVG/Image) + - `target`:目标元素信息(含宽度、高度、原遮罩元素 ID) +3. 会话传递到词云生成页 +4. 词云生成页锁定底图,只替换名单内容,生成后自动按原尺寸贴回画布 ## 当前边界 -- 贴纸库和画布文档只保存在当前浏览器本地,不会跨浏览器或跨设备同步。 -- 当前没有服务端素材库、项目文件格式或协作编辑接口。 -- SVG 导入按用户信任文件处理;编辑器预览使用图片方式加载,不在页面中直接执行 SVG 内容。 -- 当前缩放只影响编辑视图,不改变导出尺寸。 -- 当前导出目标是 SVG;没有在画布页实现 PNG/JPG 总图导出。 +- 贴纸库和画布文档只保存在当前浏览器 `localStorage`,不会跨浏览器或跨设备同步 +- 当前没有服务端素材库、项目文件格式或协作编辑接口(Projects 接口存在但服务层级较浅) +- SVG 导入按用户信任文件处理;编辑器预览使用图片方式加载,不在页面中直接执行 SVG 内容 +- 当前缩放只影响编辑视图,不改变导出尺寸 +- 当前导出目标是 SVG;没有在画布页实现 PNG/JPG 总图导出 +- 线距分析结果显示在元素属性面板中,辅助激光加工参数设定 diff --git a/docs/CONFIG.md b/docs/CONFIG.md index d120e07..ba88538 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -12,9 +12,9 @@ CLI 入口 `backend/wordcloud_generate_hybrid.py` 的顺序: 4. 调用 `finalize_runtime_config()` 派生路径、字体、输出路径 5. 调用 `set_random_seed()` -服务模式下,`POST /api/jobs` 会生成任务配置并写入: +服务模式下,`POST /api/jobs` 收到的 `params` JSON 会由服务层合并到任务配置,写入: -```text +``` backend/service_workspace/{job_id}/config.json ``` @@ -25,48 +25,110 @@ backend/service_workspace/{job_id}/config.json 当前前端 `TestWorkbench.tsx` 提交的关键字段: | 前端字段 | 后端配置 | -| --- | --- | -| `dataColIndex` | `DATA_COL_INDEX` | +|----------|----------| | `seed` | `SEED` | +| `dataColIndex` | `DATA_COL_INDEX` | | `weightColIndex` | `WEIGHT_COL_INDEX` | | `fontColor` | `FONT_COLOR` | | `nRepetitions` | `N_REPETITIONS` | -| `strokeWeights=false` | `ENABLE_STROKE_WEIGHTS=false` | +| `strokeWeights` | `ENABLE_STROKE_WEIGHTS` | +| `sizeRatio` | `SIZE_RATIO` | +| `packingEfficiency` | `PACKING_EFFICIENCY` | +| `targetFillRatio` | `TARGET_FILL_RATIO` | +| `userMinFontSize` | `USER_MIN_FONT_SIZE` | +| `userMaxFontSize` | `USER_MAX_FONT_SIZE` | +| `minReadableHeightPx` | `MIN_READABLE_HEIGHT_PX` | +| `workScale` | `WORK_SCALE` | +| `fillOn` | `FILL_ON` | +| `canvasRetryMaxRounds` | `CANVAS_RETRY_MAX_ROUNDS` | +| `canvasRetryGrowth` | `CANVAS_RETRY_GROWTH` | +| `layoutSeed` | `LAYOUT_SEED` | -前端只在重复次数大于 1 时传 `N_REPETITIONS`,只在关闭笔画权重时传 `ENABLE_STROKE_WEIGHTS=false`。 +"名单完整性"不是可关闭参数,始终是硬约束。 -## 常用配置 +## 完整配置清单 + +### 掩膜与画布 | 键 | 默认值 | 说明 | -| --- | --- | --- | -| `MODE` | `IMAGE` | 掩膜模式,`IMAGE` 或 `TEXT` | -| `MASK_IMAGE_PATH` | `7887.png` | IMAGE 模式掩膜路径,服务模式会覆盖为上传文件路径 | -| `IMAGE_CANVAS_MODE` | `WIDTH` | 图片掩膜缩放模式 | -| `FILL_ON` | `BLACK` | `BLACK` 表示黑色可填,`WHITE` 表示白色可填 | -| `EXCEL_PATH` | `四个方向汇总录取名单.xlsx` | Excel 路径,服务模式会覆盖为上传文件路径 | +|----|--------|------| +| `MODE` | `IMAGE` | `IMAGE` 或 `TEXT` | +| `MASK_IMAGE_PATH` | `7887.png` | IMAGE 模式掩膜路径;服务模式会被上传文件路径覆盖 | +| `IMAGE_CANVAS_MODE` | `WIDTH` | 图片掩膜缩放模式:`WIDTH` / `HEIGHT` / `AUTO` | +| `FILL_ON` | `BLACK` | `BLACK` = 黑色可填,`WHITE` = 白色可填 | +| `FILL_CORNERS` | `False` | 是否自动填充四角区域 | +| `CORNER_FILL_RATIO` | `0.15` | 四角填充面积占画布比例 | +| `BASE_HD_WIDTH` | `4000` | 默认高清画布宽 | +| `BASE_HD_HEIGHT` | `4000` | 默认高清画布高 | +| `MIN_READABLE_HEIGHT_PX` | `22` | 最小可读高度(像素) | +| `WORK_SCALE` | `0.18` | 高清画布到运算网格的缩放比例 | +| `CANVAS_RETRY_MAX_ROUNDS` | `1` | 画布扩大重试轮数 | +| `CANVAS_RETRY_GROWTH` | `1.12` | 完整名单放不下时的整画布边长增长比例 | + +### 文本掩膜(TEXT 模式) + +| 键 | 默认值 | 说明 | +|----|--------|------| +| `MASK_TEXT` | `A` | 用作掩膜的文本 | +| `MASK_FONT_PATH` | 项目默认字体 | 掩膜字体 | +| `MASK_FONT_SIZE` | `3000` | 掩膜文字字号 | + +### 数据与权重 + +| 键 | 默认值 | 说明 | +|----|--------|------| +| `EXCEL_PATH` | `四个方向汇总录取名单.xlsx` | Excel 路径;服务模式被上传文件覆盖 | | `DATA_COL_INDEX` | `1` | 名单列,0-based | | `WEIGHT_COL_INDEX` | `None` | 权重列,0-based | | `WEIGHT_COL_NAME` | `None` | 权重列名,优先于列索引 | | `REMOVE_DUPLICATES` | `False` | 是否对名单去重 | -| `ENABLE_STROKE_WEIGHTS` | `True` | 是否在无 Excel 权重时使用笔画复杂度权重 | +| `ENABLE_STROKE_WEIGHTS` | `True` | 是否使用笔画复杂度权重 | + +### 填充策略 + +| 键 | 默认值 | 说明 | +|----|--------|------| | `N_REPETITIONS` | `1` | 名单重复倍率 | -| `SIZE_RATIO` | `2.0` | `max_font` 相对 `min_font` 的比例 | -| `PACKING_EFFICIENCY` | `0.85` | 面积模型中的打包效率 | -| `MIN_ACCEPT_FILL_RATIO` | `0.75` | 填充率重试阈值 | -| `REQUIRE_ALL_WORDS` | `True` | 搜索阶段是否优先要求达到目标词数 | +| `TARGET_FILL_RATIO` | `0.45` | 面积模型目标笔画填充率 | +| `SIZE_RATIO` | `2.0` | `max_font` 相对 `min_font` 的比例;`1.0` 为等字号模式 | +| `PACKING_EFFICIENCY` | `0.9` | 面积模型中的打包效率 | + +### 字号硬约束 + +| 键 | 默认值 | 说明 | +|----|--------|------| | `USER_MIN_FONT_SIZE` | `None` | 用户覆盖最小字号 | | `USER_MAX_FONT_SIZE` | `None` | 用户覆盖最大字号 | -| `FONT_SCALE_MIN` | `0.5` | 二分搜索缩放下限 | -| `FONT_SCALE_MAX` | `1.2` | 二分搜索缩放上限 | -| `LIMIT_LARGE_FONTS` | `True` | 是否限制大字号数量 | -| `LARGE_FONT_LIMIT_RATIO` | `0.2` | 大字号数量上限占比 | -| `LARGE_FONT_THRESHOLD_RATIO` | `0.8` | 超过有效最大字号该比例视为大字号 | -| `LARGE_FONT_CAP_RATIO` | `0.6` | 超过大字号限制后的降级比例 | -| `ENABLE_DOT_MATRIX` | `False` | 是否用点阵补偿空白区域 | -| `CANVAS_RETRY_MAX_ROUNDS` | `1` | 画布扩大重试轮数 | +| `MIN_FONT_FLOOR` | `2` | 绝对字号下限 | + +### 字体与配色 + +| 键 | 默认值 | 说明 | +|----|--------|------| +| `WC_FONT_PATH` | 项目默认字体 | 布局字体 | +| `FONT_FALLBACK_PATHS` | 系统字体列表 | 字体回退路径 | | `FONT_COLOR` | `#000000` | 固定字体颜色;为空时使用调色板 | +| `DARK_COLOR_PALETTE` | 5 色深色 | `FILL_ON=WHITE` 时使用 | +| `LIGHT_COLOR_PALETTE` | 5 色浅色 | `FILL_ON=BLACK` 时使用 | + +### 输出 + +| 键 | 默认值 | 说明 | +|----|--------|------| +| `OUTPUT_DIR` | `.` | 输出目录 | +| `OUTPUT_PREFIX` | `""` | 输出文件前缀 | +| `OUTPUT_PNG` | `Efficient_Result_HD_AutoResize.png` | PNG 文件名 | +| `OUTPUT_SVG` | `Efficient_Result_HD_AutoResize.svg` | SVG 文件名 | +| `DB_PATH` | `wordcloud_hd.db` | SQLite 数据库文件名 | +| `METRICS_FILE` | `metrics.json` | 指标文件名 | +| `SAVE_DEBUG_IMAGES` | `False` | 是否保存调试图 | +| `DEBUG_OUTPUT_DIR` | `output` | 调试文件目录 | + +### 可复现性 + +| 键 | 默认值 | 说明 | +|----|--------|------| | `SEED` | `None` | 随机种子 | -| `LAYOUT_ORDER_MODE` | `SORTED` | 展开序列排序模式 | | `LAYOUT_SEED` | `None` | 布局顺序种子,默认继承 `SEED` | ## JSON 别名 @@ -74,9 +136,8 @@ backend/service_workspace/{job_id}/config.json `apply_json_config()` 支持部分小写别名: | 别名 | 正式配置 | -| --- | --- | +|------|----------| | `seed` | `SEED` | -| `layout_order_mode` | `LAYOUT_ORDER_MODE` | | `layout_seed` | `LAYOUT_SEED` | | `excel_path` | `EXCEL_PATH` | | `mask_image_path` | `MASK_IMAGE_PATH` | @@ -99,6 +160,7 @@ CLI 覆盖只支持 `parse_args()` 中定义的参数,不支持 `font_color` ## 路径规则 -相对路径会以 `backend` 目录作为基准解析。输出路径会在 `finalize_runtime_config()` 中创建。 - -字体会先尝试项目字体,再尝试 `FONT_FALLBACK_PATHS`。字体不可用会直接失败。 +- 相对路径以 `backend` 目录作为基准解析 +- 输出路径会在 `finalize_runtime_config()` 中自动创建目录 +- 字体先尝试项目字体,再尝试 `FONT_FALLBACK_PATHS` +- 字体不可用会直接失败并退出 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..3d2a436 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,132 @@ +# 部署说明 + +本文档覆盖本地开发、Docker 和 Ubuntu 服务器部署。 + +## 本地开发 + +### 系统要求 + +- Python 3.9+ +- Node.js 16+ + npm +- C++ 编译器(macOS: Xcode CLI Tools;Linux: `build-essential`) + +### 一键前后端联调 + +```bash +./start-all.sh +``` + +- 后端:http://localhost:8000 +- 前端:http://localhost:3000 +- 后端端口可在环境变量 `BACKEND_PORT` 中覆盖(默认 `8000`) + +### 单独启动后端 + +```bash +cd backend +./start-dev.sh +``` + +脚本行为: + +1. 探测 Python 3.9+(优先系统 Python,否则创建 `.venv`) +2. 检查依赖:`fastapi uvicorn python-multipart pydantic pandas openpyxl pillow numpy matplotlib` +3. 在外部 Python 中复用 `scipy`(ABI 匹配时),避免网络安装 +4. C++ 扩展 `ewc_core` 源码有更新时自动重新编译 +5. 确保运行时目录:`service_workspace`、`service_assets`、`service_projects` +6. 自动释放被占用的端口 +7. 以 `uvicorn --reload` 启动 FastAPI 服务 + +### 单独启动前端 + +```bash +cd frontend +npm install +npm run dev +``` + +前端通过 Vite 代理访问后端,配置见 `frontend/vite.config.ts`。 + +## Docker 部署 + +项目根目录包含 `Dockerfile` 和 `docker-compose.yml`。 + +### 构建并启动 + +```bash +docker compose up -d --build +``` + +- 前端:http://localhost:3000 +- 后端:http://localhost:8000 +- 后端文档:http://localhost:8000/docs + +### 常用命令 + +```bash +docker compose logs -f # 查看日志 +docker compose down # 停止服务 +docker compose restart # 重启服务 +``` + +## Ubuntu 服务器部署 + +使用 `install-ubuntu.sh` 一键部署: + +```bash +chmod +x install-ubuntu.sh +./install-ubuntu.sh +``` + +脚本行为: + +1. 检查并安装 Docker(Ubuntu/Debian 自动安装) +2. 确保 `docker compose` 可用 +3. 调用 `docker compose up -d --build` +4. 打印访问地址 + +可选环境变量: + +| 变量 | 说明 | +|------|------| +| `SKIP_DOCKER_INSTALL=1` | 跳过 Docker 安装检测 | +| `NO_BUILD=1` | 不强制 `--build`(沿用已有镜像) | + +首次部署会编译 C++ 扩展和前端,可能需要几分钟。 + +### release 包 + +`scripts/pack-release.sh` 用于打包 release 包,包含: + +- 预编译前端(无 Node 环境也能运行) +- Docker 部署脚本 +- 简化版 `install-ubuntu.sh` + +## 产物目录 + +运行时产生的数据和文件保存在以下目录(已加入 `.gitignore`): + +| 目录 | 用途 | +|------|------| +| `backend/service_workspace/` | 任务产物(输入/输出/配置) | +| `backend/service_assets/` | 后端素材库 | +| `backend/service_projects/` | 后端工程项目 | +| `backend/service_design_templates/` | 设计模板 | +| `backend/service_fonts/` | 上传字体 | +| `backend/.runtime/` | 运行时日志 | +| `backend/benchmark_outputs/` | 基准测试结果 | + +## 环境变量 + +| 变量 | 影响范围 | 说明 | +|------|----------|------| +| `BACKEND_PORT` | 本地开发 | 后端监听端口,默认 `8000` | +| `SKIP_DOCKER_INSTALL` | Ubuntu 部署 | 跳过 Docker 自动安装 | +| `NO_BUILD` | Ubuntu 部署 | 不强制 Docker 重建 | +| `WORDCLOUD_SCIPY_SITE` | 本地开发 | 外部 SciPy 路径加速启动 | + +## 注意事项 + +- 重启后端服务会导致内存中的任务状态丢失,但 `service_workspace` 中的文件产物不受影响 +- 首次启动时 C++ 扩展编译需要系统编译器;如果失败请检查 `build-essential` 或 Xcode CLI Tools +- 前端 `localStorage` 中的贴纸库和画布文档不会自动同步到服务器 diff --git a/docs/PROJECT_STANDARD.md b/docs/PROJECT_STANDARD.md index 5ed4bdd..6949f09 100644 --- a/docs/PROJECT_STANDARD.md +++ b/docs/PROJECT_STANDARD.md @@ -1,45 +1,115 @@ # 项目标准说明 -本文档按当前代码整理,覆盖项目边界、运行方式、输入输出和维护约定。最后核对代码时间:2026-06-09。 +本文档覆盖项目边界、目录结构、运行方式、输入输出和维护约定。核对代码时间:2026-07-25。 ## 项目目标 -本项目生成基于名单和掩膜的词云图。后端负责读取 Excel 名单、处理掩膜、计算权重、布局、渲染和导出;前端提供参数面板、任务提交、结果查看、查找和导出入口。 +本项目是一套中文词云生成+画布设计系统,核心能力: -当前项目不是通用设计平台。`Projects`、`Assets`、`Templates` 接口存在,但主要服务于当前工作台原型和素材管理,不代表完整生产级工程系统。 +1. **词云生成**:读取 Excel 名单,按掩膜轮廓填充人名,输出 PNG/SVG/DB/metrics。 +2. **画布设计**:前端提供图层化画布、贴纸库、基础形状编辑、SVG 总图导出。 +3. **同底图换名单**:画布中已有的词云底图,支持只换名单不换位置和字号(保留视觉结构)。 +4. **线距分析**:对 SVG 路径进行线距采样,辅助激光加工参数设定。 ## 目录结构 -| 路径 | 职责 | -| --- | --- | -| `backend/wordcloud_generate_hybrid.py` | CLI 入口,加载配置后调用生成管线 | -| `backend/core/config.py` | 默认配置、JSON 配置合并、CLI 覆盖、路径和字体解析 | -| `backend/core/pipeline.py` | 生成主流程:读数据、画布、掩膜、权重、布局、渲染、DB、metrics | -| `backend/core/layout.py` | Python 布局调度、字号打分、逐词放置、SVG 导出 | -| `backend/core/weights.py` | 笔画复杂度权重、Excel 权重、面积字号模型 | -| `backend/core/mask.py` | 掩膜归一化、自动画布、边界安全 padding | -| `backend/core/render.py` | 填充率计算和点阵补偿 | -| `backend/EfficientWordCloud/` | C++ 扩展及其 Python 包装 | -| `backend/service/` | FastAPI 服务、任务管理、文件存储 | -| `frontend/src/` | React 工作台 | -| `docs/` | 标准文档入口 | +``` +wordcloud/ +├── docs/ # 标准文档 +│ ├── README.md +│ ├── PROJECT_STANDARD.md +│ ├── ALGORITHM.md +│ ├── CONFIG.md +│ ├── API.md +│ ├── CANVAS_STUDIO.md +│ ├── TESTING.md +│ └── DEPLOYMENT.md +├── backend/ +│ ├── core/ # 词云核心引擎 +│ │ ├── config.py # 配置默认值、别名、类型校验 +│ │ ├── pipeline.py # 主流程:数据→掩膜→权重→布局→渲染→导出 +│ │ ├── layout.py # Python 布局调度、SVG 路径导出 +│ │ ├── weights.py # 笔画权重、Excel 权重、面积字号模型 +│ │ ├── mask.py # 掩膜生成、归一化、自动画幅 +│ │ ├── render.py # 高清精修、填充率计算、重叠检测 +│ │ ├── fonts.py # 字体缓存 +│ │ └── ewc.py # 兼容层(基类+Python 稀疏网格) +│ ├── EfficientWordCloud/ # C++ 扩展(integral grid + 精确字形碰撞) +│ │ ├── efficient_wordcloud/ +│ │ │ ├── wordcloud.py # Python 包装 +│ │ │ └── src/ewc_core.cpp # Cython 扩展 +│ │ └── setup.py # C++ 编译入口 +│ ├── service/ # FastAPI 服务 +│ │ ├── app.py # HTTP 路由 +│ │ ├── schemas.py # Pydantic 模型 +│ │ ├── runner.py # 子进程任务执行 +│ │ ├── job_manager.py # 内存任务状态管理 +│ │ ├── storage.py # 任务文件目录管理 +│ │ ├── line_spacing.py # SVG 线距分析 +│ │ └── log_config.py # 服务日志配置 +│ ├── tests/ # 单元测试 +│ │ └── test_layout_constraints.py +│ ├── tools/ # 基准工具 +│ │ └── benchmark_layout.py +│ ├── service_workspace/ # 运行时任务产物(.gitignore) +│ ├── service_assets/ # 后端素材库(.gitignore) +│ ├── service_projects/ # 后端工程项目(.gitignore) +│ ├── service_design_templates/ # 设计模板(.gitignore) +│ └── start-dev.sh # 本地后端启动脚本 +├── frontend/ +│ ├── src/ +│ │ ├── App.tsx # 页面路由:home → canvas / wordcloud / help +│ │ ├── main.tsx +│ │ ├── types.ts # 全项目 TypeScript 类型 +│ │ ├── styles.css +│ │ ├── pages/ # 页面级组件 +│ │ │ ├── TemplateHome.tsx # 首页:模板选择 +│ │ │ ├── CanvasStudio.tsx # 画布设计页 +│ │ │ ├── TestWorkbench.tsx # 词云生成页 +│ │ │ └── HelpPage.tsx # 帮助页 +│ │ ├── components/ # 可复用组件 +│ │ │ ├── AdvancedPanel.tsx +│ │ │ ├── CanvasArea.tsx +│ │ │ ├── DockTabBar.tsx +│ │ │ ├── EditPanel.tsx +│ │ │ ├── ExportPanel.tsx +│ │ │ ├── FindPanel.tsx +│ │ │ ├── FloatingPanel.tsx +│ │ │ ├── ImportPanel.tsx +│ │ │ ├── ProgressPanel.tsx +│ │ │ ├── ViewControls.tsx +│ │ │ └── AppSettingsWindow.tsx +│ │ ├── hooks/ +│ │ ├── lib/ +│ │ │ ├── api.ts # API 工具函数 +│ │ │ ├── canvasDocument.ts # 画布模型操作 +│ │ │ ├── stickerLibrary.ts # 贴纸库存取 +│ │ │ ├── svgExport.ts # SVG/Zip 序列化 +│ │ │ └── templateLibrary.ts # 模板库 +│ │ └── vite-env.d.ts +│ ├── vite.config.ts +│ ├── package.json +│ └── tsconfig.json +├── start-all.sh # 一键前后端联调 +├── install-ubuntu.sh # Ubuntu Docker 一键部署 +├── release/ +│ └── install-ubuntu.sh +├── scripts/ +│ └── pack-release.sh +└── README.md / README_zh.md # 顶层面向用户说明(非标准) +``` ## 运行方式 -### 一键前后端联调 - -在项目根目录运行: +### 一键前后端联调(推荐开发用) ```bash ./start-all.sh ``` -它会: - -- 启动 `backend/start-dev.sh` -- 启动前端 `npm run dev` -- 默认后端端口为 `8000` -- 前端 Vite 端口为 `3000` +- 后端:http://localhost:8000 +- 前端:http://localhost:3000 +- 两者通过前端 Vite 代理通信(配置见 `frontend/vite.config.ts`) ### 单独启动后端 @@ -48,7 +118,11 @@ cd backend ./start-dev.sh ``` -`start-dev.sh` 会检查 Python 依赖、必要时创建 `.venv`,并在 C++ 源码更新后重新编译 `ewc_core`。 +脚本会自动: +- 探测 Python 3.9+(优先系统 Python,否则创建 `.venv`) +- 检查并安装 `fastapi uvicorn python-multipart pydantic pandas openpyxl pillow numpy matplotlib` +- 在外部 Python 中复用 `scipy`(ABI 匹配时),避免网络安装 +- C++ 扩展 `ewc_core` 源码有更新时自动重新编译 ### 单独启动前端 @@ -57,8 +131,6 @@ cd frontend npm run dev ``` -前端通过 Vite 代理访问后端。代理配置见 `frontend/vite.config.ts`。 - ### CLI 生成 ```bash @@ -66,81 +138,73 @@ cd backend python wordcloud_generate_hybrid.py --config /path/to/config.json ``` -CLI 配置优先级: - -1. `backend/core/config.py` 默认值 -2. JSON 配置文件 -3. CLI 参数覆盖 +CLI 配置优先级见 [CONFIG.md](CONFIG.md)。 ## 输入要求 ### Excel 名单 -服务接口只接受 `.xlsx`。默认名单列为 `DATA_COL_INDEX = 1`,也就是第 2 列,索引从 0 开始。 +- 服务接口仅接受 `.xlsx` +- `DATA_COL_INDEX` 默认 `1`(第2列,0-based) +- `REMOVE_DUPLICATES` 默认 `False`,前端也默认保留重复 +- 权重列:优先 `WEIGHT_COL_NAME`,次选 `WEIGHT_COL_INDEX` +- 有效权重必须为正数 -当 `REMOVE_DUPLICATES = False` 时,Excel 中重复姓名会保留。当前前端默认保留重复。 +### 权重来源 -### 权重 - -权重来源按优先级合并: - -1. Excel 权重列:`WEIGHT_COL_NAME` 优先于 `WEIGHT_COL_INDEX` -2. 笔画复杂度权重:受 `ENABLE_STROKE_WEIGHTS` 控制 -3. 默认权重:没有权重时使用 `10` - -关闭 `ENABLE_STROKE_WEIGHTS` 且不传 Excel 权重列时,所有姓名进入均等权重。 +| 场景 | 行为 | +|------|------| +| 有 Excel 权重,笔画权重开启 | Excel 值作为基础 × 笔画复杂度归一化乘数 | +| 有 Excel 权重,笔画权重关闭 | 仅 Excel 权重 | +| 无 Excel 权重,笔画权重开启 | 仅笔画复杂度权重 | +| 两者均无 | 全部默认权重 `10` | ### 掩膜 -`MODE = IMAGE` 时必须提供 PNG/JPG/JPEG 掩膜。后端会将图片转灰度并以阈值 `200` 二值化。 - -`FILL_ON = BLACK` 时,黑色区域可填充;`FILL_ON = WHITE` 时,白色区域可填充。 +- `MODE=IMAGE` 时必须上传 PNG/JPG/JPEG 掩膜文件 +- 后端转灰度后按阈值 `200` 二值化 +- `FILL_ON=BLACK`:黑色区域可填充;`FILL_ON=WHITE`:白色区域可填充 +- `MODE=TEXT`:用指定文字生成文本掩膜 ## 输出产物 -每次服务任务会创建: +服务任务产物位置: -```text +``` backend/service_workspace/{job_id}/ input/ mask.png names.xlsx output/ - Efficient_Result_HD_AutoResize.png - Efficient_Result_HD_AutoResize.svg - Efficient_Result_HD_AutoResize_stroke.svg - wordcloud_hd.db - metrics.json - debug/ - mask_src.png - mask_hd.png - mask_small.png - occ_fast.png + Efficient_Result_HD_AutoResize.png # 最终位图 + Efficient_Result_HD_AutoResize.svg # 填充 SVG + Efficient_Result_HD_AutoResize_stroke.svg # 描边 SVG(激光雕刻适用) + wordcloud_hd.db # SQLite:word_locations 表 + metrics.json # 运行指标 + debug/ # 调试图(受 SAVE_DEBUG_IMAGES 控制) config.json ``` -产物说明: - -| 文件 | 含义 | -| --- | --- | -| PNG | 最终位图结果 | -| SVG | 填充路径 SVG | -| `_stroke.svg` | 描边 SVG,适合继续加工 | -| SQLite DB | `word_locations` 表,记录词语位置、字号、颜色、方向、包围盒 | -| metrics | 运行指标、画布尺寸、填充率、配置快照 | -| debug | 调试图,受 `SAVE_DEBUG_IMAGES` 控制 | - ## 当前限制 -- 重复填充当前按名单轮次展开,但单个词在放置失败时会独立降字号;这会导致后几轮整体字号小于前几轮。 -- `reorder_stratified()` 当前对主路径 `query_direct()` 没有实际影响,因为 `query_direct()` 不使用 `valid_coords`。 -- C++ `Grid_query_direct` 的 GIL 释放包装没有包住实际扫描调用,性能并发上还有优化空间。 -- API 中的 Jobs 存储在进程内存,服务重启后历史任务状态会丢失;文件仍保留在 `service_workspace`。 -- 旧文档中的市场分析、路线图和性能宣传不作为当前能力承诺。 +- **名单完整性是不可关闭的硬约束。** 放不下的情况下只会整批缩放字号或扩大画布,不会漏词。 +- 不存在逐词缩字号、gap filling 或大字号自动压缩路径。 +- `USER_MIN_FONT_SIZE` 和 `USER_MAX_FONT_SIZE` 是不可越过的边界;冲突时直接报错。 +- `SIZE_RATIO=1` 时所有同权重词语保持完全相同字号,面积估算和重试阶段都维持单一字号。 +- C++ 中保留旧矩形查询 API 供底层兼容性,正式流水线使用 `place_glyph_exact()` 真实字形主路径。 +- 前端贴纸库和画布文档只保存在当前浏览器 `localStorage`,不跨设备同步。 +- Jobs 状态存在内存中,服务重启后历史任务状态丢失;文件仍保留在 `service_workspace`。 +- 当前 Projects、Assets、Templates 接口主要服务当前工作台原型和素材管理,不代表完整生产级工程系统。 ## 维护约定 -- 修改算法行为时,同步更新 [ALGORITHM.md](ALGORITHM.md)。 -- 新增或删除配置项时,同步更新 [CONFIG.md](CONFIG.md)。 -- 改 HTTP 接口或响应模型时,同步更新 [API.md](API.md)。 -- 不再新增单次变更记录文档;短期变更应合并进标准文档。 +| 变更范围 | 同步更新 | +|----------|----------| +| 算法行为 | ALGORITHM.md | +| 配置项增删 | CONFIG.md | +| HTTP 接口/响应模型 | API.md | +| 前端画布功能 | CANVAS_STUDIO.md | +| 测试/基准 | TESTING.md | +| 部署方式 | DEPLOYMENT.md | + +不再新增单次变更记录文档;短期变更合并进标准文档。 diff --git a/docs/README.md b/docs/README.md index 174ffc0..fd7f3b6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,27 +1,22 @@ -# WordCloud 项目文档入口 +# WordCloud 项目文档 -本文档目录是当前项目的标准文档入口。除非某个历史文档被明确标注为“标准文档”,否则以这里列出的文档为准。 - -## 文档准则 - -- 以代码为准。文档只描述当前代码实际行为,不提前承诺未实现能力。 -- 以 `backend/core/config.py`、`backend/core/pipeline.py`、`backend/core/layout.py`、`backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp` 为算法事实来源。 -- 以 `backend/service/app.py`、`backend/service/schemas.py` 为 HTTP API 事实来源。 -- 变更记录只记录历史,不作为使用说明。 +本文档目录是标准文档入口。标准文档直接对应代码实现;历史/规划文档不作为行为依据。 ## 标准文档 | 文档 | 用途 | -| --- | --- | -| [PROJECT_STANDARD.md](PROJECT_STANDARD.md) | 项目结构、运行方式、输入输出、工程约定 | -| [ALGORITHM.md](ALGORITHM.md) | 词云生成算法、重复填充、权重、C++ 碰撞搜索 | -| [CONFIG.md](CONFIG.md) | 配置项、优先级、前端参数到后端配置的映射 | -| [API.md](API.md) | FastAPI 接口、请求格式、响应结构、产物下载 | +|------|------| +| [PROJECT_STANDARD.md](PROJECT_STANDARD.md) | 项目目标、目录结构、运行方式、输入输出、工程约定 | +| [ALGORITHM.md](ALGORITHM.md) | 词云生成算法:面积模型、权重、布局、C++ 碰撞搜索、高清精修 | +| [CONFIG.md](CONFIG.md) | 配置项清单、来源优先级、前端参数映射、类型校验 | +| [API.md](API.md) | FastAPI HTTP 接口、请求格式、响应结构、产物下载 | | [CANVAS_STUDIO.md](CANVAS_STUDIO.md) | 画布设计、贴纸库、词云作为贴纸、总图 SVG 导出 | +| [TESTING.md](TESTING.md) | 单元测试、基准测试、质量门禁 | +| [DEPLOYMENT.md](DEPLOYMENT.md) | 本地开发、Docker、Ubuntu 服务器部署 | ## 非标准/历史文档 -以下文档可能包含历史规划、阶段性设想或已经过期的实现描述,不再作为行为依据: +以下文档可能包含过期规划或阶段性描述,不再作为行为依据: - `docs/PPT-EfficientWordCloud-详细大纲-v1.0.md` - `docs/stroke-weights-optional.md` @@ -29,4 +24,10 @@ - `backend/README.md` - `backend/README_zh.md` -需要确认行为时,优先查标准文档;标准文档仍不清楚时,直接查代码。 +## 工程约定 + +- **以代码为准。** 文档描述当前代码的实际行为,不提前承诺未实现能力。 +- 算法事实来源:`backend/core/pipeline.py`、`backend/core/layout.py`、`backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp` +- HTTP API 事实来源:`backend/service/app.py`、`backend/service/schemas.py` +- 前端事实来源:`frontend/src/App.tsx`、`frontend/src/pages/TestWorkbench.tsx`、`frontend/src/components/AdvancedPanel.tsx`、`frontend/src/types.ts` +- 变更不单独记文档;短期变更直接合并进标准文档。 diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..75a154e --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,109 @@ +# 测试与基准 + +本文档覆盖当前代码中的测试工具和基准脚本。 + +## 单元测试 + +位置:`backend/tests/test_layout_constraints.py` + +运行方式: + +```bash +cd backend +python -m pytest tests/test_layout_constraints.py -v +``` + +测试矩阵(基于 `unittest`,不依赖外部服务): + +| 测试 | 目的 | +|------|------| +| `test_size_ratio_one_keeps_every_equal_weight_size_identical` | `SIZE_RATIO=1` 时所有同权重词字号完全相同 | +| `test_explicit_equal_min_max_is_exact` | `USER_MIN=USER_MAX=12` 时所有词精确为 `12px` | +| `test_same_weight_groups_receive_the_same_size` | 相同权重组内字号一致;权重组间字号递增 | +| `test_stroke_weight_is_applied_when_excel_weights_are_flat` | Excel 权重全为 `1` 时,笔画权重仍能产生区分度 | +| `test_largest_empty_square_ignores_space_outside_mask` | 最大空洞算法只计算掩膜内区域 | +| `test_conflicting_explicit_font_bounds_fail` | `MIN > MAX` 时报错而非静默回退 | +| `test_explicit_max_overrides_automatic_readability_floor` | 用户覆盖最大字号时,自动可读性下限让位于用户输入 | +| `test_base_class_never_uses_a_private_fallback_size` | 基类布局不使用隐藏回退字号 | +| `test_rendered_ink_stays_inside_mask_and_does_not_overlap` | 工作网格上:墨迹不超出掩膜、不重叠 | +| `test_hd_rendered_ink_does_not_overlap_after_scaling` | 高清放大后:零重叠像素、零碰撞边距 | + +固定种子(`SEED=LAYOUT_SEED=20260718`)确保可复现。 + +## 基准测试 + +位置:`backend/tools/benchmark_layout.py` + +运行方式: + +```bash +cd backend +python tools/benchmark_layout.py --counts 80 800 --canvas 2000 --assert-targets +``` + +参数: + +| 参数 | 说明 | +|------|------| +| `--counts COUNT [COUNT ...]` | 测试名单数量,默认 `80 800` | +| `--canvas CANVAS` | 画布尺寸,默认 `2000` | +| `--max-growth-rounds N` | 最大画布扩展轮数,默认 `1` | +| `--assert-targets` | 启用门禁检查 | +| `--output-dir PATH` | 输出目录,默认 `backend/benchmark_outputs/` | + +### 门禁检查项(`--assert-targets`) + +| 指标 | 阈值 | 含义 | +|------|------|------| +| `completeness` | `= 1.0` | 名单必须全部放入 | +| `equal_weight_font_consistent` | `True` | 等权重时字号一致 | +| `hd_overlap_pixels` | `= 0` | 高清渲染后零重叠 | +| `contour_grid_coverage` | `≥ 0.80` | 轮廓网格覆盖率(避免大块空洞) | +| `hd_true_density` | `≥ 0.10` | 真实笔画密度 | +| `total_seconds` | `< 1.0s` (count<100) / `< 5.0s` (count<1000) | 性能门槛 | + +### 输出指标 + +| 指标 | 说明 | +|------|------| +| `count` | 名单数量 | +| `canvas` | 最终画布尺寸 | +| `canvas_growth_rounds` | 画布扩展轮数 | +| `placed` | 实际放置词数 | +| `completeness` | 完整率 | +| `layout_seconds` / `render_seconds` / `total_seconds` | 各阶段耗时 | +| `work_fill_ratio` | 工作网格填充率 | +| `font_size_min` / `font_size_max` | 字号范围 | +| `equal_weight_font_consistent` | 等权重字号一致性 | +| `collision_margin` | 碰撞边距 | +| `hd_clearance_shifted_words` | 高清精修时位移词数 | +| `hd_clearance_max_shift` | 最大位移像素 | +| `hd_clearance_px` | 隔离带宽度 | +| `hd_clearance_priority_restarts` | 优先级回溯次数 | +| `hd_overlap_pixels` | 高清重叠像素(双重检查) | +| `largest_empty_square_work_px` | 工作网格最大空洞(像素) | +| `largest_empty_square_font_ratio` | 空洞相对字号比例 | +| `hd_true_density` | 高清真实笔画密度 | +| `ink_bbox_coverage` | 墨迹包围盒覆盖率 | +| `contour_grid_coverage` | 轮廓网格覆盖率 | + +基准结果保存为 JSON:`backend/benchmark_outputs/benchmark.json` + +## 扩展基准 + +基准脚本使用 `run_generation_pass()` 直接调用核心管线,绕过 HTTP 服务和文件 IO。 + +- 生成数据:`make_names()` 使用中文姓氏库和双字名库组合出不重复姓名 +- 掩膜:`make_round_mask()` 生成圆形掩膜 +- 固定种子:`SEED=LAYOUT_SEED=20260718` +- 固定配置:`SIZE_RATIO=1.0, N_REPETITIONS=1, WORK_SCALE=0.18, TARGET_FILL_RATIO=0.45` + +## CI 建议 + +```bash +cd backend +python -m pytest tests/test_layout_constraints.py -v +python tools/benchmark_layout.py --counts 80 800 --canvas 2000 --assert-targets +``` + +两次运行均应在数秒内完成。 diff --git a/frontend/src/components/AdvancedPanel.tsx b/frontend/src/components/AdvancedPanel.tsx index 13b4b52..1cc8dfe 100644 --- a/frontend/src/components/AdvancedPanel.tsx +++ b/frontend/src/components/AdvancedPanel.tsx @@ -196,7 +196,7 @@ export default function AdvancedPanel({ min={0.1} max={1} step={0.01} - onChange={v => onParamsChange({ packingEfficiency: v ?? 0.85 })} + onChange={v => onParamsChange({ packingEfficiency: v ?? 0.9 })} /> SIZE_RATIO 控制最大与最小字号跨度,默认 2.0;过大会出现极端字号差 @@ -220,13 +220,6 @@ export default function AdvancedPanel({ />
- onParamsChange({ minFontFloor: Math.max(1, Math.round(v ?? 2)) })} - /> onParamsChange({ workScale: v ?? 0.25 })} + onChange={v => onParamsChange({ workScale: v ?? 0.2 })} /> onParamsChange({ targetFillRatio: v ?? 0 })} + onChange={v => onParamsChange({ targetFillRatio: v ?? 0.45 })} />
- TARGET_FILL_RATIO=0 表示关闭目标填充检测(后端默认) + 中文笔画像素填充率约 0.30–0.55;TARGET_FILL 用于面积模型估算字号
权重 @@ -263,229 +256,9 @@ export default function AdvancedPanel({ onChange={v => onParamsChange({ strokeWeights: v })} hint="开启后笔画复杂的字更大;关闭则更接近均等字号" /> -
- onParamsChange({ logWeightRatio: v ?? 0.72 })} - /> - onParamsChange({ rankWeightRatio: v ?? 0.28 })} - /> -
- 两项建议合计约 1.0(默认 0.72 / 0.28) - -
- 大字限制 - onParamsChange({ enableSmartLargeFontReduction: v })} - hint="填不满时自动减少大字,给小词腾空间" - /> - onParamsChange({ limitLargeFonts: v })} - /> -
- onParamsChange({ largeFontLimitRatio: v ?? 0.2 })} - /> - onParamsChange({ largeFontThresholdRatio: v ?? 0.8 })} - /> -
- onParamsChange({ largeFontCapRatio: v ?? 0.6 })} - /> - 默认允许 20% 大字;超过最大字号 80% 算大字,被限时压到阈值 60% - -
- 字号搜索 -
- onParamsChange({ fontScaleMin: v ?? 0.5 })} - /> - onParamsChange({ fontScaleMax: v ?? 1.2 })} - /> -
-
- onParamsChange({ scaleSearchSteps: Math.max(1, Math.round(v ?? 7)) })} - /> - onParamsChange({ scaleSearchRounds: Math.max(1, Math.round(v ?? 5)) })} - /> -
-
- onParamsChange({ scaleDecay: v ?? 0.85 })} - /> - onParamsChange({ scaleFloor: v ?? 0.25 })} - /> -
-
- onParamsChange({ autoShrinkRounds: Math.max(0, Math.round(v ?? 4)) })} - /> -
- onParamsChange({ requireAllWords: v })} - /> -
-
- -
- 分层采样与填充重试 - onParamsChange({ enableStratifiedSampling: v })} - /> - onParamsChange({ stratifiedBands: Math.max(1, Math.round(v ?? 3)) })} - /> -
- onParamsChange({ minAcceptFillRatio: v ?? 0.75 })} - /> - onParamsChange({ fillRetryMaxRounds: Math.max(0, Math.round(v ?? 3)) })} - /> -
-
- onParamsChange({ fillRetryMaxScale: v ?? 1.5 })} - /> - onParamsChange({ growFontStep: v ?? 1.05 })} - /> -
- onParamsChange({ fillRetryRelaxLargeCap: v })} - /> - onParamsChange({ growFontOnLowFill: v })} - hint="后端默认关闭" - />
画布 - onParamsChange({ autoExpandCanvas: v })} - /> - onParamsChange({ expandForSpiral: v })} - /> -
- onParamsChange({ expandRatio: v ?? 2.5 })} - /> - onParamsChange({ maxAttempts: Math.max(1, Math.round(v ?? 5)) })} - /> -
onParamsChange({ canvasRetryMaxRounds: Math.max(0, Math.round(v ?? 1)) })} /> +
+
布局可复现 -
- - -

以上参数默认值与后端 backend/core/config.py 一致。 - 字号观感可优先调节 SIZE_RATIO大字限制 与{' '} - 笔画权重。 + 字号观感可优先调节 SIZE_RATIO目标填充率 与{' '} + 笔画权重;名单完整性始终作为硬约束。

diff --git a/frontend/src/pages/TestWorkbench.tsx b/frontend/src/pages/TestWorkbench.tsx index 14a4f7e..fb2a78c 100644 --- a/frontend/src/pages/TestWorkbench.tsx +++ b/frontend/src/pages/TestWorkbench.tsx @@ -48,58 +48,19 @@ const DEFAULT_PARAMS: JobParams = { // 字号与填充 sizeRatio: 2.0, - packingEfficiency: 0.85, - targetFillRatio: 0.0, + packingEfficiency: 0.9, + targetFillRatio: 0.45, userMinFontSize: null, userMaxFontSize: null, - minFontFloor: 2, - minReadableHeightPx: 25, - workScale: 0.25, - - // 字号搜索 - fontScaleMin: 0.5, - fontScaleMax: 1.2, - scaleSearchSteps: 7, - scaleSearchRounds: 5, - scaleDecay: 0.85, - scaleFloor: 0.25, - autoShrinkRounds: 4, - requireAllWords: true, - - // 权重混合 - logWeightRatio: 0.72, - rankWeightRatio: 0.28, - - // 大字限制 - enableSmartLargeFontReduction: true, - limitLargeFonts: true, - largeFontLimitRatio: 0.2, - largeFontThresholdRatio: 0.8, - largeFontCapRatio: 0.6, - - // 分层采样 - enableStratifiedSampling: true, - stratifiedBands: 3, - - // 填充重试 - minAcceptFillRatio: 0.75, - fillRetryRelaxLargeCap: true, - fillRetryMaxRounds: 3, - fillRetryMaxScale: 1.5, - growFontOnLowFill: false, - growFontStep: 1.05, + minReadableHeightPx: 22, + workScale: 0.18, // 画布 - autoExpandCanvas: true, - expandForSpiral: true, - expandRatio: 2.5, fillOn: 'BLACK', canvasRetryMaxRounds: 1, canvasRetryGrowth: 1.12, - maxAttempts: 5, // 布局可复现 - layoutOrderMode: 'SORTED', layoutSeed: null, }; @@ -469,40 +430,11 @@ export default function TestWorkbench({ SIZE_RATIO: params.sizeRatio, PACKING_EFFICIENCY: params.packingEfficiency, TARGET_FILL_RATIO: params.targetFillRatio, - MIN_FONT_FLOOR: params.minFontFloor, MIN_READABLE_HEIGHT_PX: params.minReadableHeightPx, WORK_SCALE: params.workScale, - FONT_SCALE_MIN: params.fontScaleMin, - FONT_SCALE_MAX: params.fontScaleMax, - SCALE_SEARCH_STEPS: params.scaleSearchSteps, - SCALE_SEARCH_ROUNDS: params.scaleSearchRounds, - SCALE_DECAY: params.scaleDecay, - SCALE_FLOOR: params.scaleFloor, - AUTO_SHRINK_ROUNDS: params.autoShrinkRounds, - REQUIRE_ALL_WORDS: params.requireAllWords, - LOG_WEIGHT_RATIO: params.logWeightRatio, - RANK_WEIGHT_RATIO: params.rankWeightRatio, - ENABLE_SMART_LARGE_FONT_REDUCTION: params.enableSmartLargeFontReduction, - LIMIT_LARGE_FONTS: params.limitLargeFonts, - LARGE_FONT_LIMIT_RATIO: params.largeFontLimitRatio, - LARGE_FONT_THRESHOLD_RATIO: params.largeFontThresholdRatio, - LARGE_FONT_CAP_RATIO: params.largeFontCapRatio, - ENABLE_STRATIFIED_SAMPLING: params.enableStratifiedSampling, - STRATIFIED_BANDS: params.stratifiedBands, - MIN_ACCEPT_FILL_RATIO: params.minAcceptFillRatio, - FILL_RETRY_RELAX_LARGE_CAP: params.fillRetryRelaxLargeCap, - FILL_RETRY_MAX_ROUNDS: params.fillRetryMaxRounds, - FILL_RETRY_MAX_SCALE: params.fillRetryMaxScale, - GROW_FONT_ON_LOW_FILL: params.growFontOnLowFill, - GROW_FONT_STEP: params.growFontStep, - AUTO_EXPAND_CANVAS: params.autoExpandCanvas, - EXPAND_FOR_SPIRAL: params.expandForSpiral, - EXPAND_RATIO: params.expandRatio, FILL_ON: params.fillOn, CANVAS_RETRY_MAX_ROUNDS: params.canvasRetryMaxRounds, CANVAS_RETRY_GROWTH: params.canvasRetryGrowth, - MAX_ATTEMPTS: params.maxAttempts, - LAYOUT_ORDER_MODE: params.layoutOrderMode, }; if (params.seed !== null) { paramsObj.SEED = params.seed; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index faa1b8e..9526959 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -25,54 +25,15 @@ export interface JobParams { targetFillRatio: number; userMinFontSize: number | null; userMaxFontSize: number | null; - minFontFloor: number; minReadableHeightPx: number; workScale: number; - // 字号搜索 - fontScaleMin: number; - fontScaleMax: number; - scaleSearchSteps: number; - scaleSearchRounds: number; - scaleDecay: number; - scaleFloor: number; - autoShrinkRounds: number; - requireAllWords: boolean; - - // 权重混合 - logWeightRatio: number; - rankWeightRatio: number; - - // 大字限制 - enableSmartLargeFontReduction: boolean; - limitLargeFonts: boolean; - largeFontLimitRatio: number; - largeFontThresholdRatio: number; - largeFontCapRatio: number; - - // 分层采样 - enableStratifiedSampling: boolean; - stratifiedBands: number; - - // 填充重试 - minAcceptFillRatio: number; - fillRetryRelaxLargeCap: boolean; - fillRetryMaxRounds: number; - fillRetryMaxScale: number; - growFontOnLowFill: boolean; - growFontStep: number; - // 画布 - autoExpandCanvas: boolean; - expandForSpiral: boolean; - expandRatio: number; fillOn: 'BLACK' | 'WHITE'; canvasRetryMaxRounds: number; canvasRetryGrowth: number; - maxAttempts: number; // 布局可复现 - layoutOrderMode: 'SORTED' | 'INTERLEAVED_RANDOM'; layoutSeed: number | null; }