tranzoom 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tranzoom/__init__.py +7 -0
- tranzoom/cli/__init__.py +3 -0
- tranzoom/cli/base.py +153 -0
- tranzoom/cli/gencommand.py +102 -0
- tranzoom/core/__init__.py +3 -0
- tranzoom/core/fractal.py +337 -0
- tranzoom/core/frame.py +253 -0
- tranzoom/core/image.py +316 -0
- tranzoom/core/palette.py +137 -0
- tranzoom/mandel.py +120 -0
- tranzoom/py.typed +0 -0
- tranzoom/utils/__init__.py +3 -0
- tranzoom/utils/template.py +45 -0
- tranzoom/zoom.py +109 -0
- tranzoom-1.0.0.dist-info/METADATA +797 -0
- tranzoom-1.0.0.dist-info/RECORD +19 -0
- tranzoom-1.0.0.dist-info/WHEEL +4 -0
- tranzoom-1.0.0.dist-info/entry_points.txt +4 -0
- tranzoom-1.0.0.dist-info/licenses/LICENSE +201 -0
tranzoom/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright 2026 <balparda@github.com> & <BellaKeri@github.com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""TranZoom: Fractal manipulation with LLMs."""
|
|
4
|
+
|
|
5
|
+
__all__: list[str] = ['__author__', '__version__']
|
|
6
|
+
__version__ = '1.0.0' # also update pyproject.toml
|
|
7
|
+
__author__ = 'Daniel Balparda <balparda@github.com>, Bella Keri <BellaKeri@github.com>'
|
tranzoom/cli/__init__.py
ADDED
tranzoom/cli/base.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright 2026 <balparda@github.com> & <BellaKeri@github.com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""CLI: Base."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import dataclasses
|
|
8
|
+
import pathlib
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from transcrypto.cli import clibase
|
|
12
|
+
|
|
13
|
+
from tranzoom.core import fractal, frame, palette
|
|
14
|
+
|
|
15
|
+
# global CLI data, and some test stuff
|
|
16
|
+
|
|
17
|
+
# if `tests/data/images/demo-mandel-seahorse-tail.png` changes you have to update this hash!
|
|
18
|
+
SEAHORSE_TAIL_HASH: str = '38824cdaa58b64496ebfd86facf4d4ba4596ab18db95ac97afd643a7a892ff83'
|
|
19
|
+
# this is tested from `tests/cli/base_test.py` & `tests_integration/test_installed_cli.py`!
|
|
20
|
+
|
|
21
|
+
# CLI options that can be re-used
|
|
22
|
+
|
|
23
|
+
DEFAULT_IMAGE_PREFIX: str = 'mandel'
|
|
24
|
+
|
|
25
|
+
# Image: output image
|
|
26
|
+
IMAGE_WIDTH_OPTION: typer.models.OptionInfo = typer.Option(
|
|
27
|
+
frame.DEFAULT_IMAGE_SIZE,
|
|
28
|
+
'-w',
|
|
29
|
+
'--width',
|
|
30
|
+
min=frame.MIN_IMAGE_SIZE,
|
|
31
|
+
max=frame.MAX_IMAGE_SIZE,
|
|
32
|
+
help=(
|
|
33
|
+
f'Width of the image; {frame.MIN_IMAGE_SIZE} ≤ w ≤ {frame.MAX_IMAGE_SIZE}; '
|
|
34
|
+
f'default is {frame.DEFAULT_IMAGE_SIZE}'
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
IMAGE_HEIGHT_OPTION: typer.models.OptionInfo = typer.Option(
|
|
38
|
+
frame.DEFAULT_IMAGE_SIZE,
|
|
39
|
+
'-h',
|
|
40
|
+
'--height',
|
|
41
|
+
min=frame.MIN_IMAGE_SIZE,
|
|
42
|
+
max=frame.MAX_IMAGE_SIZE,
|
|
43
|
+
help=(
|
|
44
|
+
f'Height of the image; {frame.MIN_IMAGE_SIZE} ≤ h ≤ {frame.MAX_IMAGE_SIZE}; '
|
|
45
|
+
f'default is {frame.DEFAULT_IMAGE_SIZE}'
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
IMAGE_PATH_OUTPUT_OPTION: typer.models.OptionInfo = typer.Option(
|
|
49
|
+
None,
|
|
50
|
+
'-o',
|
|
51
|
+
'--out',
|
|
52
|
+
exists=True,
|
|
53
|
+
file_okay=False,
|
|
54
|
+
dir_okay=True,
|
|
55
|
+
readable=True,
|
|
56
|
+
writable=True,
|
|
57
|
+
help=(
|
|
58
|
+
'The local output root directory path, ex: "~/foo/bar/"; '
|
|
59
|
+
'if not given, the image will be saved in the current working directory'
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
IMAGE_PREFIX_OPTION: typer.models.OptionInfo = typer.Option(
|
|
63
|
+
DEFAULT_IMAGE_PREFIX,
|
|
64
|
+
'--prefix',
|
|
65
|
+
help=(
|
|
66
|
+
f'Image save prefix; default: {DEFAULT_IMAGE_PREFIX!r} '
|
|
67
|
+
'(the final file name will be "<prefix>[-<date>][-<hash20>].png", note the date and the hash '
|
|
68
|
+
'can be turned off with --no-date and --no-hash, respectively)'
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
IMAGE_INCLUDE_DATE_OPTION: typer.models.OptionInfo = typer.Option(
|
|
72
|
+
True,
|
|
73
|
+
'--date/--no-date',
|
|
74
|
+
help=(
|
|
75
|
+
'If True, file names will include the date-time as YYYYMMDDhhmmss; '
|
|
76
|
+
'if False, file names will not include the date-time; default is True'
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
IMAGE_INCLUDE_HASH_OPTION: typer.models.OptionInfo = typer.Option(
|
|
80
|
+
True,
|
|
81
|
+
'--hash/--no-hash',
|
|
82
|
+
help=(
|
|
83
|
+
'If True, file names will include the hash; '
|
|
84
|
+
'if False, file names will not include the hash; default is True'
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Frame: the default frame is the one that shows the whole Mandelbrot set, which is centered at
|
|
89
|
+
# -0.75+0j and has width 2.5; the height is the same as the width by default;
|
|
90
|
+
# The set <https://en.wikipedia.org/wiki/Mandelbrot_set> is contained in the rectangle with corners
|
|
91
|
+
# -2.5-1.25j and 0.5+1.25j, which is exactly our default here
|
|
92
|
+
FRAME_CENTER_RE_OPTION: typer.models.ArgumentInfo = typer.Argument(
|
|
93
|
+
frame.DEFAULT_FRAME_CENTER_RE,
|
|
94
|
+
help=f'Real part of the center point; default is {frame.DEFAULT_FRAME_CENTER_RE!r}',
|
|
95
|
+
)
|
|
96
|
+
FRAME_CENTER_IM_OPTION: typer.models.ArgumentInfo = typer.Argument(
|
|
97
|
+
frame.DEFAULT_FRAME_CENTER_IM,
|
|
98
|
+
help=f'Imaginary part of the center point; default is {frame.DEFAULT_FRAME_CENTER_IM!r}',
|
|
99
|
+
)
|
|
100
|
+
FRAME_WIDTH_OPTION: typer.models.ArgumentInfo = typer.Argument(
|
|
101
|
+
frame.DEFAULT_FRAME_SIZE,
|
|
102
|
+
help=f'Width of the frame in the real plane; default is {frame.DEFAULT_FRAME_SIZE!r}',
|
|
103
|
+
)
|
|
104
|
+
FRAME_HEIGHT_OPTION: typer.models.ArgumentInfo = typer.Argument(
|
|
105
|
+
None, help='Height of the frame in the imaginary plane; default is None, i.e, the same as width'
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Computation Options
|
|
109
|
+
MAX_ITERATIONS_OPTION: typer.models.OptionInfo = typer.Option(
|
|
110
|
+
None,
|
|
111
|
+
'-i',
|
|
112
|
+
'--iter',
|
|
113
|
+
min=fractal.MIN_ITER,
|
|
114
|
+
max=fractal.MAX_ITER,
|
|
115
|
+
help=(
|
|
116
|
+
'Maximum iterations (depth) to compute before determining escape; '
|
|
117
|
+
f'{fractal.MIN_ITER} ≤ iter ≤ {fractal.MAX_ITER}; '
|
|
118
|
+
f'default is None (automatic search for optimal iterations --- recommended)'
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
MAX_THREADS_OPTION: typer.models.OptionInfo = typer.Option(
|
|
122
|
+
None,
|
|
123
|
+
'--threads',
|
|
124
|
+
min=1,
|
|
125
|
+
max=fractal.MAX_CONCURRENCE,
|
|
126
|
+
help=(
|
|
127
|
+
'Number of threads to use for rendering; default is None, which means to use all available '
|
|
128
|
+
f'CPU cores; will be limited to {fractal.MAX_CONCURRENCE} threads'
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Color options
|
|
133
|
+
PALETTE_OPTION: typer.models.OptionInfo = typer.Option(
|
|
134
|
+
palette.DEFAULT_PALETTE,
|
|
135
|
+
'--palette',
|
|
136
|
+
help=(
|
|
137
|
+
f'Color palette to use for rendering; default is {palette.DEFAULT_PALETTE.value!r}; '
|
|
138
|
+
f'available palettes: {sorted(p.value for p in palette.PALETTES)}'
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
|
|
144
|
+
class TranZoomConfig(clibase.CLIConfig):
|
|
145
|
+
"""TranZoom global context, storing the configuration."""
|
|
146
|
+
|
|
147
|
+
img_width: int
|
|
148
|
+
img_height: int
|
|
149
|
+
img_output_path: pathlib.Path | None
|
|
150
|
+
img_use_date: bool
|
|
151
|
+
img_use_hash: bool
|
|
152
|
+
img_path_prefix: str
|
|
153
|
+
max_threads: int | None
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright 2026 <balparda@github.com> & <BellaKeri@github.com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""CLI: Mandelbrot generation command.
|
|
4
|
+
|
|
5
|
+
<https://en.wikipedia.org/wiki/Mandelbrot_set>
|
|
6
|
+
|
|
7
|
+
README.md has good examples for different zoom levels.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import pathlib
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
from transcrypto.cli import clibase
|
|
17
|
+
from transcrypto.utils import human, timer
|
|
18
|
+
|
|
19
|
+
from tranzoom import mandel
|
|
20
|
+
from tranzoom.cli import base
|
|
21
|
+
from tranzoom.core import fractal, frame, image, palette
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mandel.app.command(
|
|
25
|
+
'gen',
|
|
26
|
+
help='Generate a Mandelbrot image.',
|
|
27
|
+
epilog=(
|
|
28
|
+
'Examples:\n\n\n\n'
|
|
29
|
+
'$ poetry run mandel gen\n\n'
|
|
30
|
+
'1024x1024 Mandelbrot in frame [(-3/4, 0) @ 5/2] ...\n\n'
|
|
31
|
+
'...\n\n'
|
|
32
|
+
'Saved to "mandel-<date>-<hash>.png"\n\n\n\n'
|
|
33
|
+
'$ poetry run mandel -w 512 -h 512 gen " -0.74303" "0.126433" "0.01611" '
|
|
34
|
+
'# note the space because of the "-"\n\n'
|
|
35
|
+
'<saves Mandelbrot to disk with center --0.74303+0.126433j and width 0.01611>'
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
@clibase.CLIErrorGuard
|
|
39
|
+
def Gen( # documentation is help/epilog/args # noqa: D103
|
|
40
|
+
*,
|
|
41
|
+
ctx: click.Context,
|
|
42
|
+
center_re: str = base.FRAME_CENTER_RE_OPTION, # type: ignore[assignment]
|
|
43
|
+
center_im: str = base.FRAME_CENTER_IM_OPTION, # type: ignore[assignment]
|
|
44
|
+
f_width: str = base.FRAME_WIDTH_OPTION, # type: ignore[assignment]
|
|
45
|
+
f_height: str | None = base.FRAME_HEIGHT_OPTION, # type: ignore[assignment]
|
|
46
|
+
max_iter: int | None = base.MAX_ITERATIONS_OPTION, # type: ignore[assignment]
|
|
47
|
+
pal: palette.Palette = base.PALETTE_OPTION, # type: ignore[assignment]
|
|
48
|
+
) -> None:
|
|
49
|
+
# check sanity
|
|
50
|
+
config: base.TranZoomConfig = ctx.obj
|
|
51
|
+
try:
|
|
52
|
+
frm: frame.Frame = frame.Frame.FromCenter(center_re, center_im, f_width, f_height)
|
|
53
|
+
except Exception as err:
|
|
54
|
+
raise click.UsageError(
|
|
55
|
+
f'Invalid coordinates: {center_re=}, {center_im=}, {f_width=}, {f_height=}'
|
|
56
|
+
) from err
|
|
57
|
+
magnification, magnitude = frm.magnification
|
|
58
|
+
magnification_str: str = (
|
|
59
|
+
# beyond 10^21, human-readable formatting becomes ridiculous, so we use scientific notation
|
|
60
|
+
human.HumanizedDecimal(float(magnification)) if magnitude < 21 else f'{magnification:e}' # noqa: PLR2004
|
|
61
|
+
)
|
|
62
|
+
config.console.print(
|
|
63
|
+
f'\n{config.img_width}x{config.img_height} Mandelbrot in frame {frm}, '
|
|
64
|
+
f'precision {frm.precision} bits, {magnification_str} magnification, '
|
|
65
|
+
f'{"AUTO" if max_iter is None else max_iter} iterations...\n'
|
|
66
|
+
)
|
|
67
|
+
# render the image
|
|
68
|
+
with timer.Timer(emit_log=False) as tmr:
|
|
69
|
+
img: image.Image = fractal.Mandelbrot(
|
|
70
|
+
frm, config.img_width, config.img_height, max_iter=max_iter, n_processes=config.max_threads
|
|
71
|
+
)
|
|
72
|
+
raw_png, raw_hash = img.AsPNG(pal=pal)
|
|
73
|
+
config.console.print(f'\nGenerated image {raw_hash!r} in {tmr}, escape range {img.escape_range}')
|
|
74
|
+
# check we can recover the hash from the PNG: should never fail unless we have a bug
|
|
75
|
+
w, h, png_hash, _ = image.GetBasicDataFromPNG(raw_png)
|
|
76
|
+
if png_hash != raw_hash or w != config.img_width or h != config.img_height:
|
|
77
|
+
raise click.ClickException(
|
|
78
|
+
f'Mismatch: expected {config.img_width}x{config.img_height}/{raw_hash!r} but '
|
|
79
|
+
f'got {w}x{h}/{png_hash!r} from PNG; this should never happen, please report this as a bug'
|
|
80
|
+
)
|
|
81
|
+
# save the image to a file named by its time/hash
|
|
82
|
+
tm_str: str = time.strftime('%Y%m%d%H%M%S', time.gmtime(timer.Now()))
|
|
83
|
+
# validate that img_path_prefix is a basename (no path separators) to prevent directory traversal
|
|
84
|
+
filename: str = config.img_path_prefix
|
|
85
|
+
if pathlib.Path(filename).name != config.img_path_prefix:
|
|
86
|
+
raise click.UsageError(
|
|
87
|
+
f'Invalid prefix: {config.img_path_prefix!r} has path separators (ex: "/" or "\\")'
|
|
88
|
+
)
|
|
89
|
+
# add date and hash to the file name if requested
|
|
90
|
+
if config.img_use_date:
|
|
91
|
+
filename += f'-{tm_str}'
|
|
92
|
+
if config.img_use_hash:
|
|
93
|
+
# use 20 chars of the hash to avoid very long file names; 20 chars = 10 bytes = 80 bits;
|
|
94
|
+
# collision is 1 in 2**40 ~ 1 in 1 trillion, which is good enough for our use case
|
|
95
|
+
filename += f'-{raw_hash[:20]}'
|
|
96
|
+
# add .png extension, make full path, and save the file
|
|
97
|
+
filename += '.png'
|
|
98
|
+
full_path: pathlib.Path = (
|
|
99
|
+
pathlib.Path(filename) if config.img_output_path is None else config.img_output_path / filename
|
|
100
|
+
)
|
|
101
|
+
full_path.write_bytes(raw_png)
|
|
102
|
+
config.console.print(f'Saved to "{full_path}"\n')
|
tranzoom/core/fractal.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright 2026 <balparda@github.com> & <BellaKeri@github.com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Fractal computing.
|
|
4
|
+
|
|
5
|
+
Heavy use of gmpy2 for arbitrary precision, which is needed to render deep zooms correctly; see
|
|
6
|
+
<https://gmpy2.readthedocs.io/en/latest/>
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from concurrent import futures
|
|
15
|
+
from typing import NoReturn
|
|
16
|
+
|
|
17
|
+
import gmpy2
|
|
18
|
+
import tqdm
|
|
19
|
+
|
|
20
|
+
from tranzoom.core import frame, image
|
|
21
|
+
|
|
22
|
+
# iteration constants
|
|
23
|
+
|
|
24
|
+
MIN_ITER: int = 1000
|
|
25
|
+
DEFAULT_ITER: int = 1000
|
|
26
|
+
HIGH_ITERS: list[int] = [100_000, 1_000_000, 10_000_000] # these are very high iteration counts
|
|
27
|
+
MAX_ITER: int = 2 ** (image.N_BYTES_UINT * 8) - 1 # 4_294_967_295, max value for array('I'), uint32
|
|
28
|
+
|
|
29
|
+
# automated search for iter
|
|
30
|
+
|
|
31
|
+
_ITER_SAFETY_FACTOR: float = 1.5 # we multiply the estimated iter by this to be safe
|
|
32
|
+
|
|
33
|
+
# multiprocessing
|
|
34
|
+
|
|
35
|
+
AVAILABLE_CPU: int = int(getattr(os, 'process_cpu_count', os.cpu_count)() or 1)
|
|
36
|
+
_MAX_PRE_PROCESS_CONCURRENCE: int = 4 # for the preprocess step, we limit the concurrency
|
|
37
|
+
MAX_CONCURRENCE: int = 16 # for the main rendering step, we limit the concurrency
|
|
38
|
+
|
|
39
|
+
# gmpy2.mpfr constants
|
|
40
|
+
_MPFR_ZERO = gmpy2.mpfr('0')
|
|
41
|
+
_MPFR_SIXTEENTH = gmpy2.mpfr('0.0625')
|
|
42
|
+
_MPFR_FOURTH = gmpy2.mpfr('0.25')
|
|
43
|
+
_MPFR_ONE = gmpy2.mpfr('1')
|
|
44
|
+
_MPFR_TWO = gmpy2.mpfr('2')
|
|
45
|
+
_MPFR_FOUR = gmpy2.mpfr('4')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Error(image.Error):
|
|
49
|
+
"""Base fractal exception."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def Mandelbrot(
|
|
53
|
+
frm: frame.Frame,
|
|
54
|
+
width: int,
|
|
55
|
+
height: int,
|
|
56
|
+
*,
|
|
57
|
+
max_iter: int | None = None,
|
|
58
|
+
progress_bar: bool = True,
|
|
59
|
+
n_processes: int | None = None,
|
|
60
|
+
) -> image.Image:
|
|
61
|
+
"""Render the frame rectangle to an Image.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
frm (Frame): The frame to render.
|
|
65
|
+
width (int): The width of the output image in pixels.
|
|
66
|
+
height (int): The height of the output image in pixels.
|
|
67
|
+
max_iter (int | None, optional): The maximum number of iterations to determine escape.
|
|
68
|
+
Defaults to None, and that means "auto".
|
|
69
|
+
progress_bar (bool, optional): Whether to show a progress bar. Defaults to True.
|
|
70
|
+
n_processes (int | None, optional): The number of processes to use for rendering. Defaults
|
|
71
|
+
to None, which means to use all available CPU cores. Will be limited to MAX_CONCURRENCE.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
image.Image: The rendered fractal image.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
Error: on error
|
|
78
|
+
|
|
79
|
+
"""
|
|
80
|
+
if n_processes is not None and n_processes < 1:
|
|
81
|
+
raise Error(f'{n_processes=} must be a positive integer or None')
|
|
82
|
+
# if max_iter is None, we do an adaptive iteration limit calculation based on a small test render
|
|
83
|
+
# BEWARE: the method call will call Mandelbrot() recursively, but with a fixed max_iter!
|
|
84
|
+
max_iter = _MandelbrotAdaptiveIterations(frm, progress_bar) if max_iter is None else max_iter
|
|
85
|
+
# determine processes
|
|
86
|
+
is_preprocess: bool = width == frame.MIN_IMAGE_SIZE and height == frame.MIN_IMAGE_SIZE
|
|
87
|
+
n_processes = n_processes or AVAILABLE_CPU
|
|
88
|
+
n_processes = min(n_processes, _MAX_PRE_PROCESS_CONCURRENCE) if is_preprocess else n_processes
|
|
89
|
+
n_processes = min(n_processes, MAX_CONCURRENCE, AVAILABLE_CPU) # never exceed CPU!
|
|
90
|
+
logging.debug(
|
|
91
|
+
f'Mandelbrot using {n_processes} process(es) for {"PRE " if is_preprocess else ""}rendering'
|
|
92
|
+
)
|
|
93
|
+
# create inputs
|
|
94
|
+
inp: list[MandelbrotTaskInput] = [
|
|
95
|
+
MandelbrotTaskInput(
|
|
96
|
+
frm=frm,
|
|
97
|
+
width=width,
|
|
98
|
+
height=height,
|
|
99
|
+
max_iter=max_iter,
|
|
100
|
+
progress_bar=progress_bar,
|
|
101
|
+
n_task=i + 1,
|
|
102
|
+
total_tasks=n_processes,
|
|
103
|
+
)
|
|
104
|
+
for i in range(n_processes)
|
|
105
|
+
]
|
|
106
|
+
# execute in processes
|
|
107
|
+
results: list[MandelbrotTaskOutput]
|
|
108
|
+
if n_processes == 1:
|
|
109
|
+
# no multiprocessing, just run the single task directly in this process (also good for debug)
|
|
110
|
+
results = [_MandelbrotComputation(inp[0])]
|
|
111
|
+
else:
|
|
112
|
+
# multiprocessing: run the tasks in separate processes and collect results
|
|
113
|
+
with futures.ProcessPoolExecutor(max_workers=n_processes) as executor:
|
|
114
|
+
results = list(executor.map(_MandelbrotComputation, inp))
|
|
115
|
+
# at this point all tasks are finished: check we have them all!
|
|
116
|
+
if len(results) != n_processes:
|
|
117
|
+
raise Error(f'Expected {n_processes} results from Mandelbrot computations, got {len(results)}')
|
|
118
|
+
img: image.Image = results[0].img # start with the first image to save time and space
|
|
119
|
+
if n_processes > 1:
|
|
120
|
+
# combine results into a single image; possible b/c each task wrote to a disjoint set of pixels
|
|
121
|
+
for result in results[1:]:
|
|
122
|
+
# copy only this task's interleaved pixels into the final image
|
|
123
|
+
n_task: int = result.n_task - 1 # convert to 0-based index for stepped slice indexing
|
|
124
|
+
img.escape[n_task::n_processes] = result.img.escape[n_task::n_processes]
|
|
125
|
+
# all copied, so we can return the final image
|
|
126
|
+
return img
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
|
|
130
|
+
class MandelbrotTaskInput:
|
|
131
|
+
"""Defines a Mandelbrot task."""
|
|
132
|
+
|
|
133
|
+
frm: frame.Frame
|
|
134
|
+
width: int
|
|
135
|
+
height: int
|
|
136
|
+
max_iter: int
|
|
137
|
+
progress_bar: bool
|
|
138
|
+
n_task: int
|
|
139
|
+
total_tasks: int
|
|
140
|
+
|
|
141
|
+
def __post_init__(self) -> None:
|
|
142
|
+
"""Validate parameters.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
Error: on error
|
|
146
|
+
|
|
147
|
+
"""
|
|
148
|
+
# check size
|
|
149
|
+
if not (frame.MIN_IMAGE_SIZE <= self.width <= frame.MAX_IMAGE_SIZE) or not (
|
|
150
|
+
frame.MIN_IMAGE_SIZE <= self.height <= frame.MAX_IMAGE_SIZE
|
|
151
|
+
):
|
|
152
|
+
raise Error(
|
|
153
|
+
f'{self.width=} and {self.height=} must be between '
|
|
154
|
+
f'{frame.MIN_IMAGE_SIZE} and {frame.MAX_IMAGE_SIZE}'
|
|
155
|
+
)
|
|
156
|
+
# sanity check iter_limit: if error, it came from the user (b/c adaptive clamps to the limits)
|
|
157
|
+
if not (MIN_ITER <= self.max_iter <= MAX_ITER):
|
|
158
|
+
raise Error(f'{self.max_iter=} must be between {MIN_ITER} and {MAX_ITER}')
|
|
159
|
+
# check task numbers
|
|
160
|
+
if not (1 <= self.n_task <= self.total_tasks):
|
|
161
|
+
raise Error(f'{self.n_task=} must be between 1 and {self.total_tasks}')
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
|
|
165
|
+
class MandelbrotTaskOutput:
|
|
166
|
+
"""Defines a Mandelbrot task output."""
|
|
167
|
+
|
|
168
|
+
img: image.Image
|
|
169
|
+
n_task: int
|
|
170
|
+
total_tasks: int
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _MandelbrotComputation(inp: MandelbrotTaskInput) -> MandelbrotTaskOutput: # noqa: PLR0914
|
|
174
|
+
"""Compute the Mandelbrot image for the given task input. ONE THREAD FOR MULTIPROCESSING.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
inp (MandelbrotTaskInput): The task input containing all parameters for the computation.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
MandelbrotTaskOutput: The rendered fractal image and task information.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
Error: on error
|
|
184
|
+
|
|
185
|
+
"""
|
|
186
|
+
is_preprocess: bool = inp.width == frame.MIN_IMAGE_SIZE and inp.height == frame.MIN_IMAGE_SIZE
|
|
187
|
+
# create image; will also check the parameters and frame validity in the Image constructor
|
|
188
|
+
img: image.Image = image.Image(inp.frm, inp.width, inp.height)
|
|
189
|
+
# compute pixel size in complex plane and check frame validity; exact computation (gmpy2.mpq)
|
|
190
|
+
dx: gmpy2.mpq
|
|
191
|
+
dy: gmpy2.mpq
|
|
192
|
+
dx, dy = inp.frm.size
|
|
193
|
+
dx, dy = dx / gmpy2.mpq(inp.width - 1), dy / gmpy2.mpq(inp.height - 1)
|
|
194
|
+
if dx <= 0 or dy <= 0:
|
|
195
|
+
raise Error(f'frame must have positive area, got {dx=} and {dy=}, should never happen')
|
|
196
|
+
# start the mpfr context for floating-point computations with the precision needed
|
|
197
|
+
with inp.frm.context:
|
|
198
|
+
# precompute x coordinates once: this matters because mpfr construction and arithmetic
|
|
199
|
+
# are relatively expensive and we can reuse the x values across rows ("inner for loop");
|
|
200
|
+
# also, this is where the "X" (real) coordinates are converted mpq->mpfr
|
|
201
|
+
xs: list[gmpy2.mpfr] = [
|
|
202
|
+
gmpy2.mpfr(inp.frm.top_re + gmpy2.mpq(i) * dx) for i in range(inp.width)
|
|
203
|
+
]
|
|
204
|
+
# create progress bar based on total pixels and the options
|
|
205
|
+
has_procs: bool = inp.total_tasks > 1
|
|
206
|
+
n_task: int = inp.n_task - 1 # convert to 0-based index for easier modulo math
|
|
207
|
+
p_bar: tqdm.tqdm[NoReturn] = tqdm.tqdm(
|
|
208
|
+
total=inp.width * inp.height,
|
|
209
|
+
desc='Pre' if is_preprocess else 'Img',
|
|
210
|
+
unit='px',
|
|
211
|
+
dynamic_ncols=True,
|
|
212
|
+
smoothing=0.1,
|
|
213
|
+
colour='green',
|
|
214
|
+
disable=not inp.progress_bar or (has_procs and n_task != 0), # show for the 1st process only
|
|
215
|
+
)
|
|
216
|
+
# iterate over pixels in row-major order, computing escape iterations in mpfr
|
|
217
|
+
px_count: int = -1
|
|
218
|
+
for py in range(inp.height):
|
|
219
|
+
# PILImage.frombytes interprets the first row written as the top row of the image, so
|
|
220
|
+
# we iterate y inverted by starting at the top and going down;
|
|
221
|
+
# this is the "outer for loop", no benefit in pre-computing y values;
|
|
222
|
+
# also, this is where the "Y" (imaginary) coordinates are converted mpq->mpfr
|
|
223
|
+
cy: gmpy2.mpfr = gmpy2.mpfr(inp.frm.top_im - gmpy2.mpq(py) * dy)
|
|
224
|
+
# iterate over columns, reusing x values and doing the escape test in mpfr for correctness
|
|
225
|
+
for px in range(inp.width):
|
|
226
|
+
px_count += 1
|
|
227
|
+
if has_procs and (px_count % inp.total_tasks) != n_task:
|
|
228
|
+
# this pixel is not for this process, skip it but still update the progress bar
|
|
229
|
+
p_bar.update(1)
|
|
230
|
+
continue
|
|
231
|
+
# either this is a solo process, or this pixel is for this process
|
|
232
|
+
cx: gmpy2.mpfr = xs[px]
|
|
233
|
+
# fast interior tests, all in mpfr: main cardioid and period-2 bulb.
|
|
234
|
+
x_minus_quarter: gmpy2.mpfr = cx - _MPFR_FOURTH
|
|
235
|
+
q: gmpy2.mpfr = x_minus_quarter * x_minus_quarter + cy * cy
|
|
236
|
+
in_cardioid: bool = q * (q + x_minus_quarter) <= _MPFR_FOURTH * cy * cy
|
|
237
|
+
x_plus_one: gmpy2.mpfr = cx + _MPFR_ONE
|
|
238
|
+
in_bulb: bool = x_plus_one * x_plus_one + cy * cy <= _MPFR_SIXTEENTH
|
|
239
|
+
if in_cardioid or in_bulb:
|
|
240
|
+
# point is in the main cardioid or period-2 bulb, so it's an interior point, no escape
|
|
241
|
+
img.escape[px_count] = inp.max_iter # carefully set this directly in the array
|
|
242
|
+
p_bar.update(1) # we touched a pixel, so update the progress bar
|
|
243
|
+
continue
|
|
244
|
+
# not in the main cardioid or period-2 bulb, do the full escape-time test in mpfr
|
|
245
|
+
zx: gmpy2.mpfr = _MPFR_ZERO
|
|
246
|
+
zy: gmpy2.mpfr = _MPFR_ZERO
|
|
247
|
+
escaped_at: int = 0
|
|
248
|
+
# escape-time loop, implemented with explicit zx/zy variables
|
|
249
|
+
for escaped_at in range(inp.max_iter): # noqa: B007
|
|
250
|
+
zx2: gmpy2.mpfr = zx * zx
|
|
251
|
+
zy2: gmpy2.mpfr = zy * zy
|
|
252
|
+
# avoid sqrt(abs(z)); compare squared magnitude to 2^2
|
|
253
|
+
if zx2 + zy2 > _MPFR_FOUR:
|
|
254
|
+
break
|
|
255
|
+
# z = z^2 + c in terms of zx/zy: zx' = zx^2 - zy^2 + cx
|
|
256
|
+
zy = _MPFR_TWO * zx * zy + cy
|
|
257
|
+
zx = zx2 - zy2 + cx
|
|
258
|
+
else:
|
|
259
|
+
escaped_at = inp.max_iter # if we didn't break, we reached max_iter, mark as non-escaped
|
|
260
|
+
img.escape[px_count] = escaped_at # carefully set this directly in the array
|
|
261
|
+
p_bar.update(1) # we touched a pixel, so update the progress bar
|
|
262
|
+
# done
|
|
263
|
+
p_bar.close()
|
|
264
|
+
img.SetDepth(inp.max_iter) # set the depth of the image to the max_iter we used
|
|
265
|
+
return MandelbrotTaskOutput(img=img, n_task=inp.n_task, total_tasks=inp.total_tasks)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _MandelbrotAdaptiveIterations(frm: frame.Frame, progress_bar: bool) -> int:
|
|
269
|
+
"""Estimate a suitable max_iter for the full image by rendering a small test image.
|
|
270
|
+
|
|
271
|
+
Current algorithm:
|
|
272
|
+
- Render a very small image (MIN_IMAGE_SIZE x MIN_IMAGE_SIZE) with a very high iteration limit
|
|
273
|
+
(HIGH_ITERS, starting with 100k and going up to 10M if needed).
|
|
274
|
+
- Build a histogram of escape iterations for the small image, and find the highest escape
|
|
275
|
+
iteration that is below the high iteration limit.
|
|
276
|
+
- Multiply that escape iteration by a safety factor _ITER_SAFETY_FACTOR to get the estimated
|
|
277
|
+
max_iter for the full image.
|
|
278
|
+
- If the estimated max_iter is above the high iteration limit, try again with a higher
|
|
279
|
+
high iteration limit from HIGH_ITERS.
|
|
280
|
+
- If we exhaust all high iteration limits in HIGH_ITERS without finding a suitable max_iter,
|
|
281
|
+
raise an Error.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
frm (Frame): The frame to render.
|
|
285
|
+
progress_bar (bool): Whether to show a progress bar during the test render.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
int: The estimated max_iter for the full image, based on the escape histogram of the test render
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
Error: if the estimated max_iter exceeds the adaptive limit
|
|
292
|
+
|
|
293
|
+
"""
|
|
294
|
+
max_iter: int = MAX_ITER
|
|
295
|
+
for high_iter in HIGH_ITERS:
|
|
296
|
+
# make the smallest image
|
|
297
|
+
img16: image.Image = Mandelbrot(
|
|
298
|
+
frm,
|
|
299
|
+
frame.MIN_IMAGE_SIZE,
|
|
300
|
+
frame.MIN_IMAGE_SIZE,
|
|
301
|
+
max_iter=high_iter,
|
|
302
|
+
progress_bar=progress_bar,
|
|
303
|
+
)
|
|
304
|
+
# estimate the needed iterations for the full image based on the smallest image;
|
|
305
|
+
# make the histogram of escape iterations for the smallest image, and find the highest escape
|
|
306
|
+
escape_histogram: dict[int, int] = {}
|
|
307
|
+
for escaped_at in img16.escape:
|
|
308
|
+
escape_histogram[escaped_at] = escape_histogram.get(escaped_at, 0) + 1
|
|
309
|
+
# sort the histogram by escape iteration; find the highest escape iteration that < high limit
|
|
310
|
+
# if all pixels hit high_iter then max_iter will be high_iter, and we WANT it to FAIL
|
|
311
|
+
histogram: list[tuple[int, int]] = sorted(escape_histogram.items())
|
|
312
|
+
max_iter = (
|
|
313
|
+
histogram[-1][0] if histogram[-1][0] != high_iter or len(histogram) == 1 else histogram[-2][0]
|
|
314
|
+
)
|
|
315
|
+
# apply safety factor and clamp
|
|
316
|
+
max_iter = min(MAX_ITER, max(MIN_ITER, int(max_iter * _ITER_SAFETY_FACTOR)))
|
|
317
|
+
if max_iter < high_iter:
|
|
318
|
+
# we found a winner!
|
|
319
|
+
if len(histogram) > 7: # noqa: PLR2004 ; 7 is 3 before, the middle, and 3 after
|
|
320
|
+
# this is usually the case: many escape values, so summarize the middle ones
|
|
321
|
+
summary_histogram: list[tuple[int, int] | tuple[str, int]] = [
|
|
322
|
+
*histogram[:3],
|
|
323
|
+
('...', sum(count for _, count in histogram[3:-3])),
|
|
324
|
+
*histogram[-3:],
|
|
325
|
+
]
|
|
326
|
+
logging.warning(f'Picked {max_iter=}: histogram {summary_histogram}')
|
|
327
|
+
else:
|
|
328
|
+
# probably a pretty rare thing, but then we can show all
|
|
329
|
+
logging.warning(f'Picked {max_iter=}: histogram {histogram}')
|
|
330
|
+
# stop here
|
|
331
|
+
return max_iter
|
|
332
|
+
# here we didn't find, so we loop to the next higher limit...
|
|
333
|
+
# if we exhausted all the high_iters without finding a suitable max_iter, we have to give up
|
|
334
|
+
raise Error(
|
|
335
|
+
f'Estimated {max_iter=} is above the adaptive limit of {HIGH_ITERS[-1]}; '
|
|
336
|
+
'maybe this frame is interior-only (all pixels are non-escaping)'
|
|
337
|
+
)
|