qsl designer

This commit is contained in:
2026-06-11 21:54:35 +02:00
parent 6150498a9e
commit 408b29896c
252 changed files with 13989 additions and 277 deletions
+780
View File
@@ -0,0 +1,780 @@
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, 01
Luma float64 // mean relative luminance, 01
}
// 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, 01
AvgLuma float64
Warmth float64 // mean (RB), 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
}
// 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 01 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), 5)] // up to 4 (bottom strip), side column uses 3
}
primary := ArchetypeSideColumn
if len(plan.inserts) == 0 {
primary = ArchetypeFullBleed
} else if len(plan.inserts) >= 4 && bandQuiet(plan.hero, gridRows-5, gridRows) {
primary = ArchetypeBottomStrip
}
alternate := alternateArchetype(primary, len(plan.inserts))
// Three proposals that vary BOTH the call style and its vertical position
// (top / bottom / natural-best), so the user gets genuinely distinct cards
// to choose from rather than three near-identical golds.
styles := []string{"gel_gold", "gel_silver", "classic_white_outline"}
if plan.cool {
styles[0], styles[1] = styles[1], styles[0] // lead with silver on cool photos
}
p1 := buildTemplateBiased(plan, primary, styles[0], flipTop)
p2 := buildTemplateBiased(plan, alternate, styles[1], flipBottom)
p3 := buildTemplateBiased(plan, alternate, styles[2], 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 alternateArchetype(primary string, inserts int) string {
switch primary {
case ArchetypeSideColumn:
if inserts >= 2 {
return ArchetypeBottomStrip
}
return ArchetypeFullBleed
case ArchetypeBottomStrip:
return ArchetypeSideColumn
default:
return ArchetypeFullBleed
}
}
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"
}
// bandQuiet reports whether the mean detail of grid rows [r0,r1) is low
// enough to host inserts or text.
func bandQuiet(p PhotoAnalysis, r0, r1 int) bool {
var sum float64
n := 0
for r := r0; r < r1; r++ {
for c := 0; c < gridCols; c++ {
sum += p.Cells[r][c].Detail
n++
}
}
return n > 0 && sum/float64(n) < 0.25
}
// 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
)
func buildTemplateBiased(plan proposalPlan, archetype, style 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
size := math.Min(zone.w*0.9/(0.72*float64(max(len(call), 3))), zone.h*0.95)
callW := 0.72 * 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: FontDisplayDefault, 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},
})
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
}
// 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))
}