From 801777e5348ed5e5665d2472f14f36673c253d66 Mon Sep 17 00:00:00 2001 From: Brianna Rainey Date: Thu, 22 Jan 2026 16:40:37 -0500 Subject: make Life component respond to audio also adds a dissolve effect between frames and a kaleidoscope effect the fancier shape types ignore audio for now. Fixes #91 --- src/avp/components/life.py | 263 +++++++++++++++++++++++++++++++++++---------- src/avp/components/life.ui | 82 ++++++++++---- 2 files changed, 267 insertions(+), 78 deletions(-) (limited to 'src') diff --git a/src/avp/components/life.py b/src/avp/components/life.py index 5b719d1..9e5e202 100644 --- a/src/avp/components/life.py +++ b/src/avp/components/life.py @@ -1,13 +1,15 @@ -from PyQt6 import QtGui, QtCore, QtWidgets +from PyQt6 import QtCore, QtWidgets from PyQt6.QtGui import QUndoCommand -from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter +from PIL import Image, ImageDraw, ImageEnhance, ImageChops, ImageFilter, ImageOps import os +from copy import copy import math import logging from ..component import Component from ..toolkit.frame import BlankFrame, scale +from .original import Component as Visualizer log = logging.getLogger("AVP.Component.Life") @@ -15,7 +17,7 @@ log = logging.getLogger("AVP.Component.Life") class Component(Component): name = "Conway's Game of Life" - version = "1.0.0" + version = "2.0.0" def widget(self, *args): super().widget(*args) @@ -62,6 +64,8 @@ class Component(Component): "customImg": self.page.checkBox_customImg, "showGrid": self.page.checkBox_showGrid, "image": self.page.lineEdit_image, + "kaleidoscope": self.page.checkBox_kaleidoscope, + "sensitivity": self.page.spinBox_sensitivity, }, colorWidgets={ "color": self.page.pushButton_color, @@ -106,6 +110,8 @@ class Component(Component): def update(self): self.updateGridSize() + + # Hide/show widgets depending on state of "custom image" checkbox if self.page.checkBox_customImg.isChecked(): self.page.label_color.setVisible(False) self.page.lineEdit_color.setVisible(False) @@ -124,6 +130,17 @@ class Component(Component): self.page.label_image.setVisible(False) self.page.lineEdit_image.setVisible(False) self.page.pushButton_pickImage.setVisible(False) + + # Disable audio sensitivity spinbox if not relevant + if ( + self.page.comboBox_shapeType.currentIndex() < 4 + or self.page.checkBox_customImg.isChecked() + ): + self.page.spinBox_sensitivity.setEnabled(True) + else: + self.page.spinBox_sensitivity.setEnabled(False) + + # Disable arrow buttons to shift the grid if the grid is empty enabled = len(self.startingGrid) > 0 for widget in self.shiftButtons: widget.setEnabled(enabled) @@ -144,16 +161,48 @@ class Component(Component): self.pxHeight = math.ceil(self.height / self.gridHeight) def previewRender(self): - return self.drawGrid(self.startingGrid) + image = self.drawGrid(self.startingGrid, self.color) + image = self.addKaleidoscopeEffect(image) + image = self.addShadow(image) + image = self.addGridLines(image) + return image def preFrameRender(self, *args, **kwargs): super().preFrameRender(*args, **kwargs) self.tickGrids = {0: self.startingGrid} + smoothConstantDown = 0.08 + 0 + smoothConstantUp = 0.8 - 0 + self.lastSpectrum = None + self.spectrumArray = {} + if self.sensitivity == 0: + return + + for i in range(0, len(self.completeAudioArray), self.sampleSize): + if self.canceled: + break + self.lastSpectrum = Visualizer.transformData( + i, + self.completeAudioArray, + self.sampleSize, + smoothConstantDown, + smoothConstantUp, + self.lastSpectrum, + self.sensitivity, + ) + self.spectrumArray[i] = copy(self.lastSpectrum) + + progress = int(100 * (i / len(self.completeAudioArray))) + if progress >= 100: + progress = 100 + pStr = "Analyzing audio: " + str(progress) + "%" + self.progressBarSetText.emit(pStr) + self.progressBarUpdate.emit(int(progress)) + def properties(self): if self.customImg and (not self.image or not os.path.exists(self.image)): return ["error"] - return [] + return ["pcm"] if self.sensitivity > 0 else [] def error(self): return "No image selected to represent life." @@ -169,65 +218,181 @@ class Component(Component): # Delete old evolution data which we shouldn't need anymore if tick - 60 in self.tickGrids: del self.tickGrids[tick - 60] - return self.drawGrid(grid) - def drawGrid(self, grid): + # Fade difference between previous and current grid + previousGrid = self.tickGrids.get(tick - 1, set()) + newColor = self.color + if not self.customImg: + r, g, b = self.color + decay = 255 / self.tickRate + decayAmount = int(decay * (frameNo % self.tickRate)) + decayColor = ( + r, + g, + b, + 255 - decayAmount, + ) + newColor = (r, g, b, min(255, decayAmount * 2)) + previousGridImage = self.drawGrid( + previousGrid, + decayColor, + ( + None + if (not self.customImg and self.shapeType > 3) + or self.sensitivity < 1 + else self.spectrumArray[frameNo * self.sampleSize] + ), + ) + image = self.drawGrid( + grid, + newColor, + ( + None + if (not self.customImg and self.shapeType > 3) or self.sensitivity < 1 + else self.spectrumArray[frameNo * self.sampleSize] + ), + grid.intersection(previousGrid), + ) + if not self.customImg: + image = Image.alpha_composite(previousGridImage, image) + image = self.addKaleidoscopeEffect(image) + image = self.addShadow(image) + image = self.addGridLines(image) + return image + + def addShadow(self, frame): + if not self.shadow: + return frame + + shadImg = ImageEnhance.Contrast(frame).enhance(0.0) + shadImg = shadImg.filter(ImageFilter.GaussianBlur(5.00)) + shadImg = ImageChops.offset(shadImg, -2, 2) + shadImg.paste(frame, box=(0, 0), mask=frame) + frame = shadImg + return frame + + def addGridLines(self, frame): + if not self.showGrid: + return frame + + drawer = ImageDraw.Draw(frame) + w, h = scale(0.05, self.width, self.height, int) + for x in range(self.pxWidth, self.width, self.pxWidth): + drawer.rectangle( + ((x, 0), (x + w, self.height)), + fill=self.color, + ) + for y in range(self.pxHeight, self.height, self.pxHeight): + drawer.rectangle( + ((0, y), (self.width, y + h)), + fill=self.color, + ) + return frame + + def addKaleidoscopeEffect(self, frame): + if not self.kaleidoscope: + return frame + + flippedImage = frame.transpose(Image.Transpose.FLIP_TOP_BOTTOM) + frame.paste(flippedImage, (0, 0), mask=flippedImage) + + flippedImage = frame.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + frame.paste(flippedImage, (0, 0), mask=flippedImage) + + flippedImage = frame.transpose(Image.Transpose.ROTATE_90) + frame.paste(flippedImage, (0, 0), mask=flippedImage) + + flippedImage = frame.transpose(Image.Transpose.ROTATE_270) + frame.paste(flippedImage, (0, 0), mask=flippedImage) + return frame + + def drawGrid(self, grid, color, spectrumData=None, didntChange=None): frame = BlankFrame(self.width, self.height) + if didntChange is None: + # this set would contain cell coords that did not change + # between the previous grid tick and this one + didntChange = set() def drawCustomImg(): try: img = Image.open(self.image) except Exception: return - img = img.resize((self.pxWidth, self.pxHeight), Image.Resampling.LANCZOS) - frame.paste(img, box=(drawPtX, drawPtY)) + img = img.resize( + ( + (self.pxWidth + audioMorphWidth), + (self.pxHeight + audioMorphHeight), + ), + Image.Resampling.LANCZOS, + ) + frame.paste( + img, + box=( + (drawPtX - (audioMorphWidth * 2)), + (drawPtY - (audioMorphHeight * 2)), + ), + ) - def drawShape(): + def drawShape(x, y): drawer = ImageDraw.Draw(frame) rect = ( - (drawPtX, drawPtY), - (drawPtX + self.pxWidth, drawPtY + self.pxHeight), + (drawPtX - audioMorphWidth, drawPtY - audioMorphHeight), + ( + drawPtX + self.pxWidth + audioMorphWidth, + drawPtY + self.pxHeight + audioMorphHeight, + ), ) shape = self.page.comboBox_shapeType.currentText().lower() + thisCellColor = color if (x, y) not in didntChange else (*color[:3], 255) # Rectangle if shape == "rectangle": - drawer.rectangle(rect, fill=self.color) + drawer.rectangle(rect, fill=thisCellColor) # Elliptical elif shape == "elliptical": - drawer.ellipse(rect, fill=self.color) + drawer.ellipse(rect, fill=thisCellColor) tenthX, tenthY = scale(10, self.pxWidth, self.pxHeight, int) smallerShape = ( ( - drawPtX + tenthX + int(tenthX / 4), - drawPtY + tenthY + int(tenthY / 2), + drawPtX + tenthX + int(tenthX / 4) - int(audioMorphWidth / 2), + drawPtY + tenthY + int(tenthY / 2) - int(audioMorphHeight / 2), ), ( - drawPtX + self.pxWidth - tenthX - int(tenthX / 4), - drawPtY + self.pxHeight - (tenthY + int(tenthY / 2)), + drawPtX + + self.pxWidth + - tenthX + - int(tenthX / 4) + + int(audioMorphWidth / 2), + drawPtY + + self.pxHeight + - (tenthY + int(tenthY / 2)) + + int(audioMorphHeight / 2), ), ) outlineShape = ( - (drawPtX + int(tenthX / 4), drawPtY + int(tenthY / 2)), ( - drawPtX + self.pxWidth - int(tenthX / 4), - drawPtY + self.pxHeight - int(tenthY / 2), + drawPtX + int(tenthX / 4) - audioMorphWidth, + drawPtY + int(tenthY / 2) - audioMorphHeight, + ), + ( + drawPtX + self.pxWidth - int(tenthX / 4) + audioMorphWidth, + drawPtY + self.pxHeight - int(tenthY / 2) + audioMorphHeight, ), ) # Circle if shape == "circle": - drawer.ellipse(outlineShape, fill=self.color) + drawer.ellipse(outlineShape, fill=thisCellColor) drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) # Lilypad elif shape == "lilypad": - drawer.pieslice(smallerShape, 290, 250, fill=self.color) + drawer.pieslice(smallerShape, 290, 250, fill=thisCellColor) # Pie elif shape == "pie": - drawer.pieslice(outlineShape, 35, 320, fill=self.color) + drawer.pieslice(outlineShape, 35, 320, fill=thisCellColor) hX, hY = scale(50, self.pxWidth, self.pxHeight, int) # halfline tX, tY = scale(33, self.pxWidth, self.pxHeight, int) # thirdline @@ -235,7 +400,7 @@ class Component(Component): # Path if shape == "path": - drawer.ellipse(rect, fill=self.color) + drawer.ellipse(rect, fill=thisCellColor) rects = { direction: False for direction in ( @@ -287,7 +452,7 @@ class Component(Component): drawPtY + self.pxHeight, ), ) - drawer.rectangle(sect, fill=self.color) + drawer.rectangle(sect, fill=thisCellColor) # Duck elif shape == "duck": @@ -304,10 +469,10 @@ class Component(Component): (drawPtX + int(qX / 4), drawPtY + int(qY * 3)), (drawPtX + int(tX * 2), drawPtY + self.pxHeight), ) - drawer.ellipse(duckBody, fill=self.color) - drawer.ellipse(duckHead, fill=self.color) - drawer.pieslice(duckWing, 130, 200, fill=self.color) - drawer.pieslice(duckBeak, 145, 200, fill=self.color) + drawer.ellipse(duckBody, fill=thisCellColor) + drawer.ellipse(duckHead, fill=thisCellColor) + drawer.pieslice(duckWing, 130, 200, fill=thisCellColor) + drawer.pieslice(duckBeak, 145, 200, fill=thisCellColor) # Peace elif shape == "peace": @@ -321,9 +486,9 @@ class Component(Component): drawPtY + self.pxHeight - int(tenthY / 2), ), ) - drawer.ellipse(outlineShape, fill=self.color) + drawer.ellipse(outlineShape, fill=thisCellColor) drawer.ellipse(smallerShape, fill=(0, 0, 0, 0)) - drawer.rectangle(line, fill=self.color) + drawer.rectangle(line, fill=thisCellColor) def slantLine(difference): return ( @@ -334,8 +499,10 @@ class Component(Component): (drawPtY + hY), ) - drawer.line(slantLine(qX), fill=self.color, width=tenthX) - drawer.line(slantLine(self.pxWidth - qX), fill=self.color, width=tenthX) + drawer.line(slantLine(qX), fill=thisCellColor, width=tenthX) + drawer.line( + slantLine(self.pxWidth - qX), fill=thisCellColor, width=tenthX + ) for x, y in grid: drawPtX = x * self.pxWidth @@ -345,30 +512,16 @@ class Component(Component): if drawPtY > self.height: continue + audioMorphWidth = ( + 0 if spectrumData is None else int(spectrumData[(drawPtX % 63) * 4] / 4) + ) + audioMorphHeight = ( + 0 if spectrumData is None else int(spectrumData[(drawPtY % 63) * 4] / 4) + ) if self.customImg: drawCustomImg() else: - drawShape() - - if self.shadow: - shadImg = ImageEnhance.Contrast(frame).enhance(0.0) - shadImg = shadImg.filter(ImageFilter.GaussianBlur(5.00)) - shadImg = ImageChops.offset(shadImg, -2, 2) - shadImg.paste(frame, box=(0, 0), mask=frame) - frame = shadImg - if self.showGrid: - drawer = ImageDraw.Draw(frame) - w, h = scale(0.05, self.width, self.height, int) - for x in range(self.pxWidth, self.width, self.pxWidth): - drawer.rectangle( - ((x, 0), (x + w, self.height)), - fill=self.color, - ) - for y in range(self.pxHeight, self.height, self.pxHeight): - drawer.rectangle( - ((0, y), (self.width, y + h)), - fill=self.color, - ) + drawShape(x, y) return frame diff --git a/src/avp/components/life.ui b/src/avp/components/life.ui index 30cf9d0..a0c8999 100644 --- a/src/avp/components/life.ui +++ b/src/avp/components/life.ui @@ -7,7 +7,7 @@ 0 0 586 - 197 + 206 @@ -29,24 +29,27 @@ + + increase number for slower animation + frames per tick - 1 + 10 - 30 + 240 - 5 + 60 - Qt::Horizontal + Qt::Orientation::Horizontal @@ -103,7 +106,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -194,7 +197,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -258,7 +261,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -272,11 +275,24 @@ + + + + Kaleidoscope + + + true + + + Shadow + + true + @@ -289,7 +305,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -309,7 +325,7 @@ Up - Qt::UpArrow + Qt::ArrowType::UpArrow @@ -319,7 +335,7 @@ Down - Qt::DownArrow + Qt::ArrowType::DownArrow @@ -329,7 +345,7 @@ Left - Qt::LeftArrow + Qt::ArrowType::LeftArrow @@ -339,14 +355,14 @@ Right - Qt::RightArrow + Qt::ArrowType::RightArrow - Qt::Horizontal + Qt::Orientation::Horizontal @@ -356,6 +372,23 @@ + + + + Audio Sensitivity + + + + + + + 40 + + + 20 + + + @@ -364,19 +397,22 @@ <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> -<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +<html><head><meta name="qrichtext" content="1" /><meta charset="utf-8" /><style type="text/css"> p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'Ubuntu'; font-size:11pt; font-weight:400; font-style:normal;"> -<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-weight:600;">Click the preview window to place a cell. Right-click to remove.</span></p> -<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- A cell with less than 2 neighbours will die from underpopulation</p> -<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- A cell with more than 3 neighbours will die from overpopulation.</p> -<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">- An empty space surrounded by 3 live cells will cause reproduction.</p></body></html> +hr { height: 1px; border-width: 0; } +li.unchecked::marker { content: "\2610"; } +li.checked::marker { content: "\2612"; } +</style></head><body style=" font-family:'Noto Sans'; font-size:10pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Ubuntu'; font-size:11pt; font-weight:600;">Click the preview window to place a cell. Right-click to remove.</span></p> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Ubuntu'; font-size:11pt;">- A cell with less than 2 neighbours will die from underpopulation</span></p> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Ubuntu'; font-size:11pt;">- A cell with more than 3 neighbours will die from overpopulation.</span></p> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Ubuntu'; font-size:11pt;">- An empty space surrounded by 3 live cells will cause reproduction.</span></p></body></html> - 80 + 80.000000000000000 - Qt::NoTextInteraction + Qt::TextInteractionFlag::NoTextInteraction false @@ -388,7 +424,7 @@ p, li { white-space: pre-wrap; } - Qt::Vertical + Qt::Orientation::Vertical -- cgit v1.2.3