feat(wordcloud): 收口在途开发(布局/存储/前端)+ R4 WCD 生产任务(jobs wcd_file)与生产订单列表

This commit is contained in:
2026-08-13 14:22:48 +08:00
parent 1d17b5e20d
commit e518540235
32 changed files with 3525 additions and 592 deletions
@@ -22,9 +22,9 @@
#include <future>
#include <atomic>
#include <random>
#include <functional>
#include <numeric>
#include <limits>
#include <functional>
#include <numeric>
#include <limits>
// ==========================================
// 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<std::pair<int, int>> 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<std::pair<int, int>> 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<int32_t> 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<int, int>& a, const std::pair<int, int>& 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<std::shared_mutex> 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<int, int>& a, const std::pair<int, int>& 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<std::shared_mutex> 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<int64_t>(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<int64_t>(probe_budget, total_positions);
for (int p = 0; p < n_probes; ++p) {
int y = std::uniform_int_distribution<int>(0, max_row)(rng);
int x = std::uniform_int_distribution<int>(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<double>::infinity();
std::uniform_int_distribution<int> row_dist(0, max_row);
std::uniform_int_distribution<int> col_dist(0, max_col);
std::uniform_real_distribution<double> 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<int> row_dist(0, max_row);
std::uniform_int_distribution<int> col_dist(0, max_col);
int best_y = -1;
int best_x = -1;
double best_score = std::numeric_limits<double>::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<int>::min();
int last_x = std::numeric_limits<int>::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<double>::infinity();
std::uniform_int_distribution<int> row_dist(0, max_row);
std::uniform_int_distribution<int> col_dist(0, max_col);
std::uniform_real_distribution<double> 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<int> row_dist(0, max_row);
std::uniform_int_distribution<int> col_dist(0, max_col);
int best_y = -1;
int best_x = -1;
double best_score = std::numeric_limits<double>::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<double> 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<int>::min();
int last_x = std::numeric_limits<int>::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<bool, std::pair<int, int>> find_spot_parallel(int box_h, int box_w, int step) {
{
std::unique_lock<std::shared_mutex> lock(mutex_);
ensure_valid_coords_center_sorted();
}
{
std::shared_lock<std::shared_mutex> lock(mutex_);
std::pair<bool, std::pair<int, int>> find_spot_parallel(int box_h, int box_w, int step) {
{
std::unique_lock<std::shared_mutex> lock(mutex_);
ensure_valid_coords_center_sorted();
}
{
std::shared_lock<std::shared_mutex> lock(mutex_);
if (dirty_count > 0 && dirty_count >= rebuild_interval) {
lock.unlock();
std::unique_lock<std::shared_mutex> 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."},
+20 -2
View File
@@ -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)),
}
+220 -83
View File
@@ -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
# <path> 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(
+314 -85
View File
@@ -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
+285 -22
View File
@@ -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)
+483 -11
View File
@@ -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 共用素材去重/注册/重映射逻辑;
状态机与产物管线复用 jobsqueued/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))
+54 -3
View File
@@ -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:
+192
View File
@@ -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,
}
+61 -6
View File
@@ -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),
)
+3
View File
@@ -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):
+74
View File
@@ -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
+85
View File
@@ -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()
+10
View File
@@ -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
+1
View File
@@ -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)