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 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>'
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: Copyright 2026 <balparda@github.com> & <BellaKeri@github.com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """CLI logic."""
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')
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: Copyright 2026 <balparda@github.com> & <BellaKeri@github.com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Core business logic."""
@@ -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
+ )