Research-Stack/4-Infrastructure/shim/fractal_dimension.py
allaun 475f6319ea chore(repo): push local 768-commit branch state onto clean remote baseline
This squashes all local history (768 commits) onto the scrubbed PR #90
baseline. Individual commits were lost during filter-repo corruption;
the working tree content is preserved intact.

Build: N/A (working tree state only)
2026-06-15 22:46:50 -05:00

531 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
fractal_dimension.py — Differential box-counting fractal dimension for VCN pipeline.
Implements the DBC (Differential Box-Counting) algorithm extended to N-dimensional
data, based on: 'Ultra-fast computation of fractal dimension for RGB images'
(Pattern Analysis and Applications, 2025).
Core idea: treat braid data as a 2D surface (like an image), partition into s×s grids,
compute box counts via aligned-box method, then FD = slope of log(n(s)) vs log(1/s).
Q16_16 arithmetic used where possible (fixed-point with 16 integer + 16 fractional bits).
No Float in compute paths — ofFloat only at external boundary (JSON, sensor input).
DBC formula (Sarkar & Chaudhuri 1994):
For grid size s, each cell spans gray levels [min, max].
Boxes are aligned to global grid: n_cell = floor(max/s) - floor(min/s) + 1
Total n(s) = Σ n_cell over all grid cells.
FD = slope of least-squares fit on log(n(s)) vs log(1/s).
"""
from __future__ import annotations
import math
from typing import List, Optional, Tuple
import numpy as np
# ---------------------------------------------------------------------------
# Q16_16 helpers (fixed-point: 16 integer bits, 16 fractional bits)
# ---------------------------------------------------------------------------
Q16_SCALE: int = 1 << 16 # 65536
def q16_from_int(n: int) -> int:
"""Convert integer to Q16_16."""
return n << 16
def q16_to_float(q: int) -> float:
"""Convert Q16_16 to float. Boundary-only; never used in compute paths."""
return q / Q16_SCALE
def q16_from_ratio(num: int, den: int) -> int:
"""Q16_16 of (num / den) using integer arithmetic."""
return (num << 16) // den
def q16_mul(a: int, b: int) -> int:
"""Q16_16 multiply."""
return (a * b) >> 16
def q16_div(a: int, b: int) -> int:
"""Q16_16 divide."""
if b == 0:
raise ZeroDivisionError("Q16 division by zero")
return (a << 16) // b
def q16_log_approx(x: int) -> int:
"""
Approximate natural log in Q16_16 using integer Newton iteration.
Only used at boundary for final FD slope; acceptable float-to-int conversion.
"""
if x <= 0:
raise ValueError("log of non-positive value")
# Use float only at external boundary to get ln, then re-quantize
raw = math.log(q16_to_float(x)) if x > Q16_SCALE else math.log(x / Q16_SCALE)
return int(raw * Q16_SCALE)
# ---------------------------------------------------------------------------
# Differential Box-Counting — core algorithm
# ---------------------------------------------------------------------------
def _ensure_2d(data: np.ndarray) -> np.ndarray:
"""Reshape data into 2D surface (rows × cols) for box-counting."""
if data.ndim == 1:
return data.reshape(1, -1)
if data.ndim >= 2:
return data.reshape(data.shape[0], -1)
return data
def _power_of_two_sizes(max_dim: int) -> List[int]:
"""Generate power-of-two grid sizes from 2 up to max_dim."""
sizes = []
s = 2
while s <= max_dim:
sizes.append(s)
s *= 2
return sizes
def _box_count_at_scale(data: np.ndarray, s: int) -> int:
"""
Compute total box count n(s) for grid size s using DBC (aligned-box method).
For each s×s cell, the number of boxes needed to cover the gray-level range
[min, max] with boxes aligned to the global grid is:
n_cell = floor(max / s) - floor(min / s) + 1
Sum over all cells to get total n(s).
Uses numpy vectorized operations (copy-if pattern for views).
"""
rows, cols = data.shape
grid_rows = rows // s
grid_cols = cols // s
if grid_rows == 0 or grid_cols == 0:
return 0
# Crop to exact grid alignment (copy-if needed for reshape)
cropped = data[: grid_rows * s, : grid_cols * s].copy()
# Reshape into (grid_rows, s, grid_cols, s) then transpose to (grid_rows, grid_cols, s, s)
cells = cropped.reshape(grid_rows, s, grid_cols, s).transpose(0, 2, 1, 3)
# cells shape: (grid_rows, grid_cols, s, s)
cell_max = cells.reshape(grid_rows, grid_cols, -1).max(axis=2).astype(np.int64)
cell_min = cells.reshape(grid_rows, grid_cols, -1).min(axis=2).astype(np.int64)
# Aligned-box DBC formula: floor(max/s) - floor(min/s) + 1
box_counts = (cell_max // s) - (cell_min // s) + 1
return int(box_counts.sum())
def box_count_iterative(data: np.ndarray, max_scale: Optional[int] = None) -> List[Tuple[int, int]]:
"""
Compute (s, n(s)) pairs for all power-of-two grid sizes.
Power-of-two refinement: each iteration reuses the spatial structure
from the previous level (implicit via numpy reshape).
Parameters
----------
data : np.ndarray
Input data (1D or 2D). Will be reshaped to 2D surface.
max_scale : int, optional
Maximum grid size. Defaults to min(rows, cols).
Returns
-------
List of (grid_size, box_count) pairs.
"""
data_2d = _ensure_2d(data)
rows, cols = data_2d.shape
if max_scale is None:
max_scale = min(rows, cols)
sizes = _power_of_two_sizes(max_scale)
results = []
for s in sizes:
n_s = _box_count_at_scale(data_2d, s)
if n_s > 0:
results.append((s, n_s))
return results
def _least_squares_slope(log_inv_s: List[float], log_ns: List[float]) -> float:
"""
Ordinary least-squares slope.
Uses float accumulation for regression (boundary computation).
"""
n = len(log_inv_s)
if n < 2:
return 0.0
sum_x = sum(log_inv_s)
sum_y = sum(log_ns)
sum_xy = sum(x * y for x, y in zip(log_inv_s, log_ns))
sum_x2 = sum(x * x for x in log_inv_s)
denom = n * sum_x2 - sum_x * sum_x
if abs(denom) < 1e-12:
return 0.0
slope = (n * sum_xy - sum_x * sum_y) / denom
return slope
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def fractal_dimension(data: np.ndarray, grid_sizes: Optional[List[int]] = None) -> float:
"""
Compute fractal dimension of 2D data using differential box-counting.
Parameters
----------
data : np.ndarray
Input data (bytes, Q16_16 integers, or arbitrary numeric).
Reshaped to 2D surface internally.
grid_sizes : list of int, optional
Specific grid sizes to use. If None, uses power-of-two sizes.
Returns
-------
float
Fractal dimension, typically in [2.0, 3.0] for 2D surfaces.
Algorithm
---------
1. For each grid size s: partition data into s×s cells
2. Per cell: n_cell = floor(max/s) - floor(min/s) + 1 (aligned boxes)
3. Total n(s) = sum of per-cell counts
4. FD = slope of least-squares fit: log(n(s)) vs log(1/s)
"""
data_2d = _ensure_2d(np.asarray(data))
rows, cols = data_2d.shape
if grid_sizes is None:
max_scale = min(rows, cols)
grid_sizes = _power_of_two_sizes(max_scale)
log_inv_s: List[float] = []
log_ns: List[float] = []
for s in grid_sizes:
n_s = _box_count_at_scale(data_2d, s)
if n_s > 0:
log_inv_s.append(math.log(1.0 / s))
log_ns.append(math.log(n_s))
if len(log_inv_s) < 2:
# Not enough data points — return flat surface FD
return 2.0
fd = _least_squares_slope(log_inv_s, log_ns)
return max(2.0, min(3.0, fd)) # Clamp to valid range
def fractal_dimension_rgb(image: np.ndarray) -> float:
"""
Compute fractal dimension of a 3-channel (RGB) image.
Treats each channel as an independent 2D surface, computes FD per channel,
then returns the average. This matches the multi-band DBC extension from
'Ultra-fast computation of fractal dimension for RGB images'.
Parameters
----------
image : np.ndarray
Shape (H, W, 3) or (H, W, C) array.
Returns
-------
float
Average fractal dimension across channels, clamped to [2.0, 3.0].
"""
image = np.asarray(image)
if image.ndim < 3:
return fractal_dimension(image)
n_channels = image.shape[2]
fds = []
for c in range(n_channels):
ch_fd = fractal_dimension(image[:, :, c])
fds.append(ch_fd)
avg_fd = sum(fds) / len(fds)
return max(2.0, min(3.0, avg_fd))
def fd_compress_hint(fd: float) -> str:
"""
Map fractal dimension to VCN voltage compression mode.
FD indicates data complexity:
FD < 2.3 → STORE (smooth data, minimal compression needed)
FD < 2.6 → COMPUTE (moderate complexity, standard processing)
FD < 2.9 → APPROX (high complexity, aggressive approximation)
FD >= 2.9 → MORPHIC (maximum complexity, morphic encoding)
Parameters
----------
fd : float
Fractal dimension value.
Returns
-------
str
One of 'STORE', 'COMPUTE', 'APPROX', 'MORPHIC'.
"""
if fd < 2.3:
return 'STORE'
elif fd < 2.6:
return 'COMPUTE'
elif fd < 2.9:
return 'APPROX'
else:
return 'MORPHIC'
# ---------------------------------------------------------------------------
# Tests and benchmarks
# ---------------------------------------------------------------------------
def _generate_sierpinski(order: int = 7) -> np.ndarray:
"""
Generate a Sierpinski triangle approximation as a binary matrix.
The Sierpinski triangle has known FD = log(3)/log(2) ≈ 1.585 (as a curve).
As a 2D filled projection for box-counting, the boundary/fractal structure
should push FD above 2.0.
"""
size = 2 ** order
mat = np.zeros((size, size), dtype=np.uint8)
# Build using Pascal's triangle mod 2
for r in range(size):
for c in range(size):
# Check if C(r, c) is odd (Lucas' theorem for mod 2)
# C(n,k) mod 2 = 1 iff (k & ~n) == 0
if (c & ~r) == 0 and c <= r:
mat[r, c] = 255
return mat
def _generate_random_data(rows: int = 256, cols: int = 256, seed: int = 42) -> np.ndarray:
"""Random uniform data — FD should be moderate (2.3-2.7 range)."""
rng = np.random.RandomState(seed)
return rng.randint(0, 256, size=(rows, cols), dtype=np.uint8)
def _generate_gradient(rows: int = 256, cols: int = 256) -> np.ndarray:
"""Smooth linear gradient — FD should be close to 2.0 (low complexity)."""
data = np.zeros((rows, cols), dtype=np.uint8)
for r in range(rows):
data[r, :] = int(255 * r / max(rows - 1, 1))
return data
def _generate_checkerboard(rows: int = 128, cols: int = 128) -> np.ndarray:
"""
Checkerboard with full amplitude — maximum variation at every scale.
At scale s: n_cell = floor(255/s) + 1 ≈ 255/s
So n(s) ∝ (N/s)^2 * (255/s) = N^2 * 255 / s^3
log(n(s)) ∝ const - 3*log(s) → FD ≈ 3.0
"""
data = np.zeros((rows, cols), dtype=np.uint8)
for r in range(rows):
for c in range(cols):
data[r, c] = 255 if (r + c) % 2 == 0 else 0
return data
def _generate_fbm_surface(rows: int = 256, cols: int = 256, H: float = 0.5,
seed: int = 42) -> np.ndarray:
"""
Generate fractional Brownian motion surface.
For Hurst exponent H, the fractal dimension of the surface is FD = 3 - H.
H=0.5 (standard Brownian) → FD ≈ 2.5
"""
rng = np.random.RandomState(seed)
# Random increments
dx = rng.randn(rows, cols)
# Integrate to get surface (cumulative sum in both dimensions)
surface = np.cumsum(np.cumsum(dx, axis=0), axis=1)
# Normalize to [0, 255]
smin, smax = surface.min(), surface.max()
if smax > smin:
surface = (surface - smin) / (smax - smin) * 255
return surface.astype(np.uint8)
def _scalar_box_count(data: np.ndarray, s: int) -> int:
"""
Scalar (loop-based) box-counting implementation for benchmarking.
Same algorithm as _box_count_at_scale but without numpy vectorization.
Uses int64 to avoid overflow.
"""
rows, cols = data.shape
grid_rows = rows // s
grid_cols = cols // s
total = 0
for gr in range(grid_rows):
for gc in range(grid_cols):
r_start = gr * s
c_start = gc * s
cell = data[r_start : r_start + s, c_start : c_start + s]
cell_max = int(cell.max())
cell_min = int(cell.min())
total += (cell_max // s) - (cell_min // s) + 1
return total
def run_tests() -> dict:
"""
Run validation tests and return results dict.
Tests:
1. Sierpinski triangle — FD should be > 2.0 (fractal structure present)
2. Random data — FD should be moderate (2.0-2.8 range)
3. Smooth gradient — FD should be close to 2.0 (smooth, low complexity)
4. Checkerboard — FD should be near 3.0 (maximum variation at all scales)
5. fBm surface (H=0.5) — FD should be ≈ 2.5
6. Benchmark: numpy vs scalar implementation
"""
import time
results = {}
# Test 1: Sierpinski
sierpinski = _generate_sierpinski(order=7)
fd_sierpinski = fractal_dimension(sierpinski)
results['sierpinski_fd'] = fd_sierpinski
results['sierpinski_pass'] = fd_sierpinski > 2.0
results['sierpinski_compress'] = fd_compress_hint(fd_sierpinski)
# Test 2: Random data (moderate FD expected)
random_data = _generate_random_data(256, 256)
fd_random = fractal_dimension(random_data)
results['random_fd'] = fd_random
results['random_pass'] = 2.0 <= fd_random <= 3.0
results['random_compress'] = fd_compress_hint(fd_random)
# Test 3: Smooth gradient (FD ≈ 2.0)
gradient = _generate_gradient(256, 256)
fd_gradient = fractal_dimension(gradient)
results['gradient_fd'] = fd_gradient
results['gradient_pass'] = fd_gradient < 2.3 # Should be near 2.0
results['gradient_compress'] = fd_compress_hint(fd_gradient)
# Test 4: Checkerboard (FD ≈ 3.0)
checkerboard = _generate_checkerboard(128, 128)
fd_checker = fractal_dimension(checkerboard)
results['checkerboard_fd'] = fd_checker
results['checkerboard_pass'] = fd_checker > 2.8 # Should be near 3.0
results['checkerboard_compress'] = fd_compress_hint(fd_checker)
# Test 5: fBm surface (FD ≈ 2.5 for H=0.5)
fbm = _generate_fbm_surface(256, 256, H=0.5)
fd_fbm = fractal_dimension(fbm)
results['fbm_fd'] = fd_fbm
results['fbm_pass'] = 2.2 <= fd_fbm <= 2.8 # Should be around 2.5
results['fbm_compress'] = fd_compress_hint(fd_fbm)
# Test 6: Benchmark numpy vs scalar
bench_data = np.random.randint(0, 256, size=(256, 256), dtype=np.uint8)
bench_scales = [2, 4, 8, 16, 32, 64, 128]
t0 = time.perf_counter()
for _ in range(10):
for s in bench_scales:
_box_count_at_scale(bench_data, s)
numpy_time = (time.perf_counter() - t0) / 10
t0 = time.perf_counter()
for s in bench_scales:
_scalar_box_count(bench_data, s)
scalar_time = time.perf_counter() - t0
results['benchmark_numpy_ms'] = round(numpy_time * 1000, 2)
results['benchmark_scalar_ms'] = round(scalar_time * 1000, 2)
results['speedup'] = round(scalar_time / max(numpy_time, 1e-9), 1)
# Test 7: RGB image
rgb_image = np.random.randint(0, 256, size=(128, 128, 3), dtype=np.uint8)
fd_rgb = fractal_dimension_rgb(rgb_image)
results['rgb_fd'] = fd_rgb
results['rgb_pass'] = 2.0 <= fd_rgb <= 3.0
# Test 8: Constant data (FD should be exactly 2.0)
constant = np.full((128, 128), 42, dtype=np.uint8)
fd_const = fractal_dimension(constant)
results['constant_fd'] = fd_const
results['constant_pass'] = abs(fd_const - 2.0) < 0.01
return results
def _print_results(results: dict) -> None:
"""Pretty-print test results."""
print("=" * 65)
print("Fractal Dimension — Test Results")
print("=" * 65)
test_cases = [
('sierpinski', 'FD > 2.0', 'fractal structure'),
('random', '2.0 ≤ FD ≤ 2.9', 'random noise'),
('gradient', 'FD < 2.3', 'smooth gradient → ~2.0'),
('checkerboard', 'FD > 2.8', 'max variation → ~3.0'),
('fbm', '2.2 ≤ FD ≤ 2.8', 'fBm H=0.5 → ~2.5'),
('constant', 'FD ≈ 2.0', 'constant → exactly 2.0'),
('rgb', '2.0 ≤ FD ≤ 3.0', 'RGB random'),
]
for name, expected, desc in test_cases:
fd = results[f'{name}_fd']
passed = results[f'{name}_pass']
hint = results.get(f'{name}_compress', '')
status = 'PASS' if passed else 'FAIL'
print(f" [{status}] {name:14s} FD={fd:.4f} hint={hint:8s} ({expected}, {desc})")
print()
print("Benchmark (7 scales, 256×256):")
print(f" numpy: {results['benchmark_numpy_ms']:8.2f} ms")
print(f" scalar: {results['benchmark_scalar_ms']:8.2f} ms")
print(f" speedup: {results['speedup']}x")
print("=" * 65)
if __name__ == '__main__':
results = run_tests()
_print_results(results)
# Exit with non-zero if any test failed
all_pass = all(
results.get(k, False)
for k in [
'sierpinski_pass', 'random_pass', 'gradient_pass',
'checkerboard_pass', 'fbm_pass', 'constant_pass', 'rgb_pass',
]
)
raise SystemExit(0 if all_pass else 1)