Rework layout engine around exact-glyph collision, add tests and docs sync

Replace the old bbox/heuristic placement (scale search rounds, large-font
capping, stratified sampling, fill-retry ladders) with an area-model font
sizing pass feeding a C++ exact-glyph collision engine (centroid-biased
spiral + random probing, HD clearance refinement, density/hole
optimization). Simplify the frontend advanced-params panel and JobParams
type to match the surviving config surface, add a layout-constraints test
suite and a repeatable benchmark tool, and bring docs/*.md back in sync
with current code (plus new TESTING.md and DEPLOYMENT.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 18:32:25 +08:00
co-authored by Claude Sonnet 5
parent bf2b138007
commit 1d17b5e20d
24 changed files with 2600 additions and 1283 deletions
+15 -22
View File
@@ -23,7 +23,8 @@ wc = EfficientWordCloud(
height=600,
font_path="/path/to/font.ttf",
max_words=200,
min_font_size=8,
min_font_size=8,
max_font_size=32,
prefer_horizontal=0.9
)
@@ -36,26 +37,18 @@ img.show()
- `width` / `height`:画布尺寸。
- `font_path`:字体路径。
- `max_words`:最大词数。
- `min_font_size`:最小字体
- `prefer_horizontal`:水平排版概率。
- `use_spiral_search`:是否启用中心优先排序搜索
## 4. 并行优化的使用说明
### 4.1 Python bbox 预取
- 自动启用,无需额外配置。
- 内部使用 `ProcessPoolExecutor`,将未来词语的 bbox 计算并行化
- 运行 `generate` 时会输出预取/等待日志,便于观察并行效果
### 4.2 C++ 并行搜索
-`use_spiral_search=True` 时启用。
- 在 C++ 内部自动进行分块并行搜索,并保持中心优先排序的结果一致性。
- `min_font_size` / `max_font_size`:本次整批布局可使用的硬字号边界
- `prefer_horizontal`:水平排版概率。
- `relative_scaling`:权重对目标字号的影响比例
- `margin`:真实字形之间的最小工作网格间距。
## 4. 放置语义
- 每个词只使用权重映射得到的目标字号
- 放不下时只尝试同字号的另一方向,不会逐词缩字号
- 调用者需要检查 `layout_` 的数量;若不完整,应整批调整字号或扩大画布后创建新实例重排。
- 项目正式流水线使用 C++ `place_glyph_exact()` 做真实字形碰撞;底层兼容类保留矩形搜索 API。
## 5. 常见问题
### 5.1 为什么字体缩小时没有并行
缩小字体后 bbox 依赖当前失败状态,需要同步确认以确保正确性
### 5.2 多进程是否会导致额外内存开销?
是的,但任务仅用于 bbox 预取,且窗口大小有限,避免过度占用。
### 5.3 若没有字体文件怎么办?
会回退到 PIL 默认字体,但测量与渲染效果可能不同。
### 5.1 若没有字体文件怎么办
会回退到 PIL 默认字体,但测量与渲染效果可能不同
@@ -22,8 +22,9 @@
#include <future>
#include <atomic>
#include <random>
#include <functional>
#include <numeric>
#include <functional>
#include <numeric>
#include <limits>
// ==========================================
// Thread Pool (avoid per-query thread creation)
@@ -114,8 +115,13 @@ public:
int w;
};
// valid coordinates sorted by distance to center (for sorted/spiral search)
std::vector<std::pair<int, int>> valid_coords;
// Original mask-free coordinates. Sorting is lazy because the active
// query_direct/query_near_center paths do not need the O(A log A) order.
std::vector<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;
@@ -133,9 +139,9 @@ public:
}
void init_from_buffer(unsigned char* raw_mask, int h, int w) {
int center_y = h / 2;
int center_x = w / 2;
valid_coords.reserve(h * w / 2);
valid_coords.reserve(h * w / 2);
uint64_t free_y_sum = 0;
uint64_t free_x_sum = 0;
// Initialize canvas from mask
ensure_canvas();
@@ -151,28 +157,48 @@ public:
if (is_blocked) {
canvas[i * width + j] = 1;
}
if (!is_blocked) {
valid_coords.push_back({i, j});
}
}
}
std::sort(valid_coords.begin(), valid_coords.end(),
[center_y, center_x](const std::pair<int, int>& a, const std::pair<int, int>& b) {
long da = (long)(a.first - center_y)*(a.first - center_y) + (long)(a.second - center_x)*(a.second - center_x);
long db = (long)(b.first - center_y)*(b.first - center_y) + (long)(b.second - center_x)*(b.second - center_x);
return da < db;
}
);
if (!is_blocked) {
valid_coords.push_back({i, j});
free_y_sum += (uint64_t)i;
free_x_sum += (uint64_t)j;
}
}
}
if (!valid_coords.empty()) {
free_center_y = (double)free_y_sum / (double)valid_coords.size();
free_center_x = (double)free_x_sum / (double)valid_coords.size();
} else {
free_center_y = (double)h * 0.5;
free_center_x = (double)w * 0.5;
}
valid_coords_center_sorted = false;
spiral_cursor = 1;
std::fill(diff.begin(), diff.end(), 0);
recent_rects.clear();
dirty_count = 0;
}
void reorder_stratified(int bands) {
std::unique_lock<std::shared_mutex> lock(mutex_);
const size_t len = valid_coords.size();
dirty_count = 0;
}
void ensure_valid_coords_center_sorted() {
if (valid_coords_center_sorted) return;
const double center_y = free_center_y;
const double center_x = free_center_x;
std::sort(valid_coords.begin(), valid_coords.end(),
[center_y, center_x](const std::pair<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;
@@ -279,8 +305,7 @@ public:
std::fill(diff.begin(), diff.end(), 0);
recent_rects.clear();
dirty_count = 0;
for (int i = 0; i < height; ++i) {
for (int i = 0; i < height; ++i) {
uint32_t row_sum = 0;
for (int j = 0; j < width; ++j) {
uint32_t val = (raw_pixels[i * width + j] > 0) ? 1 : 0;
@@ -318,10 +343,10 @@ public:
}
// Rebuild integral from the internal canvas (partial from pos_r, pos_c)
void rebuild_from_canvas(int pos_r, int pos_c) {
if (canvas.empty()) return;
rebuild_from_bitmap_partial(canvas.data(), pos_r, pos_c);
}
void rebuild_from_canvas(int pos_r, int pos_c) {
if (canvas.empty()) return;
rebuild_from_bitmap_partial(canvas.data(), pos_r, pos_c);
}
// v4: Partial integral rebuild from position (pos_r, pos_c) downward
// Optimized: use row-sum approach (like rebuild_from_bitmap) for the partial region
@@ -425,8 +450,9 @@ public:
return {-1, -1};
}
DirectResult query_direct(int box_h, int box_w, uint32_t seed) {
// Always flush before scanning
DirectResult query_direct(int box_h, int box_w, uint32_t seed) {
// Apply pending rectangle updates before reading the integral image.
// Exact-glyph placement uses its own canvas-only path.
flush();
int max_row = height - box_h;
@@ -447,11 +473,12 @@ public:
return {true, y, x};
}
// Quick random probe: try a few random positions first
// If the canvas is mostly empty, one of these will hit quickly
// This avoids the full O(H*W) scan for early words
{
int n_probes = std::min(16, (int)total_positions);
// Random probes avoid an O(H*W) scan during early and middle packing.
// Increase the budget as occupancy rises.
{
const double occ_ratio = (double)total_occupied / (double)(width * height);
const int probe_budget = (occ_ratio < 0.15) ? 32 : (occ_ratio < 0.40) ? 96 : 192;
const int n_probes = (int)std::min<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);
@@ -469,27 +496,28 @@ public:
}
if (nt <= 1) {
// Single-thread: two-pass (count then pick) is faster than
// reservoir sampling because it avoids per-position RNG calls
uint64_t total_valid = 0;
for (int i = 0; i <= max_row; ++i) {
for (int j = 0; j <= max_col; ++j) {
if (get_area_sum_fast(i, j, box_h, box_w) == 0)
++total_valid;
}
}
if (total_valid == 0) return {false, -1, -1};
uint64_t target = std::uniform_int_distribution<uint64_t>(0, total_valid - 1)(rng);
uint64_t count = 0;
// Single-pass reservoir halves the work of count-then-pick. A
// small LCG avoids invoking mt19937 for every valid position.
uint64_t count = 0;
int best_y = -1, best_x = -1;
uint32_t state = seed ? seed : 1u;
for (int i = 0; i <= max_row; ++i) {
for (int j = 0; j <= max_col; ++j) {
if (get_area_sum_fast(i, j, box_h, box_w) == 0) {
if (count == target) return {true, i, j};
++count;
// Replace the current result with probability 1/count.
state = state * 1664525u + 1013904223u;
const bool replace = (state % count) == 0;
if (replace) {
best_y = i;
best_x = j;
}
}
}
}
return {false, -1, -1};
return count == 0
? DirectResult{false, -1, -1}
: DirectResult{true, best_y, best_x};
}
// Multi-thread path: parallel count then pick
@@ -531,10 +559,193 @@ public:
}
cum += chunk_counts[t];
}
return {false, -1, -1};
}
// =========================================================
return {false, -1, -1};
}
// Sample legal positions and prefer the one whose box center is closest
// to the free-mask centroid. This gives the visually important large
// words a stable focal region without paying for a full spiral scan.
DirectResult query_near_center(int box_h, int box_w, uint32_t seed, int probes) {
flush();
int max_row = height - box_h;
int max_col = width - box_w;
if (max_row < 0 || max_col < 0) return {false, -1, -1};
std::mt19937 rng(seed);
probes = std::max(16, std::min(probes, 1024));
int best_y = -1;
int best_x = -1;
double best_score = std::numeric_limits<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};
}
// =========================================================
// v3: Batch query — process multiple (box_h, box_w) in one call
// Returns vector of {found, y, x} for each query
// =========================================================
@@ -575,9 +786,13 @@ public:
return {false, -1, -1, end_idx};
}
std::pair<bool, std::pair<int, int>> find_spot_parallel(int box_h, int box_w, int step) {
{
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_);
@@ -679,19 +894,67 @@ static PyObject* Grid_query_reservoir(PyIntegralGrid* self, PyObject* args) {
}
// v3: Direct pixel-grid scan with parallel counting
static PyObject* Grid_query_direct(PyIntegralGrid* self, PyObject* args) {
static PyObject* Grid_query_direct(PyIntegralGrid* self, PyObject* args) {
int box_h, box_w;
unsigned int seed = 0;
if (!PyArg_ParseTuple(args, "ii|I", &box_h, &box_w, &seed)) return NULL;
Py_BEGIN_ALLOW_THREADS
// query_direct is GIL-free safe (no Python objects touched)
Py_END_ALLOW_THREADS
auto r = self->grid->query_direct(box_h, box_w, seed);
auto r = self->grid->query_direct(box_h, box_w, seed);
if (r.found) return Py_BuildValue("ii", r.y, r.x);
Py_RETURN_NONE;
}
Py_RETURN_NONE;
}
static PyObject* Grid_query_near_center(PyIntegralGrid* self, PyObject* args) {
int box_h, box_w;
unsigned int seed = 0;
int probes = 160;
if (!PyArg_ParseTuple(args, "ii|Ii", &box_h, &box_w, &seed, &probes)) return NULL;
auto r = self->grid->query_near_center(box_h, box_w, seed, probes);
if (r.found) return Py_BuildValue("ii", r.y, r.x);
Py_RETURN_NONE;
}
static PyObject* Grid_place_glyph_exact(PyIntegralGrid* self, PyObject* args) {
PyObject* collision_obj;
PyObject* stamp_obj;
int glyph_h, glyph_w;
unsigned int seed = 0;
int probes = 256;
int placement_mode = 0;
if (!PyArg_ParseTuple(
args, "OOiiIii", &collision_obj, &stamp_obj, &glyph_h, &glyph_w, &seed, &probes, &placement_mode
)) return NULL;
Py_buffer collision_view;
Py_buffer stamp_view;
if (PyObject_GetBuffer(collision_obj, &collision_view, PyBUF_SIMPLE) < 0) return NULL;
if (PyObject_GetBuffer(stamp_obj, &stamp_view, PyBUF_SIMPLE) < 0) {
PyBuffer_Release(&collision_view);
return NULL;
}
const Py_ssize_t required = (Py_ssize_t)glyph_h * (Py_ssize_t)glyph_w;
if (glyph_h <= 0 || glyph_w <= 0 || collision_view.len < required || stamp_view.len < required) {
PyBuffer_Release(&collision_view);
PyBuffer_Release(&stamp_view);
PyErr_SetString(PyExc_ValueError, "glyph buffer is smaller than glyph_h * glyph_w");
return NULL;
}
auto result = self->grid->place_glyph_exact(
(const unsigned char*)collision_view.buf,
(const unsigned char*)stamp_view.buf,
glyph_h,
glyph_w,
seed,
probes,
placement_mode
);
PyBuffer_Release(&collision_view);
PyBuffer_Release(&stamp_view);
if (result.found) return Py_BuildValue("ii", result.y, result.x);
Py_RETURN_NONE;
}
// v3: Batch query — process multiple placements in one C++ call
// Input: list of (box_h, box_w, seed) tuples
@@ -768,16 +1031,16 @@ static PyObject* Grid_rebuild_from_bitmap_partial(PyIntegralGrid* self, PyObject
Py_RETURN_NONE;
}
// v4: Stamp glyph bitmap onto C++ canvas and rebuild integral
static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) {
// Stamp glyph bitmap onto C++ canvas and rebuild the affected integral region.
static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) {
PyObject* glyph_obj;
int gh, gw, pos_r, pos_c;
if (!PyArg_ParseTuple(args, "Oiiii", &glyph_obj, &gh, &gw, &pos_r, &pos_c)) return NULL;
Py_buffer view;
if (PyObject_GetBuffer(glyph_obj, &view, PyBUF_SIMPLE) < 0) return NULL;
self->grid->stamp_glyph((const unsigned char*)view.buf, gh, gw, pos_r, pos_c);
self->grid->rebuild_from_canvas(pos_r, pos_c);
self->grid->stamp_glyph((const unsigned char*)view.buf, gh, gw, pos_r, pos_c);
self->grid->rebuild_from_canvas(pos_r, pos_c);
PyBuffer_Release(&view);
Py_RETURN_NONE;
@@ -786,8 +1049,10 @@ static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) {
static PyMethodDef Grid_methods[] = {
{"reorder_stratified", (PyCFunction)Grid_reorder_stratified, METH_VARARGS, "Reorder valid coords with stratified interleaving."},
{"query_sorted", (PyCFunction)Grid_query_sorted, METH_VARARGS, "Find position using sorted coordinate list (center-out, parallel)."},
{"query_reservoir", (PyCFunction)Grid_query_reservoir, METH_VARARGS, "Find position using parallel direct-scan reservoir sampling."},
{"query_direct", (PyCFunction)Grid_query_direct, METH_VARARGS, "Direct pixel-grid scan with parallel reservoir sampling."},
{"query_reservoir", (PyCFunction)Grid_query_reservoir, METH_VARARGS, "Find position using parallel direct-scan reservoir sampling."},
{"query_direct", (PyCFunction)Grid_query_direct, METH_VARARGS, "Direct pixel-grid scan with parallel reservoir sampling."},
{"query_near_center", (PyCFunction)Grid_query_near_center, METH_VARARGS, "Sample legal positions and prefer the free-mask centroid."},
{"place_glyph_exact", (PyCFunction)Grid_place_glyph_exact, METH_VARARGS, "Place and reserve an exact glyph bitmap without changing its size."},
{"batch_query", (PyCFunction)Grid_batch_query, METH_VARARGS, "Batch placement: list of (bh,bw,seed) -> list of (y,x)|None."},
{"update", (PyCFunction)Grid_update, METH_VARARGS, "Update grid with placed rectangle."},
{"flush", (PyCFunction)Grid_flush, METH_NOARGS, "Force rebuild integral image."},
@@ -101,8 +101,6 @@ class EfficientWordCloud:
Probability a word is placed horizontally (01).
mode : str
PIL image mode ('RGB', 'RGBA', …).
use_spiral_search : bool
Use center-out sorted search (True) or reservoir sampling (False).
scale : float
Scaling factor between layout computation and final rendering.
``scale=2`` means the output image is 2× the canvas size in each
@@ -142,8 +140,6 @@ class EfficientWordCloud:
How much word frequency (vs rank) influences font size.
0 = rank only, 1 = fully frequency-driven.
When *repeat* is True, defaults to 0.
font_step : int
Step size when reducing font size to find a fit.
"""
def __init__(self,
@@ -156,16 +152,14 @@ class EfficientWordCloud:
background_color="black",
prefer_horizontal=0.9,
mode="RGB",
use_spiral_search=True,
scale=1,
scale=1,
contour_width=0,
contour_color="black",
margin=2,
color_func=None,
colormap=None,
random_state=None,
relative_scaling="auto",
font_step=1,
relative_scaling="auto",
repeat=False,
stopwords=None,
regexp=None,
@@ -185,7 +179,6 @@ class EfficientWordCloud:
self.background_color = background_color
self.prefer_horizontal = prefer_horizontal
self.mode = mode
self.use_spiral_search = use_spiral_search
self.scale = scale
self.contour_width = contour_width
self.contour_color = contour_color
@@ -197,7 +190,6 @@ class EfficientWordCloud:
else:
self.relative_scaling = relative_scaling
self.margin = margin
self.font_step = font_step
self.stopwords = stopwords if stopwords is not None else STOPWORDS
self.regexp = regexp
self.collocations = collocations
@@ -319,10 +311,9 @@ class EfficientWordCloud:
def _query(qh, qw):
return self.grid.query_direct(qh, qw, rs.randint(0, 2**31))
# v4: Ref-like linear step-down placement with bitmap occupancy
# After each word placement, stamp glyph bitmap into C++ canvas
# and rebuild integral for pixel-accurate collision detection.
# No PIL image drawn during placement — to_image() renders later.
# Each word is tried at exactly its weight-derived target size. A
# failed word may change orientation, but never receives a private
# fallback size. Whole-cloud scaling belongs to the caller.
# Dummy draw for textbbox measurement
_measure_img = Image.new("L", (1, 1))
@@ -344,36 +335,26 @@ class EfficientWordCloud:
else:
orientation = Image.ROTATE_90
tried_other_orientation = False
while True:
if font_size < self.min_font_size:
break
font = _get_font(font_size)
transposed = ImageFont.TransposedFont(font, orientation=orientation)
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
pos = None
orientations = [orientation]
if self.prefer_horizontal < 1:
orientations.append(Image.ROTATE_90 if orientation is None else None)
for candidate_orientation in orientations:
font = _get_font(font_size)
transposed = ImageFont.TransposedFont(font, orientation=candidate_orientation)
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
qh = th + self.margin
qw = tw + self.margin
pos = _query(qh, qw)
if pos is not None:
break
# No position found — try alternate orientation, then reduce size
if not tried_other_orientation and self.prefer_horizontal < 1:
orientation = Image.ROTATE_90 if orientation is None else None
tried_other_orientation = True
else:
font_size -= self.font_step
orientation = None
tried_other_orientation = False
if font_size < self.min_font_size:
# Canvas full — no more words can fit
break
pos = _query(qh, qw)
if pos is not None:
orientation = candidate_orientation
break
if pos is None:
continue
y, x = pos
# Adjust position for margin (like ref: x,y += margin // 2)