Some checks failed
CI / Test (Go 1.24.x, ubuntu-latest) (push) Successful in 1m53s
CI / Code Quality (push) Failing after 26s
CI / Security Scan (push) Failing after 11s
CI / Test Coverage (push) Successful in 1m13s
CI / Benchmarks (push) Failing after 10m22s
CI / Build CLI (push) Failing after 8s
Benchmarks / Run Benchmarks (push) Failing after 10m13s
Release / Test (push) Successful in 55s
Release / Build (amd64, darwin, ) (push) Failing after 12s
Release / Build (amd64, linux, ) (push) Failing after 6s
Release / Build (amd64, windows, .exe) (push) Failing after 12s
Release / Build (arm64, darwin, ) (push) Failing after 12s
Release / Build (arm64, linux, ) (push) Failing after 12s
Release / Release (push) Has been skipped
CI / Test (Go 1.24.x, macos-latest) (push) Has been cancelled
CI / Test (Go 1.24.x, windows-latest) (push) Has been cancelled
Move hosting from GitHub to private Gitea instance.
634 lines
17 KiB
Go
634 lines
17 KiB
Go
package renderer
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"math"
|
|
"sync"
|
|
|
|
"gitea.dockr.co/kev/go-jdenticon/internal/engine"
|
|
)
|
|
|
|
// PNG rendering constants
|
|
const (
|
|
defaultSupersamplingFactor = 8 // Default antialiasing supersampling factor
|
|
)
|
|
|
|
// Memory pools for reducing allocations during rendering
|
|
var (
|
|
// Pool for point slices used during polygon processing
|
|
// Uses pointer to slice to avoid allocation during type assertion (SA6002)
|
|
pointSlicePool = sync.Pool{
|
|
New: func() interface{} {
|
|
s := make([]engine.Point, 0, 16) // Pre-allocate reasonable capacity
|
|
return &s
|
|
},
|
|
}
|
|
|
|
// Pool for color row buffers
|
|
// Uses pointer to slice to avoid allocation during type assertion (SA6002)
|
|
colorRowBufferPool = sync.Pool{
|
|
New: func() interface{} {
|
|
s := make([]color.RGBA, 0, 1024) // Row buffer capacity
|
|
return &s
|
|
},
|
|
}
|
|
)
|
|
|
|
// ShapeCommand represents a rendering command for deferred execution
|
|
type ShapeCommand struct {
|
|
Type string // "polygon", "circle", "background"
|
|
Points []engine.Point // For polygons
|
|
Center engine.Point // For circles
|
|
Size float64 // For circles
|
|
Invert bool // For circles
|
|
Color color.RGBA
|
|
BBox image.Rectangle // Pre-calculated bounding box for culling
|
|
}
|
|
|
|
// PNGRenderer implements memory-efficient PNG generation using streaming row processing
|
|
// This eliminates the dual buffer allocation problem, reducing memory usage by ~80%
|
|
type PNGRenderer struct {
|
|
*BaseRenderer
|
|
finalImg *image.RGBA // Single buffer at target resolution
|
|
finalSize int // Target output size
|
|
bgColor color.RGBA // Background color
|
|
shapes []ShapeCommand // Queued rendering commands
|
|
}
|
|
|
|
// NewPNGRenderer creates a new memory-optimized PNG renderer
|
|
func NewPNGRenderer(iconSize int) *PNGRenderer {
|
|
// Only allocate the final image buffer - no supersampled buffer
|
|
finalBounds := image.Rect(0, 0, iconSize, iconSize)
|
|
finalImg := image.NewRGBA(finalBounds)
|
|
|
|
return &PNGRenderer{
|
|
BaseRenderer: NewBaseRenderer(iconSize),
|
|
finalImg: finalImg,
|
|
finalSize: iconSize,
|
|
shapes: make([]ShapeCommand, 0, 16), // Pre-allocate for typical use
|
|
}
|
|
}
|
|
|
|
// SetBackground sets the background color - queues background command
|
|
func (r *PNGRenderer) SetBackground(fillColor string, opacity float64) {
|
|
r.BaseRenderer.SetBackground(fillColor, opacity)
|
|
|
|
r.bgColor = r.parseColor(fillColor, opacity)
|
|
|
|
// Queue background command for proper rendering order
|
|
r.shapes = append(r.shapes, ShapeCommand{
|
|
Type: "background",
|
|
Color: r.bgColor,
|
|
BBox: image.Rect(0, 0, r.finalSize*2, r.finalSize*2), // Full supersampled bounds
|
|
})
|
|
}
|
|
|
|
// BeginShape marks the beginning of a new shape with the specified color
|
|
func (r *PNGRenderer) BeginShape(fillColor string) {
|
|
r.BaseRenderer.BeginShape(fillColor)
|
|
}
|
|
|
|
// EndShape marks the end of the currently drawn shape (no-op for queuing renderer)
|
|
func (r *PNGRenderer) EndShape() {
|
|
// No-op for command queuing approach
|
|
}
|
|
|
|
// AddPolygon queues a polygon command with pre-calculated bounding box
|
|
func (r *PNGRenderer) AddPolygon(points []engine.Point) {
|
|
if len(points) < 3 {
|
|
return // Can't render polygon with < 3 points
|
|
}
|
|
|
|
// Determine winding order for hole detection
|
|
var area float64
|
|
for i := 0; i < len(points); i++ {
|
|
p1 := points[i]
|
|
p2 := points[(i+1)%len(points)]
|
|
area += (p1.X * p2.Y) - (p2.X * p1.Y)
|
|
}
|
|
|
|
var renderColor color.RGBA
|
|
if area < 0 {
|
|
// Counter-clockwise winding (hole) - use background color
|
|
renderColor = r.bgColor
|
|
} else {
|
|
// Clockwise winding (normal shape)
|
|
renderColor = r.parseColor(r.GetCurrentColor(), 1.0)
|
|
}
|
|
|
|
// Get pooled point slice and scale points to supersampled coordinates
|
|
scaledPointsPtr := pointSlicePool.Get().(*[]engine.Point)
|
|
scaledPointsSlice := *scaledPointsPtr
|
|
defer func() {
|
|
*scaledPointsPtr = scaledPointsSlice // Update with potentially resized slice
|
|
pointSlicePool.Put(scaledPointsPtr)
|
|
}()
|
|
|
|
// Reset slice and ensure capacity
|
|
scaledPointsSlice = scaledPointsSlice[:0]
|
|
if cap(scaledPointsSlice) < len(points) {
|
|
scaledPointsSlice = make([]engine.Point, 0, len(points)*2)
|
|
}
|
|
|
|
minX, minY := math.MaxFloat64, math.MaxFloat64
|
|
maxX, maxY := -math.MaxFloat64, -math.MaxFloat64
|
|
|
|
for _, p := range points {
|
|
scaledP := engine.Point{
|
|
X: p.X * defaultSupersamplingFactor,
|
|
Y: p.Y * defaultSupersamplingFactor,
|
|
}
|
|
scaledPointsSlice = append(scaledPointsSlice, scaledP)
|
|
|
|
if scaledP.X < minX {
|
|
minX = scaledP.X
|
|
}
|
|
if scaledP.X > maxX {
|
|
maxX = scaledP.X
|
|
}
|
|
if scaledP.Y < minY {
|
|
minY = scaledP.Y
|
|
}
|
|
if scaledP.Y > maxY {
|
|
maxY = scaledP.Y
|
|
}
|
|
}
|
|
|
|
// Copy scaled points for storage in command (must copy since we're returning slice to pool)
|
|
scaledPoints := make([]engine.Point, len(scaledPointsSlice))
|
|
copy(scaledPoints, scaledPointsSlice)
|
|
|
|
// Create bounding box for culling (with safety margins)
|
|
bbox := image.Rect(
|
|
int(math.Floor(minX))-1,
|
|
int(math.Floor(minY))-1,
|
|
int(math.Ceil(maxX))+1,
|
|
int(math.Ceil(maxY))+1,
|
|
)
|
|
|
|
// Queue the polygon command
|
|
r.shapes = append(r.shapes, ShapeCommand{
|
|
Type: "polygon",
|
|
Points: scaledPoints,
|
|
Color: renderColor,
|
|
BBox: bbox,
|
|
})
|
|
}
|
|
|
|
// AddCircle queues a circle command with pre-calculated bounding box
|
|
func (r *PNGRenderer) AddCircle(topLeft engine.Point, size float64, invert bool) {
|
|
// Scale to supersampled coordinates
|
|
scaledTopLeft := engine.Point{
|
|
X: topLeft.X * defaultSupersamplingFactor,
|
|
Y: topLeft.Y * defaultSupersamplingFactor,
|
|
}
|
|
scaledSize := size * defaultSupersamplingFactor
|
|
|
|
centerX := scaledTopLeft.X + scaledSize/2.0
|
|
centerY := scaledTopLeft.Y + scaledSize/2.0
|
|
radius := scaledSize / 2.0
|
|
|
|
var renderColor color.RGBA
|
|
if invert {
|
|
renderColor = r.bgColor
|
|
} else {
|
|
renderColor = r.parseColor(r.GetCurrentColor(), 1.0)
|
|
}
|
|
|
|
// Calculate bounding box for the circle
|
|
bbox := image.Rect(
|
|
int(math.Floor(centerX-radius))-1,
|
|
int(math.Floor(centerY-radius))-1,
|
|
int(math.Ceil(centerX+radius))+1,
|
|
int(math.Ceil(centerY+radius))+1,
|
|
)
|
|
|
|
// Queue the circle command
|
|
r.shapes = append(r.shapes, ShapeCommand{
|
|
Type: "circle",
|
|
Center: engine.Point{X: centerX, Y: centerY},
|
|
Size: radius,
|
|
Color: renderColor,
|
|
BBox: bbox,
|
|
})
|
|
}
|
|
|
|
// ToPNG generates the final PNG image data using streaming row processing
|
|
func (r *PNGRenderer) ToPNG() ([]byte, error) {
|
|
return r.ToPNGWithSize(r.GetSize())
|
|
}
|
|
|
|
// ToPNGWithSize generates PNG image data with streaming row processing
|
|
func (r *PNGRenderer) ToPNGWithSize(outputSize int) ([]byte, error) {
|
|
// Execute streaming rendering pipeline
|
|
r.renderWithStreaming()
|
|
|
|
var resultImg image.Image = r.finalImg
|
|
|
|
// Scale if output size differs from internal size
|
|
if outputSize != r.finalSize {
|
|
resultImg = r.scaleImage(r.finalImg, outputSize)
|
|
}
|
|
|
|
// Encode to PNG with maximum compression
|
|
var buf bytes.Buffer
|
|
encoder := &png.Encoder{
|
|
CompressionLevel: png.BestCompression,
|
|
}
|
|
|
|
err := encoder.Encode(&buf, resultImg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("jdenticon: optimized renderer: PNG encoding failed: %w", err)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// renderWithStreaming executes the main streaming rendering pipeline
|
|
func (r *PNGRenderer) renderWithStreaming() {
|
|
supersampledWidth := r.finalSize * defaultSupersamplingFactor
|
|
|
|
// Get pooled row buffer for 2 supersampled rows - MASSIVE memory savings
|
|
rowBufferPtr := colorRowBufferPool.Get().(*[]color.RGBA)
|
|
rowBufferSlice := *rowBufferPtr
|
|
defer func() {
|
|
*rowBufferPtr = rowBufferSlice // Update with potentially resized slice
|
|
colorRowBufferPool.Put(rowBufferPtr)
|
|
}()
|
|
|
|
// Ensure buffer has correct size
|
|
requiredSize := supersampledWidth * 2
|
|
if cap(rowBufferSlice) < requiredSize {
|
|
rowBufferSlice = make([]color.RGBA, requiredSize)
|
|
} else {
|
|
rowBufferSlice = rowBufferSlice[:requiredSize]
|
|
}
|
|
|
|
// Process each final image row
|
|
for y := 0; y < r.finalSize; y++ {
|
|
// Clear row buffer to background color
|
|
for i := range rowBufferSlice {
|
|
rowBufferSlice[i] = r.bgColor
|
|
}
|
|
|
|
// Render all shapes for this row pair
|
|
r.renderShapesForRowPair(y, rowBufferSlice, supersampledWidth)
|
|
|
|
// Downsample directly into final image
|
|
r.downsampleRowPairToFinal(y, rowBufferSlice, supersampledWidth)
|
|
}
|
|
}
|
|
|
|
// renderShapesForRowPair renders all shapes that intersect the given row pair
|
|
func (r *PNGRenderer) renderShapesForRowPair(finalY int, rowBuffer []color.RGBA, supersampledWidth int) {
|
|
// Calculate supersampled Y range for this row pair
|
|
ssYStart := finalY * defaultSupersamplingFactor
|
|
ssYEnd := ssYStart + defaultSupersamplingFactor
|
|
|
|
// Render each shape that intersects this row pair
|
|
for _, shape := range r.shapes {
|
|
// Fast bounding box culling
|
|
if shape.BBox.Max.Y <= ssYStart || shape.BBox.Min.Y >= ssYEnd {
|
|
continue // Shape doesn't intersect this row pair
|
|
}
|
|
|
|
switch shape.Type {
|
|
case "polygon":
|
|
r.renderPolygonForRowPair(shape, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
|
|
case "circle":
|
|
r.renderCircleForRowPair(shape, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
|
|
}
|
|
}
|
|
}
|
|
|
|
// renderPolygonForRowPair renders a polygon for the specified row range
|
|
func (r *PNGRenderer) renderPolygonForRowPair(shape ShapeCommand, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
|
|
points := shape.Points
|
|
color := shape.Color
|
|
|
|
// Use triangle fan decomposition for simplicity
|
|
if len(points) == 3 {
|
|
// Direct triangle rendering
|
|
r.fillTriangleForRowRange(points[0], points[1], points[2], color, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
|
|
} else if len(points) == 4 && r.isRectangle(points) {
|
|
// Optimized rectangle rendering
|
|
minX, minY, maxX, maxY := r.getBoundsFloat(points)
|
|
r.fillRectForRowRange(minX, minY, maxX, maxY, color, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
|
|
} else {
|
|
// General polygon - triangle fan from first vertex
|
|
for i := 1; i < len(points)-1; i++ {
|
|
r.fillTriangleForRowRange(points[0], points[i], points[i+1], color, ssYStart, ssYEnd, rowBuffer, supersampledWidth)
|
|
}
|
|
}
|
|
}
|
|
|
|
// renderCircleForRowPair renders a circle for the specified row range
|
|
func (r *PNGRenderer) renderCircleForRowPair(shape ShapeCommand, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
|
|
centerX := shape.Center.X
|
|
centerY := shape.Center.Y
|
|
radius := shape.Size
|
|
color := shape.Color
|
|
radiusSq := radius * radius
|
|
|
|
// Process each supersampled row in the range
|
|
for y := ssYStart; y < ssYEnd; y++ {
|
|
yFloat := float64(y)
|
|
dy := yFloat - centerY
|
|
dySq := dy * dy
|
|
|
|
if dySq > radiusSq {
|
|
continue // Row doesn't intersect circle
|
|
}
|
|
|
|
// Calculate horizontal span for this row
|
|
dx := math.Sqrt(radiusSq - dySq)
|
|
xStart := int(math.Floor(centerX - dx))
|
|
xEnd := int(math.Ceil(centerX + dx))
|
|
|
|
// Clip to buffer bounds
|
|
if xStart < 0 {
|
|
xStart = 0
|
|
}
|
|
if xEnd >= supersampledWidth {
|
|
xEnd = supersampledWidth - 1
|
|
}
|
|
|
|
// Fill the horizontal span
|
|
rowIndex := (y - ssYStart) * supersampledWidth
|
|
for x := xStart; x <= xEnd; x++ {
|
|
// Verify pixel is actually inside circle
|
|
dxPixel := float64(x) - centerX
|
|
if dxPixel*dxPixel+dySq <= radiusSq {
|
|
if rowIndex+x < len(rowBuffer) {
|
|
rowBuffer[rowIndex+x] = color
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// fillTriangleForRowRange fills a triangle within the specified row range
|
|
func (r *PNGRenderer) fillTriangleForRowRange(p1, p2, p3 engine.Point, color color.RGBA, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
|
|
// Get triangle bounds
|
|
minY := math.Min(math.Min(p1.Y, p2.Y), p3.Y)
|
|
maxY := math.Max(math.Max(p1.Y, p2.Y), p3.Y)
|
|
|
|
// Clip to row range
|
|
iterYStart := int(math.Max(math.Ceil(minY), float64(ssYStart)))
|
|
iterYEnd := int(math.Min(math.Floor(maxY), float64(ssYEnd-1)))
|
|
|
|
if iterYStart > iterYEnd {
|
|
return // Triangle doesn't intersect row range
|
|
}
|
|
|
|
// Sort points by Y coordinate
|
|
x1, y1 := p1.X, p1.Y
|
|
x2, y2 := p2.X, p2.Y
|
|
x3, y3 := p3.X, p3.Y
|
|
|
|
if y1 > y2 {
|
|
x1, y1, x2, y2 = x2, y2, x1, y1
|
|
}
|
|
if y1 > y3 {
|
|
x1, y1, x3, y3 = x3, y3, x1, y1
|
|
}
|
|
if y2 > y3 {
|
|
x2, y2, x3, y3 = x3, y3, x2, y2
|
|
}
|
|
|
|
// Fill triangle using scan-line algorithm
|
|
for y := iterYStart; y <= iterYEnd; y++ {
|
|
yFloat := float64(y)
|
|
var xLeft, xRight float64
|
|
|
|
if yFloat < y2 {
|
|
// Upper part of triangle
|
|
if y2 != y1 {
|
|
slope12 := (x2 - x1) / (y2 - y1)
|
|
xLeft = x1 + slope12*(yFloat-y1)
|
|
} else {
|
|
xLeft = x1
|
|
}
|
|
if y3 != y1 {
|
|
slope13 := (x3 - x1) / (y3 - y1)
|
|
xRight = x1 + slope13*(yFloat-y1)
|
|
} else {
|
|
xRight = x1
|
|
}
|
|
} else {
|
|
// Lower part of triangle
|
|
if y3 != y2 {
|
|
slope23 := (x3 - x2) / (y3 - y2)
|
|
xLeft = x2 + slope23*(yFloat-y2)
|
|
} else {
|
|
xLeft = x2
|
|
}
|
|
if y3 != y1 {
|
|
slope13 := (x3 - x1) / (y3 - y1)
|
|
xRight = x1 + slope13*(yFloat-y1)
|
|
} else {
|
|
xRight = x1
|
|
}
|
|
}
|
|
|
|
if xLeft > xRight {
|
|
xLeft, xRight = xRight, xLeft
|
|
}
|
|
|
|
// Convert to pixel coordinates and fill
|
|
xLeftInt := int(math.Floor(xLeft))
|
|
xRightInt := int(math.Floor(xRight))
|
|
|
|
// Clip to buffer bounds
|
|
if xLeftInt < 0 {
|
|
xLeftInt = 0
|
|
}
|
|
if xRightInt >= supersampledWidth {
|
|
xRightInt = supersampledWidth - 1
|
|
}
|
|
|
|
// Fill horizontal span in row buffer
|
|
rowIndex := (y - ssYStart) * supersampledWidth
|
|
for x := xLeftInt; x <= xRightInt; x++ {
|
|
if rowIndex+x < len(rowBuffer) {
|
|
rowBuffer[rowIndex+x] = color
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// fillRectForRowRange fills a rectangle within the specified row range
|
|
func (r *PNGRenderer) fillRectForRowRange(x1, y1, x2, y2 float64, color color.RGBA, ssYStart, ssYEnd int, rowBuffer []color.RGBA, supersampledWidth int) {
|
|
// Convert to integer bounds
|
|
xStart := int(math.Floor(x1))
|
|
yStart := int(math.Floor(y1))
|
|
xEnd := int(math.Ceil(x2))
|
|
yEnd := int(math.Ceil(y2))
|
|
|
|
// Clip to row range
|
|
if yStart < ssYStart {
|
|
yStart = ssYStart
|
|
}
|
|
if yEnd > ssYEnd {
|
|
yEnd = ssYEnd
|
|
}
|
|
if xStart < 0 {
|
|
xStart = 0
|
|
}
|
|
if xEnd > supersampledWidth {
|
|
xEnd = supersampledWidth
|
|
}
|
|
|
|
// Fill rectangle in row buffer
|
|
for y := yStart; y < yEnd; y++ {
|
|
rowIndex := (y - ssYStart) * supersampledWidth
|
|
for x := xStart; x < xEnd; x++ {
|
|
if rowIndex+x < len(rowBuffer) {
|
|
rowBuffer[rowIndex+x] = color
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// downsampleRowPairToFinal downsamples 2 supersampled rows into 1 final row using box filter
|
|
func (r *PNGRenderer) downsampleRowPairToFinal(finalY int, rowBuffer []color.RGBA, supersampledWidth int) {
|
|
for x := 0; x < r.finalSize; x++ {
|
|
// Sample 2x2 block from row buffer
|
|
x0 := x * defaultSupersamplingFactor
|
|
x1 := x0 + 1
|
|
|
|
// Row 0 (first supersampled row)
|
|
idx00 := x0
|
|
idx01 := x1
|
|
|
|
// Row 1 (second supersampled row)
|
|
idx10 := supersampledWidth + x0
|
|
idx11 := supersampledWidth + x1
|
|
|
|
// Sum RGBA values from 2x2 block
|
|
var rSum, gSum, bSum, aSum uint32
|
|
|
|
if idx00 < len(rowBuffer) {
|
|
c := rowBuffer[idx00]
|
|
rSum += uint32(c.R)
|
|
gSum += uint32(c.G)
|
|
bSum += uint32(c.B)
|
|
aSum += uint32(c.A)
|
|
}
|
|
if idx01 < len(rowBuffer) {
|
|
c := rowBuffer[idx01]
|
|
rSum += uint32(c.R)
|
|
gSum += uint32(c.G)
|
|
bSum += uint32(c.B)
|
|
aSum += uint32(c.A)
|
|
}
|
|
if idx10 < len(rowBuffer) {
|
|
c := rowBuffer[idx10]
|
|
rSum += uint32(c.R)
|
|
gSum += uint32(c.G)
|
|
bSum += uint32(c.B)
|
|
aSum += uint32(c.A)
|
|
}
|
|
if idx11 < len(rowBuffer) {
|
|
c := rowBuffer[idx11]
|
|
rSum += uint32(c.R)
|
|
gSum += uint32(c.G)
|
|
bSum += uint32(c.B)
|
|
aSum += uint32(c.A)
|
|
}
|
|
|
|
// Average by dividing by 4
|
|
// #nosec G115 -- Safe: sum of 4 uint8 values (max 255*4=1020) divided by 4 always fits in uint8
|
|
avgColor := color.RGBA{
|
|
R: uint8(rSum / 4),
|
|
G: uint8(gSum / 4),
|
|
B: uint8(bSum / 4),
|
|
A: uint8(aSum / 4),
|
|
}
|
|
|
|
// Set pixel in final image
|
|
r.finalImg.Set(x, finalY, avgColor)
|
|
}
|
|
}
|
|
|
|
// Helper functions (reused from original implementation)
|
|
|
|
func (r *PNGRenderer) parseColor(colorStr string, opacity float64) color.RGBA {
|
|
if colorStr != "" && colorStr[0] != '#' {
|
|
colorStr = "#" + colorStr
|
|
}
|
|
|
|
rgba, err := engine.ParseHexColorForRenderer(colorStr, opacity)
|
|
if err != nil {
|
|
return color.RGBA{0, 0, 0, uint8(opacity * 255)}
|
|
}
|
|
|
|
return rgba
|
|
}
|
|
|
|
func (r *PNGRenderer) isRectangle(points []engine.Point) bool {
|
|
if len(points) != 4 {
|
|
return false
|
|
}
|
|
|
|
uniqueX := make(map[float64]struct{})
|
|
uniqueY := make(map[float64]struct{})
|
|
|
|
for _, p := range points {
|
|
uniqueX[p.X] = struct{}{}
|
|
uniqueY[p.Y] = struct{}{}
|
|
}
|
|
|
|
return len(uniqueX) == 2 && len(uniqueY) == 2
|
|
}
|
|
|
|
func (r *PNGRenderer) getBoundsFloat(points []engine.Point) (float64, float64, float64, float64) {
|
|
if len(points) == 0 {
|
|
return 0, 0, 0, 0
|
|
}
|
|
|
|
minX, maxX := points[0].X, points[0].X
|
|
minY, maxY := points[0].Y, points[0].Y
|
|
|
|
for _, p := range points[1:] {
|
|
if p.X < minX {
|
|
minX = p.X
|
|
}
|
|
if p.X > maxX {
|
|
maxX = p.X
|
|
}
|
|
if p.Y < minY {
|
|
minY = p.Y
|
|
}
|
|
if p.Y > maxY {
|
|
maxY = p.Y
|
|
}
|
|
}
|
|
|
|
return minX, minY, maxX, maxY
|
|
}
|
|
|
|
func (r *PNGRenderer) scaleImage(src *image.RGBA, newSize int) image.Image {
|
|
oldSize := r.finalSize
|
|
if oldSize == newSize {
|
|
return src
|
|
}
|
|
|
|
scaled := image.NewRGBA(image.Rect(0, 0, newSize, newSize))
|
|
ratio := float64(oldSize) / float64(newSize)
|
|
|
|
for y := 0; y < newSize; y++ {
|
|
for x := 0; x < newSize; x++ {
|
|
srcX := int(float64(x) * ratio)
|
|
srcY := int(float64(y) * ratio)
|
|
scaled.Set(x, y, src.At(srcX, srcY))
|
|
}
|
|
}
|
|
|
|
return scaled
|
|
}
|