setiastrosuitepro 1.6.12__py3-none-any.whl → 1.7.1.post2__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.
Files changed (42) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/_generated/build_info.py +2 -2
  5. setiastro/saspro/aberration_ai.py +128 -13
  6. setiastro/saspro/aberration_ai_preset.py +29 -3
  7. setiastro/saspro/astrospike_python.py +45 -3
  8. setiastro/saspro/blink_comparator_pro.py +116 -71
  9. setiastro/saspro/curve_editor_pro.py +72 -22
  10. setiastro/saspro/curves_preset.py +249 -47
  11. setiastro/saspro/gui/main_window.py +285 -44
  12. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  13. setiastro/saspro/gui/mixins/menu_mixin.py +8 -0
  14. setiastro/saspro/gui/mixins/toolbar_mixin.py +115 -6
  15. setiastro/saspro/histogram.py +179 -7
  16. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  17. setiastro/saspro/imageops/serloader.py +1345 -0
  18. setiastro/saspro/legacy/numba_utils.py +1 -1
  19. setiastro/saspro/live_stacking.py +24 -4
  20. setiastro/saspro/multiscale_decomp.py +30 -17
  21. setiastro/saspro/narrowband_normalization.py +1618 -0
  22. setiastro/saspro/remove_green.py +1 -1
  23. setiastro/saspro/resources.py +6 -0
  24. setiastro/saspro/rgbalign.py +456 -12
  25. setiastro/saspro/ser_stack_config.py +82 -0
  26. setiastro/saspro/ser_stacker.py +2321 -0
  27. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  28. setiastro/saspro/ser_tracking.py +206 -0
  29. setiastro/saspro/serviewer.py +1625 -0
  30. setiastro/saspro/sfcc.py +298 -64
  31. setiastro/saspro/shortcuts.py +14 -7
  32. setiastro/saspro/stacking_suite.py +21 -6
  33. setiastro/saspro/stat_stretch.py +179 -31
  34. setiastro/saspro/subwindow.py +2 -4
  35. setiastro/saspro/texture_clarity.py +593 -0
  36. setiastro/saspro/widgets/resource_monitor.py +122 -74
  37. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +3 -2
  38. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +42 -30
  39. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  40. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  41. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  42. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
