803 lines
25 KiB
Go
803 lines
25 KiB
Go
package qslcard
|
||
|
||
import (
|
||
"fmt"
|
||
"image"
|
||
_ "image/jpeg" // hero/insert photo decoding
|
||
_ "image/png"
|
||
"math"
|
||
"os"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// Analysis grid resolution (spec section 5.1): 32 columns × 20 rows.
|
||
const (
|
||
gridCols = 32
|
||
gridRows = 20
|
||
)
|
||
|
||
// Default card geometry: 140×90 mm at 300 dpi.
|
||
const (
|
||
cardW = 1654
|
||
cardH = 1063
|
||
cardDPI = 300
|
||
cardBleed = 35
|
||
)
|
||
|
||
// detail thresholds: a cell is "quiet" (usable for text) below quietDetail;
|
||
// quietRelaxed is the fallback when a photo has no large calm zone.
|
||
const (
|
||
quietDetail = 0.2
|
||
quietRelaxed = 0.3
|
||
)
|
||
|
||
// Cell is one analysis-grid cell of a photo.
|
||
type Cell struct {
|
||
Detail float64 // mean gradient magnitude, 0–1
|
||
Luma float64 // mean relative luminance, 0–1
|
||
}
|
||
|
||
// PhotoAnalysis is the downscaled feature summary of one photo. All scores
|
||
// are deterministic so "Generate designs" always yields the same proposals.
|
||
type PhotoAnalysis struct {
|
||
Path string
|
||
W, H int // original pixel size
|
||
Cells [gridRows][gridCols]Cell
|
||
Quietness float64 // share of cells with detail < 0.15
|
||
AspectFit float64 // closeness to the 14:9 card ratio, 0–1
|
||
AvgLuma float64
|
||
Warmth float64 // mean (R−B), positive = warm (sunset), negative = cool
|
||
Score float64 // hero suitability
|
||
}
|
||
|
||
// ProfileInfo is the slice of the active profile the engine needs (actual
|
||
// values are used only for sizing — templates store {profile.*} placeholders).
|
||
type ProfileInfo struct {
|
||
Callsign string
|
||
Operator string
|
||
Grid string
|
||
CQZone int
|
||
ITUZone int
|
||
Rig string
|
||
Antenna string
|
||
}
|
||
|
||
// LayoutEngine proposes card templates from analyzed photos. The heuristic
|
||
// engine is the default; a future AI engine implements the same contract and
|
||
// falls back to the heuristic one on any error.
|
||
type LayoutEngine interface {
|
||
Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]Template, error)
|
||
}
|
||
|
||
// AnalyzePhoto decodes and analyzes one photo file.
|
||
func AnalyzePhoto(path string) (PhotoAnalysis, error) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return PhotoAnalysis{}, fmt.Errorf("open photo: %w", err)
|
||
}
|
||
defer f.Close()
|
||
img, _, err := image.Decode(f)
|
||
if err != nil {
|
||
return PhotoAnalysis{}, fmt.Errorf("decode photo %s: %w", path, err)
|
||
}
|
||
return AnalyzeImage(img, path), nil
|
||
}
|
||
|
||
// AnalyzeImage computes the analysis grid of an already-decoded image.
|
||
// Exported separately so tests can feed synthetic images.
|
||
func AnalyzeImage(img image.Image, path string) PhotoAnalysis {
|
||
b := img.Bounds()
|
||
origW, origH := b.Dx(), b.Dy()
|
||
|
||
// Downscale to ≤256 px on the long edge (nearest neighbor).
|
||
scale := 1.0
|
||
if long := max(origW, origH); long > 256 {
|
||
scale = 256.0 / float64(long)
|
||
}
|
||
w := max(1, int(float64(origW)*scale))
|
||
h := max(1, int(float64(origH)*scale))
|
||
luma := make([]float64, w*h)
|
||
var sumWarm float64
|
||
for y := 0; y < h; y++ {
|
||
sy := b.Min.Y + y*origH/h
|
||
for x := 0; x < w; x++ {
|
||
sx := b.Min.X + x*origW/w
|
||
r, g, bl, _ := img.At(sx, sy).RGBA()
|
||
rf, gf, bf := float64(r)/65535, float64(g)/65535, float64(bl)/65535
|
||
luma[y*w+x] = 0.2126*rf + 0.7152*gf + 0.0722*bf
|
||
sumWarm += rf - bf
|
||
}
|
||
}
|
||
|
||
p := PhotoAnalysis{Path: path, W: origW, H: origH, Warmth: sumWarm / float64(w*h)}
|
||
var lumaSum float64
|
||
quietCells := 0
|
||
for r := 0; r < gridRows; r++ {
|
||
y0, y1 := r*h/gridRows, (r+1)*h/gridRows
|
||
for c := 0; c < gridCols; c++ {
|
||
x0, x1 := c*w/gridCols, (c+1)*w/gridCols
|
||
var dSum, lSum float64
|
||
n, dn := 0, 0
|
||
for y := y0; y < y1; y++ {
|
||
for x := x0; x < x1; x++ {
|
||
l := luma[y*w+x]
|
||
lSum += l
|
||
n++
|
||
if x+1 < w {
|
||
dSum += math.Abs(luma[y*w+x+1] - l)
|
||
dn++
|
||
}
|
||
if y+1 < h {
|
||
dSum += math.Abs(luma[(y+1)*w+x] - l)
|
||
dn++
|
||
}
|
||
}
|
||
}
|
||
cell := Cell{}
|
||
if n > 0 {
|
||
cell.Luma = lSum / float64(n)
|
||
}
|
||
if dn > 0 {
|
||
// Mean neighbor difference ≈ 0.12 in textured regions; ×8
|
||
// stretches that to ~1 so the 0–1 thresholds are meaningful.
|
||
cell.Detail = math.Min(1, dSum/float64(dn)*8)
|
||
}
|
||
p.Cells[r][c] = cell
|
||
lumaSum += cell.Luma
|
||
if cell.Detail < 0.15 {
|
||
quietCells++
|
||
}
|
||
}
|
||
}
|
||
p.AvgLuma = lumaSum / (gridRows * gridCols)
|
||
p.Quietness = float64(quietCells) / (gridRows * gridCols)
|
||
|
||
const targetAR = 14.0 / 9.0
|
||
ar := float64(origW) / float64(origH)
|
||
p.AspectFit = math.Min(ar, targetAR) / math.Max(ar, targetAR)
|
||
resNorm := math.Min(1, float64(origW*origH)/(1600.0*1029.0))
|
||
p.Score = resNorm * p.AspectFit * (0.2 + 0.8*p.Quietness)
|
||
return p
|
||
}
|
||
|
||
// ── maximal quiet rectangle ─────────────────────────────────────────────
|
||
|
||
// cellRect is a rectangle in analysis-grid cell coordinates (inclusive
|
||
// row/col start, exclusive end).
|
||
type cellRect struct{ r0, c0, r1, c1 int }
|
||
|
||
func (r cellRect) rows() int { return r.r1 - r.r0 }
|
||
func (r cellRect) cols() int { return r.c1 - r.c0 }
|
||
|
||
// maximalQuietRect finds the best axis-aligned rectangle of cells where
|
||
// quiet() is true, at least minCols × minRows, preferring the top or bottom
|
||
// third (text near the card edges keeps the photo subject visible).
|
||
// O(rows²·cols) — trivial at 20×32.
|
||
func maximalQuietRect(quiet func(r, c int) bool, minCols, minRows int) (cellRect, bool) {
|
||
best := cellRect{}
|
||
bestScore := 0.0
|
||
for r0 := 0; r0 < gridRows; r0++ {
|
||
for r1 := r0 + 1; r1 <= gridRows; r1++ {
|
||
if r1-r0 < minRows {
|
||
continue
|
||
}
|
||
run := 0
|
||
for c := 0; c <= gridCols; c++ {
|
||
ok := c < gridCols
|
||
if ok {
|
||
for r := r0; r < r1; r++ {
|
||
if !quiet(r, c) {
|
||
ok = false
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if ok {
|
||
run++
|
||
continue
|
||
}
|
||
if run >= minCols {
|
||
cand := cellRect{r0: r0, c0: c - run, r1: r1, c1: c}
|
||
score := float64(cand.rows()*cand.cols()) * positionBonus(cand)
|
||
if score > bestScore {
|
||
best, bestScore = cand, score
|
||
}
|
||
}
|
||
run = 0
|
||
}
|
||
}
|
||
}
|
||
return best, bestScore > 0
|
||
}
|
||
|
||
// positionBonus prefers rectangles whose center sits in the top or bottom
|
||
// third of the grid.
|
||
func positionBonus(r cellRect) float64 {
|
||
center := float64(r.r0+r.r1) / 2
|
||
if center < gridRows/3.0 || center > gridRows*2/3.0 {
|
||
return 1.25
|
||
}
|
||
return 1.0
|
||
}
|
||
|
||
// ── heuristic engine ────────────────────────────────────────────────────
|
||
|
||
// HeuristicEngine is the default, fully-offline layout engine.
|
||
type HeuristicEngine struct{}
|
||
|
||
// proposalPlan captures the decisions shared by the three proposals.
|
||
type proposalPlan struct {
|
||
hero PhotoAnalysis
|
||
inserts []PhotoAnalysis
|
||
profile ProfileInfo
|
||
cool bool // cool-toned photo → silver alternative
|
||
}
|
||
|
||
// Propose builds three distinct full templates (spec section 5.3):
|
||
// best archetype + gel gold, an alternate archetype, and the best archetype
|
||
// with the alternate color treatment.
|
||
func (HeuristicEngine) Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]Template, error) {
|
||
if len(photos) == 0 {
|
||
return nil, fmt.Errorf("no photos to lay out")
|
||
}
|
||
if profile.Callsign == "" {
|
||
return nil, fmt.Errorf("active profile has no callsign — set one before generating designs")
|
||
}
|
||
|
||
sorted := make([]PhotoAnalysis, len(photos))
|
||
copy(sorted, photos)
|
||
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Score > sorted[j].Score })
|
||
|
||
plan := proposalPlan{
|
||
hero: sorted[0],
|
||
profile: profile,
|
||
cool: sorted[0].Warmth < 0.02,
|
||
}
|
||
if len(sorted) > 1 {
|
||
plan.inserts = sorted[1:min(len(sorted), 3)] // hero + up to 2 inserts
|
||
}
|
||
|
||
// Only side-column inserts (or none): a bottom strip collides with the QSO
|
||
// box, which is what produced the overlapping mess.
|
||
primary := ArchetypeSideColumn
|
||
if len(plan.inserts) == 0 {
|
||
primary = ArchetypeFullBleed
|
||
}
|
||
|
||
// Three proposals that showcase genuinely different call looks (font +
|
||
// style + position), echoing the classic printed-QSL styles: a rounded
|
||
// glossy gold, a distressed slab "vintage", and an angular silver. The
|
||
// middle one is a clean full-bleed (no inserts) so the set always offers a
|
||
// minimal option.
|
||
p1 := buildTemplateBiased(plan, primary, "gel_gold", "Baloo 2", flipTop)
|
||
p2 := buildTemplateBiased(plan, ArchetypeFullBleed, "gel_gold_grunge", "Alfa Slab One", flipBottom)
|
||
p3 := buildTemplateBiased(plan, primary, "gel_silver", "Archivo Black", flipNatural)
|
||
|
||
out := []Template{p1, p2, p3}
|
||
for i := range out {
|
||
out[i].Name = fmt.Sprintf("%s — %s %d", profile.Callsign, archetypeLabel(out[i]), i+1)
|
||
if err := Validate(out[i], nil); err != nil {
|
||
return nil, fmt.Errorf("proposal %d invalid: %w", i+1, err)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func archetypeLabel(t Template) string {
|
||
inserts := 0
|
||
for _, e := range t.Elements {
|
||
if e.Type == ElemInsert {
|
||
inserts++
|
||
}
|
||
}
|
||
if inserts == 0 {
|
||
return "full bleed"
|
||
}
|
||
return "with inserts"
|
||
}
|
||
|
||
// pxRect is a rectangle in card pixel space.
|
||
type pxRect struct{ x, y, w, h float64 }
|
||
|
||
// Vertical bias for the callsign zone, used to spread the three proposals.
|
||
const (
|
||
flipNatural = iota // largest quiet rect, wherever it is
|
||
flipTop // restrict the callsign to the top half
|
||
flipBottom // restrict the callsign to the bottom half
|
||
)
|
||
|
||
// charWidthFactor is a rough per-font average glyph width (in ems) for the
|
||
// heavy display faces, used to size the callsign so it fills its zone without
|
||
// overflowing. The frontend can refine with real measureText later.
|
||
func charWidthFactor(font string) float64 {
|
||
switch font {
|
||
case "Alfa Slab One", "Rye":
|
||
return 0.82
|
||
case "Baloo 2":
|
||
return 0.72
|
||
case "Oswald":
|
||
return 0.55
|
||
default: // Archivo Black, Lilita One
|
||
return 0.72
|
||
}
|
||
}
|
||
|
||
func buildTemplateBiased(plan proposalPlan, archetype, style, font string, flip int) Template {
|
||
hero := plan.hero
|
||
crop := cropForCard(hero)
|
||
|
||
t := Template{
|
||
Schema: SchemaVersion,
|
||
Card: Card{W: cardW, H: cardH, DPI: cardDPI, BleedPx: cardBleed},
|
||
Hero: Hero{Photo: hero.Path, Crop: crop},
|
||
}
|
||
|
||
// Place inserts first so the callsign zone can steer clear of them — the
|
||
// call (and its trailing operator script) must never sit under an insert.
|
||
var insertEls []Element
|
||
var insertArea pxRect
|
||
hasInserts := archetype != ArchetypeFullBleed && len(plan.inserts) > 0
|
||
if hasInserts {
|
||
insertEls, insertArea = placeInserts(plan.inserts, hero, archetype)
|
||
}
|
||
exclude := insertArea
|
||
if exclude.w > 0 {
|
||
exclude = pxRect{x: exclude.x - 24, y: exclude.y - 24, w: exclude.w + 48, h: exclude.h + 48}
|
||
}
|
||
|
||
zone, found := callsignZone(hero, crop, archetype, flip, exclude)
|
||
if !found {
|
||
// Busy photo everywhere: darken it and drop the call on a band clear
|
||
// of the inserts.
|
||
t.Hero.Scrim = &Scrim{Enabled: true, Color: "#0b1a2e", Opacity: 0.25}
|
||
zone = fallbackZone(insertArea, flip)
|
||
}
|
||
|
||
zoneLuma := regionLuma(hero, crop, zone)
|
||
params := adaptStyle(style, zoneLuma, hero.Warmth)
|
||
|
||
call := plan.profile.Callsign
|
||
cw := charWidthFactor(font)
|
||
size := math.Min(zone.w*0.9/(cw*float64(max(len(call), 3))), zone.h*0.95)
|
||
callW := cw * size * float64(len(call))
|
||
callX := zone.x + (zone.w-callW)/2
|
||
callY := zone.y + (zone.h-size)*0.3
|
||
t.Elements = append(t.Elements, Element{
|
||
Type: ElemCallsign, Text: "{profile.callsign}",
|
||
Font: font, Size: math.Round(size),
|
||
X: math.Round(clamp(callX, 40, cardW-80)), Y: math.Round(clamp(callY, 30, cardH-size-30)),
|
||
StylePreset: style, StyleParams: params,
|
||
})
|
||
|
||
// Operator first name in script. Skipped when the profile's operator field
|
||
// is just the callsign — rendering the call a second time in cursive is
|
||
// redundant (the script slot is meant for the operator's name). Placed to
|
||
// trail past the call's tail, or below it when an insert would clip it.
|
||
if op := plan.profile.Operator; op != "" && !strings.EqualFold(op, call) {
|
||
opSize := math.Round(size * 0.42)
|
||
opX := callX + callW - size*0.12
|
||
opY := callY + size*0.6
|
||
if insertArea.w > 0 && opX+float64(len(op))*opSize*0.42 > insertArea.x {
|
||
opX = callX + size*0.12 // tuck the signature under the call instead
|
||
opY = callY + size*1.05
|
||
}
|
||
t.Elements = append(t.Elements, Element{
|
||
Type: ElemOperator, Text: "{profile.operator_name}",
|
||
Font: FontScriptDefault, Size: opSize, Rotate: -7,
|
||
X: math.Round(clamp(opX, 40, cardW-220)),
|
||
Y: math.Round(clamp(opY, 30, cardH-opSize-30)),
|
||
StylePreset: "script_white",
|
||
StyleParams: &StyleParams{OutlineColor: "#22364e", OutlineWidth: 2},
|
||
})
|
||
}
|
||
|
||
infoY := callY + size*1.45
|
||
if infoY > cardH-120 {
|
||
infoY = callY - 60 // callsign near the bottom: info line goes above
|
||
}
|
||
t.Elements = append(t.Elements, Element{
|
||
Type: ElemInfoLine,
|
||
Text: "CQ Zone {profile.cq_zone} · ITU Zone {profile.itu_zone} · Loc. {profile.grid}",
|
||
Font: FontInfoLine, Size: 29,
|
||
X: math.Round(clamp(zone.x, 40, cardW-700)), Y: math.Round(clamp(infoY, 30, cardH-70)),
|
||
StylePreset: "outlined_white",
|
||
StyleParams: &StyleParams{OutlineColor: "#22364e", OutlineWidth: 5},
|
||
})
|
||
|
||
// Operating conditions (rig / antenna), stacked next to the zones line.
|
||
// Always added so the "Show on card" toggle exists, but hidden by default
|
||
// when the profile has no rig/antenna yet (avoids an empty "Rig · Ant").
|
||
txt, stationHidden := stationLineText(plan.profile)
|
||
stationY := infoY + 40
|
||
if infoY < callY { // zones line sits above the call → stack station above it
|
||
stationY = infoY - 40
|
||
}
|
||
t.Elements = append(t.Elements, Element{
|
||
Type: ElemInfoLine, Text: txt, Hidden: stationHidden,
|
||
Font: FontInfoLine, Size: 26,
|
||
X: math.Round(clamp(zone.x, 40, cardW-700)), Y: math.Round(clamp(stationY, 30, cardH-60)),
|
||
StylePreset: "outlined_white",
|
||
StyleParams: &StyleParams{OutlineColor: "#22364e", OutlineWidth: 5},
|
||
})
|
||
|
||
occupied := []pxRect{zone}
|
||
if hasInserts {
|
||
t.Elements = append(t.Elements, insertEls...)
|
||
occupied = append(occupied, insertArea)
|
||
}
|
||
|
||
box := placeQSOBox(plan.profile, zone, occupied)
|
||
t.QSOBox = &box
|
||
occupied = append(occupied, pxRect{x: box.X, y: box.Y, w: box.W, h: box.H})
|
||
|
||
cx, cy := quietestCorner(hero, crop, occupied)
|
||
t.Elements = append(t.Elements, Element{
|
||
Type: ElemCountry, Flag: "auto", Label: "auto", Size: 30,
|
||
X: math.Round(cx), Y: math.Round(cy),
|
||
})
|
||
return t
|
||
}
|
||
|
||
// stationLineText builds the rig/antenna line for the profile and whether it
|
||
// should start hidden. When the profile has neither, it returns the full
|
||
// template text with hidden=true so the editor still offers the toggle (it
|
||
// resolves cleanly once the user fills My rig / My antenna). Values fill in
|
||
// from {profile.*} at render time.
|
||
func stationLineText(p ProfileInfo) (text string, hidden bool) {
|
||
switch {
|
||
case p.Rig != "" && p.Antenna != "":
|
||
return "Rig {qso.my_rig} · Ant {qso.my_antenna}", false
|
||
case p.Rig != "":
|
||
return "Rig {qso.my_rig}", false
|
||
case p.Antenna != "":
|
||
return "Ant {qso.my_antenna}", false
|
||
default:
|
||
return "Rig {qso.my_rig} · Ant {qso.my_antenna}", true
|
||
}
|
||
}
|
||
|
||
// cropForCard crops the photo to the card aspect ratio, sliding the window
|
||
// toward the detail centroid so the interesting content stays visible.
|
||
func cropForCard(p PhotoAnalysis) Crop {
|
||
targetAR := float64(cardW) / float64(cardH)
|
||
w, h := float64(p.W), float64(p.H)
|
||
cx, cy := detailCentroid(p)
|
||
if w/h > targetAR { // too wide: crop horizontally around the centroid
|
||
cw := h * targetAR
|
||
x := clamp(cx*w-cw/2, 0, w-cw)
|
||
return Crop{X: int(x), Y: 0, W: int(cw), H: int(h)}
|
||
}
|
||
ch := w / targetAR
|
||
y := clamp(cy*h-ch/2, 0, h-ch)
|
||
return Crop{X: 0, Y: int(y), W: int(w), H: int(ch)}
|
||
}
|
||
|
||
// detailCentroid returns the detail-weighted centroid in normalized [0,1]
|
||
// photo coordinates.
|
||
func detailCentroid(p PhotoAnalysis) (float64, float64) {
|
||
var sx, sy, sw float64
|
||
for r := 0; r < gridRows; r++ {
|
||
for c := 0; c < gridCols; c++ {
|
||
d := p.Cells[r][c].Detail
|
||
sx += d * (float64(c) + 0.5) / gridCols
|
||
sy += d * (float64(r) + 0.5) / gridRows
|
||
sw += d
|
||
}
|
||
}
|
||
if sw == 0 {
|
||
return 0.5, 0.5
|
||
}
|
||
return sx / sw, sy / sw
|
||
}
|
||
|
||
// cellInsideCrop reports whether grid cell (r,c) of the photo lies fully
|
||
// inside the crop window.
|
||
func cellInsideCrop(p PhotoAnalysis, crop Crop, r, c int) bool {
|
||
x0, x1 := c*p.W/gridCols, (c+1)*p.W/gridCols
|
||
y0, y1 := r*p.H/gridRows, (r+1)*p.H/gridRows
|
||
return x0 >= crop.X && x1 <= crop.X+crop.W && y0 >= crop.Y && y1 <= crop.Y+crop.H
|
||
}
|
||
|
||
// cellToCard converts a cell rectangle (photo grid space) to card pixels.
|
||
func cellToCard(p PhotoAnalysis, crop Crop, r cellRect) pxRect {
|
||
x0 := float64(r.c0*p.W/gridCols-crop.X) * cardW / float64(crop.W)
|
||
x1 := float64(r.c1*p.W/gridCols-crop.X) * cardW / float64(crop.W)
|
||
y0 := float64(r.r0*p.H/gridRows-crop.Y) * cardH / float64(crop.H)
|
||
y1 := float64(r.r1*p.H/gridRows-crop.Y) * cardH / float64(crop.H)
|
||
return pxRect{
|
||
x: clamp(x0, 0, cardW), y: clamp(y0, 0, cardH),
|
||
w: clamp(x1, 0, cardW) - clamp(x0, 0, cardW),
|
||
h: clamp(y1, 0, cardH) - clamp(y0, 0, cardH),
|
||
}
|
||
}
|
||
|
||
// callsignZone finds the calm zone for the callsign: ≥55% card width,
|
||
// ≥22% card height, preferring the top/bottom third. flip restricts the
|
||
// search to a vertical half (so the three proposals land in distinct
|
||
// positions), and exclude (card-space, w>0 to apply) keeps the zone off the
|
||
// insert column.
|
||
func callsignZone(p PhotoAnalysis, crop Crop, archetype string, flip int, exclude pxRect) (pxRect, bool) {
|
||
maxRow := gridRows
|
||
if archetype == ArchetypeBottomStrip {
|
||
maxRow = gridRows * 2 / 3 // the strip owns the bottom band
|
||
}
|
||
// Minimum size in cells, derived from the crop→card scale.
|
||
minCols := int(math.Ceil(0.55 * gridCols))
|
||
minRows := int(math.Ceil(0.22 * gridRows))
|
||
// flip is a preference, not a mandate: try the requested half first, then
|
||
// fall back to the natural best zone. Position variety must never push the
|
||
// callsign onto a busy region just to differ from another proposal.
|
||
tries := []int{flip}
|
||
if flip != flipNatural {
|
||
tries = append(tries, flipNatural)
|
||
}
|
||
for _, fl := range tries {
|
||
for _, threshold := range []float64{quietDetail, quietRelaxed} {
|
||
quiet := func(r, c int) bool {
|
||
if r >= maxRow || !cellInsideCrop(p, crop, r, c) {
|
||
return false
|
||
}
|
||
if fl == flipBottom && r < gridRows/2 {
|
||
return false
|
||
}
|
||
if fl == flipTop && r >= gridRows/2 {
|
||
return false
|
||
}
|
||
if exclude.w > 0 && cellCenterIn(p, crop, r, c, exclude) {
|
||
return false
|
||
}
|
||
return p.Cells[r][c].Detail < threshold
|
||
}
|
||
if rect, ok := maximalQuietRect(quiet, minCols, minRows); ok {
|
||
return cellToCard(p, crop, rect), true
|
||
}
|
||
}
|
||
}
|
||
return pxRect{}, false
|
||
}
|
||
|
||
// fallbackZone picks a callsign band when the photo has no quiet zone, kept
|
||
// clear of the inserts: opposite the strip for a wide bottom/top strip, beside
|
||
// a side column, honouring the flip preference for full-bleed cards.
|
||
func fallbackZone(insertArea pxRect, flip int) pxRect {
|
||
z := pxRect{x: cardW * 0.05, y: cardH * 0.06, w: cardW * 0.9, h: cardH * 0.26}
|
||
switch {
|
||
case insertArea.w == 0: // no inserts
|
||
if flip == flipBottom {
|
||
z.y = cardH * 0.62
|
||
}
|
||
case insertArea.w > cardW*0.6: // wide strip → band on the opposite side
|
||
if insertArea.y < cardH/2 {
|
||
z.y = cardH * 0.62
|
||
}
|
||
case insertArea.x > cardW/2: // right column
|
||
z.w = insertArea.x - 40 - z.x
|
||
if flip == flipBottom {
|
||
z.y = cardH * 0.62
|
||
}
|
||
default: // left column
|
||
z.x = insertArea.x + insertArea.w + 40
|
||
z.w = cardW - 40 - z.x
|
||
if flip == flipBottom {
|
||
z.y = cardH * 0.62
|
||
}
|
||
}
|
||
if z.w < cardW*0.35 { // guard against a degenerate sliver
|
||
z.x, z.w = cardW*0.05, cardW*0.9
|
||
}
|
||
return z
|
||
}
|
||
|
||
// cellCenterIn reports whether the card-space center of grid cell (r,c) falls
|
||
// inside rect.
|
||
func cellCenterIn(p PhotoAnalysis, crop Crop, r, c int, rect pxRect) bool {
|
||
cell := cellToCard(p, crop, cellRect{r0: r, c0: c, r1: r + 1, c1: c + 1})
|
||
cx, cy := cell.x+cell.w/2, cell.y+cell.h/2
|
||
return cx >= rect.x && cx <= rect.x+rect.w && cy >= rect.y && cy <= rect.y+rect.h
|
||
}
|
||
|
||
// regionLuma averages the luma of the photo cells under a card-space rect.
|
||
func regionLuma(p PhotoAnalysis, crop Crop, zone pxRect) float64 {
|
||
var sum float64
|
||
n := 0
|
||
for r := 0; r < gridRows; r++ {
|
||
for c := 0; c < gridCols; c++ {
|
||
cell := cellToCard(p, crop, cellRect{r0: r, c0: c, r1: r + 1, c1: c + 1})
|
||
if cell.w <= 0 || cell.h <= 0 {
|
||
continue
|
||
}
|
||
if cell.x+cell.w/2 >= zone.x && cell.x+cell.w/2 <= zone.x+zone.w &&
|
||
cell.y+cell.h/2 >= zone.y && cell.y+cell.h/2 <= zone.y+zone.h {
|
||
sum += p.Cells[r][c].Luma
|
||
n++
|
||
}
|
||
}
|
||
}
|
||
if n == 0 {
|
||
return p.AvgLuma
|
||
}
|
||
return sum / float64(n)
|
||
}
|
||
|
||
// adaptStyle tunes the preset defaults to the photo: bright placement zones
|
||
// get the dark outline + subdued halo, dark zones a stronger light halo and
|
||
// lighter outline; warm photos deepen the gold gradient.
|
||
func adaptStyle(preset string, zoneLuma, warmth float64) *StyleParams {
|
||
def := Presets[preset].Defaults
|
||
p := def // copy
|
||
if preset == "gel_gold" && warmth > 0.08 {
|
||
p.Gradient = []string{"#FFD83A", "#FFB000", "#E07E00"} // sunset ambers
|
||
}
|
||
if def.Halo != nil {
|
||
halo := *def.Halo
|
||
if zoneLuma > 0.55 {
|
||
p.OutlineColor = "#2a3f5c"
|
||
halo.Opacity = 0.25
|
||
} else {
|
||
p.OutlineColor = "#1d2e44"
|
||
halo.Opacity = 0.6
|
||
halo.Blur = 8
|
||
}
|
||
p.Halo = &halo
|
||
}
|
||
return &p
|
||
}
|
||
|
||
// placeInserts lays the remaining photos out as inserts and returns them
|
||
// plus the card area they occupy.
|
||
func placeInserts(inserts []PhotoAnalysis, hero PhotoAnalysis, archetype string) ([]Element, pxRect) {
|
||
const border = 14
|
||
if archetype == ArchetypeBottomStrip {
|
||
n := min(len(inserts), 4)
|
||
gap, margin := 24.0, 64.0
|
||
w := math.Min(420, (cardW-2*margin-float64(n-1)*gap)/float64(n))
|
||
var els []Element
|
||
x := margin
|
||
for i := 0; i < n; i++ {
|
||
ph := inserts[i]
|
||
h := w * float64(ph.H) / float64(ph.W)
|
||
rot := 1.5
|
||
if i%2 == 0 {
|
||
rot = -1.5
|
||
}
|
||
els = append(els, Element{
|
||
Type: ElemInsert, Photo: ph.Path,
|
||
X: math.Round(x), Y: math.Round(cardH - h - margin),
|
||
W: math.Round(w), BorderPx: border, Rotate: rot, Shadow: true,
|
||
})
|
||
x += w + gap
|
||
}
|
||
return els, pxRect{x: margin, y: cardH - 380, w: cardW - 2*margin, h: 380 - margin}
|
||
}
|
||
|
||
// Side column: pick the calmer half of the hero photo.
|
||
n := min(len(inserts), 3)
|
||
w, gap, margin := 400.0, 20.0, 70.0
|
||
left := halfDetail(hero, true) < halfDetail(hero, false)
|
||
x := cardW - w - margin
|
||
if left {
|
||
x = margin
|
||
}
|
||
var els []Element
|
||
y := margin
|
||
for i := 0; i < n; i++ {
|
||
ph := inserts[i]
|
||
h := w * float64(ph.H) / float64(ph.W)
|
||
if y+h > cardH-margin {
|
||
break
|
||
}
|
||
rot := 1.2
|
||
if i%2 == 0 {
|
||
rot = -1.5
|
||
}
|
||
els = append(els, Element{
|
||
Type: ElemInsert, Photo: ph.Path,
|
||
X: math.Round(x), Y: math.Round(y),
|
||
W: w, BorderPx: border, Rotate: rot, Shadow: true,
|
||
})
|
||
y += h + gap
|
||
}
|
||
return els, pxRect{x: x - 10, y: 0, w: w + 20, h: float64(cardH)}
|
||
}
|
||
|
||
// halfDetail sums cell detail over the left or right half of the photo.
|
||
func halfDetail(p PhotoAnalysis, left bool) float64 {
|
||
var sum float64
|
||
c0, c1 := gridCols/2, gridCols
|
||
if left {
|
||
c0, c1 = 0, gridCols/2
|
||
}
|
||
for r := 0; r < gridRows; r++ {
|
||
for c := c0; c < c1; c++ {
|
||
sum += p.Cells[r][c].Detail
|
||
}
|
||
}
|
||
return sum
|
||
}
|
||
|
||
// placeQSOBox puts the confirmation box in the vertical half opposite the
|
||
// callsign, on the side not occupied by inserts.
|
||
func placeQSOBox(profile ProfileInfo, zone pxRect, occupied []pxRect) QSOBox {
|
||
box := QSOBox{
|
||
Enabled: true, W: 760, H: 220,
|
||
BG: "#ffffff", BGOpacity: 0.88, Radius: 12,
|
||
Title: "Confirming QSO with {qso.callsign}",
|
||
Fields: []string{"qso_date", "time_on", "band", "mode", "rst_sent"},
|
||
Footer: "{qso.qsl_msg}",
|
||
}
|
||
box.Y = cardH - box.H - 110
|
||
if zone.y+zone.h/2 > cardH/2 {
|
||
box.Y = 90
|
||
}
|
||
box.X = 64
|
||
if overlapsAny(pxRect{x: box.X, y: box.Y, w: box.W, h: box.H}, occupied) {
|
||
box.X = cardW - box.W - 64
|
||
}
|
||
return box
|
||
}
|
||
|
||
// quietestCorner returns the top-left position for the country block in the
|
||
// calmest unoccupied corner.
|
||
func quietestCorner(p PhotoAnalysis, crop Crop, occupied []pxRect) (float64, float64) {
|
||
const margin, blockW, blockH = 64.0, 320.0, 60.0
|
||
corners := []pxRect{
|
||
{x: margin, y: cardH - margin - blockH, w: blockW, h: blockH}, // bottom-left
|
||
{x: cardW - margin - blockW, y: cardH - margin - blockH, w: blockW, h: blockH},
|
||
{x: margin, y: margin, w: blockW, h: blockH},
|
||
{x: cardW - margin - blockW, y: margin, w: blockW, h: blockH},
|
||
}
|
||
bestX, bestY := corners[0].x, corners[0].y
|
||
bestDetail := math.Inf(1)
|
||
for _, corner := range corners {
|
||
if overlapsAny(corner, occupied) {
|
||
continue
|
||
}
|
||
d := regionDetail(p, crop, corner)
|
||
if d < bestDetail {
|
||
bestDetail = d
|
||
bestX, bestY = corner.x, corner.y
|
||
}
|
||
}
|
||
return bestX, bestY
|
||
}
|
||
|
||
// regionDetail averages cell detail under a card-space rect.
|
||
func regionDetail(p PhotoAnalysis, crop Crop, zone pxRect) float64 {
|
||
var sum float64
|
||
n := 0
|
||
for r := 0; r < gridRows; r++ {
|
||
for c := 0; c < gridCols; c++ {
|
||
cell := cellToCard(p, crop, cellRect{r0: r, c0: c, r1: r + 1, c1: c + 1})
|
||
if cell.w <= 0 || cell.h <= 0 {
|
||
continue
|
||
}
|
||
if cell.x+cell.w/2 >= zone.x && cell.x+cell.w/2 <= zone.x+zone.w &&
|
||
cell.y+cell.h/2 >= zone.y && cell.y+cell.h/2 <= zone.y+zone.h {
|
||
sum += p.Cells[r][c].Detail
|
||
n++
|
||
}
|
||
}
|
||
}
|
||
if n == 0 {
|
||
return 1
|
||
}
|
||
return sum / float64(n)
|
||
}
|
||
|
||
func overlapsAny(a pxRect, rects []pxRect) bool {
|
||
for _, b := range rects {
|
||
if a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func clamp(v, lo, hi float64) float64 {
|
||
if hi < lo {
|
||
return lo
|
||
}
|
||
return math.Max(lo, math.Min(hi, v))
|
||
}
|