qsl designer
This commit is contained in:
@@ -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, 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
|
||||
}
|
||||
|
||||
// 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), 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))
|
||||
}
|
||||
Reference in New Issue
Block a user