@@ -0,0 +1,56 @@
1
+ <svg width="500" height="500" viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg">
2
+ <defs>
3
+ <linearGradient id="skyGradient" x1="0%" y1="0%" x2="0%" y2="100%">
4
+ <stop offset="0%" style="stop-color:#0d0221;stop-opacity:1" />
5
+ <stop offset="100%" style="stop-color:#2a0e66;stop-opacity:1" />
6
+ </linearGradient>
7
+ <linearGradient id="sunGradient" x1="0%" y1="0%" x2="0%" y2="100%">
8
+ <stop offset="0%" style="stop-color:#ff9a00;stop-opacity:1" />
9
+ <stop offset="40%" style="stop-color:#ff0055;stop-opacity:1" />
10
+ <stop offset="100%" style="stop-color:#9900ff;stop-opacity:1" />
11
+ </linearGradient>
12
+ <linearGradient id="textGradient" x1="0%" y1="0%" x2="100%" y2="100%">
13
+ <stop offset="0%" style="stop-color:#00ffff;stop-opacity:1" />
14
+ <stop offset="100%" style="stop-color:#ff00ff;stop-opacity:1" />
15
+ </linearGradient>
16
+ <filter id="neonGlow" x="-50%" y="-50%" width="200%" height="200%">
17
+ <feGaussianBlur stdDeviation="3.0" result="coloredBlur"/>
18
+ <feMerge>
19
+ <feMergeNode in="coloredBlur"/>
20
+ <feMergeNode in="SourceGraphic"/>
21
+ </feMerge>
22
+ </filter>
23
+ </defs>
24
+ <rect width="500" height="500" fill="url(#skyGradient)" />
25
+ <g transform="translate(250, 220)">
26
+ <circle r="120" fill="url(#sunGradient)" />
27
+ <rect x="-120" y="20" width="240" height="4" fill="#2a0e66" opacity="0.8" />
28
+ <rect x="-120" y="35" width="240" height="6" fill="#2a0e66" opacity="0.8" />
29
+ <rect x="-120" y="52" width="240" height="8" fill="#2a0e66" opacity="0.8" />
30
+ <rect x="-120" y="72" width="240" height="12" fill="#2a0e66" opacity="0.8" />
31
+ <rect x="-120" y="96" width="240" height="16" fill="#2a0e66" opacity="0.8" />
32
+ </g>
33
+ <g stroke="#ff00ff" stroke-width="2" opacity="0.4">
34
+ <line x1="0" y1="350" x2="500" y2="350" />
35
+ <line x1="0" y1="380" x2="500" y2="380" />
36
+ <line x1="0" y1="420" x2="500" y2="420" />
37
+ <line x1="0" y1="470" x2="500" y2="470" />
38
+ <line x1="250" y1="350" x2="250" y2="500" />
39
+ <line x1="250" y1="350" x2="50" y2="500" />
40
+ <line x1="250" y1="350" x2="-150" y2="500" />
41
+ <line x1="250" y1="350" x2="450" y2="500" />
42
+ <line x1="250" y1="350" x2="650" y2="500" />
43
+ </g>
44
+ <g font-family="'Times New Roman', Times, serif" font-weight="bold" font-style="italic" font-size="360" text-anchor="middle">
45
+ <text x="217" y="390" fill="#00ffff" opacity="0.7">TC</text>
46
+ <text x="233" y="390" fill="#ff0055" opacity="0.7">TC</text>
47
+ <text x="225" y="390" fill="url(#textGradient)" stroke="#ffffff" stroke-width="3" filter="url(#neonGlow)">TC</text>
48
+ </g>
49
+ <g fill="#ffffff" opacity="0.8">
50
+ <circle cx="50" cy="50" r="2" />
51
+ <circle cx="450" cy="80" r="2" />
52
+ <circle cx="100" cy="150" r="1.5" />
53
+ <circle cx="400" cy="20" r="1.5" />
54
+ <path d="M420 120 L422 130 L432 132 L422 134 L420 144 L418 134 L408 132 L418 130 Z" fill="#00ffff" />
55
+ </g>
56
+ </svg>
Binary file
@@ -1,3 +1,3 @@
1
1
  # Auto-generated at build time. Do not edit.
2
- BUILD_TIMESTAMP = "2026-01-07T16:45:00Z"
3
- APP_VERSION = "1.6.12"
2
+ BUILD_TIMESTAMP = "2026-01-15T16:55:53Z"
3
+ APP_VERSION = "1.7.1.post2"
@@ -7,13 +7,37 @@ import numpy as np
7
7
  import sys
8
8
  import platform # add
9
9
  import time
10
+ import subprocess
10
11
 
11
12
  IS_APPLE_ARM = (sys.platform == "darwin" and platform.machine() == "arm64")
12
13
 
14
+ def _has_nvidia_gpu() -> bool:
15
+ """Check if system has an NVIDIA GPU (Linux/Windows)."""
16
+ try:
17
+ if platform.system() == "Linux":
18
+ r = subprocess.run(["nvidia-smi", "-L"], capture_output=True, timeout=2)
19
+ return "GPU" in (r.stdout.decode("utf-8", errors="ignore") or "")
20
+ elif platform.system() == "Windows":
21
+ try:
22
+ ps = subprocess.run(
23
+ ["powershell", "-NoProfile", "-Command",
24
+ "(Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name) -join ';'"],
25
+ capture_output=True, timeout=2
26
+ )
27
+ out = (ps.stdout.decode("utf-8", errors="ignore") or "").lower()
28
+ return "nvidia" in out
29
+ except Exception:
30
+ w = subprocess.run(["wmic", "path", "win32_VideoController", "get", "name"],
31
+ capture_output=True, timeout=2)
32
+ return "nvidia" in (w.stdout.decode("utf-8", errors="ignore") or "").lower()
33
+ except Exception:
34
+ pass
35
+ return False
36
+
13
37
  from PyQt6.QtCore import Qt, QThread, pyqtSignal, QStandardPaths, QSettings
