Initial project baseline

This commit is contained in:
2026-07-04 02:40:45 +08:00
commit d5d8caef2f
86 changed files with 15590 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
# EfficientWordCloud Core / 核心引擎
(English below)
## 🇨🇳 中文说明
**EfficientWordCloud** 是本项目的高性能 C++ 核心扩展库。它负责处理最耗时的碰撞检测和空间搜索任务,是生成 4K/8K 超高清词云的基础。
### 核心技术
1. **积分图 (Integral Image)**
使用 O(1) 时间复杂度计算任意矩形区域的像素和。这意味着无论单词多大,碰撞检测的耗时都是恒定的,实现了瞬时检测。
2. **螺旋搜索 (Spiral Search)**
替代传统的随机尝试算法,采用“从中心向外”的螺旋扫描策略。这不仅大幅提高了填充率,还将大图生成速度提升了 **50-100 倍**
3. **空间索引 (Spatial Indexing)**
内部维护一个层级网格,快速剔除无效区域。
### 依赖说明
- 核心扩展包依赖:`numpy``pillow``matplotlib`
- 主脚本额外依赖:`pandas`(用于 `wordcloud_generate_hybrid.py` 读取 Excel
安装示例:
```bash
pip install numpy pillow matplotlib
# 如果你要运行仓库根目录主脚本,再额外安装:
pip install pandas
```
### 编译安装
在当前目录下运行以下命令,将在 `efficient_wordcloud` 文件夹中生成编译好的扩展文件(`.so``.pyd`):
```bash
python3 setup.py build_ext --inplace
```
### 性能对比
| 分辨率 | 原版 wordcloud | EfficientWordCloud (Spiral) | 提速 |
| :--- | :--- | :--- | :--- |
| 1920x1080 | ~2.5s | ~0.05s | **50x** |
| 8000x4000 | ~60s+ | ~0.5s | **120x+** |
---
## 🇺🇸 English Description
**EfficientWordCloud** is the high-performance C++ backend for this project. It handles the computationally expensive collision detection and spatial queries, enabling the generation of 4K/8K ultra-HD word clouds.
### Key Technologies
1. **Integral Images**:
Calculates the sum of pixels in any rectangular area in O(1) time. This allows for instantaneous collision checks regardless of the word size.
2. **Spiral Search**:
Replaces the brute-force random sampling with a "Center-Out" spiral heuristic. This significantly improves packing density and boosts performance by **50-100x** on large canvases.
3. **Spatial Indexing**:
Maintains an internal hierarchical grid to quickly cull invalid regions.
### Build & Install
Run the following command in this directory to build the extension in-place (generates `.so` or `.pyd` inside `efficient_wordcloud` folder):
```bash
python3 setup.py build_ext --inplace
```
### Performance Comparison
| Resolution | Original Library | EfficientWordCloud (Spiral) | Speedup |
| :--- | :--- | :--- | :--- |
| 1920x1080 | ~2.5s | ~0.05s | **50x** |
| 8000x4000 | ~60s+ | ~0.5s | **120x+** |
+84
View File
@@ -0,0 +1,84 @@
# EfficientWordCloud 并行优化设计文稿
## 1. 目标与背景
本设计针对 EfficientWordCloud 的两个性能瓶颈进行优化:
- **Python 侧**:文本渲染与 bbox 计算属于 CPU 密集型,原流程在主循环中串行执行。
- **C++ 侧**:可用位置搜索为线性扫描,且非线程安全,限制多核利用。
目标:
- 在 Python 侧引入并行 bbox 预取,降低主循环阻塞。
- 在 C++ 侧实现线程安全与分块并行搜索,提升多核性能。
## 2. 总体架构
### 2.1 并行策略
- **Python 侧**Producer-Consumer 预取模式。
- 使用 `ProcessPoolExecutor` 异步计算未来词语的 bbox。
- 主循环消费预取结果;若字体缩小,则回退为同步计算。
- **C++ 侧**Chunked Parallel Search。
- 将候选坐标分块,每块由一个异步任务扫描。
- 收集所有任务结果,选择最小索引对应的位置,以保持“中心优先”的排序策略。
### 2.2 线程安全模型
- `IntegralGrid` 内部引入 `std::shared_mutex`
- **读**`get_area_sum`)在并行搜索期间不再加锁,假设搜索阶段只读且在生成坐标与积分图后保持快照。
- **写**`update_rect_add`)当前实现默认由单线程 Python 调用,移除了锁以减少开销;如需多线程更新需恢复写锁保护。
- 重建积分图(`rebuild_integral`)只在写路径触发。
## 3. C++ 核心设计
### 3.1 数据结构扩展
-`IntegralGrid` 内部新增:
```cpp
mutable std::shared_mutex mutex_;
```
### 3.2 并行搜索流程
1. 计算线程数(`hardware_concurrency`),确定分块大小。
2. 对 `valid_coords` 分块后并行扫描,每块返回一个 `SearchResult`。
3. 主线程汇总所有结果,选取最小索引(全局最佳位置)。
4. 在搜索期间释放 GIL
```cpp
Py_BEGIN_ALLOW_THREADS
...
Py_END_ALLOW_THREADS
```
### 3.3 关键函数
- `search_range(start, end, box_h, box_w, step)`:扫描单块坐标。
- `find_spot_parallel(box_h, box_w, step)`:组织并行任务并聚合结果。
- `Grid_query_sorted`:绑定层调用 `find_spot_parallel`。
## 4. Python 侧设计
### 4.1 可序列化的测量函数
模块级函数保证可被 `ProcessPoolExecutor` 调度:
```python
def measure_text(text, font_path, size, rotate) -> (int, int)
```
### 4.2 ParallelBBoxFetcher
职责:
- 维护任务队列。
- 提交预取任务。
- 主循环中拉取结果(若无缓存则同步测量)。
接口:
- `prefetch(items)`
- `get(key, text, size, rotate)`
### 4.3 主流程集成
- 预先生成 `rotation_flags`,确保预取与实际一致。
- 预取前 `N` 个词的 bbox。
- 逐词处理时,提前预取下一批的 bbox。
- 字体缩小后回退同步,保证正确性。
## 5. 构建配置
- 编译标准升级为 C++17 以使用 `std::shared_mutex`。
## 6. 影响与兼容性
- 对外 API 保持不变。
- 性能提升取决于多核环境及词数量。
- 并行搜索仍保证排序一致性。
## 7. 风险与对策
- **多进程 bbox 测量开销**:采用预取窗口控制并发规模。
- **锁开销**:只在必要时加锁,读操作使用共享锁。
- **一致性**:聚合结果按最小索引保证行为与原排序一致。
+61
View File
@@ -0,0 +1,61 @@
# EfficientWordCloud 使用文档
## 1. 构建与安装
在项目根目录执行:
```bash
python setup.py build
python setup.py install
```
## 2. 基本使用示例
```python
from efficient_wordcloud.wordcloud import EfficientWordCloud
freq = {
"hello": 100,
"world": 80,
"efficient": 60,
"wordcloud": 40
}
wc = EfficientWordCloud(
width=800,
height=600,
font_path="/path/to/font.ttf",
max_words=200,
min_font_size=8,
prefer_horizontal=0.9
)
wc.generate(freq)
img = wc.to_image()
img.show()
```
## 3. 关键参数说明
- `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++ 内部自动进行分块并行搜索,并保持中心优先排序的结果一致性。
## 5. 常见问题
### 5.1 为什么字体缩小时没有并行?
缩小字体后 bbox 依赖当前失败状态,需要同步确认以确保正确性。
### 5.2 多进程是否会导致额外内存开销?
是的,但任务仅用于 bbox 预取,且窗口大小有限,避免过度占用。
### 5.3 若没有字体文件怎么办?
会回退到 PIL 默认字体,但测量与渲染效果可能不同。
@@ -0,0 +1,8 @@
from .wordcloud import (
EfficientWordCloud,
STOPWORDS,
random_color_func,
colormap_color_func,
get_single_color_func,
)
from .color_from_image import ImageColorGenerator
@@ -0,0 +1,55 @@
# Ported from ref/word_cloud/wordcloud/color_from_image.py (MIT License)
import numpy as np
from PIL import ImageFont
class ImageColorGenerator:
"""Color generator that samples colors from a reference image.
Each word is colored using the mean RGB of the region it occupies in
*image*. Pass an instance as ``color_func`` to ``EfficientWordCloud``.
Parameters
----------
image : ndarray, shape (H, W, 3) or (H, W, 4)
Reference color image. Should match the word-cloud canvas size.
default_color : tuple (R, G, B) or None
Fallback color when the word region falls outside *image*.
If None, a ValueError is raised instead.
"""
def __init__(self, image, default_color=None):
if image.ndim not in (2, 3):
raise ValueError(
"ImageColorGenerator needs an image with ndim 2 or 3, "
"got %d" % image.ndim
)
if image.ndim == 3 and image.shape[2] not in (3, 4):
raise ValueError(
"A color image must have 3 or 4 channels, got %d"
% image.shape[2]
)
self.image = image
self.default_color = default_color
def __call__(self, word, font_size, font_path, position, orientation,
**kwargs):
"""Return the mean color of the image patch under the word bounding box."""
font = ImageFont.truetype(font_path, font_size)
from PIL import ImageFont as _IF
transposed_font = _IF.TransposedFont(font, orientation=orientation)
box = transposed_font.getbbox(word) # (left, top, right, bottom)
x, y = position # (row, col) i.e. (y, x) in PIL
patch = self.image[x:x + box[2], y:y + box[3]]
if patch.ndim == 3:
patch = patch[:, :, :3] # drop alpha
reshape = patch.reshape(-1, 3)
if not np.all(np.array(reshape.shape) > 0):
if self.default_color is None:
raise ValueError(
"ImageColorGenerator: word region is outside the image. "
"Pass default_color to suppress this error."
)
return "rgb(%d, %d, %d)" % tuple(self.default_color)
color = np.mean(reshape, axis=0)
return "rgb(%d, %d, %d)" % tuple(color.astype(int))
@@ -0,0 +1,829 @@
/*
* EfficientWordCloud Core (ewc_core) v3.0
*
* v3 changes:
* - Direct pixel-grid scan (query_direct) — like ref, O(1) integral image
* check per position, sequential memory access, cache-friendly
* - Parallel reservoir sampling with thread pool (no std::async overhead)
* - Batch query API to eliminate Python↔C++ per-word overhead
* - Partial integral rebuild (only affected subregion)
*/
#include <Python.h>
#include <vector>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <cstdint>
#include <utility>
#include <mutex>
#include <shared_mutex>
#include <thread>
#include <future>
#include <atomic>
#include <random>
#include <functional>
#include <numeric>
// ==========================================
// Thread Pool (avoid per-query thread creation)
// ==========================================
class ThreadPool {
std::vector<std::thread> workers;
std::vector<std::function<void()>> tasks;
std::mutex mtx;
std::condition_variable cv;
std::atomic<bool> stop{false};
public:
ThreadPool(unsigned int n) {
for (unsigned int i = 0; i < n; ++i) {
workers.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [this] { return stop.load() || !tasks.empty(); });
if (stop.load() && tasks.empty()) return;
task = std::move(tasks.back());
tasks.pop_back();
}
task();
}
});
}
}
template<class F>
std::future<typename std::invoke_result<F>::type> submit(F&& f) {
using R = typename std::invoke_result<F>::type;
auto p = std::make_shared<std::promise<R>>();
auto fut = p->get_future();
{
std::lock_guard<std::mutex> lock(mtx);
if constexpr (std::is_void_v<R>) {
tasks.push_back([p, f=std::forward<F>(f)]() mutable {
try { f(); p->set_value(); }
catch (...) { p->set_exception(std::current_exception()); }
});
} else {
tasks.push_back([p, f=std::forward<F>(f)]() mutable {
try { p->set_value(f()); }
catch (...) { p->set_exception(std::current_exception()); }
});
}
}
cv.notify_one();
return fut;
}
~ThreadPool() {
stop.store(true);
cv.notify_all();
for (auto& w : workers) w.join();
}
};
static ThreadPool& get_pool() {
static unsigned int n = std::max(2u, std::thread::hardware_concurrency());
static ThreadPool pool(n);
return pool;
}
static unsigned int get_num_threads() {
static unsigned int n = std::max(2u, std::thread::hardware_concurrency());
return n;
}
// ==========================================
// Core Data Structure
// ==========================================
class IntegralGrid {
public:
uint32_t* data;
int width;
int height;
mutable std::shared_mutex mutex_;
struct Rect {
int r;
int c;
int h;
int w;
};
// valid coordinates sorted by distance to center (for sorted/spiral search)
std::vector<std::pair<int, int>> valid_coords;
// Lazy update buffers
std::vector<int32_t> diff;
std::vector<Rect> recent_rects;
int dirty_count = 0;
int rebuild_interval = 16; // v3: more frequent rebuilds (was 64), since rebuild is cheaper now
IntegralGrid(int h, int w) : width(w), height(h) {
data = new uint32_t[width * height]();
diff.resize(width * height, 0);
}
~IntegralGrid() {
if (data) delete[] data;
}
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);
// Initialize canvas from mask
ensure_canvas();
for (int i = 0; i < h; ++i) {
uint32_t row_sum = 0;
for (int j = 0; j < w; ++j) {
bool is_blocked = (raw_mask[i * w + j] > 0);
uint32_t val = is_blocked ? 1 : 0;
row_sum += val;
uint32_t prev_row = (i > 0) ? data[(i - 1) * width + j] : 0;
data[i * width + j] = row_sum + prev_row;
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;
}
);
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();
if (bands <= 1 || len == 0) return;
if ((size_t)bands > len) bands = (int)len;
std::vector<size_t> band_sizes(bands, 0);
size_t base = len / bands;
size_t rem = len % bands;
for (int b = 0; b < bands; ++b)
band_sizes[b] = base + (b < (int)rem ? 1 : 0);
std::vector<size_t> band_starts(bands, 0);
size_t offset = 0, max_band_size = 0;
for (int b = 0; b < bands; ++b) {
band_starts[b] = offset;
offset += band_sizes[b];
if (band_sizes[b] > max_band_size) max_band_size = band_sizes[b];
}
std::vector<std::pair<int, int>> reordered;
reordered.reserve(len);
for (size_t i = 0; i < max_band_size; ++i)
for (int b = 0; b < bands; ++b)
if (i < band_sizes[b])
reordered.push_back(valid_coords[band_starts[b] + i]);
valid_coords.swap(reordered);
}
// O(1) rectangle emptiness check via integral image + recent-rect overlap
inline uint32_t get_area_sum(int r, int c, int h, int w) const {
int r_bottom = r + h - 1;
int c_right = c + w - 1;
int r_top = r - 1;
int c_left = c - 1;
uint32_t A = (r_top >= 0 && c_left >= 0) ? data[r_top * width + c_left] : 0;
uint32_t B = (r_top >= 0) ? data[r_top * width + c_right] : 0;
uint32_t C = (c_left >= 0) ? data[r_bottom * width + c_left] : 0;
uint32_t D = data[r_bottom * width + c_right];
uint32_t base_sum = D - B - C + A;
if (base_sum > 0) return base_sum;
if (!recent_rects.empty()) {
int r2 = r + h;
int c2 = c + w;
for (const auto& rect : recent_rects) {
int rr2 = rect.r + rect.h;
int cc2 = rect.c + rect.w;
if (r < rr2 && r2 > rect.r && c < cc2 && c2 > rect.c) return 1;
}
}
return 0;
}
// O(1) fast check without recent_rects (for direct scan where we'll call flush first)
inline uint32_t get_area_sum_fast(int r, int c, int h, int w) const {
int r_bottom = r + h - 1;
int c_right = c + w - 1;
int r_top = r - 1;
int c_left = c - 1;
uint32_t A = (r_top >= 0 && c_left >= 0) ? data[r_top * width + c_left] : 0;
uint32_t B = (r_top >= 0) ? data[r_top * width + c_right] : 0;
uint32_t C = (c_left >= 0) ? data[r_bottom * width + c_left] : 0;
uint32_t D = data[r_bottom * width + c_right];
return D - B - C + A;
}
void rebuild_integral() {
if (dirty_count <= 0) return;
std::vector<int32_t> delta_prev_row(width, 0);
std::vector<int32_t> delta_row(width, 0);
std::vector<uint32_t> integral_prev_row(width, 0);
for (int i = 0; i < height; ++i) {
uint32_t row_sum = 0;
for (int j = 0; j < width; ++j) {
int idx = i * width + j;
int32_t up = (i > 0) ? delta_prev_row[j] : 0;
int32_t left = (j > 0) ? delta_row[j - 1] : 0;
int32_t up_left = (i > 0 && j > 0) ? delta_prev_row[j - 1] : 0;
int32_t delta = diff[idx] + up + left - up_left;
delta_row[j] = delta;
row_sum += (uint32_t)delta;
uint32_t prev = (i > 0) ? integral_prev_row[j] : 0;
uint32_t di = row_sum + prev;
data[idx] += di;
integral_prev_row[j] = di;
}
delta_prev_row.swap(delta_row);
}
std::fill(diff.begin(), diff.end(), 0);
recent_rects.clear();
dirty_count = 0;
}
// Force flush: rebuild integral so get_area_sum_fast is safe
void flush() {
if (dirty_count > 0) rebuild_integral();
}
// v4: Rebuild integral image from raw pixel array (bitmap occupancy like ref)
// raw_pixels: H×W uint8 array where >0 = occupied
void rebuild_from_bitmap(const unsigned char* raw_pixels) {
// Reset lazy update state since we're rebuilding everything
std::fill(diff.begin(), diff.end(), 0);
recent_rects.clear();
dirty_count = 0;
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;
row_sum += val;
uint32_t prev_row = (i > 0) ? data[(i - 1) * width + j] : 0;
data[i * width + j] = row_sum + prev_row;
}
}
}
// Persistent canvas bitmap (C++ side) — avoids PIL→numpy conversion overhead
std::vector<uint8_t> canvas;
void ensure_canvas() {
if (canvas.empty()) {
canvas.resize(width * height, 0);
}
}
// Stamp a small glyph bitmap onto the canvas at position (pos_r, pos_c)
// glyph: gh×gw uint8 array where >0 = occupied pixel
void stamp_glyph(const unsigned char* glyph, int gh, int gw, int pos_r, int pos_c) {
ensure_canvas();
for (int i = 0; i < gh; ++i) {
int ri = pos_r + i;
if (ri < 0 || ri >= height) continue;
for (int j = 0; j < gw; ++j) {
int cj = pos_c + j;
if (cj < 0 || cj >= width) continue;
if (glyph[i * gw + j] > 0) {
canvas[ri * width + cj] = 1;
}
}
}
}
// 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);
}
// v4: Partial integral rebuild from position (pos_r, pos_c) downward
// Optimized: use row-sum approach (like rebuild_from_bitmap) for the partial region
void rebuild_from_bitmap_partial(const unsigned char* raw_pixels, int pos_r, int pos_c) {
// Reset lazy update state
dirty_count = 0;
recent_rects.clear();
// Clamp to valid range
if (pos_r < 0) pos_r = 0;
if (pos_c < 0) pos_c = 0;
// For each row from pos_r, recompute integral from pos_c onward
// Using the fast row-sum approach
for (int i = pos_r; i < height; ++i) {
const unsigned char* pixel_row = raw_pixels + i * width;
uint32_t* data_row = data + i * width;
// Compute row_sum for columns [0, pos_c) from existing data
// row_sum at pos_c = data[i][pos_c-1] - data[i-1][pos_c-1] (if prev row available)
// But we need the actual row prefix sum, not the integral
// Instead: use the standard formula: integral(i,j) = pixel(i,j) + left + up - diag
// But optimize by accumulating row_sum separately
// row_sum = sum of pixel_row[pos_c..j] for current row
uint32_t row_sum;
if (pos_c > 0) {
// Get the row prefix sum at pos_c-1 from existing integral
uint32_t prev_row_integral = (i > 0) ? data[(i-1) * width + (pos_c - 1)] : 0;
row_sum = data_row[pos_c - 1] - prev_row_integral;
// But wait - data_row[pos_c-1] might be stale for rows > pos_r
// Actually for i == pos_r, row pos_r-1 hasn't been modified, so data[pos_r-1] is correct
// For i > pos_r, data[i-1] has been updated in previous iteration, so it's correct
// And data_row[pos_c-1] is unchanged (we don't modify columns < pos_c)
// So row_sum = integral[i][pos_c-1] - integral[i-1][pos_c-1]
// This gives us the row prefix sum for columns [0, pos_c-1]
} else {
row_sum = 0;
}
for (int j = pos_c; j < width; ++j) {
row_sum += (pixel_row[j] > 0) ? 1 : 0;
uint32_t prev_row = (i > 0) ? data[(i - 1) * width + j] : 0;
data_row[j] = row_sum + prev_row;
}
}
}
void update_rect_add(int r, int c, int h, int w) {
int end_r = std::min(height, r + h);
int end_c = std::min(width, c + w);
if (r < 0 || c < 0 || end_r <= r || end_c <= c) return;
recent_rects.push_back({r, c, end_r - r, end_c - c});
diff[r * width + c] += 1;
if (end_r < height) diff[end_r * width + c] -= 1;
if (end_c < width) diff[r * width + end_c] -= 1;
if (end_r < height && end_c < width) diff[end_r * width + end_c] += 1;
dirty_count++;
if (dirty_count >= rebuild_interval) rebuild_integral();
}
// =========================================================
// v3: Direct pixel-grid scan (like ref's query_integral_image)
// Scans all (i,j) where 0<=i<=H-bh, 0<=j<=W-bw in memory order
// Two-pass: count hits, then pick random one
// =========================================================
struct DirectResult {
bool found;
int y, x;
};
// Count valid positions in row range [row_start, row_end)
uint64_t count_valid_rows(int row_start, int row_end, int box_h, int box_w) const {
uint64_t count = 0;
int max_col = width - box_w;
for (int i = row_start; i < row_end; ++i) {
for (int j = 0; j <= max_col; ++j) {
if (get_area_sum_fast(i, j, box_h, box_w) == 0)
++count;
}
}
return count;
}
// Pick the nth valid position in row range [row_start, row_end)
std::pair<int,int> pick_nth_valid(int row_start, int row_end, int box_h, int box_w, uint64_t target) const {
uint64_t count = 0;
int max_col = width - box_w;
for (int i = row_start; i < row_end; ++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 {i, j};
++count;
}
}
}
return {-1, -1};
}
DirectResult query_direct(int box_h, int box_w, uint32_t seed) {
// Always flush before scanning
flush();
int max_row = height - box_h;
int max_col = width - box_w;
if (max_row < 0 || max_col < 0) return {false, -1, -1};
int n_rows = max_row + 1;
int n_cols = max_col + 1;
int64_t total_positions = (int64_t)n_rows * n_cols;
std::mt19937 rng(seed);
// Fast path: if the entire canvas is empty, all positions are valid
uint32_t total_occupied = data[(height - 1) * width + (width - 1)];
if (total_occupied == 0) {
int y = std::uniform_int_distribution<int>(0, max_row)(rng);
int x = std::uniform_int_distribution<int>(0, max_col)(rng);
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);
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);
if (get_area_sum_fast(y, x, box_h, box_w) == 0) {
return {true, y, x};
}
}
}
// Adaptive: use single thread for small canvases, multi for large
unsigned int nt = 1;
if (total_positions > 200000) {
nt = get_num_threads();
nt = std::min(nt, (unsigned int)n_rows);
}
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;
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;
}
}
}
return {false, -1, -1};
}
// Multi-thread path: parallel count then pick
std::vector<uint64_t> chunk_counts(nt, 0);
std::vector<int> chunk_starts(nt), chunk_ends(nt);
int rows_per = (n_rows + nt - 1) / nt;
for (unsigned int t = 0; t < nt; ++t) {
chunk_starts[t] = t * rows_per;
chunk_ends[t] = std::min(n_rows, (int)(t + 1) * rows_per);
}
auto& pool = get_pool();
std::vector<std::future<uint64_t>> futs;
futs.reserve(nt);
for (unsigned int t = 0; t < nt; ++t) {
if (chunk_starts[t] >= chunk_ends[t]) break;
futs.push_back(pool.submit([this, cs=chunk_starts[t], ce=chunk_ends[t], box_h, box_w]() {
return this->count_valid_rows(cs, ce, box_h, box_w);
}));
}
uint64_t total = 0;
for (size_t t = 0; t < futs.size(); ++t) {
chunk_counts[t] = futs[t].get();
total += chunk_counts[t];
}
if (total == 0) return {false, -1, -1};
uint64_t target = std::uniform_int_distribution<uint64_t>(0, total - 1)(rng);
uint64_t cum = 0;
for (size_t t = 0; t < futs.size(); ++t) {
if (cum + chunk_counts[t] > target) {
auto pos = pick_nth_valid(chunk_starts[t], chunk_ends[t], box_h, box_w, target - cum);
return {true, pos.first, pos.second};
}
cum += chunk_counts[t];
}
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
// =========================================================
std::vector<DirectResult> batch_query(const std::vector<std::tuple<int,int,uint32_t>>& queries) {
std::vector<DirectResult> results;
results.reserve(queries.size());
for (auto& [bh, bw, seed] : queries) {
auto r = query_direct(bh, bw, seed);
if (r.found) {
update_rect_add(r.y, r.x, bh, bw);
}
results.push_back(r);
}
return results;
}
// =========================================================
// Legacy methods (kept for backward compatibility)
// =========================================================
struct SearchResult {
bool found;
int y, x;
size_t index;
};
SearchResult search_range(size_t start_idx, size_t end_idx, int box_h, int box_w, int step) const {
for (size_t i = start_idx; i < end_idx; i += step) {
int y = valid_coords[i].first;
int x = valid_coords[i].second;
if (y + box_h <= height && x + box_w <= width) {
if (get_area_sum(y, x, box_h, box_w) == 0) {
return {true, y, x, i};
}
}
}
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_);
if (dirty_count > 0 && dirty_count >= rebuild_interval) {
lock.unlock();
std::unique_lock<std::shared_mutex> write_lock(mutex_);
if (dirty_count > 0 && dirty_count >= rebuild_interval)
rebuild_integral();
}
}
const size_t len = valid_coords.size();
if (len == 0) return {false, {-1, -1}};
SearchResult best = {false, -1, -1, len};
unsigned int nt = get_num_threads();
nt = std::min(nt, (unsigned int)len);
const size_t chunk = (len + nt - 1) / nt;
auto& pool = get_pool();
std::vector<std::future<SearchResult>> futs;
futs.reserve(nt);
Py_BEGIN_ALLOW_THREADS
for (unsigned int i = 0; i < nt; ++i) {
size_t s = i * chunk, e = std::min(len, s + chunk);
if (s >= e) break;
futs.push_back(pool.submit([this, s, e, box_h, box_w, step]() {
return this->search_range(s, e, box_h, box_w, step);
}));
}
for (auto& f : futs) {
auto res = f.get();
if (res.found && res.index < best.index) best = res;
}
Py_END_ALLOW_THREADS
if (best.found) return {true, {best.y, best.x}};
return {false, {-1, -1}};
}
};
// ==========================================
// Python Bindings
// ==========================================
typedef struct {
PyObject_HEAD
IntegralGrid* grid;
} PyIntegralGrid;
static void Grid_dealloc(PyIntegralGrid* self) {
if (self->grid) delete self->grid;
Py_TYPE(self)->tp_free((PyObject*)self);
}
static PyObject* Grid_new(PyTypeObject* type, PyObject* args, PyObject* kwds) {
PyIntegralGrid* self = (PyIntegralGrid*)type->tp_alloc(type, 0);
if (self) self->grid = NULL;
return (PyObject*)self;
}
static int Grid_init(PyIntegralGrid* self, PyObject* args, PyObject* kwds) {
PyObject* mask_obj;
int h, w;
if (!PyArg_ParseTuple(args, "Oii", &mask_obj, &h, &w)) return -1;
Py_buffer view;
if (PyObject_GetBuffer(mask_obj, &view, PyBUF_SIMPLE) < 0) return -1;
self->grid = new IntegralGrid(h, w);
self->grid->init_from_buffer((unsigned char*)view.buf, h, w);
PyBuffer_Release(&view);
return 0;
}
static PyObject* Grid_reorder_stratified(PyIntegralGrid* self, PyObject* args) {
int bands = 0;
if (!PyArg_ParseTuple(args, "i", &bands)) return NULL;
self->grid->reorder_stratified(bands);
Py_RETURN_NONE;
}
static PyObject* Grid_query_sorted(PyIntegralGrid* self, PyObject* args) {
int box_h, box_w, step = 1;
if (!PyArg_ParseTuple(args, "ii|i", &box_h, &box_w, &step)) return NULL;
auto result = self->grid->find_spot_parallel(box_h, box_w, step);
if (result.first) return Py_BuildValue("ii", result.second.first, result.second.second);
Py_RETURN_NONE;
}
static PyObject* Grid_query_reservoir(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;
// v3: delegate to query_direct for parallel reservoir
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;
}
// v3: Direct pixel-grid scan with parallel counting
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);
if (r.found) return Py_BuildValue("ii", r.y, r.x);
Py_RETURN_NONE;
}
// v3: Batch query — process multiple placements in one C++ call
// Input: list of (box_h, box_w, seed) tuples
// Output: list of (y, x) or None for each
static PyObject* Grid_batch_query(PyIntegralGrid* self, PyObject* args) {
PyObject* query_list;
if (!PyArg_ParseTuple(args, "O", &query_list)) return NULL;
PyObject* seq = PySequence_Fast(query_list, "expected a sequence");
if (!seq) return NULL;
Py_ssize_t n = PySequence_Fast_GET_SIZE(seq);
std::vector<std::tuple<int,int,uint32_t>> queries;
queries.reserve(n);
for (Py_ssize_t i = 0; i < n; ++i) {
PyObject* item = PySequence_Fast_GET_ITEM(seq, i);
int bh, bw;
unsigned int seed = 0;
if (!PyArg_ParseTuple(item, "ii|I", &bh, &bw, &seed)) {
Py_DECREF(seq);
return NULL;
}
queries.push_back({bh, bw, seed});
}
Py_DECREF(seq);
auto results = self->grid->batch_query(queries);
PyObject* result_list = PyList_New(n);
for (Py_ssize_t i = 0; i < n; ++i) {
if (results[i].found) {
PyList_SET_ITEM(result_list, i, Py_BuildValue("ii", results[i].y, results[i].x));
} else {
Py_INCREF(Py_None);
PyList_SET_ITEM(result_list, i, Py_None);
}
}
return result_list;
}
static PyObject* Grid_update(PyIntegralGrid* self, PyObject* args) {
int r, c, h, w;
if (!PyArg_ParseTuple(args, "iiii", &r, &c, &h, &w)) return NULL;
self->grid->update_rect_add(r, c, h, w);
Py_RETURN_NONE;
}
static PyObject* Grid_flush(PyIntegralGrid* self, PyObject* args) {
self->grid->flush();
Py_RETURN_NONE;
}
// v4: Rebuild integral from full bitmap array
static PyObject* Grid_rebuild_from_bitmap(PyIntegralGrid* self, PyObject* args) {
PyObject* arr_obj;
if (!PyArg_ParseTuple(args, "O", &arr_obj)) return NULL;
Py_buffer view;
if (PyObject_GetBuffer(arr_obj, &view, PyBUF_SIMPLE) < 0) return NULL;
self->grid->rebuild_from_bitmap((const unsigned char*)view.buf);
PyBuffer_Release(&view);
Py_RETURN_NONE;
}
// v4: Partial rebuild from position (pos_r, pos_c)
static PyObject* Grid_rebuild_from_bitmap_partial(PyIntegralGrid* self, PyObject* args) {
PyObject* arr_obj;
int pos_r, pos_c;
if (!PyArg_ParseTuple(args, "Oii", &arr_obj, &pos_r, &pos_c)) return NULL;
Py_buffer view;
if (PyObject_GetBuffer(arr_obj, &view, PyBUF_SIMPLE) < 0) return NULL;
self->grid->rebuild_from_bitmap_partial((const unsigned char*)view.buf, pos_r, pos_c);
PyBuffer_Release(&view);
Py_RETURN_NONE;
}
// v4: Stamp glyph bitmap onto C++ canvas and rebuild integral
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);
PyBuffer_Release(&view);
Py_RETURN_NONE;
}
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."},
{"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."},
{"rebuild_from_bitmap", (PyCFunction)Grid_rebuild_from_bitmap, METH_VARARGS, "Rebuild integral from pixel bitmap array."},
{"rebuild_from_bitmap_partial", (PyCFunction)Grid_rebuild_from_bitmap_partial, METH_VARARGS, "Partial integral rebuild from (pos_r, pos_c)."},
{"stamp_and_rebuild", (PyCFunction)Grid_stamp_and_rebuild, METH_VARARGS, "Stamp glyph + partial integral rebuild (avoids PIL->numpy)."},
{NULL}
};
static PyTypeObject PyIntegralGridType = {
PyVarObject_HEAD_INIT(NULL, 0)
"ewc_core.IntegralGrid",
sizeof(PyIntegralGrid),
0,
(destructor)Grid_dealloc,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
Py_TPFLAGS_DEFAULT,
"Integral Grid v3",
0, 0, 0, 0, 0, 0,
Grid_methods,
0, 0, 0, 0, 0, 0, 0,
(initproc)Grid_init,
0,
Grid_new,
};
static PyModuleDef ewc_module = {
PyModuleDef_HEAD_INIT, "ewc_core", "EfficientWordCloud Core v3", -1, NULL, NULL, NULL, NULL, NULL
};
PyMODINIT_FUNC PyInit_ewc_core(void) {
PyObject* m;
if (PyType_Ready(&PyIntegralGridType) < 0) return NULL;
m = PyModule_Create(&ewc_module);
if (!m) return NULL;
Py_INCREF(&PyIntegralGridType);
if (PyModule_AddObject(m, "IntegralGrid", (PyObject *)&PyIntegralGridType) < 0) return NULL;
return m;
}
@@ -0,0 +1,192 @@
a
about
above
after
again
against
all
also
am
an
and
any
are
aren't
as
at
be
because
been
before
being
below
between
both
but
by
can
can't
cannot
com
could
couldn't
did
didn't
do
does
doesn't
doing
don't
down
during
each
else
ever
few
for
from
further
get
had
hadn't
has
hasn't
have
haven't
having
he
he'd
he'll
he's
hence
her
here
here's
hers
herself
him
himself
his
how
how's
however
http
i
i'd
i'll
i'm
i've
if
in
into
is
isn't
it
it's
its
itself
just
k
let's
like
me
more
most
mustn't
my
myself
no
nor
not
of
off
on
once
only
or
other
otherwise
ought
our
ours
ourselves
out
over
own
r
same
shall
shan't
she
she'd
she'll
she's
should
shouldn't
since
so
some
such
than
that
that's
the
their
theirs
them
themselves
then
there
there's
therefore
these
they
they'd
they'll
they're
they've
this
those
through
to
too
under
until
up
very
was
wasn't
we
we'd
we'll
we're
we've
were
weren't
what
what's
when
when's
where
where's
which
while
who
who's
whom
why
why's
with
won't
would
wouldn't
www
you
you'd
you'll
you're
you've
your
yours
yourself
yourselves
@@ -0,0 +1,109 @@
# Ported from ref/word_cloud/wordcloud/tokenization.py (MIT License)
from __future__ import division
from itertools import tee
from operator import itemgetter
from collections import defaultdict
from math import log
def _l(k, n, x):
"""Dunning log-likelihood helper."""
return log(max(x, 1e-10)) * k + log(max(1 - x, 1e-10)) * (n - k)
def _collocation_score(count_bigram, count1, count2, n_words):
"""Dunning likelihood ratio collocation score."""
if n_words <= count1 or n_words <= count2:
return 0
N, c12, c1, c2 = n_words, count_bigram, count1, count2
p = c2 / N
p1 = c12 / c1
p2 = (c2 - c12) / (N - c1)
score = (
_l(c12, c1, p) + _l(c2 - c12, N - c1, p)
- _l(c12, c1, p1) - _l(c2 - c12, N - c1, p2)
)
return -2 * score
def _pairwise(iterable):
a, b = tee(iterable)
next(b, None)
return zip(a, b)
def process_tokens(words, normalize_plurals=True):
"""Count words, normalizing case and optionally merging plurals.
Returns
-------
counts : dict str -> int
standard_forms : dict lowercase_str -> canonical_str
"""
d = defaultdict(dict)
for word in words:
wl = word.lower()
case_dict = d[wl]
case_dict[word] = case_dict.get(word, 0) + 1
if normalize_plurals:
merged_plurals = {}
for key in list(d.keys()):
if key.endswith('s') and not key.endswith('ss'):
singular = key[:-1]
if singular in d:
for word, count in d[key].items():
sing_form = word[:-1]
d[singular][sing_form] = d[singular].get(sing_form, 0) + count
merged_plurals[key] = singular
del d[key]
fused_cases = {}
standard_cases = {}
item1 = itemgetter(1)
for word_lower, case_dict in d.items():
first = max(case_dict.items(), key=item1)[0]
fused_cases[first] = sum(case_dict.values())
standard_cases[word_lower] = first
if normalize_plurals:
for plural, singular in merged_plurals.items():
standard_cases[plural] = standard_cases.get(singular, singular)
return fused_cases, standard_cases
def unigrams_and_bigrams(words, stopwords, normalize_plurals=True,
collocation_threshold=30):
"""Return word counts including statistically significant bigrams."""
bigrams = [
p for p in _pairwise(words)
if not any(w.lower() in stopwords for w in p)
]
unigrams = [w for w in words if w.lower() not in stopwords]
n_words = len(unigrams)
counts_unigrams, standard_form = process_tokens(
unigrams, normalize_plurals=normalize_plurals)
counts_bigrams, _ = process_tokens(
[" ".join(b) for b in bigrams], normalize_plurals=normalize_plurals)
orig_counts = counts_unigrams.copy()
for bigram_string, count in counts_bigrams.items():
parts = bigram_string.split(" ", 1)
if len(parts) != 2:
continue
word1 = standard_form.get(parts[0].lower(), parts[0])
word2 = standard_form.get(parts[1].lower(), parts[1])
if word1 not in orig_counts or word2 not in orig_counts:
continue
score = _collocation_score(count, orig_counts[word1],
orig_counts[word2], n_words)
if score > collocation_threshold:
counts_unigrams[word1] -= count
counts_unigrams[word2] -= count
counts_unigrams[bigram_string] = count
# Remove non-positive counts
return {w: c for w, c in counts_unigrams.items() if c > 0}
@@ -0,0 +1,630 @@
import numpy as np
import random as _random
from random import Random
import colorsys
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import re
import os
import sys
from .ewc_core import IntegralGrid
from .tokenization import process_tokens, unigrams_and_bigrams
_FILE = os.path.dirname(__file__)
_STOPWORDS_PATH = os.path.join(_FILE, "stopwords")
def _load_stopwords():
if os.path.exists(_STOPWORDS_PATH):
with open(_STOPWORDS_PATH, encoding="utf-8") as f:
return set(map(str.strip, f))
# Fallback: minimal English stopwords so the module still works without the file
return {"the", "a", "an", "and", "or", "but", "in", "on", "at", "to",
"for", "of", "with", "is", "are", "was", "were", "be", "been",
"it", "its", "this", "that", "i", "you", "he", "she", "we", "they"}
STOPWORDS = _load_stopwords()
# ---------------------------------------------------------------------------
# Color functions (mirrors ref/word_cloud/wordcloud/wordcloud.py)
# ---------------------------------------------------------------------------
def random_color_func(word=None, font_size=None, position=None,
orientation=None, font_path=None, random_state=None):
"""Random hue color generation (HSL, saturation=80%, lightness=50%)."""
if random_state is None:
random_state = Random()
return "hsl(%d, 80%%, 50%%)" % random_state.randint(0, 255)
class colormap_color_func:
"""Color function backed by a matplotlib colormap."""
def __init__(self, colormap):
import matplotlib.pyplot as plt
self.colormap = plt.get_cmap(colormap)
def __call__(self, word, font_size, position, orientation,
random_state=None, **kwargs):
if random_state is None:
random_state = Random()
r, g, b, _ = np.maximum(0, 255 * np.array(
self.colormap(random_state.uniform(0, 1))))
return "rgb({:.0f}, {:.0f}, {:.0f})".format(r, g, b)
def get_single_color_func(color):
"""Return a color func that varies only the HSV value for a given color.
Accepted values are PIL/Pillow color strings, e.g. 'deepskyblue', '#00b4d2'.
"""
from PIL import ImageColor
old_r, old_g, old_b = ImageColor.getrgb(color)
h, s, v = colorsys.rgb_to_hsv(old_r / 255., old_g / 255., old_b / 255.)
def single_color_func(word=None, font_size=None, position=None,
orientation=None, font_path=None, random_state=None):
if random_state is None:
random_state = Random()
r, g, b = colorsys.hsv_to_rgb(h, s, random_state.uniform(0.2, 1))
return "rgb({:.0f}, {:.0f}, {:.0f})".format(r * 255, g * 255, b * 255)
return single_color_func
import logging
class EfficientWordCloud:
"""
EfficientWordCloud Generation Class.
Uses C++ backend (ewc_core) for high-performance collision detection.
Optimized for high-resolution generation (4k/8k+).
Parameters
----------
width, height : int
Canvas size (ignored when mask is provided).
mask : ndarray or None
Shape mask. White pixels (255) are treated as blocked, others as free.
font_path : str or None
Path to a TrueType font file.
max_words : int
Maximum number of words to place.
min_font_size : int
Smallest font size to use.
max_font_size : int or None
Largest font size. Derived automatically when None.
background_color : color
PIL-compatible background color.
prefer_horizontal : float
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
dimension while layout is still computed at base resolution.
Equivalent to ref's ``scale`` parameter.
contour_width : float
If > 0 and mask is set, draw the mask contour on the output image.
contour_color : color
PIL-compatible color for the mask contour (default 'black').
margin : int
Pixel gap between words.
stopwords : set of str or None
Words to exclude when processing text. Defaults to built-in STOPWORDS.
regexp : str or None
Override the regex used to tokenize text (default ``r"\\w[\\w']+"``).
collocations : bool
Whether to detect bigrams (default True).
collocation_threshold : int
Dunning score threshold for bigrams (default 30).
normalize_plurals : bool
Strip trailing 's' to merge plurals (default True).
include_numbers : bool
Keep numeric tokens when processing text (default False).
min_word_length : int
Minimum character length for a token to be kept (default 0).
color_func : callable or None
``color_func(word, font_size, position, orientation, font_path,
random_state) -> color``. Overrides *colormap*.
colormap : str or matplotlib colormap or None
Matplotlib colormap used when *color_func* is None.
random_state : int, Random, or None
Seed for reproducibility.
repeat : bool
If True, repeat words (with decreasing weight) until *max_words* or
*min_font_size* is reached (default False).
relative_scaling : float (01)
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,
width=400, height=200,
mask=None,
font_path=None,
max_words=200,
min_font_size=4,
max_font_size=None,
background_color="black",
prefer_horizontal=0.9,
mode="RGB",
use_spiral_search=True,
scale=1,
contour_width=0,
contour_color="black",
margin=2,
color_func=None,
colormap=None,
random_state=None,
relative_scaling="auto",
font_step=1,
repeat=False,
stopwords=None,
regexp=None,
collocations=True,
collocation_threshold=30,
normalize_plurals=True,
include_numbers=False,
min_word_length=0):
self.width = width
self.height = height
self.mask = mask
self.font_path = font_path
self.max_words = max_words
self.min_font_size = min_font_size
self.max_font_size = max_font_size
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
self.repeat = repeat
# relative_scaling default mirrors ref: 0 when repeat, else 0.5
if relative_scaling == "auto":
self.relative_scaling = 0 if repeat else 0.5
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
self.collocation_threshold = collocation_threshold
self.normalize_plurals = normalize_plurals
self.include_numbers = include_numbers
self.min_word_length = min_word_length
# Random state
if isinstance(random_state, int):
self.random_state = Random(random_state)
elif random_state is None:
self.random_state = Random()
else:
self.random_state = random_state
# Color function
if color_func is not None:
self.color_func = color_func
elif colormap is not None:
self.color_func = colormap_color_func(colormap)
else:
self.color_func = random_color_func
self.layout_ = []
# Handle mask
if self.mask is not None:
self.width = self.mask.shape[1]
self.height = self.mask.shape[0]
if self.mask.dtype == bool:
self.boolean_mask = self.mask.astype(np.uint8) * 255
elif self.mask.ndim == 3:
# White pixels (all channels == 255) are blocked
self.boolean_mask = np.where(
np.all(self.mask[:, :, :3] == 255, axis=-1), 255, 0
).astype(np.uint8)
else:
self.boolean_mask = self.mask.astype(np.uint8)
else:
self.boolean_mask = np.zeros((self.height, self.width), dtype=np.uint8)
# Initialize C++ grid (>0 = occupied)
self.grid = IntegralGrid(self.boolean_mask, self.height, self.width)
def generate_from_frequencies(self, frequencies, max_font_size=None):
"""Generate word cloud from a dict of {word: frequency}.
Parameters
----------
frequencies : dict
max_font_size : int or None
Override self.max_font_size for this call (used internally for
the automatic font-size estimation).
"""
sorted_freq = sorted(frequencies.items(), key=lambda x: x[1], reverse=True)
if not sorted_freq:
raise ValueError("Need at least 1 word to generate a word cloud.")
sorted_freq = sorted_freq[:self.max_words]
# Normalize so the top word = 1.0
max_freq = float(sorted_freq[0][1])
sorted_freq = [(w, f / max_freq) for w, f in sorted_freq]
# --- repeat: pad list up to max_words with down-weighted copies ---
if self.repeat and len(sorted_freq) < self.max_words:
import math
times_extend = math.ceil(self.max_words / len(sorted_freq)) - 1
base = list(sorted_freq)
downweight = base[-1][1]
for i in range(times_extend):
factor = downweight ** (i + 1)
sorted_freq.extend([(w, f * factor) for w, f in base])
sorted_freq = sorted_freq[:self.max_words]
self.words_ = dict(sorted_freq)
# --- auto max_font_size estimation (mirrors ref) ---
effective_max = max_font_size if max_font_size is not None else self.max_font_size
if effective_max is None:
if len(sorted_freq) == 1:
effective_max = self.height
else:
# Trial run with just the first 2 words to estimate a good max size.
# We must reinitialize the grid after so it is clean for the real run.
_repeat_bak = self.repeat
self.repeat = False
self.generate_from_frequencies(dict(sorted_freq[:2]),
max_font_size=self.height)
self.repeat = _repeat_bak
sizes = [s for _, s, *_ in self.layout_]
try:
effective_max = int(2 * sizes[0] * sizes[1] / (sizes[0] + sizes[1]))
except (IndexError, ZeroDivisionError):
effective_max = sizes[0] if sizes else self.height
# Reinitialize the C++ grid so the trial run does not consume space
self.grid = IntegralGrid(self.boolean_mask, self.height, self.width)
rs = self.random_state
# No PIL image needed during placement — C++ canvas handles collision.
# The PIL image is constructed lazily in to_image().
self.layout_ = []
font_size = int(effective_max)
last_freq = 1.0
# E1: font object cache {size -> ImageFont}
font_cache: dict = {}
def _get_font(size):
if size not in font_cache:
try:
font_cache[size] = ImageFont.truetype(self.font_path, size)
except IOError:
font_cache[size] = ImageFont.load_default()
return font_cache[size]
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.
# Dummy draw for textbbox measurement
_measure_img = Image.new("L", (1, 1))
_measure_draw = ImageDraw.Draw(_measure_img)
for idx, (word, freq) in enumerate(sorted_freq):
if freq == 0:
continue
# Relative-scaling font size adjustment (mirrors ref logic)
rs_val = self.relative_scaling
if rs_val != 0:
font_size = int(round(
(rs_val * (freq / float(last_freq)) + (1 - rs_val)) * font_size
))
if rs.random() < self.prefer_horizontal:
orientation = None
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]
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
y, x = pos
# Adjust position for margin (like ref: x,y += margin // 2)
draw_x = x + self.margin // 2
draw_y = y + self.margin // 2
# Get glyph bitmap and stamp into C++ canvas
font = _get_font(font_size)
transposed = ImageFont.TransposedFont(font, orientation=orientation)
glyph_mask = transposed.getmask(word, mode="L")
gw, gh = glyph_mask.size
glyph_arr = np.frombuffer(bytes(glyph_mask), dtype=np.uint8).reshape(gh, gw)
# Stamp glyph into C++ canvas + rebuild integral
self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x)
color = self.color_func(
word=word,
font_size=font_size,
position=(y, x),
orientation=orientation,
font_path=self.font_path,
random_state=rs,
)
self.layout_.append((word, font_size, (y, x), orientation, color))
last_freq = freq
return self
def generate(self, text):
"""Generate word cloud from raw text (calls process_text + generate_from_frequencies)."""
return self.generate_from_text(text)
def generate_from_text(self, text):
"""Process *text* into word frequencies, then generate the word cloud."""
words = self.process_text(text)
self.generate_from_frequencies(words)
return self
def process_text(self, text):
"""Tokenize *text* and return ``{word: count}`` after filtering.
Applies regexp splitting, stopword removal, number/length filters,
plural normalization, and optional bigram collocation detection.
"""
min_len = self.min_word_length
pattern = r"\w[\w']+" if min_len <= 1 else r"\w[\w']+"
regexp = self.regexp if self.regexp is not None else pattern
words = re.findall(regexp, text)
# Strip possessive 's
words = [w[:-2] if w.lower().endswith("'s") else w for w in words]
if not self.include_numbers:
words = [w for w in words if not w.isdigit()]
if self.min_word_length:
words = [w for w in words if len(w) >= self.min_word_length]
stopwords_lower = {s.lower() for s in self.stopwords}
if self.collocations:
word_counts = unigrams_and_bigrams(
words, stopwords_lower,
normalize_plurals=self.normalize_plurals,
collocation_threshold=self.collocation_threshold,
)
else:
words = [w for w in words if w.lower() not in stopwords_lower]
word_counts, _ = process_tokens(words, self.normalize_plurals)
self.words_ = word_counts
return word_counts
def to_image(self):
"""Render the layout to a PIL Image, respecting *scale* and *contour*."""
s = self.scale
out_w = int(self.width * s)
out_h = int(self.height * s)
img = Image.new(self.mode, (out_w, out_h), self.background_color)
draw = ImageDraw.Draw(img)
for word, size, (y, x), orient, color in self.layout_:
try:
font = ImageFont.truetype(self.font_path, int(size * s))
except Exception:
font = ImageFont.load_default()
transposed_font = ImageFont.TransposedFont(font, orientation=orient)
draw.text((int(x * s), int(y * s)), word, font=transposed_font, fill=color)
return self._draw_contour(img)
def _draw_contour(self, img):
"""Draw mask contour on *img* if contour_width > 0."""
if self.mask is None or self.contour_width == 0:
return img
# Build boolean mask: True where drawing area (not blocked)
if self.mask.ndim == 3:
blocked = np.all(self.mask[:, :, :3] == 255, axis=-1)
else:
blocked = self.mask == 255
mask_uint8 = (~blocked).astype(np.uint8) * 255
contour = Image.fromarray(mask_uint8)
contour = contour.resize(img.size)
contour = contour.filter(ImageFilter.FIND_EDGES)
contour_arr = np.array(contour)
# Zero out border pixels so edges aren't drawn at image boundary
contour_arr[[0, -1], :] = 0
contour_arr[:, [0, -1]] = 0
# Gaussian blur controls perceived width (divide by 10 for sub-pixel)
radius = self.contour_width / 10
contour = Image.fromarray(contour_arr)
contour = contour.filter(ImageFilter.GaussianBlur(radius=radius))
contour_arr = np.array(contour) > 0
contour_3d = np.dstack([contour_arr] * 3)
result = np.array(img.convert("RGB")) * ~contour_3d
if self.contour_color != "black":
color_img = Image.new("RGB", img.size, self.contour_color)
result = result + np.array(color_img) * contour_3d
out = Image.fromarray(result.astype(np.uint8))
if self.mode == "RGBA":
out = out.convert("RGBA")
return out
def to_array(self, copy=None):
"""Return the word cloud as a numpy ndarray (H x W x channels)."""
image = self.to_image()
if copy is None:
return np.asarray(image)
try:
return np.asarray(image, copy=copy)
except TypeError:
return np.asarray(image)
def __array__(self, copy=None):
return self.to_array(copy=copy)
def to_file(self, filename):
"""Save to *filename* and return self (for chaining)."""
img = self.to_image()
img.save(filename, optimize=True)
return self
def recolor(self, random_state=None, color_func=None, colormap=None):
"""Re-apply colors to the current layout without regenerating it.
Parameters
----------
random_state : int, Random, or None
color_func : callable or None
colormap : str or matplotlib colormap or None
"""
if isinstance(random_state, int):
random_state = Random(random_state)
elif random_state is None:
random_state = Random()
if color_func is None:
if colormap is not None:
color_func = colormap_color_func(colormap)
else:
color_func = self.color_func
self.layout_ = [
(word, font_size, position, orientation,
color_func(word=word, font_size=font_size, position=position,
orientation=orientation, font_path=self.font_path,
random_state=random_state))
for word, font_size, position, orientation, _ in self.layout_
]
return self
def to_svg(self, filename=None):
"""Export as SVG with scale, correct rotation transforms and XML escaping.
Parameters
----------
filename : str or None
If given, write to this file. Otherwise return the SVG string.
"""
from xml.sax import saxutils
s = self.scale
out_w = int(self.width * s)
out_h = int(self.height * s)
# Derive font metadata from the actual font file
try:
_font_probe = ImageFont.truetype(self.font_path, 12)
raw_family, raw_style = _font_probe.getname()
except Exception:
raw_family, raw_style = "sans-serif", "Regular"
raw_style_lower = raw_style.lower()
font_weight = "bold" if "bold" in raw_style_lower else "normal"
if "italic" in raw_style_lower:
font_style = "italic"
elif "oblique" in raw_style_lower:
font_style = "oblique"
else:
font_style = "normal"
font_family = repr(raw_family)
lines = [
f'<svg width="{out_w}" height="{out_h}" xmlns="http://www.w3.org/2000/svg">',
f'<style>text{{font-family:{font_family};font-weight:{font_weight};'
f'font-style:{font_style};}}</style>',
]
if self.background_color is not None:
lines.append(
f'<rect width="100%" height="100%" style="fill:{self.background_color}"/>'
)
for word, size, (y, x), orient, color in self.layout_:
scaled_size = int(size * s)
try:
font = ImageFont.truetype(self.font_path, scaled_size)
except Exception:
font = ImageFont.load_default()
(size_x, size_y), (offset_x, offset_y) = font.font.getsize(word)
ascent, _ = font.getmetrics()
min_x = -offset_x
max_x = size_x - offset_x
max_y = ascent - offset_y
sx = int(x * s)
sy = int(y * s)
if orient == Image.ROTATE_90:
tx = sx + max_y
ty = sy + max_x - min_x
transform = f"translate({tx},{ty}) rotate(-90)"
else:
tx = sx + min_x
ty = sy + max_y
transform = f"translate({tx},{ty})"
lines.append(
f'<text transform="{transform}" font-size="{scaled_size}" '
f'style="fill:{color}">{saxutils.escape(word)}</text>'
)
lines.append("</svg>")
svg_str = "\n".join(lines)
if filename is not None:
with open(filename, "w", encoding="utf-8") as f:
f.write(svg_str)
return svg_str
+28
View File
@@ -0,0 +1,28 @@
from setuptools import setup, Extension, find_packages
# Define C++ extension
ewc_core = Extension(
'efficient_wordcloud.ewc_core',
sources=['efficient_wordcloud/src/ewc_core.cpp'],
extra_compile_args=['-O3', '-std=c++17'],
language='c++',
)
setup(
name='efficient_wordcloud',
version='1.0.0',
description='A high-performance Word Cloud generator using Spiral Search and Integral Images.',
author='Antigravity',
packages=find_packages(),
ext_modules=[ewc_core],
install_requires=[
'numpy>=1.19.0',
'pillow>=8.0.0',
'matplotlib>=3.3.0'
],
extras_require={
# Main script (`wordcloud_generate_hybrid.py`) reads Excel via pandas.
'script': ['pandas>=1.3.0'],
},
python_requires='>=3.7',
)