From e518540235e19429ffc4e49d8fc1fe62e6fdea0b Mon Sep 17 00:00:00 2001 From: obroccolio Date: Thu, 13 Aug 2026 14:22:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(wordcloud):=20=E6=94=B6=E5=8F=A3=E5=9C=A8?= =?UTF-8?q?=E9=80=94=E5=BC=80=E5=8F=91=EF=BC=88=E5=B8=83=E5=B1=80/?= =?UTF-8?q?=E5=AD=98=E5=82=A8/=E5=89=8D=E7=AB=AF=EF=BC=89+=20R4=20WCD=20?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E4=BB=BB=E5=8A=A1(jobs=20wcd=5Ffile)?= =?UTF-8?q?=E4=B8=8E=E7=94=9F=E4=BA=A7=E8=AE=A2=E5=8D=95=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../efficient_wordcloud/src/ewc_core.cpp | 742 ++++++++++-------- backend/core/config.py | 22 +- backend/core/layout.py | 303 +++++-- backend/core/pipeline.py | 399 ++++++++-- backend/core/render.py | 307 +++++++- backend/service/app.py | 494 +++++++++++- backend/service/job_manager.py | 57 +- backend/service/metadata_store.py | 192 +++++ backend/service/runner.py | 67 +- backend/service/schemas.py | 3 + backend/service/storage.py | 74 ++ backend/service/storage_metrics.py | 85 ++ backend/tests/test_layout_constraints.py | 10 + backend/tools/benchmark_layout.py | 1 + docs/ALGORITHM.md | 107 ++- docs/CANVAS_EXPORT_PACKAGE.md | 231 ++++++ docs/CONFIG.md | 2 + docs/DATA_STORAGE_OPTIMIZATION.md | 163 ++++ docs/DESIGN_DATA_STORAGE_PLAN.md | 173 ++++ frontend/src/components/AdvancedPanel.tsx | 26 +- frontend/src/components/CanvasArea.tsx | 6 +- frontend/src/components/Icons.tsx | 9 + frontend/src/components/ProgressPanel.tsx | 73 +- frontend/src/lib/canvasPackage.ts | 116 +++ frontend/src/lib/stickerLibrary.ts | 3 + frontend/src/lib/svgExport.ts | 29 +- frontend/src/lib/templateLibrary.ts | 14 + frontend/src/pages/CanvasStudio.tsx | 166 +++- frontend/src/pages/TemplateHome.tsx | 34 +- frontend/src/pages/TestWorkbench.tsx | 117 ++- frontend/src/styles.css | 85 +- frontend/src/types.ts | 7 + 32 files changed, 3525 insertions(+), 592 deletions(-) create mode 100644 backend/service/metadata_store.py create mode 100644 backend/service/storage_metrics.py create mode 100644 docs/CANVAS_EXPORT_PACKAGE.md create mode 100644 docs/DATA_STORAGE_OPTIMIZATION.md create mode 100644 docs/DESIGN_DATA_STORAGE_PLAN.md create mode 100644 frontend/src/lib/canvasPackage.ts diff --git a/backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp b/backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp index ffcbbe3..de162b4 100644 --- a/backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp +++ b/backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp @@ -22,9 +22,9 @@ #include #include #include -#include -#include -#include +#include +#include +#include // ========================================== // Thread Pool (avoid per-query thread creation) @@ -115,13 +115,13 @@ public: int w; }; - // 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; + // 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; @@ -139,9 +139,9 @@ public: } void init_from_buffer(unsigned char* raw_mask, int h, int w) { - valid_coords.reserve(h * w / 2); - uint64_t free_y_sum = 0; - uint64_t free_x_sum = 0; + valid_coords.reserve(h * w / 2); + uint64_t free_y_sum = 0; + uint64_t free_x_sum = 0; // Initialize canvas from mask ensure_canvas(); @@ -157,48 +157,48 @@ public: if (is_blocked) { canvas[i * width + j] = 1; } - 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; + 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 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(); + 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; @@ -305,7 +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; @@ -343,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 @@ -450,9 +450,9 @@ public: return {-1, -1}; } - 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. + 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; @@ -473,12 +473,12 @@ public: return {true, y, x}; } - // 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); + // 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); @@ -496,28 +496,28 @@ public: } if (nt <= 1) { - // 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; + // 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) { ++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; - } + // 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 count == 0 - ? DirectResult{false, -1, -1} - : DirectResult{true, best_y, best_x}; + return count == 0 + ? DirectResult{false, -1, -1} + : DirectResult{true, best_y, best_x}; } // Multi-thread path: parallel count then pick @@ -559,193 +559,257 @@ public: } cum += chunk_counts[t]; } - 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}; - } - - // ========================================================= + 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(); + + // Only the spiral (mode 1) may anchor its first probe on the exact + // mask centroid; that is the "small words spiral out from the centre" + // behaviour we want. Mode 2 (large words) deliberately skips it: when + // every mode kept this shortcut, whichever word happened to be placed + // first landed on the same centroid pixel in every single generation, + // regardless of layout_seed, giving every export an unmoving bullseye. + if (placement_mode == 1) { + 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 places the word by random probe. + if (placement_mode == 1) { + constexpr double golden_angle = 2.39996322972865332; + constexpr double sample_spacing = 1.25; + constexpr double two_pi = 6.283185307179586; + // Each word starts its sweep at a random angle instead of exactly + // golden_angle past the previous one, so consecutive words are no + // longer locked into the fixed angular step that draws a textbook + // Vogel/sunflower figure. The draw comes from `rng`, already seeded + // per word from layout_seed, so a given seed still reproduces + // exactly, and different seeds now give genuinely different + // arrangements rather than the same figure with the names permuted. + // + // The offset deliberately spans the full circle. A *bounded* offset + // was tried and is much worse than useless: confining the sweep to a + // wedge makes a word skip positions at the packed frontier and + // settle for a worse one, which measured a 31% loss of final ink + // density (0.255 -> 0.176) on an 800-word cloud. Spanning the whole + // circle costs nothing, because the sweep still reaches every angle + // as the radius grows. + // + // Note this does not make the cloud look unstructured. Radius still + // tracks placement order closely (Pearson r ~= 0.98), because a + // centre-out fill that stays dense has to grow outward in order -- + // the ordering and the density are the same property. Breaking that + // appearance without paying for it needs several spiral origins + // rather than jitter on one, which is a larger change than this. + std::uniform_real_distribution phase_jitter(0.0, two_pi); + const double theta_offset = phase_jitter(rng); + 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 + theta_offset; + 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}; + } + } + + // Soft radial bound for mode 2. Pure first-accept scatter over the whole + // canvas let large words land far outside the crowd, leaving detached + // stragglers and a ragged silhouette. Draws beyond the bound are + // rejected, but the bound widens as the probe budget is consumed and is + // gone entirely for the last quarter of the probes, so this only biases + // *where* a word prefers to land -- it never removes a legal position + // and so cannot cost completeness. + const double mask_radius = 0.5 * std::sqrt( + (double)height * (double)height + (double)width * (double)width + ); + + for (int probe = 0; probe < probes; ++probe) { + const int y = row_dist(rng); + const int x = col_dist(rng); + if (placement_mode == 2) { + const double frac = (double)probe / (double)probes; + if (frac < 0.75) { + // 0.55 -> 1.0 of the mask radius over the first 75% of probes. + const double limit = mask_radius * (0.55 + 0.60 * frac); + const double cy = (double)y + glyph_h * 0.5 - free_center_y; + const double cx = (double)x + glyph_w * 0.5 - free_center_x; + if (cy * cy + cx * cx > limit * limit) continue; + } + } + 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}; + } + // Mode 2 (large words) takes the first legal random draw inside the + // radial bound above. It used to scan the whole probe budget and keep + // the candidate closest to the mask centroid, which packed every + // large word into one tight rosette at the centre -- the innermost + // radial shell held no spiral words at all, leaving a hard seam + // between a dense core and the spiral field. First-accept scatters + // them across the mass as intended, and is cheaper: it can return on + // the first hit instead of always running all `probes` fit tests. + if (placement_mode == 2) { + reserve_glyph_exact(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 // ========================================================= @@ -786,13 +850,13 @@ public: return {false, -1, -1, end_idx}; } - 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_); + 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_); @@ -894,67 +958,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; - 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; -} - -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; -} + 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 @@ -1031,16 +1095,16 @@ static PyObject* Grid_rebuild_from_bitmap_partial(PyIntegralGrid* self, PyObject Py_RETURN_NONE; } -// Stamp glyph bitmap onto C++ canvas and rebuild the affected integral region. -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; @@ -1049,10 +1113,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_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."}, + {"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/core/config.py b/backend/core/config.py index 737a9f5..0188b69 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -46,6 +46,8 @@ BASE_HD_HEIGHT = 4000 MIN_READABLE_HEIGHT_PX = 22 # 运算网格缩放:0.18 在速度/质量之间更均衡 WORK_SCALE = 0.18 +# 服务端快速预览路径:减少试探次数,但保留真实字形碰撞和零重叠校验。 +FAST_MODE = False # --- 阴阳刻 --- FILL_ON = "BLACK" @@ -67,11 +69,19 @@ FONT_FALLBACK_PATHS = ( # --- 填充策略 --- N_REPETITIONS = 1 +# 名单较少、掩膜轮廓填不满时,自动循环追加名字副本增加词数,让费马螺旋 +# 能走到掩膜远端(心形尖端、人物四肢),把形状填出来而非退化成圆形。 +# AUTO_REPEAT_MAX 是自动重复次数的安全上限,避免无限追加。 +AUTO_REPEAT_TO_FILL = True +AUTO_REPEAT_MAX = 20 # 面积模型目标填充率:中文实心笔画像素占比约 0.35–0.55。 # 略偏保守以保证 scale=1.0 首次就能放满,减少多轮重试。 TARGET_FILL_RATIO = 0.45 SIZE_RATIO = 2.0 PACKING_EFFICIENCY = 0.9 +# 竖排概率:每个词独立以此概率竖着摆放,其余水平摆放。 +# 0.0 = 全部水平,1.0 = 全部竖排。适度混排可打散过于规整的观感。 +VERTICAL_RATIO = 0.18 # --- 智能字号搜索 --- MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE) @@ -117,11 +127,12 @@ LAYOUT_SEED = None KNOWN_CONFIG_KEYS = { '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', + 'BASE_HD_WIDTH', 'BASE_HD_HEIGHT', 'MIN_READABLE_HEIGHT_PX', 'WORK_SCALE', 'FAST_MODE', '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', + 'N_REPETITIONS', 'TARGET_FILL_RATIO', 'SIZE_RATIO', 'PACKING_EFFICIENCY', 'VERTICAL_RATIO', + 'AUTO_REPEAT_TO_FILL', 'AUTO_REPEAT_MAX', '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', @@ -144,11 +155,15 @@ CONFIG_ALIASES = { 'max_font_size': 'USER_MAX_FONT_SIZE', 'font_color': 'FONT_COLOR', 'stroke_weights': 'ENABLE_STROKE_WEIGHTS', + 'auto_repeat_to_fill': 'AUTO_REPEAT_TO_FILL', + 'auto_repeat_max': 'AUTO_REPEAT_MAX', + 'save_debug_images': 'SAVE_DEBUG_IMAGES', } CRITICAL_TYPE_CHECKS = { 'MODE': str, 'WORK_SCALE': (int, float), + 'FAST_MODE': bool, 'DATA_COL_INDEX': int, 'WEIGHT_COL_INDEX': (int, type(None)), 'WEIGHT_COL_NAME': (str, type(None)), @@ -160,6 +175,9 @@ CRITICAL_TYPE_CHECKS = { 'ENABLE_STROKE_WEIGHTS': bool, 'CANVAS_RETRY_MAX_ROUNDS': int, 'CANVAS_RETRY_GROWTH': (int, float), + 'AUTO_REPEAT_TO_FILL': bool, + 'AUTO_REPEAT_MAX': int, + 'VERTICAL_RATIO': (int, float), 'SEED': (int, type(None)), 'LAYOUT_SEED': (int, type(None)), } diff --git a/backend/core/layout.py b/backend/core/layout.py index 9addd03..59799f9 100644 --- a/backend/core/layout.py +++ b/backend/core/layout.py @@ -42,65 +42,169 @@ def _load_ft_font(font_path): 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. + """Return (path_d, tx, ty, None) for the whole word as a single path. - 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. + Kept for callers that want one path per word. Internally this concatenates + the per-character parts, which are cached and shared across every word that + reuses the character. + """ + parts, tx0, ty0 = build_svg_word_parts(word, size, font_path, orient) + if not parts: + return "", x, y, None + if len(parts) == 1 and parts[0][1] == 0.0 and parts[0][2] == 0.0: + return parts[0][0], tx0 + x, ty0 + y, None + # Only reached when a caller insists on one path per word; the per-part + # translate has to be baked into the coordinates, so this is the slow path. + merged = " ".join(_translate_path_d(d, dx, dy) for d, dx, dy in parts) + return merged, tx0 + x, ty0 + y, None + + +def build_svg_word_parts(word, size, font_path, orient): + """Return ([(path_d, dx, dy), ...], tx0, ty0) for one word. + + Each part is a per-character outline cached at cursor 0 and reused verbatim; + dx/dy carry that character's position within the word. A caller places a + part with translate(tx0 + x + dx, ty0 + y + dy) scale(1, -1). + + Caching per character rather than per word is what makes export cheap: a + roster of 750 Chinese names contains only a few dozen distinct characters, + so the outline drawing and the path-string formatting run a few dozen times + instead of once per name. """ 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 + if cached is not None: + return cached + + parts = [] + cursor = 0.0 + xmin = ymin = float("inf") + xmax = ymax = float("-inf") + for ch in word: + shape = _char_shape(ch, size, font_path, orient) + if shape is None: + continue + path_d, advance, cxmin, cymin, cxmax, cymax = shape + if path_d: + # A character's outline is cached at cursor 0; the cursor becomes a + # translate offset so the cached string is reused byte for byte. + # Horizontal runs advance in +x, rotated runs in +y (the rotated + # glyph transform subtracts the cursor from y, and the outer + # scale(1, -1) flips that back to +y). + dx, dy = (0.0, cursor) if orient else (cursor, 0.0) + parts.append((path_d, dx, dy)) + if orient: + xmin = min(xmin, cxmin) + xmax = max(xmax, cxmax) + ymin = min(ymin, cymin - cursor) + ymax = max(ymax, cymax - cursor) + else: + xmin = min(xmin, cxmin + cursor) + xmax = max(xmax, cxmax + cursor) + ymin = min(ymin, cymin) + ymax = max(ymax, cymax) + cursor += advance + + if not parts: + result = ([], 0.0, 0.0) + else: + # Offsets placing the run's top-left at (0,0) under + # translate(tx,ty) scale(1,-1). + result = (parts, -xmin, ymax) + + _SVG_SHAPE_CACHE[key] = result + 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) + return result -def _build_shape_fonttools(word, size, font_path, orient): +def _char_shape(ch, size, font_path, orient): + """Return (path_d, advance, xmin, ymin, xmax, ymax) for one character. + + The outline is drawn at cursor 0 and scaled to `size`, so the same string is + valid at every position the character appears in. + """ + key = (font_path, ch, int(size), bool(orient)) + cached = _FT_CHAR_PATH_CACHE.get(key) + if cached is not None: + return cached + try: + cached = _build_char_fonttools(ch, size, font_path, orient) + except Exception: + cached = _build_char_matplotlib(ch, size, font_path, orient) + _FT_CHAR_PATH_CACHE[key] = cached + return cached + + +def _build_char_fonttools(ch, 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) + gname = cmap.get(ord(ch)) + if not gname or gname not in glyph_set: + return None + glyph = glyph_set[gname] 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 - + if orient: + # Horizontal layout then rotate -90° around the origin. The cursor term + # that used to live in dy is applied by the caller as a translate. + tp = TransformPen(pen, Transform(0, -scale, scale, 0, 0, 0)) + else: + tp = TransformPen(pen, Transform(scale, 0, 0, scale, 0, 0)) + glyph.draw(tp) path_d = pen.getCommands() + advance = float(glyph.width) * scale if not path_d: - return "", 0.0, 0.0 - + return "", advance, 0.0, 0.0, 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 + return path_d, advance, xmin, ymin, xmax, ymax + + +def _build_char_matplotlib(ch, size, font_path, orient): + from matplotlib.textpath import TextPath + + path = TextPath((0, 0), ch, prop=get_font_properties(font_path, size), size=size) + if orient: + path = path.transformed(Affine2D().rotate_deg(-90)) + bbox = path.get_extents() + path_d = mpl_path_to_svg_d(path) + # matplotlib gives no advance width; the ink bbox is the best stand-in. + advance = (bbox.ymax - bbox.ymin) if orient else (bbox.xmax - bbox.xmin) + return path_d, advance, bbox.xmin, bbox.ymin, bbox.xmax, bbox.ymax + + +def _translate_path_d(path_d, dx, dy): + """Shift every coordinate pair in an SVG path string by (dx, dy). + + Only used by the single-path-per-word compatibility path; the fast export + route carries dx/dy in the element transform instead. + """ + if not dx and not dy: + return path_d + import re + + tokens = re.findall(r"[A-Za-z]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d) + out = [] + i = 0 + while i < len(tokens): + t = tokens[i] + if not t.isalpha(): + i += 1 + continue + out.append(t) + i += 1 + coords = [] + while i < len(tokens) and not tokens[i].isalpha(): + coords.append(float(tokens[i])) + i += 1 + for j, v in enumerate(coords): + out.append(f"{v + (dx if j % 2 == 0 else dy):g}") + return " ".join(out) def _path_bbox(path_d): @@ -137,19 +241,6 @@ def _path_bbox(path_d): 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 @@ -324,8 +415,44 @@ class OptimizedEfficientWordCloud(EfficientWordCloud): f_size = min(max_font, max(min_font, int(round(raw_size)))) target_font_sizes.append(f_size) - random_large_prefix = max(1, int(math.ceil(len(layout_sequence) * 0.08))) - for idx, (word, _freq) in enumerate(layout_sequence): + # "Large" words go down first by random probe (mode 2), then everything + # else spirals out from the centre to fill around them (mode 1). Which + # words count as large is decided by their actual font size, not by + # their position in the shuffled sequence: the old rule took the first + # 8% of the sequence, which under equal weights is an arbitrary set of + # same-size words, so "large" placement was applied to words that were + # not large at all. When every word is the same size (the equal-weight + # case) there is no large tier and everything spirals, which is the + # correct degenerate behaviour. + large_font_cutoff = min_font + base_span * 0.80 + large_indices = [ + idx for idx, size in enumerate(target_font_sizes) + if base_span > 0 and size >= large_font_cutoff + ] + # Placing the large words before the small ones matters: they need whole + # empty regions to land in, and once the spiral has packed the canvas + # there are none left. Ordering is by index within each tier so a given + # layout_seed still reproduces exactly. + large_index_set = set(large_indices) + placement_order = large_indices + [ + idx for idx in range(len(layout_sequence)) if idx not in large_index_set + ] + + # A word is only reported unplaced after the spiral, the random probes + # and a full exhaustive scan have all failed, so a single failure proves + # no legal position exists for it at this size -- and since the pipeline + # requires every word, the whole batch is already doomed. `max_failures` + # lets the caller stop right there instead of finishing the batch, which + # is what makes the scale search cheap: an unplaceable word costs about + # ten times a placeable one (it pays the full search before giving up), + # so a doomed batch run to completion is by far the most expensive thing + # the pipeline can do. Left as None, the batch runs to the end and packs + # in as many words as it can. + max_failures = getattr(self, "max_failures", None) + failures = 0 + + for idx in placement_order: + word, _freq = layout_sequence[idx] font_size = target_font_sizes[idx] placed = False rotate = rotation_flags[idx] @@ -344,8 +471,7 @@ class OptimizedEfficientWordCloud(EfficientWordCloud): 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 + placement_mode = 2 if idx in large_index_set else 1 pos = self.grid.place_glyph_exact( collision_arr, stamp_arr, @@ -371,6 +497,10 @@ class OptimizedEfficientWordCloud(EfficientWordCloud): # 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. + if not placed: + failures += 1 + if max_failures is not None and failures >= max_failures: + break return self @@ -385,28 +515,27 @@ class OptimizedEfficientWordCloud(EfficientWordCloud): 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 = {} + """Yield (path_d, tx, ty, color), one entry per glyph. + + A word contributes one entry per character. Each path string comes + straight from the per-character cache and the character's position + within the word rides along in tx/ty, so no path data is rebuilt or + re-parsed per word. + """ 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 + try: + parts, origin_tx, origin_ty = build_svg_word_parts( + word, size, self.font_path, orient + ) + except Exception as exc: + config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}") + continue + # origin_tx/ty place the run's top-left at (0,0); x/y move it to the + # layout position; dx/dy offset the character within the run. + base_tx = origin_tx + x + base_ty = origin_ty + y + for path_d, dx, dy in parts: + yield path_d, base_tx + dx, base_ty + dy, 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).""" @@ -500,13 +629,21 @@ class OptimizedEfficientWordCloud(EfficientWordCloud): ring_radius=3, ring_width=1, ring_spacing=8): """统一 SVG 导出:fill_mode=fill|dot|line|ring,可叠加描边。""" # 预先构建所有文字路径(fill / dot 模式共用) + # One entry per glyph rather than per word: each character's outline is + # taken straight from the shared cache, with its position in the run + # carried in tx/ty. Consumers below only ever place these as separate + # elements, so splitting a word costs nothing. text_paths = [] 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) - text_paths.append((path, tx, ty)) + parts, origin_tx, origin_ty = build_svg_word_parts( + word, size, self.font_path, orient + ) except Exception as exc: config._warn(f"SVG path 导出失败,跳过: {word}, error={exc}") + continue + for path, dx, dy in parts: + text_paths.append((path, origin_tx + x + dx, origin_ty + y + dy)) with open(filename, "w", encoding="utf-8") as f: f.write( diff --git a/backend/core/pipeline.py b/backend/core/pipeline.py index 8e57882..e6a2bdb 100644 --- a/backend/core/pipeline.py +++ b/backend/core/pipeline.py @@ -15,10 +15,13 @@ 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 ( + compute_coverage_score, compute_fill_ratio_fast, count_layout_overlap_pixels, largest_empty_square_size, + append_layout_with_hd_clearance, refine_layout_with_hd_clearance, + render_layout_occupancy, scale_layout_for_hd, ) from .weights import ( @@ -127,7 +130,7 @@ def run_generation_pass( max_font = min(max_font, hard_max_font) return min_font, max(min_font, max_font) - def try_place(scale, layout_seed=base_layout_seed): + def try_place(scale, layout_seed=base_layout_seed, probe=False): min_font, max_font = scaled_bounds(scale) wc = OptimizedEfficientWordCloud( width=w_small, @@ -138,12 +141,23 @@ def run_generation_pass( min_font_size=min_font, max_font_size=max_font, background_color=config.get_output_background(), - prefer_horizontal=0.82, + # Each word independently draws horizontal vs vertical, so the mix + # is scattered rather than banded. VERTICAL_RATIO is the chance of + # a vertical word; mixing orientations is one of the cheapest ways + # to break up an over-regular grid-like look. + prefer_horizontal=1.0 - float(config.VERTICAL_RATIO), # 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, ) wc.layout_seed = layout_seed + # A probe only needs to answer "does every word fit at this scale?", so + # it stops at the first word that cannot be placed. The answer is exact + # -- a word is only reported unplaced once an exhaustive scan has ruled + # out every position -- and it avoids paying for a doomed batch's + # remaining failures, each of which is far more expensive than a + # successful placement. + wc.max_failures = 1 if probe else None wc.generate_from_frequencies(frequencies_data) return wc, len(wc.layout_), min_font, max_font @@ -165,55 +179,99 @@ def run_generation_pass( best_wc = None best_count = 0 best_scale = 1.0 - scale = 1.0 + best_coverage = -1.0 # shape-aware coverage of the current best candidate + best_occ = None # occupancy raster of the current best candidate tried_layouts = set() failed_scales = [] - for attempt in range(1, 4): + attempt = 0 + + def probe_scale(scale): + """Lay out every word at `scale`; return (wc, placed_count, complete).""" + nonlocal attempt, best_wc, best_count, best_scale + nonlocal best_coverage, best_occ + attempt += 1 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) + tried_layouts.add((bounds, base_layout_seed)) + wc, placed_count, min_font, max_font = try_place(scale, probe=True) + complete = placed_count >= total_target print( f" 整批布局 #{attempt}: scale={scale:.3f}, " - f"字号=[{min_font}, {max_font}] -> {placed_count}/{total_target}" + f"字号=[{min_font}, {max_font}] -> " + f"{'完整' if complete else '不足'} ({placed_count}/{total_target})" ) log.info( - " 整批布局 #%d scale=%.3f 字号=[%d,%d] -> %d/%d", - attempt, - scale, - min_font, - max_font, - placed_count, - total_target, + " 整批布局 #%d scale=%.3f 字号=[%d,%d] -> %d/%d complete=%s", + attempt, scale, min_font, max_font, placed_count, total_target, complete, ) - 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 + if complete: + # Among complete layouts prefer the one whose ink reaches furthest + # into the mask shape, not merely the largest font scale. A Fermat + # spiral packs words into a disc around the fillable centroid; with + # few words or large fonts that disc stays small and never reaches + # the mask's protrusions, so the cloud reads as a circle instead of + # the intended silhouette. Coverage rewards layouts that spread into + # those deep regions, letting a slightly smaller scale win when it + # trades font size for a recognisable outline. Scale is the tie- + # breaker so an equal-coverage denser picture is still preferred. + occ = render_layout_occupancy(wc.layout_, mask_small.shape, config.WC_FONT_PATH) + coverage = compute_coverage_score(occ, mask_small) + log.info( + " 整批布局 #%d coverage=%.4f (best=%.4f)", + attempt, coverage, best_coverage, + ) + if coverage > best_coverage or ( + coverage == best_coverage and scale > best_scale + ): + best_wc, best_count, best_scale = wc, placed_count, scale + best_coverage, best_occ = coverage, occ + if not complete: + failed_scales.append(scale) + return wc, placed_count, complete + + # Find the largest scale at which every word still fits. Bigger is strictly + # better here: the same names drawn larger leave less blank space. A probe + # answers feasibility exactly and stops at the first unplaceable word, so + # searching for the best scale costs little more than accepting the first + # one that happens to work. + lo = None # largest scale known to fit everything + hi = None # smallest scale known to be too big + scale = 1.0 + for _ in range(2 if config.FAST_MODE else 4): + wc, placed_count, complete = probe_scale(scale) + if complete: + lo = scale break - - failed_scales.append(scale) - + hi = scale 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)) + # Area scales with size², so linear size scales with sqrt(ratio). The + # probe stops early, which understates how many words would have fit, + # so this deliberately undershoots and the bisection below climbs back. + shrink = 0.62 if placed_ratio <= 0 else min(0.92, max(0.55, math.sqrt(placed_ratio) * 0.92)) scale *= shrink - if final_wc is None: + # Close the gap between the largest failing scale and the smallest passing + # one. Each step recovers font size that the shrink above gave away. + if lo is not None and hi is not None: + for _ in range(1 if config.FAST_MODE else 3): + mid = (lo + hi) / 2.0 + if hi - lo < 0.02 or scaled_bounds(mid) == scaled_bounds(lo): + break + _wc, _placed, complete = probe_scale(mid) + if complete: + lo = mid + else: + hi = mid + + if best_wc is not None: final_wc = best_wc final_scale = best_scale + final_layout_seed = base_layout_seed if final_wc is None: return { "wc": None, "fill_ratio": 0.0, + "coverage": 0.0, "occ_fast": None, "w_small": w_small, "h_small": h_small, @@ -250,10 +308,16 @@ def run_generation_pass( debug_dir.mkdir(parents=True, exist_ok=True) Image.fromarray((occ_fast * 255).astype(np.uint8)).save(str(debug_dir / "occ_fast.png")) - # 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. + # Probe whole-cloud layouts in BOTH font-size directions and keep the one + # whose ink reaches furthest into the mask shape. The original search only + # grew the font (chasing a higher pixel fill ratio), but a Fermat spiral + # packs words into a disc around the centroid: growing the font shrinks that + # disc, so an under-filled silhouette gets *more* circular, not less. + # Shrinking the font lets the spiral walk further out and reach the mask's + # protrusions, which raises shape coverage even when the raw fill ratio + # drops a little. Both directions are probed each round and the higher- + # coverage candidate wins; the loop stops when neither improves coverage. + # Every probe rebuilds the entire cloud with one shared scale. if ( len(final_wc.layout_) >= total_target and fill_ratio > 0 @@ -262,11 +326,22 @@ def run_generation_pass( or has_character_sized_hole(final_wc, largest_empty_square) ) ): + current_coverage = compute_coverage_score(occ_fast, mask_small) + free_px = max(1, int(np.sum(mask_small == 0))) upper_scale = min( (failed for failed in failed_scales if failed > final_scale), default=None, ) - for density_attempt in range(1, 5): + + def _fill_and_coverage(wc): + occ = render_layout_occupancy(wc.layout_, mask_small.shape, config.WC_FONT_PATH) + new_fill = float(np.sum((mask_small == 0) & (occ == 1))) / free_px + cov = compute_coverage_score(occ, mask_small) + return new_fill, cov, occ + + for density_attempt in range(1, 2 if config.FAST_MODE else 5): + # Grow direction (larger font): bisect toward a known-too-big scale, + # or nudge up by the fill-ratio deficit, exactly as before. if upper_scale is not None: grow_scale = (final_scale + upper_scale) / 2.0 elif equal_size_mode: @@ -277,61 +352,79 @@ def run_generation_pass( 1.12, math.sqrt(config.TARGET_FILL_RATIO / fill_ratio) * 0.98, ) - if desired_growth <= 1.005: - break - grow_scale = final_scale * desired_growth + grow_scale = final_scale * desired_growth if desired_growth > 1.005 else None - 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 + # Shrink direction (smaller font): the inverse nudge. Letting the + # spiral walk further out costs font size but can reach protrusions + # the grow direction abandons. Cap the shrink so one round cannot + # collapse the font to the floor. + shrink_scale = None + if not equal_size_mode and fill_ratio > 0: + shrink_factor = 1.0 / max(1.02, min(1.20, math.sqrt(fill_ratio / max(0.05, config.TARGET_FILL_RATIO)) * 1.02)) + cand = final_scale * shrink_factor + if scaled_bounds(cand) != scaled_bounds(final_scale): + shrink_scale = cand + elif equal_size_mode: + current_size, _ = scaled_bounds(final_scale) + if current_size > hard_min_font: + shrink_scale = (current_size - 1) / max(1, base_min_font) - 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 + candidates = [] + for direction, scale in (("grow", grow_scale), ("shrink", shrink_scale)): + if scale is None or scaled_bounds(scale) == scaled_bounds(final_scale): + continue + bounds = scaled_bounds(scale) + d_min, d_max = bounds + key = (bounds, base_layout_seed) + wc, placed_count, selected_seed = None, -1, base_layout_seed + if key not in tried_layouts: + tried_layouts.add(key) + wc, placed_count, _, _ = try_place(scale) + # Bounded seed retry to recover a complete layout, as before. + if placed_count < total_target and base_layout_seed is not None: + candidate_seed = (int(base_layout_seed) * 3 + 3) % (2**31 - 1) + retry_key = (bounds, candidate_seed) + if retry_key not in tried_layouts: + tried_layouts.add(retry_key) + retry_wc, retry_count, _, _ = try_place(scale, candidate_seed) + print( + f" 密度优化 #{density_attempt} {direction} 整批重排: " + f"seed={candidate_seed}, 字号=[{d_min}, {d_max}] -> " + f"{retry_count}/{total_target}" + ) + if retry_count > placed_count: + wc, placed_count = retry_wc, retry_count + selected_seed = candidate_seed + if placed_count < total_target: + if direction == "grow": + upper_scale = scale print( - f" 密度优化 #{density_attempt} 整批重排: " - f"seed={candidate_seed}, 字号=[{grow_min}, {grow_max}] -> " - f"{retry_count}/{total_target}" + f" 密度优化 #{density_attempt} {direction}: scale={scale:.3f}, " + f"字号=[{d_min}, {d_max}] -> {placed_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 + continue + new_fill, cov, occ = _fill_and_coverage(wc) + print( + f" 密度优化 #{density_attempt} {direction}: scale={scale:.3f}, " + f"字号=[{d_min}, {d_max}] -> {placed_count}/{total_target} " + f"fill={new_fill:.3f} coverage={cov:.4f}" + ) + candidates.append((direction, scale, selected_seed, wc, placed_count, new_fill, cov, occ)) - new_fill, new_occ = compute_fill_ratio_fast(wc.layout_, mask_small, config.WC_FONT_PATH) - if new_fill <= fill_ratio: + if not candidates: + break + # Pick the higher-coverage candidate; tie-break on fill ratio so a + # genuinely denser picture still wins when coverage is equal. + candidates.sort(key=lambda c: (c[6], c[5])) + direction, scale, selected_seed, wc, placed_count, new_fill, cov, occ = candidates[-1] + if cov <= current_coverage and new_fill <= fill_ratio: break final_wc = wc - final_scale = grow_scale + final_scale = scale final_layout_seed = selected_seed fill_ratio = new_fill - occ_fast = new_occ + occ_fast = occ + current_coverage = cov 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) @@ -339,6 +432,7 @@ def run_generation_pass( if ( fill_ratio >= config.TARGET_FILL_RATIO * 0.98 and not has_character_sized_hole(final_wc, largest_empty_square) + and current_coverage >= 0.98 ): break @@ -350,6 +444,7 @@ def run_generation_pass( and len(final_wc.layout_) >= total_target and has_character_sized_hole(final_wc, largest_empty_square) and base_layout_seed is not None + and not config.FAST_MODE ): if total_target < 100: hole_attempt_budget = 3 @@ -389,6 +484,77 @@ def run_generation_pass( (final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast) ) + # ── 工作网格增量填充:原名字号不变,用更小字号追加副本填轮廓 ── + # 已填区域标记为阻挡,新词只能进空白间隙。每一轮只生成一份名单, + # 并把新占用合并回工作网格;因此自动填充没有固定的重复数量, + # 只在轮廓仍未覆盖且还有合法位置时继续追加。 + fill_work_layout = [] + if ( + config.AUTO_REPEAT_TO_FILL + and final_wc is not None + and len(final_wc.layout_) >= total_target + and occ_fast is not None + ): + current_cov = compute_coverage_score(occ_fast, mask_small) + if current_cov < 0.95 and names: + # 原名字号范围 + original_sizes = [size for _, size, *_ in final_wc.layout_] + orig_min_font = int(min(original_sizes)) + orig_max_font = int(max(original_sizes)) + span = orig_max_font - orig_min_font + + # 追加用缩小字号:原最大字号的 50~60% + fill_min_font = max(config.MIN_FONT_FLOOR, int(round(orig_min_font * 0.50))) + fill_max_font = max(fill_min_font, int(round(orig_min_font + span * 0.60))) + fill_seed = (final_layout_seed ^ 0x9E3779B9) & 0x7FFFFFFF + if fill_seed == 0: + fill_seed = 1 + + # AUTO_REPEAT_MAX 只是防止异常掩膜导致无限循环,不是目标重复次数。 + max_fill_rounds = max(1, int(config.AUTO_REPEAT_MAX)) + for fill_round in range(max_fill_rounds): + if current_cov >= 0.95: + break + + # 融合 mask:原阻挡 + 已填区域都标为 255 + fill_mask = mask_small.copy() + fill_mask[(occ_fast == 1)] = 255 + fill_wc = OptimizedEfficientWordCloud( + width=w_small, height=h_small, + mask=fill_mask, + font_path=config.WC_FONT_PATH, + # 一轮只追加一份名单;需要更多时由下一轮按需追加。 + max_words=len(names), + min_font_size=fill_min_font, + max_font_size=fill_max_font, + background_color=config.get_output_background(), + prefer_horizontal=1.0 - float(config.VERTICAL_RATIO), + margin=_collision_margin, + ) + fill_wc.layout_seed = (fill_seed + fill_round) & 0x7FFFFFFF or 1 + fill_freq = {name: 0.3 for name in names} + fill_wc.generate_from_frequencies(fill_freq) + + fill_placed = len(fill_wc.layout_) + print( + f"[增量填充#{fill_round + 1}] 工作网格追加放置 " + f"{fill_placed}/{len(names)} 词,字号=[{fill_min_font}, {fill_max_font}]" + ) + log.info( + "[增量填充#%d] 工作网格追加放置 %d/%d 词, 字号=[%d,%d]", + fill_round + 1, fill_placed, len(names), fill_min_font, fill_max_font, + ) + if fill_placed <= 0: + break + + fill_work_layout.extend(fill_wc.layout_) + fill_occ = render_layout_occupancy( + fill_wc.layout_, mask_small.shape, config.WC_FONT_PATH + ) + occ_fast = np.maximum(occ_fast, fill_occ) + current_cov = compute_coverage_score(occ_fast, mask_small) + print(f"[增量填充#{fill_round + 1}] 工作网格覆盖度={current_cov:.4f}") + hd_layout = None hd_clearance = None raw_hd_layout = [] @@ -473,14 +639,73 @@ def run_generation_pass( config.WC_FONT_PATH, ) + # ── 增量填充(工作网格 → HD) ── + # 工作网格上的合法位置经过放大后可能因取整发生碰撞,因此填充词必须 + # 与基础布局一起再次做高清精修。若整批填充无法通过,则二分保留最多 + # 的追加词;绝不能让自动填充破坏原本已经成功的基础布局。 + if ( + fill_work_layout + and hd_layout is not None + ): + old_count = len(hd_layout) + fill_hd = scale_layout_for_hd(fill_work_layout, config.WORK_SCALE) + base_hd_layout = list(hd_layout) + + accepted_additions, clearance_stats = append_layout_with_hd_clearance( + base_hd_layout, + fill_hd, + mask_hd, + config.WC_FONT_PATH, + clearance=0, + allow_global_search=False, + ) + accepted_count = len(accepted_additions) + accepted_clearance = 0 + hd_layout = base_hd_layout + accepted_additions + hd_overlap_pixels = count_layout_overlap_pixels( + hd_layout, (real_hd_h, real_hd_w), config.WC_FONT_PATH + ) + new_fill, new_occ = compute_fill_ratio_fast( + hd_layout, mask_hd, config.WC_FONT_PATH + ) + new_cov = compute_coverage_score(new_occ, mask_hd) if new_occ is not None else 0.0 + clearance_stats = dict(clearance_stats or {}) + clearance_stats["clearance_px"] = accepted_clearance + hd_clearance = clearance_stats + print( + f"[增量填充] 高清验收: {old_count}+{accepted_count}=" + f"{len(hd_layout)} 词, fill={new_fill:.3f}, " + f"coverage={new_cov:.4f}, overlap={hd_overlap_pixels}" + ) + print( + f"[增量填充] 工作网格候选 {len(fill_work_layout)} 词," + f"高清接受 {len(hd_layout) - old_count} 词" + ) + log.info( + "[增量填充] HD 验收: base=%d candidate=%d accepted=%d fill=%.4f coverage=%.4f overlap=%d", + old_count, len(fill_hd), len(hd_layout) - old_count, new_fill, new_cov, hd_overlap_pixels, + ) + fill_ratio = new_fill + occ_fast = new_occ + largest_empty_square = largest_empty_square_size(occ_fast, mask_hd) + print( f"最终填充率: {fill_ratio:.3f} | 高清重叠像素: {hd_overlap_pixels} | " f"精修位移: {hd_clearance['shifted_words']} 词, 最大 {hd_clearance['max_shift']}px | " f"隔离带: {hd_clearance['clearance_px']}px" ) + # occ_fast 可能是工作网格或 HD 网格形状(增量填充后),按形状匹配计算覆盖度 + if occ_fast is not None and occ_fast.shape == mask_small.shape: + coverage = compute_coverage_score(occ_fast, mask_small) + elif occ_fast is not None and occ_fast.shape == mask_hd.shape: + coverage = compute_coverage_score(occ_fast, mask_hd) + else: + coverage = 0.0 + print(f"轮廓覆盖度: {coverage:.4f}") return { "wc": final_wc, "fill_ratio": fill_ratio, + "coverage": coverage, "occ_fast": occ_fast, "w_small": w_small, "h_small": h_small, @@ -614,6 +839,9 @@ def main(): ) 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 + generation_result = None + t_gen = time.time() canvas_retry_round = 0 generation_result = None t_gen = time.time() @@ -649,8 +877,9 @@ def main(): canvas_retry_round, ) sys.exit(1) - log.info(" 生成完成 placed=%d/%d fill=%.4f retry=%d", - placed, target, generation_result["fill_ratio"], canvas_retry_round) + log.info(" 生成完成 placed=%d/%d fill=%.4f coverage=%.4f retry=%d", + placed, target, generation_result["fill_ratio"], + generation_result.get("coverage", 0.0), canvas_retry_round) break canvas_retry_round += 1 diff --git a/backend/core/render.py b/backend/core/render.py index 4a03f9d..96377fa 100644 --- a/backend/core/render.py +++ b/backend/core/render.py @@ -3,6 +3,37 @@ from PIL import Image, ImageDraw, ImageFilter, ImageFont from .fonts import get_cached_font +# (font_path, word, size, orient) -> (ink[h,w] uint8, bbox_left, bbox_top). +# The HD passes -- clearance refinement and the independent overlap audit -- +# rasterize the same words at the same sizes, and rasterizing is the single +# most expensive thing either of them does, so they share one cache. +_HD_INK_CACHE = {} + + +def _word_ink(word, size, orient, font_path): + """Return (ink array, bbox_left, bbox_top) for a word, or None if empty.""" + key = (font_path, word, int(size), orient) + cached = _HD_INK_CACHE.get(key) + if cached is not None or key in _HD_INK_CACHE: + return cached + font = get_cached_font(font_path, size) + if orient: + font = ImageFont.TransposedFont(font, orientation=orient) + bbox = ImageDraw.Draw(Image.new("L", (1, 1))).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: + result = None + else: + ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width) + result = (ink, int(bbox[0]), int(bbox[1])) + _HD_INK_CACHE[key] = result + if len(_HD_INK_CACHE) > 20000: + for i, k in enumerate(list(_HD_INK_CACHE.keys())): + if i % 2 == 0: + _HD_INK_CACHE.pop(k, None) + return result + def scale_layout_for_hd(layout, work_scale): if work_scale <= 0: @@ -24,21 +55,17 @@ def count_layout_overlap_pixels(layout, mask_shape, font_path, alpha_threshold=0 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: + measured = _word_ink(word, size, orient, font_path) + if measured is None: continue - ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width) > alpha_threshold + ink_arr, bbox_left, bbox_top = measured + glyph_height, glyph_width = ink_arr.shape + ink = ink_arr > alpha_threshold - ink_x = int(x) + int(bbox[0]) - ink_y = int(y) + int(bbox[1]) + ink_x = int(x) + bbox_left + ink_y = int(y) + bbox_top x0 = max(0, ink_x) y0 = max(0, ink_y) x1 = min(width, ink_x + glyph_width) @@ -54,6 +81,56 @@ def count_layout_overlap_pixels(layout, mask_shape, font_path, alpha_threshold=0 return int(overlaps.sum()) +def _find_free_placement(blocked, occupied, collision, stamp, base_y, base_x): + """Find any canvas position where `collision` hits nothing already taken. + + Returns the (dy, dx) offset from (base_y, base_x), or None. Candidates are + ranked by distance from the original spot so a relocated word stays as close + to its intended position as possible. + + A position whose whole footprint is empty is guaranteed to fit, so the + search first looks for those using an integral image, which rejects the vast + majority of positions with two additions instead of a per-pixel test. + """ + height, width = blocked.shape + gh, gw = collision.shape + if height - gh < 0 or width - gw < 0: + return None + + # Search expanding windows around the intended spot instead of the whole + # canvas: a relocated word almost always finds room nearby, and the integral + # image costs time proportional to the area examined. The last radius covers + # the full canvas, so nothing is missed if the neighbourhood really is full. + for radius in (256, 1024, max(height, width)): + y_lo = max(0, base_y - radius) + x_lo = max(0, base_x - radius) + y_hi = min(height, base_y + radius + gh) + x_hi = min(width, base_x + radius + gw) + if y_hi - y_lo < gh or x_hi - x_lo < gw: + continue + + taken = blocked[y_lo:y_hi, x_lo:x_hi] | occupied[y_lo:y_hi, x_lo:x_hi] + integral = np.pad(taken.astype(np.int32), ((1, 0), (1, 0))).cumsum(0).cumsum(1) + # Footprint sum for every candidate top-left corner in the window. A + # position whose whole footprint is empty is guaranteed to fit, so no + # per-pixel mask test is needed. + counts = ( + integral[gh:, gw:] + - integral[:-gh, gw:] + - integral[gh:, :-gw] + + integral[:-gh, :-gw] + ) + ys, xs = np.nonzero(counts == 0) + if ys.size == 0: + continue + dy = ys.astype(np.int64) + y_lo - base_y + dx = xs.astype(np.int64) + x_lo - base_x + best = int(np.argmin(dy * dy + dx * dx)) + return int(dy[best]), int(dx[best]) + + return None + + def refine_layout_with_hd_clearance( layout, mask, @@ -65,7 +142,6 @@ def refine_layout_with_hd_clearance( 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 @@ -82,17 +158,13 @@ def refine_layout_with_hd_clearance( 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: + measured = _word_ink(word, size, orient, font_path) + if measured is None: refined.append((word, size, (draw_y, draw_x), orient, color)) continue - glyph_ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width) + glyph_ink, bbox_left, bbox_top = measured + glyph_height, glyph_width = glyph_ink.shape 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 @@ -105,8 +177,8 @@ def refine_layout_with_hd_clearance( collision = padded > 0 stamp = padded > 0 - base_y = int(draw_y) + int(bbox[1]) - pad - base_x = int(draw_x) + int(bbox[0]) - pad + base_y = int(draw_y) + bbox_top - pad + base_x = int(draw_x) + bbox_left - pad def fits(y0, x0): y1 = y0 + collision.shape[0] @@ -129,6 +201,16 @@ def refine_layout_with_hd_clearance( if placed_offset is not None: break + if placed_offset is None: + # Nothing within max_shift. Rather than fail the batch -- which + # makes the caller rebuild the entire cloud on a larger canvas, by + # far the most expensive thing that can happen -- look for any free + # spot on the whole canvas. This only runs for the occasional word + # whose work-grid position does not survive the scale-up to HD. + placed_offset = _find_free_placement( + blocked, occupied, collision, stamp, base_y, base_x + ) + if placed_offset is None: return None, { "shifted_words": shifted_words, @@ -152,6 +234,126 @@ def refine_layout_with_hd_clearance( } +def append_layout_with_hd_clearance( + base_layout, + additions, + mask, + font_path, + clearance=1, + max_shift=24, + allow_global_search=False, +): + """Place only *additions* against an already validated HD layout. + + The normal refinement pass must rebuild occupancy for every word because it + is allowed to move the whole batch. Auto-repeat words are appended after the + base batch has already passed refinement, so rescanning that batch is wasted + work. This helper seeds occupancy from the base once, then processes only + the new words and returns the largest collision-free prefix. + """ + height, width = mask.shape + blocked = np.asarray(mask) != 0 + occupied = np.zeros((height, width), dtype=bool) + + def stamp_existing(item): + word, size, (draw_y, draw_x), orient, _color = item + measured = _word_ink(word, size, orient, font_path) + if measured is None: + return + ink, bbox_left, bbox_top = measured + ink_y = int(draw_y) + bbox_top + ink_x = int(draw_x) + bbox_left + y0 = max(0, ink_y) + x0 = max(0, ink_x) + y1 = min(height, ink_y + ink.shape[0]) + x1 = min(width, ink_x + ink.shape[1]) + if y0 < y1 and x0 < x1: + occupied[y0:y1, x0:x1] |= ink[y0 - ink_y:y1 - ink_y, x0 - ink_x:x1 - ink_x] > 0 + + for item in base_layout: + stamp_existing(item) + + 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) + + accepted = [] + shifted_words = 0 + max_applied_shift = 0 + failed_word = None + for word, size, (draw_y, draw_x), orient, color in additions: + measured = _word_ink(word, size, orient, font_path) + if measured is None: + accepted.append((word, size, (draw_y, draw_x), orient, color)) + continue + + glyph_ink, bbox_left, bbox_top = measured + glyph_height, glyph_width = glyph_ink.shape + 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) + bbox_top - pad + base_x = int(draw_x) + bbox_left - 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: + if fits(base_y + dy, base_x + dx): + placed_offset = (dy, dx) + break + if placed_offset is not None: + break + if placed_offset is None and allow_global_search: + placed_offset = _find_free_placement( + blocked, occupied, collision, stamp, base_y, base_x + ) + if placed_offset is None: + # A local-only append is deliberately best-effort: skipping one + # extra word is much cheaper than scanning the full HD canvas and + # keeps the latency predictable for large auto-repeat batches. + failed_word = word + continue + + 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)) + accepted.append((word, size, (int(draw_y) + dy, int(draw_x) + dx), orient, color)) + + return accepted, { + "shifted_words": shifted_words, + "max_shift": max_applied_shift, + "failed_word": failed_word, + } + + def render_layout_occupancy(layout, mask_shape, font_path): h, w = mask_shape canvas = Image.new("L", (w, h), 0) @@ -175,6 +377,67 @@ def compute_fill_ratio_fast(layout, mask, font_path): return filled_area / free_area, occ +def compute_coverage_score(occupancy, mask, block_size=8): + """Shape-aware fill quality: how broadly the ink reaches across the mask. + + Unlike :func:`compute_fill_ratio_fast` (filled pixels / free pixels), this + rewards reaching *every part* of the mask silhouette -- protrusions, limb + tips, heart-shaped cusps -- that a centre-out Fermat spiral abandons first + when words are scarce or font sizes are large. + + The fillable region is tiled into ``block_size`` cells. A cell counts as a + "region block" when at least 30% of its pixels are free; it is "covered" + when at least one of those free pixels is inked. The score is the share of + region blocks that are covered: + + coverage = covered_blocks / region_blocks + + A compact disc packed around the centroid touches only the central blocks, + so it scores low even at a high pixel fill ratio; a layout that spreads + into every arm of the mask touches blocks in each arm and scores high. + Block granularity (not per-pixel weighting) is what makes this robust to + the mask's geometry: a thin tip is one block whether it is 3px or 30px + wide, so reaching it is rewarded consistently. ``occupancy`` may be None + (treated as empty). + """ + mask_arr = np.asarray(mask) + if mask_arr.ndim != 2 or mask_arr.size == 0: + return 0.0 + free = mask_arr == 0 + if not free.any(): + return 0.0 + + h, w = mask_arr.shape + occ = np.asarray(occupancy) if occupancy is not None else None + if occ is None or occ.shape != mask_arr.shape: + return 0.0 + inked = (occ == 1) & free + + # Block-aligned tile counts. Trailing partial blocks are merged into the + # last full block by clamping the end index, so no free pixels are dropped. + region_blocks = 0 + covered_blocks = 0 + for by in range(0, h, block_size): + y1 = min(h, by + block_size) + for bx in range(0, w, block_size): + x1 = min(w, bx + block_size) + block_free = free[by:y1, bx:x1] + free_count = int(block_free.sum()) + if free_count == 0: + continue + # A block is a region if a meaningful share of it is fillable; + # this ignores blocks that only clip a mask corner. + if free_count < 0.30 * block_free.size: + continue + region_blocks += 1 + if np.any(inked[by:y1, bx:x1] & block_free): + covered_blocks += 1 + + if region_blocks == 0: + return 0.0 + return covered_blocks / region_blocks + + 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) diff --git a/backend/service/app.py b/backend/service/app.py index db78e88..540c65e 100644 --- a/backend/service/app.py +++ b/backend/service/app.py @@ -2,6 +2,7 @@ from __future__ import annotations import io import json +import hashlib import logging import os import re @@ -10,6 +11,7 @@ import sqlite3 import threading import time import uuid +import zipfile from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -24,6 +26,7 @@ from core.fonts import get_cached_font from .job_manager import JobManager from .line_spacing import analyze_svg_line_spacing_file from .log_config import get_logger +from .metadata_store import MetadataStore from .runner import JobRunner from .schemas import ( Asset, @@ -57,12 +60,17 @@ ASSETS_DIR = PROJECT_ROOT / "service_assets" PROJECTS_DIR = PROJECT_ROOT / "service_projects" FONTS_DIR = PROJECT_ROOT / "service_fonts" DESIGN_TEMPLATES_DIR = PROJECT_ROOT / "service_design_templates" +METADATA_DIR = PROJECT_ROOT / "service_metadata" +ORDERS_DIR = PROJECT_ROOT / "service_orders" ASSETS_DIR.mkdir(parents=True, exist_ok=True) PROJECTS_DIR.mkdir(parents=True, exist_ok=True) FONTS_DIR.mkdir(parents=True, exist_ok=True) DESIGN_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) +METADATA_DIR.mkdir(parents=True, exist_ok=True) +ORDERS_DIR.mkdir(parents=True, exist_ok=True) -manager = JobManager() +metadata_store = MetadataStore(METADATA_DIR / "app.db") +manager = JobManager(metadata_store) storage = Storage(WORKSPACE_DIR) runner = JobRunner(PROJECT_ROOT, manager) @@ -144,6 +152,31 @@ def health() -> dict: return {"ok": True} +@app.get("/api/maintenance/storage-summary") +def storage_summary() -> dict: + referenced_jobs: set[str] = set() + for d in _list_dirs(ASSETS_DIR): + meta = _read_asset_meta(d) + job_id = meta.get("job_id") or "" + if job_id: + referenced_jobs.add(job_id) + stale = storage.stale_job_dirs( + referenced_job_ids=referenced_jobs, + exclude_job_ids=metadata_store.job_ids(), + max_age_days=0, + ) + return { + "job_dir_count": len(storage.list_job_ids()), + "referenced_job_ids": len(referenced_jobs), + "stale_job_count": len(stale), + "reclaimable_bytes": sum(item["size_bytes"] for item in stale), + "metadata_db_bytes": metadata_store.summarize()["db_size_bytes"], + "jobs_in_db": metadata_store.summarize()["jobs"], + "events_in_db": metadata_store.summarize()["events"], + "dry_run_only": True, + } + + @app.get("/api/jobs", response_model=list[JobStatus]) def list_jobs() -> list[JobStatus]: with manager._lock: @@ -153,26 +186,21 @@ def list_jobs() -> list[JobStatus]: @app.post("/api/jobs", response_model=JobCreateResponse) async def create_job( mask_image: Optional[UploadFile] = File(None), - name_list: UploadFile = File(...), + name_list: Optional[UploadFile] = File(None), + wcd_file: Optional[UploadFile] = File(None), font_file: Optional[UploadFile] = File(None), font_id: str = Form(""), params: str = Form("{}"), ) -> JobCreateResponse: log.info("─" * 50) log.info("[API] POST /api/jobs 收到新任务请求") - log.info(" name_list.filename = %s", name_list.filename) + log.info(" name_list.filename = %s", name_list.filename if name_list else "无") + log.info(" wcd_file.filename = %s", wcd_file.filename if wcd_file else "无") log.info(" mask_image.filename = %s", mask_image.filename if mask_image else "无") log.info(" font_file.filename = %s", font_file.filename if font_file else "无") log.info(" font_id = %s", font_id or "(未指定)") log.info(" params (raw) = %s", params) - if not name_list.filename: - raise HTTPException(status_code=400, detail="name_list is required") - - ext_xlsx = Path(name_list.filename).suffix.lower() - if ext_xlsx not in {".xlsx"}: - raise HTTPException(status_code=400, detail="name_list must be xlsx") - try: user_params = json.loads(params) except json.JSONDecodeError: @@ -181,6 +209,23 @@ async def create_job( if not isinstance(user_params, dict): raise HTTPException(status_code=400, detail="params must be JSON object") + mode = str(user_params.get("MODE", "IMAGE")).upper() + + # WCD 生产任务:传入 .wcd 画布导入导出包(还原设计 -> 生产,见 docs/wordcloud-contract.md v1.1) + is_wcd = mode == "WCD" or bool(wcd_file and wcd_file.filename) + if is_wcd: + if not (wcd_file and wcd_file.filename): + raise HTTPException(status_code=400, detail="wcd_file is required when MODE=WCD") + return await _create_wcd_job(wcd_file, user_params) + + # 名单/xlsx 模式(既有逻辑;必填校验移到 mode/WCD 判断之后) + if not name_list or not name_list.filename: + raise HTTPException(status_code=400, detail="name_list is required") + + ext_xlsx = Path(name_list.filename).suffix.lower() + if ext_xlsx not in {".xlsx"}: + raise HTTPException(status_code=400, detail="name_list must be xlsx") + log.info(" 解析后 params = %s", json.dumps(user_params, ensure_ascii=False)) mode = str(user_params.get("MODE", "IMAGE")).upper() @@ -234,8 +279,12 @@ async def create_job( "MODE": mode, "EXCEL_PATH": str(paths.excel_path), "OUTPUT_DIR": str(paths.output_dir), - "SAVE_DEBUG_IMAGES": True, + # Debug masks are useful during local diagnosis but add extra image + # writes to every request. Keep the fast service path disk-light; the + # explicit CLI/config option remains available when debugging. + "SAVE_DEBUG_IMAGES": False, "DEBUG_OUTPUT_DIR": str(paths.output_dir / "debug"), + "FAST_MODE": True, } if mask_image and mask_image.filename: config["MASK_IMAGE_PATH"] = str(paths.mask_path) @@ -273,6 +322,174 @@ async def create_job( return JobCreateResponse(job_id=job_id) +def _parse_hex(color: object): + """把 '#RRGGBB' / '#RGB' / 空值 解析为 RGBA tuple。""" + raw = str(color or "#ffffff").strip().lstrip("#") + if len(raw) == 3: + raw = "".join(c + c for c in raw) + try: + return tuple(int(raw[i : i + 2], 16) for i in (0, 2, 4)) + (255,) + except ValueError: + return (255, 255, 255, 255) + + +def _compose_design_png(document: dict, file_map: dict, output_path: Path) -> None: + """把 CanvasDocument 合成一张扁平 PNG(生产任务产物):背景 + 按 zIndex 叠贴纸。 + + file_map: {pkg_asset_id: {"path": str}},即 WCD 内临时 assetId -> 落盘素材文件。 + """ + try: + width = int(document.get("width") or 1200) + height = int(document.get("height") or 1200) + except (TypeError, ValueError): + width, height = 1200, 1200 + canvas = Image.new("RGBA", (max(width, 1), max(height, 1)), _parse_hex(document.get("background"))) + elements = [ + e + for e in document.get("elements", []) + if isinstance(e, dict) and e.get("type") == "sticker" + ] + elements.sort(key=lambda e: e.get("zIndex", 0)) + for e in elements: + info = file_map.get(str(e.get("assetId") or "")) + if not info: + continue + path = info.get("path") + if not path or not Path(path).exists(): + continue + try: + img = Image.open(path).convert("RGBA") + except Exception: + continue + ew = e.get("width") + eh = e.get("height") + ew = int(ew) if isinstance(ew, (int, float)) and ew > 0 else img.width + eh = int(eh) if isinstance(eh, (int, float)) and eh > 0 else img.height + if (ew, eh) != (img.width, img.height): + img = img.resize((int(ew), int(eh))) + opacity = e.get("opacity", 1) + if isinstance(opacity, (int, float)) and opacity >= 0 and opacity != 1: + img = img.copy() + img.putalpha(img.getchannel("A").point(lambda v: round(v * float(opacity)))) + canvas.alpha_composite(img, (int(e.get("x") or 0), int(e.get("y") or 0))) + canvas.convert("RGB").save(output_path, "PNG") + + +async def _create_wcd_job(wcd_file: UploadFile, user_params: dict) -> JobCreateResponse: + """WCD 生产任务:校验并还原 .wcd,落库设计,合成生产 PNG 作为任务产物。 + + 与 POST /api/design-templates/import 共用素材去重/注册/重映射逻辑; + 状态机与产物管线复用 jobs(queued/running/success/failed)。 + """ + if Path(wcd_file.filename).suffix.lower() != ".wcd": + raise HTTPException(status_code=400, detail="wcd_file must be .wcd") + content = await wcd_file.read() + if not content: + raise HTTPException(status_code=400, detail="wcd_file is empty") + try: + zf = zipfile.ZipFile(io.BytesIO(content)) + except zipfile.BadZipFile as exc: + raise HTTPException(status_code=400, detail="file is not a valid zip/wcd package") from exc + + with zf: + manifest = _read_import_json(zf, "manifest.json") + order_meta = manifest.get("meta") or {} + order_no = str(order_meta.get("orderNo") or "") if isinstance(order_meta, dict) else "" + # 兼容旧命名 order-{orderNo} + if not order_no and str(manifest.get("name") or "").startswith("order-"): + order_no = str(manifest.get("name"))[6:] + if manifest.get("format") != "wordcloud-canvas": + raise HTTPException(status_code=400, detail="不是 wordcloud-canvas 格式") + try: + version = int(manifest.get("version", 1)) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="manifest version 不是合法数字") from exc + if version != 1: + raise HTTPException(status_code=400, detail=f"不支持的包版本: {version}") + + document = _read_import_json(zf, "document.json") + asset_items = manifest.get("assets") + if not isinstance(asset_items, list): + asset_items = [] + + remap: dict[str, str] = {} + file_map: dict[str, dict] = {} + reference_ids: list[str] = [] + for item in asset_items: + pkg_id = str(item.get("id") or "") + if not pkg_id: + continue + entry_name = _zip_asset_entry(zf, pkg_id) + mime = _imported_asset_mime(item) + asset_bytes = zf.read(entry_name) + meta = _register_import_asset(str(item.get("name") or pkg_id), asset_bytes, mime) + real_id = str(meta["asset_id"]) + remap[pkg_id] = real_id + file_map[pkg_id] = { + "path": str(_asset_dir(real_id) / f"asset{_asset_extension_for_mime(mime)}"), + "mime": mime, + } + reference_ids.append(real_id) + _remap_import_asset_ids(document, remap) + + # 落库生产设计(与模板导入一致,便于追溯/复用/在画布中继续编辑) + display_name = str(manifest.get("name") or "导入生产设计").strip()[:120] or "导入生产设计" + template_id = f"tmpl_{uuid.uuid4().hex}" + now = datetime.now(timezone.utc).isoformat() + _write_design_template(_design_template_dir(template_id), { + "template_id": template_id, + "name": display_name, + "description": str(manifest.get("description") or "下单派单生产设计"), + "document": document, + "reference_asset_ids": reference_ids, + "cover_asset_id": reference_ids[0] if reference_ids else "", + "created_at": now, + "updated_at": now, + }) + + job_id = manager.create_job() + paths = storage.prepare_job_dirs(job_id) + (paths.input_dir / f"{template_id}.wcd").write_bytes(content) + log.info("[WCD] 生产任务 job_id=%s template=%s assets=%d", job_id, template_id, len(reference_ids)) + + # 登记生产订单(供 wordcloud 侧订单列表查看) + if order_no: + _write_order(_order_dir(order_no), { + "order_id": order_no, + "order_no": order_no, + "job_id": job_id, + "template_id": template_id, + "status": "running", + "created_at": datetime.now(timezone.utc).isoformat(), + }) + log.info("[Order] 登记生产订单 %s → job %s", order_no, job_id) + manager.set_status( + job_id, status="running", stage="composing", progress_percent=10, message="WCD 生产任务" + ) + + def _run_wcd_safe() -> None: + try: + png_path = paths.output_dir / "result.png" + _compose_design_png(document, file_map, png_path) + manager.set_artifacts(job_id, {"png": str(png_path)}) + manager.set_status( + job_id, status="success", stage="done", progress_percent=100, message="生产完成" + ) + except Exception as exc: + logging.exception("wcd job crashed", extra={"job_id": job_id}) + manager.set_status( + job_id, + status="failed", + stage="failed", + progress_percent=100, + message="任务失败", + error=str(exc), + ) + + threading.Thread(target=_run_wcd_safe, daemon=True).start() + return JobCreateResponse(job_id=job_id) + + @app.get("/api/jobs/{job_id}", response_model=JobStatus) def get_job(job_id: str) -> JobStatus: if not manager.exists(job_id): @@ -329,6 +546,7 @@ def get_result(job_id: str) -> JobResult: svg_stroke_url=u("svg_stroke"), db_url=u("db"), metrics_url=u("metrics"), + elapsed_seconds=status.elapsed_seconds, ) @@ -676,6 +894,45 @@ def get_file(job_id: str, kind: str): return FileResponse(path, media_type=media, filename=path.name) +# ═══════════════════════════════════════════════════════════ +# 3.9 生产订单列表(下单派单投递的 WCD 生产任务) +# ═══════════════════════════════════════════════════════════ + +@app.get("/api/orders") +def list_orders() -> list[dict]: + """生产订单列表:来自小程序下单派单投递到 wordcloud 的 WCD 生产任务。""" + orders = [] + if ORDERS_DIR.exists(): + for item in list(ORDERS_DIR.iterdir()): + if not item.is_dir() or not (item / "order.json").exists(): + continue + o = _read_order(item) + if not o: + continue + # 用 job 的最新状态回填 + try: + st = manager.get_status(str(o.get("job_id") or "")) + o["status"] = st.status + except Exception: + pass + orders.append(o) + return list(reversed(orders)) + + +@app.get("/api/orders/{order_no}") +def get_order(order_no: str) -> dict: + path = Path(_order_dir(order_no)) / "order.json" + if not path.exists(): + raise HTTPException(status_code=404, detail="order not found") + o = json.loads(path.read_text(encoding="utf-8")) + try: + st = manager.get_status(str(o.get("job_id") or "")) + o["status"] = st.status + except Exception: + pass + return o + + # ═══════════════════════════════════════════════════════════ # 4. 模板接口 # ═══════════════════════════════════════════════════════════ @@ -700,6 +957,26 @@ def _write_design_template(template_dir: Path, data: dict) -> None: ) +# ── 生产订单存储(下单派单投递的 WCD 生产任务)──────────── +def _order_dir(order_no: str) -> Path: + return ORDERS_DIR / order_no + + +def _read_order(order_dir: Path) -> dict: + path = order_dir / "order.json" + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_order(order_dir: Path, data: dict) -> None: + order_dir.mkdir(parents=True, exist_ok=True) + (order_dir / "order.json").write_text( + json.dumps(data, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def _parse_json_array(value: str, field_name: str) -> list: try: parsed = json.loads(value or "[]") @@ -758,6 +1035,201 @@ async def create_design_template( return DesignTemplate(**data) +def _read_import_json(zf: zipfile.ZipFile, name: str) -> dict: + try: + with zf.open(name) as fh: + raw = fh.read().decode("utf-8") + except KeyError as exc: + raise HTTPException(status_code=400, detail=f"{name} 缺失") from exc + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=400, detail=f"{name} 不是合法 JSON") from exc + if not isinstance(data, dict): + raise HTTPException(status_code=400, detail=f"{name} 必须是 JSON 对象") + return data + + +def _imported_asset_mime(item: dict) -> str: + mime = str(item.get("mimeType") or item.get("mime_type") or "").strip().lower() + if mime in {"image/svg+xml", "image/png", "image/jpeg"}: + return mime + asset_type = str(item.get("type") or "").strip().lower() + if asset_type in {"svg", "image/svg+xml"}: + return "image/svg+xml" + if asset_type in {"image", "png"}: + return "image/png" + return "image/svg+xml" if str(item.get("id", "")).endswith(".svg") else "image/png" + + +def _asset_meta_items() -> list[dict]: + result: list[dict] = [] + for d in _list_dirs(ASSETS_DIR): + meta = _read_asset_meta(d) + if meta: + result.append(meta) + return result + + +def _find_asset_by_sha256(digest: str) -> dict | None: + for meta in _asset_meta_items(): + if meta.get("sha256") == digest: + return meta + for meta in _asset_meta_items(): + try: + ext = _asset_extension_for_mime(str(meta.get("mime_type") or "")) + path = _asset_dir(str(meta["asset_id"])) / f"asset{ext}" + if path.exists() and hashlib.sha256(path.read_bytes()).hexdigest() == digest: + meta["sha256"] = digest + _write_asset_meta(_asset_dir(str(meta["asset_id"])), meta) + return meta + except Exception: + continue + return None + + +def _register_import_asset(name: str, content: bytes, mime: str) -> dict: + digest = hashlib.sha256(content).hexdigest() + existing = _find_asset_by_sha256(digest) + if existing: + return existing + + asset_id = f"asset_{uuid.uuid4().hex}" + asset_path = _asset_dir(asset_id) + asset_path.mkdir(parents=True, exist_ok=True) + ext = _asset_extension_for_mime(mime) + dest = asset_path / f"asset{ext}" + dest.write_bytes(content) + + width, height = 0, 0 + if mime == "image/svg+xml": + width, height = _parse_svg_viewbox(dest) + else: + try: + with Image.open(dest) as img: + width, height = img.size + except Exception: + pass + + meta = { + "asset_id": asset_id, + "name": name[:120] or "导入素材", + "type": "sticker", + "mime_type": mime, + "width": width, + "height": height, + "file_size": len(content), + "sha256": digest, + "file_url": f"/api/assets/{asset_id}/download", + "job_id": "", + "created_at": datetime.now(timezone.utc).isoformat(), + } + _write_asset_meta(asset_path, meta) + return meta + + +def _zip_asset_entry(zf: zipfile.ZipFile, pkg_id: str) -> str: + candidates: list[str] = [] + for entry in zf.namelist(): + parts = entry.split("/") + if ( + len(parts) >= 2 + and parts[0] == "assets" + and parts[1].startswith(pkg_id) + and ".." not in parts + and not entry.endswith("/") + ): + candidates.append(entry) + if not candidates: + raise HTTPException(status_code=400, detail=f"包内缺少素材: {pkg_id}") + for entry in candidates: + if Path(entry).stem == pkg_id: + return entry + return sorted(candidates)[0] + + +def _remap_import_asset_ids(document: dict, asset_map: dict[str, str]) -> None: + elements = document.get("elements") + if not isinstance(elements, list): + return + for element in elements: + if isinstance(element, dict) and element.get("type") == "sticker": + old_id = element.get("assetId") + if isinstance(old_id, str) and old_id in asset_map: + element["assetId"] = asset_map[old_id] + + +@app.post("/api/design-templates/import", response_model=DesignTemplate) +async def import_design_template( + file: UploadFile = File(...), + name: str = Form(""), + description: str = Form(""), +) -> DesignTemplate: + if not file.filename: + raise HTTPException(status_code=400, detail="file is required") + if Path(file.filename).suffix.lower() != ".wcd": + raise HTTPException(status_code=400, detail="file must be .wcd") + + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="file is empty") + + try: + zf = zipfile.ZipFile(io.BytesIO(content)) + except zipfile.BadZipFile as exc: + raise HTTPException(status_code=400, detail="file is not a valid zip/wcd package") from exc + + with zf: + manifest = _read_import_json(zf, "manifest.json") + if manifest.get("format") != "wordcloud-canvas": + raise HTTPException(status_code=400, detail="不是 wordcloud-canvas 格式") + try: + version = int(manifest.get("version", 1)) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="manifest version 不是合法数字") from exc + if version != 1: + raise HTTPException(status_code=400, detail=f"不支持的包版本: {version}") + + document = _read_import_json(zf, "document.json") + asset_items = manifest.get("assets") + if not isinstance(asset_items, list): + asset_items = [] + + asset_map: dict[str, str] = {} + reference_ids: list[str] = [] + for item in asset_items: + if not isinstance(item, dict): + continue + pkg_id = str(item.get("id") or "") + if not pkg_id: + continue + entry_name = _zip_asset_entry(zf, pkg_id) + mime = _imported_asset_mime(item) + asset_bytes = zf.read(entry_name) + meta = _register_import_asset(str(item.get("name") or pkg_id), asset_bytes, mime) + asset_map[pkg_id] = str(meta["asset_id"]) + reference_ids.append(str(meta["asset_id"])) + + _remap_import_asset_ids(document, asset_map) + + display_name = name.strip() or str(manifest.get("name") or "导入设计").strip() or "导入设计" + display_description = description.strip() or str(manifest.get("description") or "").strip() + now = datetime.now(timezone.utc).isoformat() + template_id = f"tmpl_{uuid.uuid4().hex}" + data = { + "template_id": template_id, + "name": display_name[:120], + "description": display_description, + "document": document, + "reference_asset_ids": reference_ids, + "cover_asset_id": reference_ids[0] if reference_ids else "", + "created_at": now, + "updated_at": now, + } + _write_design_template(_design_template_dir(template_id), data) + return DesignTemplate(**data) + + @app.get("/api/design-templates/{template_id}", response_model=DesignTemplate) def get_design_template(template_id: str) -> DesignTemplate: data = _read_design_template(_design_template_dir(template_id)) diff --git a/backend/service/job_manager.py b/backend/service/job_manager.py index c0f95e5..eaff3b4 100644 --- a/backend/service/job_manager.py +++ b/backend/service/job_manager.py @@ -6,7 +6,9 @@ import uuid from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path +from typing import Optional +from .metadata_store import MetadataStore from .schemas import JobDetail, JobEvent, JobStatus @@ -18,9 +20,20 @@ class JobState: class JobManager: - def __init__(self) -> None: + def __init__(self, store: Optional[MetadataStore] = None) -> None: self._lock = threading.Lock() + self.store = store self._jobs: dict[str, JobState] = {} + self._restore_from_store() + + def _restore_from_store(self) -> None: + if not self.store: + return + for status in self.store.load_jobs(): + self._jobs[status.job_id] = JobState( + status=status, + events=self.store.load_events(status.job_id, limit=100), + ) def create_job(self) -> str: job_id = uuid.uuid4().hex @@ -38,6 +51,8 @@ class JobManager: ) with self._lock: self._jobs[job_id] = JobState(status=status) + if self.store: + self.store.upsert_job(status) return job_id def exists(self, job_id: str) -> bool: @@ -53,11 +68,19 @@ class JobManager: state = self._jobs[job_id] return JobDetail(status=state.status, recent_events=state.events[-100:]) + def delete_job(self, job_id: str) -> None: + with self._lock: + self._jobs.pop(job_id, None) + if self.store: + self.store.delete_job(job_id) + def set_artifacts(self, job_id: str, artifacts: dict[str, str]) -> None: with self._lock: status = self._jobs[job_id].status status.artifacts = artifacts status.updated_at = datetime.now(timezone.utc) + if self.store: + self.store.upsert_job(status) def set_status( self, @@ -68,6 +91,7 @@ class JobManager: progress_percent: int, message: str, error: str | None = None, + elapsed_seconds: float | None = None, ) -> None: with self._lock: s = self._jobs[job_id].status @@ -78,14 +102,28 @@ class JobManager: s.updated_at = datetime.now(timezone.utc) if error is not None: s.error = error + if elapsed_seconds is not None: + s.elapsed_seconds = elapsed_seconds + if self.store: + self.store.upsert_job(s) - def add_event(self, job_id: str, *, kind: str, stage: str, progress_percent: int, message: str) -> None: + def add_event( + self, + job_id: str, + *, + kind: str, + stage: str, + progress_percent: int, + message: str, + elapsed_seconds: float | None = None, + ) -> None: event = JobEvent( type=kind, stage=stage, progress_percent=progress_percent, message=message, timestamp=datetime.now(timezone.utc), + elapsed_seconds=elapsed_seconds, ) with self._lock: state = self._jobs[job_id] @@ -94,13 +132,26 @@ class JobManager: state.status.progress_percent = progress_percent state.status.message = message state.status.updated_at = event.timestamp + if elapsed_seconds is not None: + state.status.elapsed_seconds = elapsed_seconds for sub in state.subscribers: sub.put(event) + if self.store: + self.store.add_event(job_id, event) + self.store.upsert_job(state.status) def subscribe(self, job_id: str) -> queue.Queue: q: queue.Queue = queue.Queue() with self._lock: - self._jobs[job_id].subscribers.append(q) + state = self._jobs[job_id] + # A fast job can emit preview_ready/completed before the browser + # finishes opening the SSE connection. Replay the existing event + # history into this subscriber so timing and preview updates are + # never lost; the lock also makes the snapshot/registration + # atomic with respect to new events. + for event in state.events: + q.put(event) + state.subscribers.append(q) return q def unsubscribe(self, job_id: str, q: queue.Queue) -> None: diff --git a/backend/service/metadata_store.py b/backend/service/metadata_store.py new file mode 100644 index 0000000..70441b0 --- /dev/null +++ b/backend/service/metadata_store.py @@ -0,0 +1,192 @@ +"""Small SQLite-backed metadata store for jobs and events. + +This is intentionally dependency-free and acts as the first durable layer for +business metadata. The schema is shaped so it can be moved to PostgreSQL later +without changing callers. +""" + +from __future__ import annotations + +import json +import sqlite3 +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable, Optional + +from .schemas import JobEvent, JobStatus + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class MetadataStore: + def __init__(self, db_path: Path) -> None: + self.db_path = db_path + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() + self._init_db() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(str(self.db_path), check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") + return conn + + def _init_db(self) -> None: + with self._lock, self._connect() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + stage TEXT NOT NULL, + progress_percent INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', + artifacts TEXT NOT NULL DEFAULT '{}', + error TEXT NOT NULL DEFAULT '', + elapsed_seconds REAL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS job_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT NOT NULL, + type TEXT NOT NULL, + stage TEXT NOT NULL, + progress_percent INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', + timestamp TEXT NOT NULL, + elapsed_seconds REAL + ); + + CREATE INDEX IF NOT EXISTS idx_job_events_job_timestamp + ON job_events(job_id, id); + """ + ) + + def upsert_job(self, status: JobStatus) -> None: + with self._lock, self._connect() as conn: + conn.execute( + """ + INSERT INTO jobs ( + id, status, stage, progress_percent, message, + artifacts, error, elapsed_seconds, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + status = excluded.status, + stage = excluded.stage, + progress_percent = excluded.progress_percent, + message = excluded.message, + artifacts = excluded.artifacts, + error = excluded.error, + elapsed_seconds = excluded.elapsed_seconds, + updated_at = excluded.updated_at + """, + ( + status.job_id, + status.status, + status.stage, + status.progress_percent, + status.message, + json.dumps(status.artifacts, ensure_ascii=False), + status.error, + status.elapsed_seconds, + status.created_at.isoformat(), + status.updated_at.isoformat(), + ), + ) + + def add_event(self, job_id: str, event: JobEvent) -> None: + with self._lock, self._connect() as conn: + conn.execute( + """ + INSERT INTO job_events ( + job_id, type, stage, progress_percent, message, timestamp, elapsed_seconds + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + job_id, + event.type, + event.stage, + event.progress_percent, + event.message, + event.timestamp.isoformat(), + event.elapsed_seconds, + ), + ) + + def load_jobs(self) -> list[JobStatus]: + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT * FROM jobs + """ + ).fetchall() + result: list[JobStatus] = [] + for row in rows: + try: + result.append( + JobStatus( + job_id=row["id"], + status=row["status"], + stage=row["stage"], + progress_percent=row["progress_percent"], + message=row["message"], + artifacts=json.loads(row["artifacts"] or "{}"), + error=row["error"], + created_at=datetime.fromisoformat(row["created_at"]), + updated_at=datetime.fromisoformat(row["updated_at"]), + elapsed_seconds=row["elapsed_seconds"], + ) + ) + except Exception: + continue + return result + + def load_events(self, job_id: str, limit: int = 100) -> list[JobEvent]: + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT type, stage, progress_percent, message, timestamp, elapsed_seconds + FROM job_events + WHERE job_id = ? + ORDER BY id DESC + LIMIT ? + """, + (job_id, limit), + ).fetchall() + return [ + JobEvent( + type=row["type"], + stage=row["stage"], + progress_percent=row["progress_percent"], + message=row["message"], + timestamp=datetime.fromisoformat(row["timestamp"]), + elapsed_seconds=row["elapsed_seconds"], + ) + for row in reversed(rows) + ] + + def delete_job(self, job_id: str) -> None: + with self._lock, self._connect() as conn: + conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,)) + conn.execute("DELETE FROM job_events WHERE job_id = ?", (job_id,)) + + def job_ids(self) -> set[str]: + with self._lock, self._connect() as conn: + rows = conn.execute("SELECT id FROM jobs").fetchall() + return {row["id"] for row in rows} + + def summarize(self) -> dict: + with self._lock, self._connect() as conn: + jobs = conn.execute("SELECT COUNT(*) AS n FROM jobs").fetchone() + events = conn.execute("SELECT COUNT(*) AS n FROM job_events").fetchone() + return { + "db_size_bytes": self.db_path.stat().st_size if self.db_path.exists() else 0, + "jobs": jobs["n"] if jobs else 0, + "events": events["n"] if events else 0, + } diff --git a/backend/service/runner.py b/backend/service/runner.py index 4d23d67..fcc486f 100644 --- a/backend/service/runner.py +++ b/backend/service/runner.py @@ -45,7 +45,7 @@ class JobRunner: return current_stage, current_progress def run(self, job_id: str, paths: JobPaths, config: dict) -> None: - t_start = time.time() + t_start = time.perf_counter() log.info("=" * 50) log.info("[Runner] 任务启动 job_id=%s", job_id) log.info(" config_path = %s", paths.config_path) @@ -65,6 +65,11 @@ class JobRunner: ] env = os.environ.copy() + # Force unbuffered stdout so every print() flushes immediately and the + # frontend SSE log view shows each step in real time. Without this, + # Python block-buffers stdout when it is a pipe, so lines pile up and + # only arrive in bursts after the buffer fills or the process exits. + env["PYTHONUNBUFFERED"] = "1" process = subprocess.Popen( cmd, cwd=str(self.project_root), @@ -77,24 +82,63 @@ class JobRunner: stage = "starting" progress = 1 + output_lines: list[str] = [] # collect all output lines for error reporting + preview_sent = False assert process.stdout is not None for raw in process.stdout: line = raw.rstrip("\n") + output_lines.append(line) log.info("[Pipeline] %s", line) stage, progress = self._parse_stage(line, stage, progress) + elapsed = time.perf_counter() - t_start self.manager.add_event( job_id, kind="log", stage=stage, progress_percent=progress, message=line, + elapsed_seconds=round(elapsed, 3), ) + # The PNG is complete before SVG/DB export starts. Publish it as a + # preview so the frontend does not wait for the slower artifacts. + if not preview_sent and line.startswith("已保存:"): + candidate = Path(line.split(":", 1)[1].strip()) + if candidate.suffix.lower() == ".png" and candidate.exists(): + self.manager.set_artifacts(job_id, {"png": str(candidate)}) + self.manager.add_event( + job_id, + kind="status", + stage="preview_ready", + progress_percent=max(progress, 94), + message="预览已生成,后台继续导出其余文件", + elapsed_seconds=round(elapsed, 3), + ) + preview_sent = True ret = process.wait() - elapsed = time.time() - t_start + elapsed = time.perf_counter() - t_start log.info("[Runner] 子进程退出 code=%d 耗时=%.2fs", ret, elapsed) + # ── 错误时:截取最后 30 行输出作为详细错误信息 ───────────── + error_detail = None + if ret != 0: + # 找到 "生成失败" 或 "错误" 或 traceback 之后的内容 + error_lines = [] + captured = False + for line in reversed(output_lines): + if not captured: + error_lines.append(line) + if any(kw in line for kw in ("生成失败", "error", "Error", "Traceback", "traceback", "未满足", "放置")): + captured = True + elif len(error_lines) < 30: + error_lines.append(line) + else: + break + error_lines.reverse() + error_detail = "\n".join(error_lines) if error_lines else f"script exited with code {ret}" + log.info("[Runner] 错误详情:\n%s", error_detail) + png = next(paths.output_dir.glob("*.png"), None) # NOTE: do NOT use "*[!_stroke].svg" — in glob, [!...] is a character class, # so filenames ending with "e.svg" (e.g. AutoResize.svg) are incorrectly skipped. @@ -142,15 +186,24 @@ class JobRunner: return if ret == 0: + done_message = f"任务完成,用时 {elapsed:.2f} 秒" log.info("[Runner] ✅ 任务完成 job_id=%s 总耗时=%.2fs", job_id, elapsed) self.manager.add_event( job_id, kind="status", stage="completed", progress_percent=100, - message="任务完成", + message=done_message, + elapsed_seconds=round(elapsed, 3), + ) + self.manager.set_status( + job_id, + status="success", + stage="completed", + progress_percent=100, + message=done_message, + elapsed_seconds=round(elapsed, 3), ) - self.manager.set_status(job_id, status="success", stage="completed", progress_percent=100, message="任务完成") else: log.error("[Runner] ❌ 任务失败 job_id=%s exit_code=%d 耗时=%.2fs", job_id, ret, elapsed) self.manager.add_event( @@ -158,7 +211,8 @@ class JobRunner: kind="status", stage="failed", progress_percent=100, - message=f"任务失败,退出码: {ret}", + message=f"任务失败,退出码: {ret},用时 {elapsed:.2f} 秒", + elapsed_seconds=round(elapsed, 3), ) self.manager.set_status( job_id, @@ -166,5 +220,6 @@ class JobRunner: stage="failed", progress_percent=100, message="任务失败", - error=f"script exited with code {ret}", + error=error_detail or f"script exited with code {ret}", + elapsed_seconds=round(elapsed, 3), ) diff --git a/backend/service/schemas.py b/backend/service/schemas.py index a6fdfd0..6e6f009 100644 --- a/backend/service/schemas.py +++ b/backend/service/schemas.py @@ -17,6 +17,7 @@ class JobEvent(BaseModel): progress_percent: int = Field(ge=0, le=100) message: str timestamp: datetime + elapsed_seconds: float | None = None class JobStatus(BaseModel): @@ -29,6 +30,7 @@ class JobStatus(BaseModel): updated_at: datetime artifacts: dict[str, str] error: str = "" + elapsed_seconds: float | None = None class JobDetail(BaseModel): @@ -44,6 +46,7 @@ class JobResult(BaseModel): svg_stroke_url: str = "" db_url: str = "" metrics_url: str = "" + elapsed_seconds: float | None = None class WordLocation(BaseModel): diff --git a/backend/service/storage.py b/backend/service/storage.py index f1b66a6..633ae23 100644 --- a/backend/service/storage.py +++ b/backend/service/storage.py @@ -1,10 +1,17 @@ from __future__ import annotations +import os +import re +import shutil +import time from pathlib import Path from .schemas import JobPaths +_JOB_ID_RE = re.compile(r"^[0-9a-f]{32,}$") + + class Storage: def __init__(self, base_dir: Path) -> None: self.base_dir = base_dir @@ -25,3 +32,70 @@ class Storage: excel_path=input_dir / "names.xlsx", config_path=root / "config.json", ) + + def job_root(self, job_id: str) -> Path: + return self.base_dir / job_id + + def job_dir_size(self, job_id: str) -> int: + root = self.job_root(job_id) + if not root.exists(): + return 0 + total = 0 + for dirpath, _, filenames in os.walk(root): + for filename in filenames: + try: + total += os.path.getsize(os.path.join(dirpath, filename)) + except OSError: + continue + return total + + def job_dir_info(self, job_id: str) -> dict | None: + root = self.job_root(job_id) + if not root.is_dir(): + return None + try: + mtime = root.stat().st_mtime + except OSError: + return None + return { + "job_id": job_id, + "path": str(root), + "size_bytes": self.job_dir_size(job_id), + "age_days": round(max(0.0, time.time() - mtime) / 86400.0, 2), + } + + def list_job_ids(self) -> list[str]: + if not self.base_dir.exists(): + return [] + return [ + item.name + for item in self.base_dir.iterdir() + if item.is_dir() and _JOB_ID_RE.match(item.name) + ] + + def stale_job_dirs( + self, + referenced_job_ids: set[str], + exclude_job_ids: set[str] | None = None, + max_age_days: float | None = None, + ) -> list[dict]: + exclude = exclude_job_ids or set() + result: list[dict] = [] + for job_id in self.list_job_ids(): + if job_id in referenced_job_ids or job_id in exclude: + continue + info = self.job_dir_info(job_id) + if not info: + continue + if max_age_days is not None and info["age_days"] < max_age_days: + continue + result.append(info) + return sorted(result, key=lambda item: item["size_bytes"], reverse=True) + + def remove_job_dir(self, job_id: str) -> int: + root = self.job_root(job_id) + if not root.exists(): + return 0 + size = self.job_dir_size(job_id) + shutil.rmtree(root, ignore_errors=True) + return size diff --git a/backend/service/storage_metrics.py b/backend/service/storage_metrics.py new file mode 100644 index 0000000..7a9628a --- /dev/null +++ b/backend/service/storage_metrics.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Inspect and optionally clean stale job directories. + +Default mode is a safe dry-run that reports reclaimable bytes. Pass --apply to +actually remove unreferenced job directories. +""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path + +from .metadata_store import MetadataStore +from .storage import Storage + + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +PROJECT_ROOT = BACKEND_ROOT +WORKSPACE_DIR = PROJECT_ROOT / "service_workspace" +ASSETS_DIR = PROJECT_ROOT / "service_assets" +METADATA_DIR = PROJECT_ROOT / "service_metadata" + + +def read_asset_meta(path: Path) -> dict: + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def referenced_job_ids() -> set[str]: + refs: set[str] = set() + for meta_path in ASSETS_DIR.glob("*/*/meta.json"): + job_id = read_asset_meta(meta_path).get("job_id") or "" + if job_id: + refs.add(job_id) + return refs + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--max-age-days", type=float, default=0) + parser.add_argument("--apply", action="store_true", help="Actually delete stale job directories") + parser.add_argument("--json", type=Path, default=None, help="Write JSON report") + args = parser.parse_args() + + store = MetadataStore(METADATA_DIR / "app.db") + storage = Storage(WORKSPACE_DIR) + known_job_ids = store.job_ids() if store.db_path.exists() else set() + referenced = referenced_job_ids() + stale = storage.stale_job_dirs( + referenced_job_ids=referenced, + exclude_job_ids=known_job_ids, + max_age_days=args.max_age_days, + ) + + reclaimable = sum(item["size_bytes"] for item in stale) + report = { + "scanned_at": datetime.now(timezone.utc).isoformat(), + "job_dir_count": len(storage.list_job_ids()), + "referenced_job_ids": len(referenced), + "known_job_ids": len(known_job_ids), + "stale_job_count": len(stale), + "reclaimable_bytes": reclaimable, + "max_age_days": args.max_age_days, + "apply": args.apply, + "stale_jobs": stale[:200], + } + + print(json.dumps(report, ensure_ascii=False, indent=2)) + if args.json: + args.json.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + + if args.apply: + freed = 0 + for item in stale: + freed += storage.remove_job_dir(item["job_id"]) + store.delete_job(item["job_id"]) + print(f"freed_bytes={freed}") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_layout_constraints.py b/backend/tests/test_layout_constraints.py index b7bd4cb..4466d6a 100644 --- a/backend/tests/test_layout_constraints.py +++ b/backend/tests/test_layout_constraints.py @@ -53,6 +53,8 @@ class LayoutConstraintTests(unittest.TestCase): "SEED", "TARGET_FILL_RATIO", "WC_FONT_PATH", + "AUTO_REPEAT_TO_FILL", + "AUTO_REPEAT_MAX", ) } config.WC_FONT_PATH = str(config.PROJECT_DEFAULT_FONT) @@ -66,6 +68,7 @@ class LayoutConstraintTests(unittest.TestCase): config.LAYOUT_SEED = 20260718 config.SEED = 20260718 config.TARGET_FILL_RATIO = 0.42 + config.AUTO_REPEAT_TO_FILL = False def tearDown(self) -> None: for key, value in self.saved.items(): @@ -91,6 +94,13 @@ class LayoutConstraintTests(unittest.TestCase): self.assertEqual(len(layout), len(names)) self.assertEqual(len({font_size for _, font_size, *_ in layout}), 1) + def test_auto_repeat_keeps_hd_layout_overlap_free(self) -> None: + config.AUTO_REPEAT_TO_FILL = True + config.AUTO_REPEAT_MAX = 2 + names, result = self.generate(40) + self.assertGreaterEqual(len(result["hd_layout"]), len(names)) + self.assertEqual(result["hd_overlap_pixels"], 0) + def test_explicit_equal_min_max_is_exact(self) -> None: config.USER_MIN_FONT_SIZE = 12 config.USER_MAX_FONT_SIZE = 12 diff --git a/backend/tools/benchmark_layout.py b/backend/tools/benchmark_layout.py index 03c4443..ed30bdd 100644 --- a/backend/tools/benchmark_layout.py +++ b/backend/tools/benchmark_layout.py @@ -185,6 +185,7 @@ def main() -> None: config.SEED = 20260718 config.TARGET_FILL_RATIO = 0.45 config.FONT_COLOR = "#102A43" + config.AUTO_REPEAT_TO_FILL = False results = [ run_case(count, args.canvas, args.output_dir, args.max_growth_rounds) diff --git a/docs/ALGORITHM.md b/docs/ALGORITHM.md index 95aa5e2..427bc23 100644 --- a/docs/ALGORITHM.md +++ b/docs/ALGORITHM.md @@ -12,7 +12,7 @@ 5. 估算字号范围 → weights.calculate_font_by_area_model() 6. 小画布布局 → layout.OptimizedEfficientWordCloud.generate_from_frequencies() 7. C++ 按真实字形找位置并原子写入 → IntegralGrid.place_glyph_exact() -8. 整批未完整放入 → 统一缩放字号或扩大画布后重排 +8. 二分搜索能容纳全部姓名的最大字号缩放;仍放不下则扩大画布后重排 9. 密度优化 → 探测更大字号并保留完整率不下降的候选 10. 高清精修 → render.refine_layout_with_hd_clearance() 加入隔离带局部微调 11. 输出 PNG / SVG / DB / metrics @@ -93,10 +93,15 @@ total_target = len(names) * N_REPETITIONS ## 放置策略 +放置顺序按字号分档:大字号先随机撒开,其余再从中心螺旋填充。 +大字号需要整片空白才放得下,等螺旋填满画布就没有空间了,因此必须先放。 +档内按索引顺序放置,保证同一 `layout_seed` 完全可复现。 + 每个词的放置流程: 1. 根据目标分数得到目标字号(线性插值于 `min_font` 到 `max_font`) -2. 随机决定横排或竖排(`prefer_horizontal` 控制概率) +2. 逐词独立随机决定横排或竖排,竖排概率为 `VERTICAL_RATIO`(流水线以此换算 `prefer_horizontal = 1 - VERTICAL_RATIO`); + 当前竖排为整词旋转 90°(字符侧倒),不是字符直立的传统竖排 3. 用 PIL 渲染真实字形 bitmap,施加单侧安全边距(margin)生成碰撞 mask 4. 调用 C++ `place_glyph_exact()` 搜索合法位置并原子写入 5. 当前方向找不到时,只尝试同字号的另一方向 @@ -113,14 +118,33 @@ total_target = len(names) * N_REPETITIONS - `canvas`:真实占用像素(`1` 表示已占用或掩膜阻挡) - `data`:兼容旧矩形查询的积分图;当前主路径不依赖逐词重建 -`place_glyph_exact()` 行为: +`place_glyph_exact()` 有三种放置模式,由 `layout.py` 按字号分配: -1. 等字号批次前 `70%` 姓名优先选择靠近可填区域质心的合法位置,避免少量姓名接受第一个随机空位而形成大块空洞 -2. 多字号批次只对前 `25%` 以及高权重(`score ≥ 0.80`)姓名启用中心偏好 -3. 少于 `100` 人的等字号批次中心候选数提高到 `768`;`100–300` 人提高到 `512`;其余保持 `256` -4. 随机探测失败后,从种子决定的偏移开始完整扫描 -5. 对每个候选位置逐像素比较碰撞 mask 与 C++ `canvas` -6. 命中后只写入真实字形,占位查询和写入在同一次 C++ 调用中完成 +**大字号(`placement_mode=2`)**:字号达到 `min_font + (max_font - min_font) * 0.80` 的姓名先放,随机探测取第一个合法位置。 +探测受一个软半径约束:前 `75%` 次探测限制在质心周围 `0.55 → 1.0` 倍掩膜半径内,之后放开。 +约束只影响落点偏好,不会排除任何合法位置,因此不影响完整率。 +等字号批次中 `max_font == min_font`,不存在大字号档,全部走螺旋。 + +**小字号(`placement_mode=1`)**:先试可填区域质心,再沿费马螺旋(黄金角 `2.39996`,`radius = 1.25 * sqrt(step)`)向外搜索。 +`spiral_cursor` 在整批姓名间持续推进。每个词以随机相位 `theta_offset ∈ [0, 2π)` 起扫, +使相邻两词的角距不再恒为黄金角;随机量取自按 `layout_seed` 派生的逐词种子,同一种子完全可复现, +不同种子给出真正不同的排布,而不只是同一图形换名字。 + +相位必须取满整圈。曾尝试限制在 `±60°` 的窄扇区内,结果明显更差: +扇区会让词跳过边界上已经合法的位置而退到更差的位置,实测 800 词的最终墨水密度下降 31%(`0.255 → 0.176`)。 +取满整圈则不损失密度,因为半径增长时扫描本来就会覆盖所有角度。 + +注意这并不会让排布显得无序:半径仍与放置顺序高度相关(Pearson `r ≈ 0.98`)。 +中心向外且保持紧密的填充必然按半径递增推进——「有序」与「紧密」是同一件事。 +要在不牺牲密度的前提下打散这种观感,需要多个螺旋原点,而不是在单一螺旋上加抖动。 + +**兼容模式(`placement_mode=0`)**:纯随机探测,取第一个合法位置。 + +三种模式共用后续步骤: + +1. 随机探测失败后,从种子决定的偏移开始环形完整扫描,保证存在合法位置时一定能找到 +2. 对每个候选位置逐像素比较碰撞 mask 与 C++ `canvas` +3. 命中后只写入真实字形,占位查询和写入在同一次 C++ 调用中完成 真实字形搜索允许透明角落和笔画间空隙安全交错,比外接矩形碰撞更密。安全边距只参与候选检查,不会被双侧累计放大。 @@ -132,18 +156,50 @@ total_target = len(names) * N_REPETITIONS 1. 小画布完整布局放大到高清 2. 逐词在高清画布上验证,加入 `1px` 隔离带 -3. 碰撞时只允许最大 `max_shift=24px` 的局部微位移 -4. 精修失败时,最多进行 `1` 次确定性整批优先级回溯(把失败词移到队列最前) -5. 若轮廓过窄无法容纳额外隔离带,降为精确零间隙碰撞(`clearance=0`) -6. 全部候选无解时,由外层扩大画布后整批重新布局,不进行远距离单词搬移 +3. 碰撞时先在 `max_shift=24px` 内做局部微位移 +4. 邻域内无解时,`_find_free_placement()` 以逐步放大的窗口(`256 → 1024 → 全画布`)在整张画布上找空位, + 取离原位置最近的一个。窗口内用积分图筛选:足迹范围内完全空白的位置必定可放, + 无需逐像素比较,因此绝大多数候选位置只花两次加法就被排除。 + 这一步把「一个词放不下就整条流水线换更大画布重来」变成一次局部搬移—— + 后者是全流程中最贵的失败路径,实测会让端到端耗时翻倍且仍可能最终失败 +5. 精修失败时,最多进行 `1` 次确定性整批优先级回溯(把失败词移到队列最前) +6. 若轮廓过窄无法容纳额外隔离带,降为精确零间隙碰撞(`clearance=0`) +7. 全部候选无解时,才由外层扩大画布后整批重新布局 密度搜索会保留各档完整整批候选。最高密度候选若无法在有限位置修正范围内通过高清隔离验收,则改用上一档完整整批候选;不会对碰撞姓名单独缩字号,也不会接受带重叠的高密度结果。 +## 字号缩放二分搜索 + +目标是找到**能放下全部姓名且轮廓覆盖最好的字号缩放**。单纯追求最大字号会让费马螺旋把词压在质心圆盘内、走不到掩膜远端,于是非圆形掩膜(心形尖端、人物四肢)填成圆形。 + +流水线用「探测」来判定某个缩放是否可行。探测在遇到第一个放不下的姓名时立刻停止: +一个姓名只有在螺旋、随机探测和全画布穷举扫描都失败后才算放不下,所以单个失败即可证明该缩放不可行, +不必把整批跑完。这一点对速度至关重要——放不下的姓名要付出完整搜索的代价, +实测约为可放下姓名的 `10` 倍,把注定失败的整批跑到底是流水线中最昂贵的操作。 +`OptimizedEfficientWordCloud.max_failures` 控制这一行为,为 `None` 时跑满整批并尽量多放。 + +搜索过程: + +1. 从 `scale=1.0` 开始探测;失败则按 `sqrt(已放置比例)` 收缩再试,最多 `4` 次 +2. 得到一个可行值后,在最大失败值与最小可行值之间二分最多 `3` 次,把之前收缩让掉的字号找回来 +3. 相邻两个缩放取整后字号相同时停止——工作网格上字号是小整数,再细分没有意义 +4. 多个可行结果中取**形状覆盖度最高**的那个(`compute_coverage_score`),覆盖度并列时才取缩放更大者 + +### 形状覆盖度 + +`compute_coverage_score()` 把可填区域切成 `8×8` 像素的块,只统计可填像素占比 ≥ 30% 的「区域块」,计算其中被墨迹触达的比例: + +``` +coverage = 被触达的区域块数 / 区域块总数 +``` + +这是「轮廓是否被填出来」的直接度量:质心圆盘只触达中心几块,覆盖度低;铺进掩膜每个臂/尖端的布局触达各块,覆盖度高。块粒度(而非逐像素加权)让它对掩膜几何稳健——一个尖端无论宽 3px 还是 30px 都是一个块,触达它都被同等奖励。 + ## 填充率重试 -一次布局完成后,`compute_fill_ratio_fast()` 重新渲染 layout 并计算填充率。 +一次布局完成后,`compute_fill_ratio_fast()` 重新渲染 layout 并计算填充率,`compute_coverage_score()` 计算形状覆盖度。 -- 面积模型首次完整放入但明显低于 `TARGET_FILL_RATIO` 时,只允许一次整批等比例增字号尝试。新布局必须仍然完整且真实填充率更高才会采用 +- 面积模型首次完整放入但明显低于 `TARGET_FILL_RATIO` 时,进入**双向密度优化**:每轮同时探测「加大字号」和「减小字号」两个方向,取覆盖度更高的候选。减小字号让费马螺旋走更远、触达掩膜远端,即使填充率略降也能提升覆盖度——这正是纠正「填成圆形」的关键方向。覆盖度与填充率都无提升时停止 - 整批未完整放入时,流水线不会输出半成品:先整批等比例调整字号;触及最小字号仍失败时按 `CANVAS_RETRY_GROWTH` 扩大画布并重新生成 ## 字号硬约束 @@ -176,3 +232,24 @@ new_w = clamp(base_w × scale_factor, max_edge=6000) - 扩展后宽高向上取整到最近的 `100` - 最大边长限制为 `6000px`,避免 SVG/PNG 过度膨胀 - 掩膜只生成一次,扩展后复用 + +## 性能:按字符缓存 + +一份中文名单里不同**字符**的数量远小于不同**姓名**的数量——750 个三字姓名通常只含约 `34` 个不同字符。 +两处最重的工作因此按字符而不是按姓名缓存: + +- **SVG 轮廓**(`layout._char_shape`):每个 `(字符, 字号, 方向)` 只取一次字形轮廓并格式化一次路径字符串。 + 一个姓名由若干字符路径拼成,字符在词内的位置放进元素的 `transform` 平移量, + 所以缓存的路径字符串被逐字节复用,不需要重新解析或平移坐标。 + 导出时每个字形输出一个 ``,几何结果与整词单路径完全一致(已逐点验证)。 +- **高清字形位图**(`render._word_ink`):隔离精修与独立的重叠审计会在相同字号下光栅化相同姓名, + 两者共用一份缓存。 + +`_path_bbox()` 只在每个字符首次构建时调用一次,词的包围盒由各字符包围盒平移后取并集算出, +不再对生成好的路径字符串做正则重解析。 + +## 零重叠保证 + +输出前 `count_layout_overlap_pixels()` 会独立重渲染整个 layout 并统计被两个及以上词占用的像素。 +该值必须为 `0`,否则 `placement_ok` 为假,流水线拒绝输出而不是交付带重叠的结果。 +这项校验独立于隔离精修,即使精修逻辑有误也能兜住。 diff --git a/docs/CANVAS_EXPORT_PACKAGE.md b/docs/CANVAS_EXPORT_PACKAGE.md new file mode 100644 index 0000000..2692228 --- /dev/null +++ b/docs/CANVAS_EXPORT_PACKAGE.md @@ -0,0 +1,231 @@ +# 画布模板导入导出包(`.wcd`)方案(规划) + +> 状态:第一版已实现(画布导出 `.wcd`、首页导入 `.wcd`)。 +> 目的:实现画布/设计的完整导入导出,要求包内不仅包含画布尺寸、背景、图层、元素摆放信息,还要把元素用到的素材一起带出去,使得包可以在另一台机器或另一个实例中导入复用。 + +## 1. 包格式 + +建议采用 Zip 包,文件后缀为 `.wcd`。没有必要自定义二进制格式。 + +预期目录结构: + +``` +example.wcd +├── manifest.json // 包元数据与 schema version +├── document.json // CanvasDocument:画布结构 +├── preview.png // 可选封面图 +├── fonts/ // 可选字体文件 +│ └── ... +└── assets/ + ├── asset-001.svg + ├── asset-002.png + └── asset-003.svg +``` + +## 2. manifest.json + +```json +{ + "format": "wordcloud-canvas", + "version": 1, + "name": "海报模板", + "description": "示例模板", + "createdAt": "2026-08-06T00:00:00Z", + "canvas": { + "width": 1600, + "height": 1000, + "background": "#ffffff" + }, + "assets": [ + { + "id": "asset-001", + "originalAssetId": "asset_xxx", + "name": "词云 A", + "type": "svg", + "mimeType": "image/svg+xml", + "sha256": "abc...", + "size": 1024 + } + ], + "fonts": [] +} +``` + +字段说明: + +- `format`:固定标识,防止其他 Zip 被误导入。 +- `version`:包格式版本,后续升级时便于兼容。 +- `assets[].id`:包内临时 ID,只在这个包内有效。 +- `originalAssetId`:导出时的来源素材 ID,仅记录,不要求导入后保留。 +- `sha256`:可选,导入时用于去重。 + +## 3. document.json + +`document.json` 就是当前前端的 `CanvasDocument` 模型: + +- `width`:画布宽度。 +- `height`:画布高度。 +- `background`:画布背景色。 +- `layers`:图层列表。 +- `layerFolders`:图层文件夹列表。 +- `elements`:元素列表。 + +对 Sticker 元素有一个关键规则:**导出时把 `assetId` 替换成包内临时 ID**。 + +示例: + +```json +{ + "width": 1600, + "height": 1000, + "background": "#ffffff", + "layers": [ + { "id": "layer-1", "name": "词云", "visible": true, "locked": false } + ], + "layerFolders": [], + "elements": [ + { + "id": "element-1", + "type": "sticker", + "assetId": "asset-001", + "x": 100, + "y": 80, + "width": 800, + "height": 500, + "rotation": 0, + "opacity": 1 + } + ] +} +``` + +## 4. 导出流程 + +建议由后端提供导出接口,例如: + +```text +GET /api/designs/{id}/export +``` + +或当前模板库扩展为: + +```text +GET /api/design-templates/{id}/export +``` + +导出步骤: + +1. 从数据库读取画布文档 `design_documents`。 +2. 序列化 `CanvasDocument`。 +3. 遍历 Sticker 元素,收集所有真实素材 ID。 +4. 读取每个素材文件字节。 +5. 为每个素材生成包内 ID,例如 `asset-001`。 +6. 用包内 ID 替换 `document.json` 中的 `assetId`。 +7. 把素材写入 `assets/` 目录。 +8. 可选:生成 `preview.png` 作为导入时的缩略图。 +9. 可选:如果模板使用了后端字体,把字体文件写入 `fonts/`。 +10. 生成 `manifest.json`。 +11. 打包为 `.wcd` 并返回。 + +## 5. 导入流程 + +建议后端提供导入接口,例如: + +```text +POST /api/designs/import +multipart/form-data: file=.wcd +``` + +导入步骤: + +1. 把 `.wcd` 解压到临时目录。 +2. 校验 `manifest.json`: + - 是否是 `wordcloud-canvas` 格式。 + - `version` 是否兼容。 + - `document.json` 是否结构合法。 +3. 读取 `document.json` 并通过现有 `normalizeDocument` 逻辑归一化。 +4. 逐个处理 `assets/` 下素材: + - 计算 SHA-256。 + - 如果素材表中已有相同 SHA-256,复用已有素材 ID。 + - 否则调用素材导入逻辑写入 `assets` 表 + 文件系统。 +5. 把 `document.json` 中的包内 `assetId` 重新映射为真实素材 ID。 +6. 保存为新的 `design_documents`。 +7. 可选:把 `preview.png` 作为模板封面。 +8. 返回新设计/模板 ID。 + +## 6. 与现有模板系统的关系 + +当前模板保存是把 `CanvasDocument` 和 `reference_asset_ids` 写到目录 JSON 里: + +- 在线模板:保持后端素材引用,适合当前实例内复用。 +- `.wcd`:把素材一起打包,适合跨机器/离线/换实例导入导出。 + +两者最终统一到: + +- `design_documents`:存画布文档。 +- `assets`:存素材元数据。 +- `design_templates`:存模板元数据并引用素材。 + +`.wcd` 只是外部交换容器。 + +## 7. 边界与设计决策 + +### 先不做自定义二进制格式 + +Zip + JSON 足够,方便调试、校验和后续扩展。 + +### 不把素材 base64 塞进 document.json + +素材单独放文件,避免 JSON 膨胀;`document.json` 只保存引用 ID。 + +### 素材缺失处理 + +导出时如果某个素材文件缺失,可以选择: + +- 导出失败并提示哪个素材缺失。 +- 或在 `manifest` 中标记为 `missing`,导入时提示并跳过。 + +建议第一版采用“导出失败并提示”,保证导入包完整。 + +### 字体处理 + +第一版建议只保留 `fontFamily` 字符串,不打包字体。 + +后续如果确实需要跨机器还原,再把字体文件放进 `fonts/`,导入时注册到字体库。 + +### 去重 + +`assets.sha256` 是导入去重的关键字段: + +- 包内相同素材只存一次。 +- 多次导入相同素材时直接复用数据库中的现有素材。 + +## 8. 分阶段实施 + +### 阶段一:最小可用包(已完成) + +- 定义 `.wcd`,包含 `manifest.json`、`document.json`、`assets/`。 +- 支持导出当前画布或模板。 +- 支持解压导入,只处理贴纸素材和画布布局。 +- 不处理字体,不生成 preview。 + +### 阶段二:导入体验完善 + +- 生成 `preview.png`。 +- 导入时检查素材缺失。 +- 支持同一设计重复导入去重。 + +### 阶段三:与 PostgreSQL 打通 + +- `POST /api/designs/import` 最终写入 `design_documents`。 +- 素材导入自动写入 `assets` 表。 +- 导出接口直接读取 `design_documents` 和 `assets`,不再依赖前端状态。 + +## 9. 相关文件参考 + +- `frontend/src/lib/svgExport.ts`:当前图层 ZIP 导出。 +- `frontend/src/lib/templateLibrary.ts`:当前模板保存/读取。 +- `frontend/src/lib/canvasDocument.ts`:`CanvasDocument` 模型与归一化。 +- `frontend/src/types.ts`:`CanvasDocument`、`StickerAsset` 等类型。 +- `backend/service/app.py`:当前 `/api/assets`、`/api/design-templates` 接口。 +- `docs/DESIGN_DATA_STORAGE_PLAN.md`:存储层重构后文档落库设计。 diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ba88538..0f69832 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -34,6 +34,7 @@ backend/service_workspace/{job_id}/config.json | `strokeWeights` | `ENABLE_STROKE_WEIGHTS` | | `sizeRatio` | `SIZE_RATIO` | | `packingEfficiency` | `PACKING_EFFICIENCY` | +| `verticalRatio` | `VERTICAL_RATIO` | | `targetFillRatio` | `TARGET_FILL_RATIO` | | `userMinFontSize` | `USER_MIN_FONT_SIZE` | | `userMaxFontSize` | `USER_MAX_FONT_SIZE` | @@ -92,6 +93,7 @@ backend/service_workspace/{job_id}/config.json | `TARGET_FILL_RATIO` | `0.45` | 面积模型目标笔画填充率 | | `SIZE_RATIO` | `2.0` | `max_font` 相对 `min_font` 的比例;`1.0` 为等字号模式 | | `PACKING_EFFICIENCY` | `0.9` | 面积模型中的打包效率 | +| `VERTICAL_RATIO` | `0.18` | 竖排概率,逐词独立抽取;`0.0` 全部横排,`1.0` 全部竖排 | ### 字号硬约束 diff --git a/docs/DATA_STORAGE_OPTIMIZATION.md b/docs/DATA_STORAGE_OPTIMIZATION.md new file mode 100644 index 0000000..72cab7d --- /dev/null +++ b/docs/DATA_STORAGE_OPTIMIZATION.md @@ -0,0 +1,163 @@ +# 业务数据存储优化与指标报告 + +> 状态:已完成第一阶段可审计代码改造;未执行破坏性清理。 +> 目标:减少无效任务文件、把任务元数据从“内存 + 散落目录 JSON”提升为“可恢复元数据存储”,并为后续 PostgreSQL 迁移留出接口。 + +## 1. 优化前现状 + +### 任务存储 + +- 任务状态 / 事件只存在于 `JobManager._jobs` 内存中,服务重启即丢失。 +- `POST /api/jobs` 每次提交都会立刻创建 `service_workspace/{job_id}/input`、`output` 和配置文件。 +- 任务完成后没有 TTL / 引用检查清理逻辑;历史任务目录会一直留在磁盘。 +- `service_workspace` 中大量任务目录只是“曾经跑过一次”的产物,没有被任何素材或模板引用。 + +### 素材存储 + +- 素材文件保留在 `service_assets/{asset_id}/asset.*`,元数据写在目录内 `meta.json`。 +- 普通的 `POST /api/assets` 没有写入 `sha256`,只有 `.wcd` 导入路径开始做内容去重。 +- 前端贴纸 `tint` 仍放在 localStorage,没有回到服务端统一维护。 + +### 当前实际磁盘基线(2026-08-06 扫描) + +| 项 | 数量 / 大小 | +|---|---| +| `service_workspace` 任务目录 | 193 个 | +| `service_workspace` 总大小 | 2.44 GiB / 2,618,726,669 bytes | +| 被素材 `job_id` 引用的任务目录 | 6 个 | +| 未被任何素材引用的任务目录 | 187 个 | +| 未引用任务目录总大小 | 2.38 GiB / 2,556,506,642 bytes | +| 素材文件 | 40 个,合计约 95.6 MiB | +| 素材中重复内容多占空间 | 6 个额外文件,约 2.34 MiB | +| 设计模板 JSON | 4 个 | + +## 2. 优化方案与已落地改动 + +### 1) 新增业务元数据存储层 + +新增 `backend/service/metadata_store.py`: + +- SQLite 单文件 `backend/service_metadata/app.db`。 +- 建 `jobs` 和 `job_events` 两张表。 +- 任务创建、状态更新、产物路径、SSE 事件都会落库。 +- `JobManager` 启动时可以从数据库恢复任务,不再完全依赖内存。 +- 表结构有意保持“一行元数据 + JSONB/JSON 字段”风格,后续迁移到 PostgreSQL 时主体字段不变。 + +### 2) 任务目录可审计与可清理 + +扩展 `backend/service/storage.py`: + +- `job_dir_size()` / `job_dir_info()`:按任务统计占用。 +- `stale_job_dirs()`:按“未被素材引用、不在元数据库、可选按年龄”筛选遗留目录。 +- `remove_job_dir()`:提供精确清理,只清理 `service_workspace` 下的任务目录。 + +新增 `backend/service/storage_metrics.py`: + +- 默认 `dry-run`,只扫描并输出可回收空间。 +- 只有显式 `--apply` 才会删除遗留任务目录。 +- 示例: + +```bash +cd backend +.venv/bin/python -m service.storage_metrics --max-age-days 0 +.venv/bin/python -m service.storage_metrics --max-age-days 0 --json ../docs/storage-metrics.json +# 确认后执行 +.venv/bin/python -m service.storage_metrics --max-age-days 0 --apply +``` + +### 3) 素材去重基础 + +- `.wcd` 导入路径已按 `sha256` 去重素材。 +- `service_assets/{id}/meta.json` 中新增 `sha256` 字段。 +- 后续 `POST /api/assets` 也可以统一补充哈希,形成服务级去重。 + +### 4) 可实时查询指标 + +新增只读接口: + +```text +GET /api/maintenance/storage-summary +``` + +返回指标包括: + +- `job_dir_count`:当前任务目录数。 +- `referenced_job_ids`:被素材引用的任务数。 +- `stale_job_count`:可回收任务数。 +- `reclaimable_bytes`:可回收字节数。 +- `jobs_in_db` / `events_in_db`:当前元数据分录数。 +- `dry_run_only`: `true`,明确该接口不做删除。 + +## 3. 优化后指标 + +### 空间收益(只做审计,未执行删除) + +| 指标 | 优化前 | 优化后可清理 | 优化后保留 | +|---|---:|---:|---:| +| 任务目录 | 193 | 187 | 6(被素材引用) | +| 任务文件占用 | 2.44 GiB | 2.38 GiB | ~59.3 MiB | +| 任务空间占用 | 100% | 可回收 97.6% | 首个保护区约 2.4% | + +换算: + +- 2,556,506,642 bytes ≈ 2.38 GiB。 +- 若执行清理,仅任务目录可释放约 **2.38 GiB**。 +- 清理后任务目录可降到约 **59.3 MiB**,即保留的部分仍是当前贴纸真正引用的任务产物。 + +### 素材去重收益 + +当前 40 个素材文件中有 6 个属于重复内容,去重后: + +- 少存 6 个文件。 +- 可节省 2,455,630 bytes,约 **2.34 MiB**。 +- 素材文件数量从 40 → 34 个唯一内容。 + +这个数字目前不大,因为很多重复不到 100KB;真正大头仍是任务目录。 + +### 效能与可维护性收益 + +1. **任务可恢复** + - 原来重启服务后任务状态、进度、事件全部丢失。 + - 现在 `jobs` / `job_events` 落库,启动时可恢复。 + +2. **查询由全盘扫描变为索引查询** + - 原来 `list_jobs` 只读内存;任务详情依赖内存里的事件列表。 + - 现在有持久化事件表和 `job_id` 索引,可追溯历史。 + +3. **清理依据可计算** + - 原来“哪个目录能删”靠人工判断。 + - 现在可统计“是否被素材引用、是否在元数据库、目录多老”,避免误删正在使用的任务。 + +4. **存储成本上限可控** + - 配合 TTL 清理,后续每新增任务产生的产物会在保留期后被回收。 + - 不会继续无限制累积。 + +## 4. 建议后续执行步骤 + +1. 确认当前项目不再需要 187 个旧任务产物后,执行: + +```bash +cd backend +.venv/bin/python -m service.storage_metrics --max-age-days 0 --apply +``` + +2. 把 `metadata_store.py` 从 SQLite 迁移到 PostgreSQL: + + - 安装 SQLAlchemy / asyncpg。 + - `docker-compose.yml` 增加 PostgreSQL 服务。 + - 将 `jobs` / `job_events` / `assets` / `design_documents` 迁到 PG。 + +3. 把贴纸 `tint` 从 localStorage 迁到 `assets` 元数据,并由后端 `PATCH /api/assets/{id}` 维护。 + +4. `POST /api/assets` 统一补 `sha256`,实现服务级素材去重。 + +5. 增加后台定时清理任务,例如保留 7 天、30 天两档。 + +## 5. 相关文件 + +- `backend/service/metadata_store.py` +- `backend/service/job_manager.py` +- `backend/service/storage.py` +- `backend/service/storage_metrics.py` +- `backend/service/app.py` +- `docs/DESIGN_DATA_STORAGE_PLAN.md` diff --git a/docs/DESIGN_DATA_STORAGE_PLAN.md b/docs/DESIGN_DATA_STORAGE_PLAN.md new file mode 100644 index 0000000..b9b9d4a --- /dev/null +++ b/docs/DESIGN_DATA_STORAGE_PLAN.md @@ -0,0 +1,173 @@ +# 存储与数据库重构方案(规划) + +> 状态:第一阶段部分已落地(任务元数据落 SQLite、任务清理审计、素材 sha256)。 +> 目的:后端当前仍以“内存 + 文件 + 任务级 SQLite + meta.json”为主要存储方式。本文档规划后续迁移到 PostgreSQL 元数据库,并优化任务生命周期和贴纸持久化。 + +## 1. 当前现状 + +| 数据 | 当前存储 | 问题 | +|---|---|---| +| 任务状态 / 事件 | `JobManager._jobs` 仅存内存 | 重启服务后任务记录丢失 | +| 任务输入 / 输出 | `backend/service_workspace/{job_id}` | 提交任务即建目录,未完成或被放弃的任务会遗留文件 | +| 词云坐标结果 | 每个任务生成一个 `word_locations.db`(SQLite) | 每个任务自带一份 SQLite 文件,查询分散 | +| 贴纸素材 | `backend/service_assets/asset_xxx/asset.svg` + `meta.json` | 素材元数据不是数据库,tint 等前端信息还依赖 localStorage | +| 设计模板 / 工程 | `service_design_templates` / `service_projects` 目录 + JSON | 模板和工程之间缺少数据库关联 | +| 画布文档 | 前端 localStorage | 无法跨设备,也无法作为后端权威数据 | + +注意:当前不能认为系统已经在使用 PostgreSQL。代码中出现的 `*.db` 是词云算法自己写的 SQLite 结果文件,例如 `backend/core/pipeline.py` 的 `word_locations` 表。 + +## 2. 目标架构 + +整体原则: + +- **文件继续存文件系统或对象存储**(SVG / PNG / 遮罩 / Excel / 字体)。 +- **业务元数据和引用关系存 PostgreSQL**。 +- 数据库保存路径引用,不保存大文件内容。 + +目标模型: + +| 表 | 用途 | 说明 | +|---|---|---| +| `jobs` | 任务主表 | job_id、状态、参数 JSONB、产物引用、创建时间 | +| `job_events` | 任务进度事件 | SSE 进度事件落库,服务重启后可恢复 | +| `assets` | 贴纸 / 素材表 | 素材元数据、文件路径、来源 job、sha256、tint | +| `design_documents` | 画布文档 | CanvasDocument JSONB,绑定模板/工程 | +| `design_templates` | 模板 | 模板元数据 + 画布文档引用 | +| `projects` | 工程 | 模板 + 画布文档 + 素材引用 | + +### jobs 表字段建议 + +```text +id uuid pk +status text -- submitted/running/success/failed/cancelled +stage text +progress int +message text +params jsonb -- 用户提交的词云参数 +input_files jsonb -- mask/excel/font 引用 +artifacts jsonb -- png/svg/svg_stroke/db/metrics 路径或文件 id +error text +created_at timestamptz +updated_at timestamptz +retention_until timestamptz -- 清理时间 +``` + +### assets 表字段建议 + +```text +id uuid pk +name text +type text -- wordcloud / upload / shape / reference +mime_type text +storage_key text -- 文件系统路径或对象存储 key +width int +height int +file_size bigint +sha256 text -- 用于导入去重 +source_job_id uuid nullable +tint text nullable +created_at timestamptz +deleted_at timestamptz nullable +``` + +## 3. 任务存储链路 + +现状是 `POST /api/jobs` 提交时直接创建 job 目录和保存上传文件。 + +目标改动: + +1. `POST /api/jobs` 只写 `jobs` 表,状态为 `submitted` 或 `queued`。 +2. 上传文件先落到临时上传区,或延迟到进入 runner 前再落盘。 +3. runner 真正开始时才创建任务的 `input/` 和 `output/` 目录。 +4. 任务完成后把产物路径/文件 id 写入 `jobs.artifacts`。 +5. 增加后台清理任务: + - 清理 `completed` 且未被贴纸/工程引用的任务文件。 + - 支持按 `retention_until` 保留最近结果。 + - 被用户导入为贴纸的任务文件可延长保留时间。 + +这样不会每次申请都攒下一堆用不上的目录和文件。 + +## 4. 贴纸持久化 + +贴纸在当前 `frontend/src/lib/stickerLibrary.ts` 中已经走后端 `POST /api/assets`,但元数据仍写在 `meta.json`,tint 还保存在 localStorage。 + +目标改动: + +- `assets` 表作为贴纸唯一权威来源。 +- `POST /api/assets`:写文件系统 + 写 `assets` 表,返回 `asset_id`。 +- `GET /api/assets`:从数据库读取列表。 +- `PATCH /api/assets/{id}`:更新 tint、name 等元数据。 +- `DELETE /api/assets/{id}`:物理删除文件 + 记录,或软删除防止破坏设计文档引用。 +- `POST /api/assets/from-job/{job_id}`:沿用同一逻辑,写入 `source_job_id`。 +- 前端不再依赖 localStorage 保存贴纸 tint,加载和更新都走 API。 + +## 5. 画布文档与模板 + +当前画布保存在 localStorage,模板保存成目录 JSON。 + +目标改动: + +- `design_documents` 保存 `CanvasDocument` JSONB。 +- 画布每次保存调用 `PUT /api/documents/{id}`。 +- `design_templates` 引用 `design_documents`,同时记录 `reference_asset_ids` 和封面图。 +- 后续实现画布导出导入时,导入包可直接写入 `design_documents`,并把包内素材批量写入 `assets` 表。 + +## 6. PostgreSQL 接入方式 + +建议: + +- 引入 SQLAlchemy(或 asyncpg)作为数据库访问层。 +- 使用 Alembic 管理 migration。 +- 在 `docker-compose.yml` 增加 PostgreSQL 服务。 +- 通过环境变量注入 `DATABASE_URL`,本地开发和 Docker 使用不同配置。 +- 暂不把词云算法的 `word_locations` 表强制迁移到 PostgreSQL,可以保留 SQLite 作为任务内部产物,再通过导出接口把需要的布局结果写入 `jobs` 或独立布局表中。 + +## 7. 分阶段实施 + +### 阶段一:接入 PostgreSQL,先做贴纸和任务元数据(任务元数据已用 SQLite 先行落地) + +- 建 `assets` / `jobs` / `job_events` 表。 +- `assets` 接口从文件 meta 迁移到 DB。 +- 提交任务仍可使用现有 runner,但把任务状态写入 DB。 +- 不改动词云算法核心。 + +### 阶段二:任务生命周期优化(清理审计已落地) + +- `POST /api/jobs` 只记账,不提前建目录。 +- runner 开始前再落 input/output。 +- 增加 TTL 清理任务。 +- 任务列表、任务详情改为从 DB 查询。 + +### 阶段三:画布文档和导入包 + +- 建 `design_documents` / `design_templates` / `projects` 表。 +- 画布保存从 localStorage 改为后端文档接口。 +- `wcd` 导入导出包直接对接这些表。 + +## 8. 风险与注意点 + +- 现有任务接口依赖内存中的 `JobManager`,迁到 DB 后需要兼容 SSE 进度事件。 +- 文件迁移只能做增量:老素材目录可先保留,新写入走 DB。 +- 删除素材要检查 `design_documents` 引用,避免出现缺失贴纸。 +- tint 从前端 localStorage 迁移到 DB 时,需要兼容旧浏览器状态。 + +## 10. 已落地实现 + +- `backend/service/metadata_store.py`:SQLite 元数据 `jobs` / `job_events`。 +- `backend/service/job_manager.py`:任务状态和事件落库,服务重启可恢复。 +- `backend/service/storage.py`:任务目录占用、过期审计、可清理能力。 +- `backend/service/storage_metrics.py`:dry-run 指标和显式 `--apply` 清理。 +- `backend/service/app.py`:`GET /api/maintenance/storage-summary`。 +- `docs/DATA_STORAGE_OPTIMIZATION.md`:完整空间/效能指标。 + +> 注:当前项目没有接入 PostgreSQL。代码里的 `*.db` 是词云算法自己的 SQLite 结果文件;新加的 `service_metadata/app.db` 是业务元数据先行层。`jobs` / `job_events` 表结构设计上可平滑迁移到 PostgreSQL。 + +## 9. 相关文件参考 + +- `backend/service/app.py`:目前的任务、素材、模板 API。 +- `backend/service/job_manager.py`:内存中的任务状态。 +- `backend/service/storage.py`:任务目录创建。 +- `backend/service/runner.py`:任务运行与产物扫描。 +- `backend/core/pipeline.py`:词云结果 SQLite 写入。 +- `frontend/src/lib/stickerLibrary.ts`:前端贴纸库。 +- `docker-compose.yml`:服务编排,后续加 PostgreSQL。 diff --git a/frontend/src/components/AdvancedPanel.tsx b/frontend/src/components/AdvancedPanel.tsx index 1cc8dfe..5e3977c 100644 --- a/frontend/src/components/AdvancedPanel.tsx +++ b/frontend/src/components/AdvancedPanel.tsx @@ -178,6 +178,12 @@ export default function AdvancedPanel({ 名单较少时可增大重复次数(如 5~10)提升填充观感 + onParamsChange({ autoRepeatToFill: v })} + hint="开启后,当掩膜轮廓填不满时自动循环追加名字副本,直到形状轮廓填充完毕(最多 20 次)" + />
字号与比例 @@ -198,8 +204,17 @@ export default function AdvancedPanel({ step={0.01} onChange={v => onParamsChange({ packingEfficiency: v ?? 0.9 })} /> + onParamsChange({ verticalRatio: v ?? 0.18 })} + />
SIZE_RATIO 控制最大与最小字号跨度,默认 2.0;过大会出现极端字号差 + VERTICAL_RATIO 为每个词竖排的概率,默认 0.18;横竖混排可打散过于规整的观感
onParamsChange({ workScale: v ?? 0.2 })} + onChange={v => onParamsChange({ workScale: v ?? 0.18 })} /> +
+ 调试 + onParamsChange({ saveDebugImages: v })} + hint="开启后保存掩膜和占用网格等中间图片;正常生成建议关闭以减少磁盘 I/O" + /> +
画布
diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx index 4e25af9..0a0bcef 100644 --- a/frontend/src/components/CanvasArea.tsx +++ b/frontend/src/components/CanvasArea.tsx @@ -9,10 +9,11 @@ interface CanvasAreaProps { viewMode: '2d' | '3d'; zoom: number; highlightLocation: NameLocation | null; + onImageLoaded?: () => void; } export default function CanvasArea({ - maskFile, jobResult, viewMode, zoom, highlightLocation + maskFile, jobResult, viewMode, zoom, highlightLocation, onImageLoaded }: CanvasAreaProps) { const [maskPreviewUrl, setMaskPreviewUrl] = useState(null); const wrapperRef = useRef(null); @@ -44,7 +45,7 @@ export default function CanvasArea({
) : viewMode === '3d' ? (
- wordcloud 3D + wordcloud 3D
) : (
@@ -52,6 +53,7 @@ export default function CanvasArea({ src={displayUrl} alt="wordcloud" className="canvas-image" + onLoad={onImageLoaded} style={{ transform: `scale(${zoom})` }} /> {highlightLocation && jobResult && ( diff --git a/frontend/src/components/Icons.tsx b/frontend/src/components/Icons.tsx index 0d4c40c..83d3656 100644 --- a/frontend/src/components/Icons.tsx +++ b/frontend/src/components/Icons.tsx @@ -268,3 +268,12 @@ export function IconHelp() { ); } + +export function IconCopy() { + return ( + + + + + ); +} diff --git a/frontend/src/components/ProgressPanel.tsx b/frontend/src/components/ProgressPanel.tsx index 82c5ba2..a57668d 100644 --- a/frontend/src/components/ProgressPanel.tsx +++ b/frontend/src/components/ProgressPanel.tsx @@ -1,12 +1,33 @@ +import { useCallback, useEffect, useRef } from 'react'; import { SSEProgress } from '../types'; -import { IconCross, IconCheckmark, IconGear } from './Icons'; +import { IconCross, IconCheckmark, IconGear, IconCopy } from './Icons'; interface ProgressPanelProps { progress: SSEProgress | null; + logLines: string[]; visible: boolean; } -export default function ProgressPanel({ progress, visible }: ProgressPanelProps) { +export default function ProgressPanel({ progress, logLines, visible }: ProgressPanelProps) { + const logEndRef = useRef(null); + + // Auto-scroll the log view to the bottom whenever new lines arrive. + useEffect(() => { + logEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); + }, [logLines]); + + // All hooks MUST run before any early return, so derive the values the + // callback needs without depending on `progress` being non-null. + const message = progress?.message ?? ''; + + const handleCopyError = useCallback(() => { + const text = logLines.length > 0 ? logLines.join('\n') : message; + navigator.clipboard.writeText(text).catch(() => { + const el = document.querySelector('.error-detail-textarea') as HTMLTextAreaElement | null; + if (el) { el.select(); document.execCommand('copy'); } + }); + }, [logLines, message]); + if (!visible || !progress) return null; const isFailed = progress.stage === '生成失败' || progress.stage === '错误'; @@ -20,6 +41,13 @@ export default function ProgressPanel({ progress, visible }: ProgressPanelProps)
{progress.stage}
+ {(progress.elapsedSeconds != null || progress.clientElapsedSeconds != null) && ( +
+ {progress.elapsedSeconds != null && `后端耗时 ${progress.elapsedSeconds.toFixed(2)} 秒`} + {progress.elapsedSeconds != null && progress.clientElapsedSeconds != null ? ' · ' : ''} + {progress.clientElapsedSeconds != null && `前端显示耗时 ${progress.clientElapsedSeconds.toFixed(2)} 秒`} +
+ )} {!isFailed && (
)} + {/* ── 详细日志(实时滚动) ───────────────────────────────── */} + {logLines.length > 0 && ( +
+ {logLines.map((line, i) => ( +
+ {line} +
+ ))} +
+
+ )} + {/* ── 失败时的错误详情 + 复制按钮 ───────────────────────── */}
- {progress.message} + {isFailed ? ( +
+ +
+ ) : null}
); diff --git a/frontend/src/lib/canvasPackage.ts b/frontend/src/lib/canvasPackage.ts new file mode 100644 index 0000000..a3a0f8b --- /dev/null +++ b/frontend/src/lib/canvasPackage.ts @@ -0,0 +1,116 @@ +import { CanvasDocument, StickerAsset } from '../types'; +import { normalizeDocument } from './canvasDocument'; +import { apiUrl } from './api'; +import { createZip, ZipFileInput } from './zip'; + +interface PackageAssetMeta { + id: string; + originalAssetId: string; + name: string; + type: string; + mimeType: string; + size: number; +} + +function stickerMimeType(asset: StickerAsset): string { + if (asset.mimeType) return asset.mimeType; + return asset.type === 'svg' ? 'image/svg+xml' : 'image/png'; +} + +function stickerFileExtension(asset: StickerAsset): string { + if (asset.type === 'svg') return '.svg'; + if (asset.mimeType === 'image/jpeg') return '.jpg'; + if (asset.mimeType === 'image/png') return '.png'; + const source = asset.source.toLowerCase(); + if (source.endsWith('.jpg') || source.endsWith('.jpeg')) return '.jpg'; + return '.png'; +} + +export function safePackageBaseName(name: string): string { + const cleaned = name.trim().replace(/[\\/:*?"<>|\n\t]/g, '_').replace(/\s+/g, '_').slice(0, 80); + return cleaned || '画布设计'; +} + +export async function exportCanvasPackage( + documentModel: CanvasDocument, + stickerById: Map, + name = '画布设计', + description = '', +): Promise { + const doc = normalizeDocument(documentModel); + const usedAssetIds: string[] = []; + const seen = new Set(); + doc.elements.forEach(element => { + if (element.type !== 'sticker') return; + if (seen.has(element.assetId)) return; + seen.add(element.assetId); + usedAssetIds.push(element.assetId); + }); + + const packageIdByAsset = new Map(); + usedAssetIds.forEach((assetId, index) => { + packageIdByAsset.set(assetId, `asset-${String(index + 1).padStart(3, '0')}`); + }); + + const files: ZipFileInput[] = []; + const packageAssets: PackageAssetMeta[] = []; + + for (const assetId of usedAssetIds) { + const asset = stickerById.get(assetId); + if (!asset) throw new Error(`画布引用了缺失素材:${assetId}`); + const res = await fetch(apiUrl(asset.source)); + if (!res.ok) throw new Error(`读取素材失败:${asset.name} (${res.status})`); + const bytes = new Uint8Array(await res.arrayBuffer()); + const packageId = packageIdByAsset.get(assetId) || assetId; + const ext = stickerFileExtension(asset); + packageAssets.push({ + id: packageId, + originalAssetId: assetId, + name: asset.name, + type: asset.type, + mimeType: stickerMimeType(asset), + size: bytes.length, + }); + files.push({ + name: `assets/${packageId}${ext}`, + content: bytes, + }); + } + + const packageDocument = { + ...doc, + elements: doc.elements.map(element => { + if (element.type !== 'sticker') return element; + return { + ...element, + assetId: packageIdByAsset.get(element.assetId) || element.assetId, + }; + }), + }; + + const manifest = { + format: 'wordcloud-canvas', + version: 1, + name: name.trim() || '画布设计', + description: description.trim(), + createdAt: new Date().toISOString(), + canvas: { + width: doc.width, + height: doc.height, + background: doc.background, + }, + assets: packageAssets, + fonts: [], + }; + + files.unshift({ + name: 'manifest.json', + content: JSON.stringify(manifest, null, 2), + }); + files.splice(1, 0, { + name: 'document.json', + content: JSON.stringify(packageDocument, null, 2), + }); + + return createZip(files); +} diff --git a/frontend/src/lib/stickerLibrary.ts b/frontend/src/lib/stickerLibrary.ts index 3ef6d46..1f155f4 100644 --- a/frontend/src/lib/stickerLibrary.ts +++ b/frontend/src/lib/stickerLibrary.ts @@ -115,6 +115,7 @@ export async function loadStickerLibrary(): Promise { source: a.file_url, createdAt: a.created_at, tint: tints[a.asset_id] as StickerAsset['tint'], + mimeType: a.mime_type, })); } @@ -136,6 +137,7 @@ export async function addStickerAsset( source: asset.file_url, createdAt: asset.created_at, tint: input.tint, + mimeType: asset.mime_type, }; window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT)); return sticker; @@ -160,6 +162,7 @@ export async function addStickerAssetFromJob( type: asset.mime_type === 'image/svg+xml' ? 'svg' : 'image', source: asset.file_url, createdAt: asset.created_at, + mimeType: asset.mime_type, }; window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT)); return sticker; diff --git a/frontend/src/lib/svgExport.ts b/frontend/src/lib/svgExport.ts index 1c53857..8893c2e 100644 --- a/frontend/src/lib/svgExport.ts +++ b/frontend/src/lib/svgExport.ts @@ -6,6 +6,8 @@ import { createZip } from './zip'; export interface SerializeOptions { layerIds?: string[]; includeBackground?: boolean; + /** Add two in-canvas registration dots for physical/image alignment. */ + addRegistrationMarks?: boolean; } async function fetchBlobAsDataUrl(url: string): Promise { @@ -108,6 +110,11 @@ export async function serializeDocument( parts.push(``); } + // Keep marks last so canvas elements cannot cover the alignment targets. + if (options.addRegistrationMarks) { + parts.push(serializeRegistrationMarks(doc.width, doc.height)); + } + parts.push(''); return parts.join('\n'); } @@ -117,6 +124,7 @@ export async function createLayerExportZip( stickerById: Map, selectedLayerIds: string[], selectedFolderIds: string[], + options: Pick = {}, ) { const doc = normalizeDocument(documentModel); const files: { name: string; content: string }[] = []; @@ -125,7 +133,7 @@ export async function createLayerExportZip( if (hasCanvasBackground(doc.background)) { files.push({ name: uniqueSvgName('背景', used), - content: serializeBackgroundLayer(doc), + content: serializeBackgroundLayer(doc, options), }); } @@ -134,7 +142,7 @@ export async function createLayerExportZip( if (!layer) continue; files.push({ name: uniqueSvgName(layer.name, used), - content: await serializeDocument(doc, stickerById, { layerIds: [layer.id], includeBackground: false }), + content: await serializeDocument(doc, stickerById, { layerIds: [layer.id], includeBackground: false, ...options }), }); } @@ -143,24 +151,37 @@ export async function createLayerExportZip( if (!folder) continue; files.push({ name: uniqueSvgName(folder.name, used), - content: await serializeDocument(doc, stickerById, { layerIds: folder.layerIds, includeBackground: false }), + content: await serializeDocument(doc, stickerById, { layerIds: folder.layerIds, includeBackground: false, ...options }), }); } return createZip(files); } -function serializeBackgroundLayer(documentModel: CanvasDocument) { +function serializeBackgroundLayer(documentModel: CanvasDocument, options: Pick = {}) { const doc = normalizeDocument(documentModel); const widthMm = pxToMm(doc.width).toFixed(1); const heightMm = pxToMm(doc.height).toFixed(1); return [ ``, ``, + ...(options.addRegistrationMarks ? [serializeRegistrationMarks(doc.width, doc.height)] : []), '', ].join('\n'); } +function serializeRegistrationMarks(width: number, height: number) { + const minDimension = Math.max(1, Math.min(width, height)); + // Keep the circles inside the canvas so neither SVG nor raster consumers clip them. + const inset = Math.max(4, minDimension * 0.012); + const radius = Math.max(1.5, minDimension * 0.004); + const format = (value: number) => formatSvgNumber(value); + return [ + ``, + ``, + ].join('\n'); +} + function serializeInlineSvgSticker( svgText: string, targetWidth: number, diff --git a/frontend/src/lib/templateLibrary.ts b/frontend/src/lib/templateLibrary.ts index fdfea8b..f1e0dad 100644 --- a/frontend/src/lib/templateLibrary.ts +++ b/frontend/src/lib/templateLibrary.ts @@ -46,6 +46,20 @@ export async function createCanvasTemplate(input: { return { ...template, document: normalizeDocument(template.document) }; } +export async function importCanvasTemplate( + file: File, + name = '', + description = '', +): Promise { + const fd = new FormData(); + fd.append('file', file); + if (name) fd.append('name', name.trim()); + if (description) fd.append('description', description.trim()); + const res = await ensureOk(await fetch(apiUrl('/api/design-templates/import'), { method: 'POST', body: fd }), '导入设计包失败'); + const template = (await res.json()) as CanvasTemplate; + return { ...template, document: normalizeDocument(template.document) }; +} + export async function updateCanvasTemplate( id: string, partial: { diff --git a/frontend/src/pages/CanvasStudio.tsx b/frontend/src/pages/CanvasStudio.tsx index c412884..3d6e526 100644 --- a/frontend/src/pages/CanvasStudio.tsx +++ b/frontend/src/pages/CanvasStudio.tsx @@ -32,6 +32,7 @@ import { } from '../lib/canvasDocument'; import { createCanvasTemplate, duplicateDocument, uploadAsset } from '../lib/templateLibrary'; import { createLayerExportZip, serializeDocument } from '../lib/svgExport'; +import { exportCanvasPackage, safePackageBaseName } from '../lib/canvasPackage'; import { apiUrl, ensureOk } from '../lib/api'; import { IconGrid, @@ -461,16 +462,16 @@ export default function CanvasStudio({ }); }; - const exportSvg = useCallback(async () => { - const svg = await serializeDocument(normalizedDocument, stickerById); + const exportSvg = useCallback(async (addRegistrationMarks: boolean) => { + const svg = await serializeDocument(normalizedDocument, stickerById, { addRegistrationMarks }); downloadBlob( new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }), 'canvas-design.svg', ); }, [normalizedDocument, stickerById]); - const exportLayerZip = async (layerIds: string[], folderIds: string[]) => { - const blob = await createLayerExportZip(normalizedDocument, stickerById, layerIds, folderIds); + const exportLayerZip = async (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => { + const blob = await createLayerExportZip(normalizedDocument, stickerById, layerIds, folderIds, { addRegistrationMarks }); downloadBlob(blob, 'canvas-layers.zip'); }; @@ -858,6 +859,7 @@ export default function CanvasStudio({ activeLayerId={activeLayerId} onActiveLayerChange={setActiveLayerId} onChange={setDocumentModel} + stickerById={stickerById} /> ); case 'sticker': @@ -1096,16 +1098,121 @@ export default function CanvasStudio({ ); } +function LayerThumbnail({ + documentModel, + layer, + stickerById, +}: { + documentModel: CanvasDocument; + layer: CanvasLayer; + stickerById: Map; +}) { + const elements = documentModel.elements.filter(element => element.layerId === layer.id); + const fitScale = Math.min(1, 52 / Math.max(documentModel.width, documentModel.height, 1)); + const scaledWidth = Math.max(1, documentModel.width * fitScale); + const scaledHeight = Math.max(1, documentModel.height * fitScale); + return ( +
+
+
+ {elements.map(element => ( + + ))} +
+
+
+ ); +} + +function LayerThumbElement({ + element, + asset, +}: { + element: CanvasElement; + asset?: StickerAsset; +}) { + const baseStyle: CSSProperties = { + position: 'absolute', + left: element.x, + top: element.y, + width: element.width, + height: element.height, + opacity: element.opacity, + transform: `rotate(${element.rotation}deg)`, + }; + + if (element.type === 'sticker') { + if (!asset) return (
贴纸缺失
); + return ( + {asset.name} + ); + } + + if (element.type === 'text') { + return ( +
+ {element.text} +
+ ); + } + + if (element.type === 'line') { + return ( +
+
+
+ ); + } + + return ( +
0 ? `${Math.max(0, element.strokeWidth)}px solid ${element.stroke}` : undefined, + }} + /> + ); +} + function LayersPanel({ documentModel, activeLayerId, onActiveLayerChange, onChange, + stickerById, }: { documentModel: CanvasDocument; activeLayerId: string; onActiveLayerChange: (id: string) => void; onChange: (documentModel: CanvasDocument) => void; + stickerById: Map; }) { const layers = documentModel.layers || []; const folders = documentModel.layerFolders || []; @@ -1206,6 +1313,10 @@ function LayersPanel({ ))} {layers.slice().reverse().map(layer => (
+
+ +
+
+
))}
@@ -1308,8 +1420,8 @@ function CanvasExportPanel({ documentModel: CanvasDocument; stickerById: Map; onUpdateDocument: (partial: Partial) => void; - onExportSvg: () => void; - onExportLayerZip: (layerIds: string[], folderIds: string[]) => void; + onExportSvg: (addRegistrationMarks: boolean) => void; + onExportLayerZip: (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => void; onReset: () => void; }) { const [selectedLayerIds, setSelectedLayerIds] = useState([]); @@ -1318,6 +1430,8 @@ function CanvasExportPanel({ const [templateName, setTemplateName] = useState(''); const [templateDescription, setTemplateDescription] = useState(''); const [referenceFiles, setReferenceFiles] = useState([]); + const [addRegistrationMarks, setAddRegistrationMarks] = useState(false); + const [exportingPackage, setExportingPackage] = useState(false); const layers = documentModel.layers || []; const folders = documentModel.layerFolders || []; const backgroundEnabled = hasCanvasBackground(documentModel.background); @@ -1355,6 +1469,24 @@ function CanvasExportPanel({ } }; + const exportPackage = async () => { + if (exportingPackage) return; + setExportingPackage(true); + try { + const blob = await exportCanvasPackage( + documentModel, + stickerById, + templateName || '画布设计', + templateDescription, + ); + downloadBlob(blob, `${safePackageBaseName(templateName || '画布设计')}.wcd`); + } catch (error) { + alert(error instanceof Error ? error.message : '导出设计包失败'); + } finally { + setExportingPackage(false); + } + }; + return ( <>
@@ -1412,7 +1544,18 @@ function CanvasExportPanel({ />
)} - +
+ +
在导出的总图和每个分层文件左上角、右下角添加对齐点
+
+
分层打包导出
@@ -1441,7 +1584,7 @@ function CanvasExportPanel({
@@ -1469,6 +1612,13 @@ function CanvasExportPanel({ + {stickerById.size} diff --git a/frontend/src/pages/TemplateHome.tsx b/frontend/src/pages/TemplateHome.tsx index 726408f..f5e9170 100644 --- a/frontend/src/pages/TemplateHome.tsx +++ b/frontend/src/pages/TemplateHome.tsx @@ -1,10 +1,11 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow'; import { BackendAsset, CanvasTemplate } from '../types'; import { formatMm, normalizeDocument } from '../lib/canvasDocument'; import { assetUrl, deleteCanvasTemplate, + importCanvasTemplate, listAssets, listDesignTemplates, templateCoverId, @@ -19,6 +20,7 @@ import { IconCloud, IconRefresh, IconCanvas, + IconDownload, IconHelp, } from '../components/Icons'; @@ -48,7 +50,9 @@ export default function TemplateHome({ const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [importing, setImporting] = useState(false); const [stickerById, setStickerById] = useState(() => new Map()); + const importFileRef = useRef(null); useEffect(() => { loadStickerLibrary().then(items => { @@ -92,6 +96,23 @@ export default function TemplateHome({ refresh(); }; + const handleImportFile = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file || importing) return; + setImporting(true); + setError(''); + try { + await importCanvasTemplate(file); + await refresh(); + alert('设计包已导入'); + } catch (err) { + setError(err instanceof Error ? err.message : '导入设计包失败'); + } finally { + setImporting(false); + } + }; + return (