14
38
  from PyQt6.QtWidgets import (
15
39
  QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFileDialog,
16
- QComboBox, QSpinBox, QProgressBar, QMessageBox, QCheckBox, QLineEdit
40
+ QComboBox, QSpinBox, QProgressBar, QMessageBox, QCheckBox, QLineEdit, QApplication
17
41
  )
18
42
  from PyQt6.QtGui import QIcon
19
43
  from setiastro.saspro.config import Config
@@ -145,10 +169,13 @@ def _restore_output(arr: np.ndarray, channels_last: bool, was_uint16: bool, H: i
145
169
  arr = arr[0] # (H,W)
146
170
  return arr
147
171
 
148
- def run_onnx_tiled(session, img: np.ndarray, patch_size=512, overlap=64, progress_cb=None) -> np.ndarray:
172
+ def run_onnx_tiled(session, img: np.ndarray, patch_size=512, overlap=64,
173
+ progress_cb=None, cancel_cb=None) -> np.ndarray:
149
174
  """
150
175
  session: onnxruntime.InferenceSession
151
176
  img: mono (H,W) or RGB (H,W,3) numpy array
177
+
178
+ cancel_cb: callable -> bool, return True to cancel
152
179
  """
153
180
  arr, channels_last, was_uint16 = _prepare_input(img) # (C,H,W)
154
181
  arr, H0, W0 = _pad_C_HW(arr, patch_size)
@@ -168,11 +195,15 @@ def run_onnx_tiled(session, img: np.ndarray, patch_size=512, overlap=64, progres
168
195
  for c in range(C):
169
196
  for i in hs:
170
197
  for j in ws:
171
- patch = arr[c:c+1, i:i+patch_size, j:j+patch_size] # (1, P, P)
198
+ if cancel_cb and cancel_cb():
199
+ raise RuntimeError("Canceled")
200
+
201
+ patch = arr[c:c+1, i:i+patch_size, j:j+patch_size] # (1,P,P)
172
202
  inp = np.ascontiguousarray(patch[np.newaxis, ...], dtype=np.float32) # (1,1,P,P)
173
203
 
174
204
  out_patch = session.run(None, {inp_name: inp})[0] # (1,1,P,P)
175
205
  out_patch = np.squeeze(out_patch, axis=0) # (1,P,P)
206
+
176
207
  out[c:c+1, i:i+patch_size, j:j+patch_size] += out_patch * win
177
208
  wgt[c:c+1, i:i+patch_size, j:j+patch_size] += win
178
209
 
@@ -184,7 +215,6 @@ def run_onnx_tiled(session, img: np.ndarray, patch_size=512, overlap=64, progres
184
215
  arr = out / wgt
185
216
  return _restore_output(arr, channels_last, was_uint16, H0, W0)
186
217
 
187
-
188
218
  # ---------- providers ----------
189
219
  def pick_providers(auto_gpu=True) -> list[str]:
190
220
  """
@@ -248,9 +278,11 @@ def _preserve_border(dst: np.ndarray, src: np.ndarray, px: int = 10) -> np.ndarr
248
278
 
249
279
  # ---------- worker ----------
250
280
  class _ONNXWorker(QThread):
251
- progressed = pyqtSignal(int) # 0..100
252
- failed = pyqtSignal(str)
253
- finished_ok= pyqtSignal(np.ndarray)
281
+ progressed = pyqtSignal(int) # 0..100
282
+ failed = pyqtSignal(str)
283
+ finished_ok = pyqtSignal(np.ndarray)
284
+ canceled = pyqtSignal()
285
+ log_message = pyqtSignal(str) # for console logging
254
286
 
255
287
  def __init__(self, model_path: str, image: np.ndarray, patch: int, overlap: int, providers: list[str]):
256
288
  super().__init__()
@@ -260,33 +292,115 @@ class _ONNXWorker(QThread):
260
292
  self.overlap = overlap
261
293
  self.providers = providers
262
294
  self.used_provider = None
295
+ self._cancel = False # cooperative flag
296
+
297
+ def cancel(self):
298
+ # Safe to call from UI thread
299
+ self._cancel = True
300
+ self.requestInterruption()
301
+
302
+ def _is_canceled(self) -> bool:
303
+ return self._cancel or self.isInterruptionRequested()
263
304
 
264
305
  def run(self):
265
306
  if ort is None:
266
307
  self.failed.emit("onnxruntime is not installed.")
267
308
  return
309
+
310
+ # If canceled before start, exit cleanly
311
+ if self._is_canceled():
312
+ self.canceled.emit()
313
+ return
314
+
315
+ # Log available providers for debugging
316
+ avail_providers = ort.get_available_providers()
317
+ gpu_providers = [p for p in self.providers if p != "CPUExecutionProvider"]
318
+ has_nvidia = _has_nvidia_gpu()
319
+
320
+ self.log_message.emit(f"🔍 Available ONNX providers: {', '.join(avail_providers)}")
321
+ self.log_message.emit(f"🔍 Attempting providers: {', '.join(self.providers)}")
322
+ print(f"🔍 Available ONNX providers: {', '.join(avail_providers)}")
323
+ print(f"🔍 Attempting providers: {', '.join(self.providers)}")
324
+
325
+ # Check if NVIDIA GPU is present but CUDA provider is missing
326
+ if has_nvidia and "CUDAExecutionProvider" not in avail_providers:
327
+ msg = ("⚠️ GPU NVIDIA détecté mais CUDAExecutionProvider n'est pas disponible.\n"
328
+ " Vous devez installer 'onnxruntime-gpu' au lieu de 'onnxruntime'.\n"
329
+ " Commande: pip uninstall onnxruntime && pip install onnxruntime-gpu")
330
+ self.log_message.emit(msg)
331
+ print(msg)
332
+
268
333
  try:
269
334
  sess = ort.InferenceSession(self.model_path, providers=self.providers)
270
335
  self.used_provider = (sess.get_providers()[0] if sess.get_providers() else None)
271
- except Exception:
336
+ # Log successful GPU usage
337
+ if self.used_provider != "CPUExecutionProvider" and gpu_providers:
338
+ msg = f"✅ Aberration AI: Using GPU provider {self.used_provider}"
339
+ self.log_message.emit(msg)
340
+ print(msg)
341
+ elif has_nvidia and self.used_provider == "CPUExecutionProvider":
342
+ msg = ("⚠️ GPU NVIDIA détecté mais utilisation du CPU.\n"
343
+ " Installez 'onnxruntime-gpu' pour utiliser le GPU.")
344
+ self.log_message.emit(msg)
345
+ print(msg)
346
+ except Exception as e:
347
+ # Log the actual error for debugging
348
+ error_msg = str(e)
349
+ msg = f"⚠️ Aberration AI: GPU provider failed: {error_msg}"
350
+ self.log_message.emit(msg)
351
+ print(msg)
352
+ self.log_message.emit(f"Available providers: {', '.join(avail_providers)}")
353
+ print(f"Available providers: {', '.join(avail_providers)}")
354
+ self.log_message.emit(f"Attempted providers: {', '.join(self.providers)}")
355
+ print(f"Attempted providers: {', '.join(self.providers)}")
356
+
357
+ # Check if onnxruntime-gpu is installed (CUDA provider should be available if it is)
358
+ if "CUDAExecutionProvider" in self.providers and "CUDAExecutionProvider" not in avail_providers:
359
+ if has_nvidia:
360
+ msg = ("❌ CUDAExecutionProvider non disponible alors qu'un GPU NVIDIA est présent.\n"
361
+ " Installez 'onnxruntime-gpu': pip uninstall onnxruntime && pip install onnxruntime-gpu")
362
+ else:
363
+ msg = "⚠️ CUDAExecutionProvider not available. You may need to install onnxruntime-gpu instead of onnxruntime."
364
+ self.log_message.emit(msg)
365
+ print(msg)
366
+
272
367
  # fallback CPU if GPU fails
273
368
  try:
274
369
  sess = ort.InferenceSession(self.model_path, providers=["CPUExecutionProvider"])
275
- self.used_provider = "CPUExecutionProvider" # NEW
370
+ self.used_provider = "CPUExecutionProvider"
371
+ msg = f"⚠️ Aberration AI: Falling back to CPU (GPU initialization failed: {error_msg})"
372
+ self.log_message.emit(msg)
373
+ print(msg)
276
374
  except Exception as e2:
277
- self.failed.emit(f"Failed to init ONNX session:\n{e2}")
375
+ self.failed.emit(f"Failed to init ONNX session:\nGPU error: {error_msg}\nCPU error: {e2}")
278
376
  return
279
377
 
280
378
  def cb(frac):
281
379
  self.progressed.emit(int(frac * 100))
282
380
 
283
381
  try:
284
- out = run_onnx_tiled(sess, self.image, self.patch, self.overlap, cb)
382
+ out = run_onnx_tiled(
383
+ sess,
384
+ self.image,
385
+ self.patch,
386
+ self.overlap,
387
+ progress_cb=cb,
388
+ cancel_cb=self._is_canceled,
389
+ )
285
390
  except Exception as e:
286
- self.failed.emit(str(e)); return
391
+ # Normalize cancel
392
+ msg = str(e) or "Error"
393
+ if "Canceled" in msg:
394
+ self.canceled.emit()
395
+ else:
396
+ self.failed.emit(msg)
397
+ return
287
398
 
288
- self.finished_ok.emit(out)
399
+ if self._is_canceled():
400
+ self.canceled.emit()
401
+ return
289
402
 
403
+ self.finished_ok.emit(out)
290
404
 
291
405
  # ---------- dialog ----------
292
406
  class AberrationAIDialog(QDialog):
@@ -758,6 +872,7 @@ class AberrationAIDialog(QDialog):
758
872
  self._worker.failed.connect(self._on_failed)
759
873
  self._worker.finished_ok.connect(self._on_ok)
760
874
  self._worker.finished.connect(self._on_worker_finished)
875
+ self._worker.log_message.connect(self._log) # Connect log messages to console
761
876
  self._worker.start()
762
877
 
763
878
 
@@ -4,7 +4,7 @@ import os
4
4
  import time
5
5
  import numpy as np
6
6
  from PyQt6.QtCore import QTimer
7
- from PyQt6.QtWidgets import QDialog, QVBoxLayout, QProgressBar, QPushButton, QMessageBox, QFormLayout, QDialogButtonBox, QSpinBox, QCheckBox, QComboBox, QLabel
7
+ from PyQt6.QtWidgets import QDialog, QVBoxLayout, QProgressBar, QPushButton, QMessageBox, QFormLayout, QDialogButtonBox, QSpinBox, QCheckBox, QComboBox, QLabel, QApplication
8
8
 
9
9
  from PyQt6.QtCore import QSettings
10
10
  # reuse everything from the UI module
@@ -91,13 +91,29 @@ def run_aberration_ai_via_preset(main, preset: dict | None = None, doc=None):
91
91
  worker = _ONNXWorker(model, img, patch, overlap, providers)
92
92
  worker.progressed.connect(bar.setValue)
93
93
 
94
+ def _cancel_clicked():
95
+ btn.setEnabled(False)
96
+ btn.setText("Canceling…")
97
+ worker.cancel() # <-- SAFE
98
+ QApplication.processEvents()
99
+
94
100
  def _fail(msg: str):
95
101
  try:
96
102
  if hasattr(main, "_log"):
97
103
  main._log(f"❌ Aberration AI failed: {msg}")
98
104
  except Exception:
99
105
  pass
100
- QMessageBox.critical(main, "Aberration AI", msg)
106
+ # If canceled, don't pop an error box
107
+ if "Canceled" not in (msg or ""):
108
+ QMessageBox.critical(main, "Aberration AI", msg)
109
+ dlg.close()
110
+
111
+ def _canceled():
112
+ try:
113
+ if hasattr(main, "_log"):
114
+ main._log("⛔ Aberration AI canceled.")
115
+ except Exception:
116
+ pass
101
117
  dlg.close()
102
118
 
103
119
  def _ok(out: np.ndarray):
@@ -157,13 +173,23 @@ def run_aberration_ai_via_preset(main, preset: dict | None = None, doc=None):
157
173
  dlg.close()
158
174
 
159
175
  worker.failed.connect(_fail)
176
+ worker.canceled.connect(_canceled) # <-- NEW
160
177
  worker.finished_ok.connect(_ok)
161
178
  worker.finished.connect(lambda: btn.setEnabled(False))
162
- btn.clicked.connect(worker.terminate)
179
+
180
+ btn.clicked.connect(_cancel_clicked)
181
+
182
+ # If user closes dialog via window X, also cancel
183
+ dlg.rejected.connect(_cancel_clicked)
163
184
 
164
185
  worker.start()
165
186
  dlg.exec()
166
187
 
188
+ # Ensure the worker is not left running after the modal closes
189
+ if worker.isRunning():
190
+ worker.cancel()
191
+ worker.wait(2000) # don't hang forever; just give it a moment
192
+
167
193
  # clear the guard after a brief tick so downstream signals don’t re-open UI
168
194
  def _clear():
169
195
  for k in ("_aberration_ai_headless_running", "_aberration_ai_guard"):
@@ -685,9 +685,51 @@ def render_spikes(output: np.ndarray, stars: List[Star], config: SpikeConfig, ct
685
685
 
686
686
  # Main spikes
687
687
  if config.intensity > 0:
688
- for i in range(qty):
689
- theta = main_angle_rad + (i * (math.pi * 2) / float(qty))
690
- ...
688
+ rainbow_str = config.rainbow_spike_intensity if (config.enable_rainbow and config.rainbow_spikes) else 0
689
+ for i in range(int(config.quantity)):
690
+ theta = main_angle_rad + (i * (math.pi * 2) / float(config.quantity))
691
+ cos_t = math.cos(theta)
692
+ sin_t = math.sin(theta)
693
+
694
+ start_x = star.x + cos_t * 0.5
695
+ start_y = star.y + sin_t * 0.5
696
+ end_x = star.x + cos_t * base_length
697
+ end_y = star.y + sin_t * base_length
698
+
699
+ # Standard Spike
700
+ # Base star color, fading to zero alpha
701
+ c_end = (star.color.r/255.0, star.color.g/255.0, star.color.b/255.0, 0.0)
702
+
703
+ # If rainbow enabled, standard spike is dimmed (matches preview logic)
704
+ opacity_mult = 0.4 if rainbow_str > 0 else 1.0
705
+ c_start = (color[0], color[1], color[2], color[3] * opacity_mult)
706
+
707
+ draw_line_gradient(output, start_x, start_y, end_x, end_y,
708
+ c_start, c_end, thickness, config.sharpness)
709
+
710
+ # Rainbow Overlay
711
+ if rainbow_str > 0:
712
+ stops = 10
713
+ for s in range(stops):
714
+ p1 = s / stops
715
+ p2 = (s + 1) / stops
716
+ if p1 > config.rainbow_spike_length:
717
+ break
718
+
719
+ hue = (p1 * 360.0 * config.rainbow_spike_frequency) % 360.0
720
+ a_rainbow = min(1.0, config.intensity * rainbow_str * 2.0) * (1.0 - p1)
721
+ r_seg, g_seg, b_seg = hsl_to_rgb(hue / 360.0, 0.8, 0.6)
722
+ c_seg = (r_seg, g_seg, b_seg, a_rainbow)
723
+
724
+ # Calculate segment positions
725
+ sx = start_x + (end_x - start_x) * p1
726
+ sy = start_y + (end_y - start_y) * p1
727
+ ex = start_x + (end_x - start_x) * p2
728
+ ey = start_y + (end_y - start_y) * p2
729
+
730
+ # Draw rainbow segment with constant color
731
+ draw_line_gradient(output, sx, sy, ex, ey,
732
+ c_seg, c_seg, thickness, 1.0)
691
733
 
692
734
  # Secondary spikes
693
735
  if config.secondary_intensity > 0: