feat(honeypot): 蜜罐安全状况周期汇总(周报/月报 + 地图 + 邮件)
以周/月为单位汇总蜜罐命中,把来源 IP 通过公共服务查地理位置,画到地图上,
生成 HTML 报告并发邮件。同时补齐 ipinfo 无法回答「这个 IP 在哪儿」的问题。
新增 IP 地理定位层 internal/security/geoip:
- 与 internal/security/ipinfo 正交。ipinfo 走 cdncheck 离线指纹,只认云厂商/CDN/WAF,
答的是「是不是代理」;肉鸡(家宽、企业宽带、移动网络)必然被判为
「未识别(普通网络)」,所以「在哪儿」必须另建一套。
- ip-api.com 批量打底(100 个/请求、1.4s 节流)+ 腾讯位置服务精修国内
(一 IP 一请求,只精修命中数最高的前 N 个,受 ip_geo_tencent_key 控制)。
- Result{Geo, Unresolved, Requests, Failures} 三态。服务明确回答「没有这个地址」
才允许写失败缓存;请求超时/限流/5xx 一律不写 —— 否则一次抖动会把整批 IP
钉成「查不到」三天,此后每份报告的定位覆盖率都莫名变成 0。
- addrs 按调用方给的顺序构建(不是 map 遍历):腾讯精修只取前 N 个,乱序会让
每次运行精修到不同 IP,配额花在长尾上。
报告生成 internal/security/report:
- period:PreviousWeek / PreviousMonth,窗口一律 [start, end) 左闭右开,
相邻两期不重叠也不漏边界整点;period_key(week:2026-W44)做幂等。
- aggregate:按 shared.LimitKey 合并(IPv4 精确 / IPv6 折 /64);国家、城市、
路径、每日趋势;显式统计未定位命中数(覆盖率 60% 的图看起来和 100% 一样)。
- mapdraw:自绘等距圆柱投影气泡图。零海岸线零国界 —— 国界/台湾/南海诸岛绘制
必须符合国家标准,自绘极易画错,真实底图交给腾讯。经纬网 + 度数刻度 +
标签防重叠(同一都市圈两城市坐标差 0.01°,曾把「AU 55」和「AU 30」叠成
看起来像 AU 305 的假数字)。
- html:自包含报告页,内嵌 SVG + 腾讯交互地图(无 key 时给申请指引,
代码里不预置任何 key)。
- countryname:港澳台统一显示「中国香港/中国澳门/中国台湾」,常见国家用中文名。
存储与调度:
- ip_geo_cache(主键 = LimitKey,成功 TTL 90 天 / 明确无数据 TTL 3 天)
- security_report_logs(period_key 唯一索引 → 重启、错过时间窗都能正确补发,
失败重试上限 5 次后停止)
- honeypot report --period week|month [--to] [--dir] [--no-email]
- web 启动时按配置挂 30 分钟一轮的调度器(与保留期清理共用停止信号,
defer 移出 if 外,避免「清理关掉、报告开着」时 goroutine 泄漏)
配置([security]):ip_geo_provider=auto|ipapi|tencent|none、ip_geo_tencent_key、
honeypot_report_enabled / _weekly / _monthly / _email / _dir。
顺带修复:
- route/honeypot_test.go 的 captureStore 缺 AggregateSources/AggregatePaths/
TimestampsBetween,整个 route 包编译不过。
- templates/render_test.go 的 mockLoggedUser 缺 TelegramChatID,3 个渲染测试失败。
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package report
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
// 画布尺寸。2:1 是等距圆柱(equirectangular)投影的天然比例:
|
||||
// 经度 360°、纬度 180°,正好铺满矩形。
|
||||
const (
|
||||
mapWidth = 960
|
||||
mapHeight = 480
|
||||
)
|
||||
|
||||
// 配色。刻意用浅色:邮件模板是白底,深色地图嵌进去像个补丁。
|
||||
var (
|
||||
colOcean = color.RGBA{0xF1, 0xF5, 0xF9, 0xFF}
|
||||
colGrid = color.RGBA{0xCB, 0xD5, 0xE1, 0xFF}
|
||||
colGridKey = color.RGBA{0x94, 0xA3, 0xB8, 0xFF}
|
||||
colBubble = color.RGBA{0xEF, 0x44, 0x44, 0xFF}
|
||||
colRim = color.RGBA{0xB9, 0x1C, 0x1C, 0xFF}
|
||||
colText = color.RGBA{0x33, 0x41, 0x55, 0xFF}
|
||||
)
|
||||
|
||||
// project 把经纬度映射到画布坐标(等距圆柱投影)。
|
||||
//
|
||||
// ⚠️ 这里不画任何海岸线或国界。原因不是偷懒:国界、台湾与南海诸岛的绘制
|
||||
// 必须符合国家标准,自绘底图极易画错,而画错地图是比「图不好看」严重得多
|
||||
// 的问题。需要真实底图时用腾讯地图(本包另出的 HTML 版带它)。
|
||||
// 经纬网 + 气泡已经足以回答「攻击主要来自哪个半球/哪个区域」。
|
||||
func project(lat, lon float64) (x, y float64) {
|
||||
x = (lon + 180) / 360 * mapWidth
|
||||
y = (90 - lat) / 180 * mapHeight
|
||||
return x, y
|
||||
}
|
||||
|
||||
// bubbleRadius 按命中数相对最大值的平方根缩放。
|
||||
//
|
||||
// 用 sqrt 而不是线性:几次命中和上千次命中如果按线性画,前者会小到看不见;
|
||||
// 面积正比于数值是气泡图的通行做法(人眼比较的是面积而不是半径)。
|
||||
func bubbleRadius(hits, maxHits int64) float64 {
|
||||
if maxHits <= 0 {
|
||||
return 4
|
||||
}
|
||||
ratio := math.Sqrt(float64(hits) / float64(maxHits))
|
||||
return 3.5 + 17*ratio
|
||||
}
|
||||
|
||||
// bubbles 从城市聚合里挑出可画的点,按命中数降序。
|
||||
func bubbles(sum *Summary) []CityRow {
|
||||
pts := make([]CityRow, 0, len(sum.Cities))
|
||||
for _, c := range sum.Cities {
|
||||
// 坐标越界(IP 库偶尔给出 0,0 或越界值)直接丢弃:
|
||||
// 画到几内亚湾比不画更误导。
|
||||
if math.IsNaN(c.Lat) || math.IsNaN(c.Lon) ||
|
||||
c.Lat < -90 || c.Lat > 90 || c.Lon < -180 || c.Lon > 180 {
|
||||
continue
|
||||
}
|
||||
if c.Lat == 0 && c.Lon == 0 {
|
||||
continue
|
||||
}
|
||||
pts = append(pts, c)
|
||||
}
|
||||
sort.Slice(pts, func(i, j int) bool {
|
||||
if pts[i].Hits != pts[j].Hits {
|
||||
return pts[i].Hits > pts[j].Hits
|
||||
}
|
||||
return pts[i].City < pts[j].City
|
||||
})
|
||||
return pts
|
||||
}
|
||||
|
||||
// maxCityHits 返回城市命中数的最大值(气泡缩放基准)。
|
||||
func maxCityHits(pts []CityRow) int64 {
|
||||
var m int64
|
||||
for _, p := range pts {
|
||||
if p.Hits > m {
|
||||
m = p.Hits
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// graticule 经纬网:经度每 30°、纬度每 30° 一条,赤道与本初子午线加粗。
|
||||
func graticule() (xs, ys []float64) {
|
||||
for lon := -180.0; lon <= 180; lon += 30 {
|
||||
xs = append(xs, lon)
|
||||
}
|
||||
for lat := -90.0; lat <= 90; lat += 30 {
|
||||
ys = append(ys, lat)
|
||||
}
|
||||
return xs, ys
|
||||
}
|
||||
|
||||
// labelBox 已放好的标签占位框。
|
||||
type labelBox struct {
|
||||
x0, y0, x1, y1 float64
|
||||
}
|
||||
|
||||
// labelPacker 给标签找不重叠的位置。
|
||||
//
|
||||
// 为什么必须做:同一个都市圈的不同城市,经纬度只差 0.01°,标签会精确地压在
|
||||
// 一起,两行字叠成一串谁也读不出的字符。实测出现过「AU 305」这种由
|
||||
// 「AU 55」与「AU 30」叠出来的假数字 —— 这比不标更糟:读者会把它当真实数据。
|
||||
// 宁可少标几个,也不能让图上出现一个读错就是错的数字。
|
||||
type labelPacker struct {
|
||||
placed []labelBox
|
||||
width, height float64
|
||||
}
|
||||
|
||||
func newLabelPacker(width, height float64) *labelPacker {
|
||||
return &labelPacker{width: width, height: height}
|
||||
}
|
||||
|
||||
// place 在气泡右侧找位置,放不下就换左侧,两侧都占则放弃。
|
||||
// 返回标签框左上角坐标。
|
||||
func (p *labelPacker) place(bx, by, r, textW, textH float64) (x, y float64, ok bool) {
|
||||
top := by - textH/2
|
||||
for _, cx := range []float64{bx + r + 3, bx - r - 3 - textW} {
|
||||
if cx < 2 || cx+textW > p.width-2 || top < 1 || top+textH > p.height-1 {
|
||||
continue
|
||||
}
|
||||
box := labelBox{cx - 2, top - 1, cx + textW + 2, top + textH + 1}
|
||||
if p.collides(box) {
|
||||
continue
|
||||
}
|
||||
p.placed = append(p.placed, box)
|
||||
return cx, top, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func (p *labelPacker) collides(b labelBox) bool {
|
||||
for _, o := range p.placed {
|
||||
if b.x0 < o.x1 && o.x0 < b.x1 && b.y0 < o.y1 && o.y0 < b.y1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 经纬度刻度的取值。只标每 60° / 30°,标满会把气泡和标签挤没。
|
||||
var (
|
||||
tickLons = []float64{-180, -120, -60, 0, 60, 120, 180}
|
||||
tickLats = []float64{-60, -30, 0, 30, 60}
|
||||
)
|
||||
|
||||
// latLabel 把纬度格式化成 30N / 30S 这样的刻度。
|
||||
func latLabel(lat float64) string {
|
||||
switch {
|
||||
case lat == 0:
|
||||
return "0"
|
||||
case lat > 0:
|
||||
return fmt.Sprintf("%.0fN", lat)
|
||||
default:
|
||||
return fmt.Sprintf("%.0fS", -lat)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderSVG 生成内联 SVG 世界气泡图。用于 HTML 报告附件(浏览器里看)。
|
||||
func RenderSVG(sum *Summary) string {
|
||||
pts := bubbles(sum)
|
||||
maxHits := maxCityHits(pts)
|
||||
lons, lats := graticule()
|
||||
|
||||
var b bytes.Buffer
|
||||
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="100%%" role="img" aria-label="蜜罐命中来源分布世界地图">`, mapWidth, mapHeight)
|
||||
fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#F1F5F9"/>`, mapWidth, mapHeight)
|
||||
|
||||
// 经纬网
|
||||
for _, lon := range lons {
|
||||
x, _ := project(0, lon)
|
||||
stroke := "#CBD5E1"
|
||||
w := "0.6"
|
||||
if lon == 0 {
|
||||
stroke = "#94A3B8"
|
||||
w = "1"
|
||||
}
|
||||
fmt.Fprintf(&b, `<line x1="%.1f" y1="0" x2="%.1f" y2="%d" stroke="%s" stroke-width="%s"/>`, x, x, mapHeight, stroke, w)
|
||||
}
|
||||
for _, lat := range lats {
|
||||
_, y := project(lat, 0)
|
||||
stroke := "#CBD5E1"
|
||||
w := "0.6"
|
||||
if lat == 0 {
|
||||
stroke = "#94A3B8"
|
||||
w = "1"
|
||||
}
|
||||
fmt.Fprintf(&b, `<line x1="0" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="%s"/>`, y, mapWidth, y, stroke, w)
|
||||
}
|
||||
|
||||
// 经纬度刻度:没有底图时这是读者唯一的方向参照,不能省。
|
||||
for _, lon := range tickLons {
|
||||
x, _ := project(0, lon)
|
||||
if x < 12 {
|
||||
x = 12
|
||||
}
|
||||
if x > mapWidth-12 {
|
||||
x = mapWidth - 12
|
||||
}
|
||||
fmt.Fprintf(&b, `<text x="%.1f" y="%d" font-size="9" fill="#94A3B8" text-anchor="middle" font-family="monospace">%s</text>`,
|
||||
x, mapHeight-4, lonLabel(lon))
|
||||
}
|
||||
for _, lat := range tickLats {
|
||||
_, y := project(lat, 0)
|
||||
fmt.Fprintf(&b, `<text x="3" y="%.1f" font-size="9" fill="#94A3B8" font-family="monospace">%s</text>`,
|
||||
y+3, latLabel(lat))
|
||||
}
|
||||
|
||||
// 气泡
|
||||
for _, p := range pts {
|
||||
x, y := project(p.Lat, p.Lon)
|
||||
r := bubbleRadius(p.Hits, maxHits)
|
||||
fmt.Fprintf(&b, `<circle cx="%.1f" cy="%.1f" r="%.1f" fill="#EF4444" fill-opacity="0.35" stroke="#B91C1C" stroke-opacity="0.8" stroke-width="1"><title>%s · %s:%d 次命中(%d 个来源)</title></circle>`,
|
||||
x, y, r, html.EscapeString(p.City), html.EscapeString(p.Country), p.Hits, p.Sources)
|
||||
}
|
||||
|
||||
// 标注前 8 个城市:放过大的气泡,标签不重叠才画(理由见 labelPacker)。
|
||||
packer := newLabelPacker(mapWidth, mapHeight)
|
||||
for i, p := range pts {
|
||||
if i >= 8 {
|
||||
break
|
||||
}
|
||||
x, y := project(p.Lat, p.Lon)
|
||||
r := bubbleRadius(p.Hits, maxHits)
|
||||
label := cityLabel(p)
|
||||
tx, ty, ok := packer.place(x, y, r, svgTextWidth(label), 12)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, `<text x="%.1f" y="%.1f" font-size="11" fill="#334155" font-family="system-ui,-apple-system,Segoe UI,sans-serif">%s</text>`,
|
||||
tx, ty+10, html.EscapeString(label))
|
||||
}
|
||||
b.WriteString(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// cityLabel 优先用城市名,退到省/州,最后退到国家名。
|
||||
func cityLabel(p CityRow) string {
|
||||
if p.City != "" {
|
||||
return fmt.Sprintf("%s %d", p.City, p.Hits)
|
||||
}
|
||||
if p.Region != "" {
|
||||
return fmt.Sprintf("%s %d", p.Region, p.Hits)
|
||||
}
|
||||
return fmt.Sprintf("%s %d", p.Country, p.Hits)
|
||||
}
|
||||
|
||||
// svgTextWidth 粗估一行文字在 11px 下的宽度。
|
||||
// CJK 按全宽算、其余按 0.62 倍字宽算 —— 只用于判断是否会撞在一起,
|
||||
// 精确到像素没有意义(何况最终字体由浏览器决定)。
|
||||
func svgTextWidth(s string) float64 {
|
||||
var w float64
|
||||
for _, r := range s {
|
||||
if r > 0x2E80 {
|
||||
w += 11
|
||||
} else {
|
||||
w += 6.8
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// lonLabel 把经度格式化成 90°E / 120°W 这样的刻度。
|
||||
func lonLabel(lon float64) string {
|
||||
switch {
|
||||
case lon == 0:
|
||||
return "0"
|
||||
case lon > 0:
|
||||
return fmt.Sprintf("%.0fE", lon)
|
||||
default:
|
||||
return fmt.Sprintf("%.0fW", -lon)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderPNG 生成邮件正文用的位图世界气泡图。
|
||||
//
|
||||
// 为什么要位图:邮件客户端不执行 JS,且 Gmail / Outlook 会剥离内联 SVG。
|
||||
// 只有以 CID 内嵌的 PNG 能保证「打开邮件就看得到地图」。
|
||||
//
|
||||
// 图上文字只能是 ASCII(Go 标准库里唯一的无依赖位图字体 basicfont 只有
|
||||
// 7x13 的拉丁字形),所以标签用国家码 + 次数,中文说明放在邮件正文里。
|
||||
func RenderPNG(sum *Summary) ([]byte, error) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, mapWidth, mapHeight))
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{colOcean}, image.Point{}, draw.Src)
|
||||
|
||||
lons, lats := graticule()
|
||||
for _, lon := range lons {
|
||||
x, _ := project(0, lon)
|
||||
c := colGrid
|
||||
if lon == 0 {
|
||||
c = colGridKey
|
||||
}
|
||||
drawVLine(img, x, c)
|
||||
}
|
||||
for _, lat := range lats {
|
||||
_, y := project(lat, 0)
|
||||
c := colGrid
|
||||
if lat == 0 {
|
||||
c = colGridKey
|
||||
}
|
||||
drawHLine(img, y, c)
|
||||
}
|
||||
|
||||
pts := bubbles(sum)
|
||||
maxHits := maxCityHits(pts)
|
||||
// 从小到大画:大气泡后画才能盖住小气泡,否则重要的点被长尾挡住。
|
||||
for i := len(pts) - 1; i >= 0; i-- {
|
||||
p := pts[i]
|
||||
x, y := project(p.Lat, p.Lon)
|
||||
r := bubbleRadius(p.Hits, maxHits)
|
||||
fillCircle(img, x, y, r, colBubble, 0.35)
|
||||
strokeCircle(img, x, y, r, colRim, 0.8)
|
||||
}
|
||||
|
||||
// 刻度先画:气泡后画会盖住刻度,保证气泡在视觉上优先。
|
||||
for _, lon := range tickLons {
|
||||
x, _ := project(0, lon)
|
||||
s := lonLabel(lon)
|
||||
tx := x - basicfontWidth(s)/2
|
||||
if tx < 2 {
|
||||
tx = 2
|
||||
}
|
||||
if tx+basicfontWidth(s) > mapWidth-2 {
|
||||
tx = mapWidth - 2 - basicfontWidth(s)
|
||||
}
|
||||
drawText(img, tx, mapHeight-15, s, colGridKey, false)
|
||||
}
|
||||
for _, lat := range tickLats {
|
||||
_, y := project(lat, 0)
|
||||
drawText(img, 3, y-7, latLabel(lat), colGridKey, false)
|
||||
}
|
||||
|
||||
// 前 8 个城市标国家码 + 次数,重叠的跳过(理由见 labelPacker)。
|
||||
packer := newLabelPacker(mapWidth, mapHeight)
|
||||
for i, p := range pts {
|
||||
if i >= 8 {
|
||||
break
|
||||
}
|
||||
x, y := project(p.Lat, p.Lon)
|
||||
r := bubbleRadius(p.Hits, maxHits)
|
||||
label := fmt.Sprintf("%s %d", p.CountryCode, p.Hits)
|
||||
tx, ty, ok := packer.place(x, y, r, basicfontWidth(label), 13)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
drawText(img, tx, ty, label, colText, true)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// basicfontWidth 7x13 位图字体的定宽推进值:每个字符正好 7px。
|
||||
func basicfontWidth(s string) float64 { return float64(len(s)) * 7 }
|
||||
|
||||
// drawText 用 7x13 位图字体写一行 ASCII 文字,(x, top) 是文本框左上角。
|
||||
//
|
||||
// outline=true 时先描一圈白,避免文字压在气泡或网格上糊成一片。
|
||||
func drawText(img *image.RGBA, x, top float64, s string, c color.RGBA, outline bool) {
|
||||
dot := fixed.P(int(x), int(top)+11)
|
||||
if outline {
|
||||
for _, off := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
|
||||
h := &font.Drawer{
|
||||
Dst: img, Src: &image.Uniform{color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}},
|
||||
Face: basicfont.Face7x13,
|
||||
Dot: fixed.P(int(x)+off[0], int(top)+11+off[1]),
|
||||
}
|
||||
h.DrawString(s)
|
||||
}
|
||||
}
|
||||
d := &font.Drawer{Dst: img, Src: &image.Uniform{c}, Face: basicfont.Face7x13, Dot: dot}
|
||||
d.DrawString(s)
|
||||
}
|
||||
|
||||
// fillCircle 画一个带 1px 抗锯齿边缘的实心圆(alpha 混合)。
|
||||
func fillCircle(img *image.RGBA, cx, cy, r float64, c color.RGBA, alpha float64) {
|
||||
drawCircleRing(img, cx, cy, 0, r, c, alpha)
|
||||
}
|
||||
|
||||
// strokeCircle 画一个 1px 宽的圆环。
|
||||
func strokeCircle(img *image.RGBA, cx, cy, r float64, c color.RGBA, alpha float64) {
|
||||
drawCircleRing(img, cx, cy, r-0.5, r+0.5, c, alpha)
|
||||
}
|
||||
|
||||
// drawCircleRing 填充内半径 inner 到外半径 outer 之间的环(inner=0 即实心圆)。
|
||||
func drawCircleRing(img *image.RGBA, cx, cy, inner, outer float64, c color.RGBA, alpha float64) {
|
||||
x0 := int(math.Floor(cx - outer - 1))
|
||||
x1 := int(math.Ceil(cx + outer + 1))
|
||||
y0 := int(math.Floor(cy - outer - 1))
|
||||
y1 := int(math.Ceil(cy + outer + 1))
|
||||
if x0 < 0 {
|
||||
x0 = 0
|
||||
}
|
||||
if y0 < 0 {
|
||||
y0 = 0
|
||||
}
|
||||
if x1 > img.Bounds().Dx() {
|
||||
x1 = img.Bounds().Dx()
|
||||
}
|
||||
if y1 > img.Bounds().Dy() {
|
||||
y1 = img.Bounds().Dy()
|
||||
}
|
||||
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := x0; x < x1; x++ {
|
||||
dx := float64(x) + 0.5 - cx
|
||||
dy := float64(y) + 0.5 - cy
|
||||
d := math.Hypot(dx, dy)
|
||||
// 与圆环相交的覆盖率:边缘 1px 内线性过渡,起到抗锯齿作用。
|
||||
cov := math.Min(outer-d, d-inner) + 0.5
|
||||
if cov <= 0 {
|
||||
continue
|
||||
}
|
||||
if cov > 1 {
|
||||
cov = 1
|
||||
}
|
||||
blendPixel(img, x, y, c, alpha*cov)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drawVLine / drawHLine 画 1px 网格线(半透明,不抢气泡的视觉权重)。
|
||||
func drawVLine(img *image.RGBA, x float64, c color.RGBA) {
|
||||
xi := int(math.Round(x))
|
||||
if xi < 0 || xi >= img.Bounds().Dx() {
|
||||
return
|
||||
}
|
||||
for y := 0; y < img.Bounds().Dy(); y++ {
|
||||
blendPixel(img, xi, y, c, 0.9)
|
||||
}
|
||||
}
|
||||
|
||||
func drawHLine(img *image.RGBA, y float64, c color.RGBA) {
|
||||
yi := int(math.Round(y))
|
||||
if yi < 0 || yi >= img.Bounds().Dy() {
|
||||
return
|
||||
}
|
||||
for x := 0; x < img.Bounds().Dx(); x++ {
|
||||
blendPixel(img, x, yi, c, 0.9)
|
||||
}
|
||||
}
|
||||
|
||||
// blendPixel 把颜色以 alpha 混合到目标像素上。
|
||||
func blendPixel(img *image.RGBA, x, y int, c color.RGBA, alpha float64) {
|
||||
if alpha <= 0 {
|
||||
return
|
||||
}
|
||||
if alpha > 1 {
|
||||
alpha = 1
|
||||
}
|
||||
i := img.PixOffset(x, y)
|
||||
dst := img.Pix[i : i+4 : i+4]
|
||||
img.Pix[i+0] = uint8(float64(c.R)*alpha + float64(dst[0])*(1-alpha))
|
||||
img.Pix[i+1] = uint8(float64(c.G)*alpha + float64(dst[1])*(1-alpha))
|
||||
img.Pix[i+2] = uint8(float64(c.B)*alpha + float64(dst[2])*(1-alpha))
|
||||
img.Pix[i+3] = 0xFF
|
||||
}
|
||||
Reference in New Issue
Block a user