mirror of
https://github.com/libretro/ludo.git
synced 2026-11-07 17:27:59 -10:00
Compare commits
7
Commits
master
...
kivutar/ocr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
720bdfe58a | ||
|
|
04c481906b | ||
|
|
518dc203a5 | ||
|
|
5352ab2bca | ||
|
|
ea3873befa | ||
|
|
65c7f62eb0 | ||
|
|
9cc51e50ff |
No files matched your search
@@ -10,6 +10,7 @@ require (
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||
github.com/mholt/archiver/v3 v3.5.1
|
||||
github.com/otiai10/gosseract/v2 v2.4.1
|
||||
github.com/pelletier/go-toml v1.9.5
|
||||
github.com/tanema/gween v0.0.0-20250522035225-e874ee3ae01a
|
||||
github.com/youpy/go-wav v0.3.2
|
||||
|
||||
@@ -43,6 +43,8 @@ github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssn
|
||||
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
|
||||
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/otiai10/gosseract/v2 v2.4.1 h1:G8AyBpXEeSlcq8TI85LH/pM5SXk8Djy2GEXisgyblRw=
|
||||
github.com/otiai10/gosseract/v2 v2.4.1/go.mod h1:1gNWP4Hgr2o7yqWfs6r5bZxAatjOIdqWxJLWsTsembk=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
|
||||
@@ -21,5 +21,6 @@ var keyBinds = map[glfw.Key]uint32{
|
||||
glfw.KeySpace: ActionFastForwardToggle,
|
||||
glfw.KeyP: ActionMenuToggle,
|
||||
glfw.KeyF: ActionFullscreenToggle,
|
||||
glfw.KeyT: ActionOCRSubtitle,
|
||||
glfw.KeyEscape: ActionShouldClose,
|
||||
}
|
||||
+3
-1
@@ -45,8 +45,10 @@ const (
|
||||
ActionShouldClose uint32 = lr.DeviceIDJoypadR3 + 3
|
||||
// ActionFastForwardToggle will run the core as fast as possible
|
||||
ActionFastForwardToggle uint32 = lr.DeviceIDJoypadR3 + 4
|
||||
// ActionOCRSubtitle will capture the current frame, run OCR, and display it as a subtitle
|
||||
ActionOCRSubtitle uint32 = lr.DeviceIDJoypadR3 + 5
|
||||
// ActionLast is used for iterating
|
||||
ActionLast uint32 = lr.DeviceIDJoypadR3 + 5
|
||||
ActionLast uint32 = lr.DeviceIDJoypadR3 + 6
|
||||
)
|
||||
|
||||
// joystickCallback is triggered when a joypad is plugged.
|
||||
|
||||
@@ -62,6 +62,7 @@ func runLoop(vid *video.Video, m *menu.Menu) {
|
||||
vid.Render()
|
||||
m.Render(dt)
|
||||
}
|
||||
vid.RenderSubtitle()
|
||||
m.RenderNotifications()
|
||||
if state.FastForward {
|
||||
glfw.SwapInterval(0)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package menu
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/libretro/ludo/audio"
|
||||
"github.com/libretro/ludo/input"
|
||||
"github.com/libretro/ludo/libretro"
|
||||
@@ -213,6 +216,27 @@ func (m *Menu) ProcessHotkeys() {
|
||||
}
|
||||
}
|
||||
|
||||
if input.Pressed[0][input.ActionOCRSubtitle] == 1 && state.CoreRunning && !state.MenuActive {
|
||||
text, err := m.Video.OCRCurrentFrame()
|
||||
if err != nil {
|
||||
ntf.DisplayAndLog(ntf.Error, "OCR", "%v", err)
|
||||
} else {
|
||||
m.Video.SetSubtitle(text, 8*time.Second)
|
||||
if text != "" {
|
||||
log.Printf("[OCR] %s\n", text)
|
||||
}
|
||||
preview := []rune(text)
|
||||
if len(preview) > 80 {
|
||||
preview = append(preview[:80], '…')
|
||||
}
|
||||
if len(preview) == 0 {
|
||||
ntf.DisplayAndLog(ntf.Info, "OCR", "(no text detected)")
|
||||
} else {
|
||||
ntf.DisplayAndLog(ntf.Info, "OCR", "%s", string(preview))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close if ActionShouldClose is pressed, but display a confirmation dialog
|
||||
// in case a game is running
|
||||
if input.Pressed[0][input.ActionShouldClose] == 1 {
|
||||
|
||||
@@ -28,11 +28,11 @@ func buildCoreOptions() Scene {
|
||||
for _, v := range core.Options.Vars {
|
||||
v := v
|
||||
list.children = append(list.children, entry{
|
||||
label: strings.Replace(v.Desc, "%", "%%", -1),
|
||||
label: strings.ReplaceAll(v.Desc, "%", "%%"),
|
||||
icon: "subsetting",
|
||||
stringValue: func() string {
|
||||
val := v.Choices[v.Choice]
|
||||
return strings.Replace(val, "%", "%%", -1)
|
||||
return "◀ " + strings.ReplaceAll(val, "%", "%%") + " ▶"
|
||||
},
|
||||
incr: func(direction int) {
|
||||
v.Choice += direction
|
||||
|
||||
@@ -60,7 +60,7 @@ func buildSettings() Scene {
|
||||
icon: "folder",
|
||||
value: f.Value,
|
||||
stringValue: func() string {
|
||||
return "[" + utils.FileName(f.Value().(string)) + "]"
|
||||
return "【" + utils.FileName(f.Value().(string)) + "】"
|
||||
},
|
||||
widget: widgets[f.Tag("widget")],
|
||||
callbackOK: func() {
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package ocr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/otiai10/gosseract/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
clientMu sync.Mutex
|
||||
client *gosseract.Client
|
||||
)
|
||||
|
||||
func initClient() *gosseract.Client {
|
||||
if client != nil {
|
||||
return client
|
||||
}
|
||||
|
||||
client = gosseract.NewClient()
|
||||
client.SetLanguage("jpn")
|
||||
return client
|
||||
}
|
||||
|
||||
// TextFromImage runs OCR on an image and returns the detected text.
|
||||
func TextFromImage(img image.Image) (string, error) {
|
||||
processed := preprocess(img)
|
||||
|
||||
path := filepath.Join(fmt.Sprintf("ludo-ocr-%d.png", time.Now().UnixNano()))
|
||||
if err := saveDebugImage(path, processed); err != nil {
|
||||
log.Printf("[OCR] failed to save debug image: %v", err)
|
||||
} else {
|
||||
log.Printf("[OCR] saved debug image: %s", path)
|
||||
}
|
||||
|
||||
buf := bytes.Buffer{}
|
||||
enc := png.Encoder{CompressionLevel: png.BestSpeed}
|
||||
if err := enc.Encode(&buf, processed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
clientMu.Lock()
|
||||
defer clientMu.Unlock()
|
||||
|
||||
c := initClient()
|
||||
if err := c.SetImageFromBytes(buf.Bytes()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Try a focused page segmentation first, then broaden if empty.
|
||||
c.SetPageSegMode(gosseract.PSM_SINGLE_BLOCK)
|
||||
text, err := c.Text()
|
||||
if err == nil {
|
||||
text = strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
if err == nil && text == "" {
|
||||
c.SetPageSegMode(gosseract.PSM_AUTO)
|
||||
text, err = c.Text()
|
||||
if err == nil {
|
||||
text = strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
|
||||
return text, err
|
||||
}
|
||||
|
||||
func preprocess(img image.Image) image.Image {
|
||||
b := img.Bounds()
|
||||
w := b.Dx()
|
||||
h := b.Dy()
|
||||
|
||||
// Binarize to keep only the observed text color (#cbc6dd).
|
||||
const (
|
||||
targetR = 0xcb
|
||||
targetG = 0xc6
|
||||
targetB = 0xdd
|
||||
)
|
||||
|
||||
bin := image.NewGray(img.Bounds())
|
||||
for y := bin.Bounds().Min.Y; y < bin.Bounds().Max.Y; y++ {
|
||||
for x := bin.Bounds().Min.X; x < bin.Bounds().Max.X; x++ {
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
rr := int(r >> 8)
|
||||
gg := int(g >> 8)
|
||||
bb := int(b >> 8)
|
||||
|
||||
if rr == targetR && gg == targetG && bb == targetB {
|
||||
bin.SetGray(x, y, color.Gray{Y: 255})
|
||||
} else {
|
||||
bin.SetGray(x, y, color.Gray{Y: 0})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Upscale the frame to help OCR decode small text.
|
||||
scale := 6
|
||||
resized := imaging.Resize(bin, w*scale, h*scale, imaging.Linear)
|
||||
|
||||
return resized
|
||||
}
|
||||
|
||||
func saveDebugImage(path string, img image.Image) error {
|
||||
fd, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fd.Close()
|
||||
return png.Encode(fd, img)
|
||||
}
|
||||
@@ -22,10 +22,10 @@ import (
|
||||
// Widget sets the graphical representation of the value.
|
||||
type Settings struct {
|
||||
VideoFullscreen bool `hide:"ludos" toml:"video_fullscreen" label:"Video Fullscreen" fmt:"%t" widget:"switch"`
|
||||
VideoMonitorIndex int `toml:"video_monitor_index" label:"Video Monitor Index" fmt:"%d"`
|
||||
VideoFilter string `toml:"video_filter" label:"Video Filter" fmt:"<%s>"`
|
||||
VideoMonitorIndex int `toml:"video_monitor_index" label:"Video Monitor Index" fmt:"◀ %d ▶"`
|
||||
VideoFilter string `toml:"video_filter" label:"Video Filter" fmt:"◀ %s ▶"`
|
||||
VideoDarkMode bool `toml:"video_dark_mode" label:"Video Dark Mode" fmt:"%t" widget:"switch"`
|
||||
VideoTheme string `toml:"video_theme" label:"Video Theme" fmt:"<%s>"`
|
||||
VideoTheme string `toml:"video_theme" label:"Video Theme" fmt:"◀ %s ▶"`
|
||||
|
||||
AudioVolume float32 `toml:"audio_volume" label:"Audio Volume" fmt:"%.1f" widget:"range"`
|
||||
|
||||
|
||||
+208
-107
@@ -14,7 +14,7 @@ import (
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
type character struct {
|
||||
type glyph struct {
|
||||
x, y int
|
||||
width int // Glyph width
|
||||
height int // Glyph height
|
||||
@@ -35,7 +35,7 @@ const (
|
||||
|
||||
// A Font allows rendering of text to an OpenGL context.
|
||||
type Font struct {
|
||||
fontChar []*character
|
||||
glyphs map[rune]*glyph
|
||||
vao uint32
|
||||
vbo uint32
|
||||
program uint32
|
||||
@@ -47,6 +47,15 @@ type Font struct {
|
||||
|
||||
type point [4]float32
|
||||
|
||||
type glyphMetrics struct {
|
||||
bounds fixed.Rectangle26_6
|
||||
advance fixed.Int26_6
|
||||
width int
|
||||
height int
|
||||
ascent int
|
||||
descent int
|
||||
}
|
||||
|
||||
func max(a, b float32) float32 {
|
||||
if a > b {
|
||||
return a
|
||||
@@ -54,8 +63,172 @@ func max(a, b float32) float32 {
|
||||
return b
|
||||
}
|
||||
|
||||
func appendRange(runes []rune, start, end rune) []rune {
|
||||
for r := start; r <= end; r++ {
|
||||
runes = append(runes, r)
|
||||
}
|
||||
return runes
|
||||
}
|
||||
|
||||
func defaultRunes() []rune {
|
||||
runes := make([]rune, 0, 22000)
|
||||
runes = appendRange(runes, 32, 126)
|
||||
runes = append(runes, '◀', '▶', '【', '】')
|
||||
runes = appendRange(runes, 0x00A0, 0x017F)
|
||||
runes = appendRange(runes, 0x3040, 0x30FF)
|
||||
runes = appendRange(runes, 0x4E00, 0x9FAF)
|
||||
return runes
|
||||
}
|
||||
|
||||
func getGlyphMetrics(face font.Face, ttf *truetype.Font, ch rune, scale int32) (glyphMetrics, error) {
|
||||
gBnd, gAdv, ok := face.GlyphBounds(ch)
|
||||
if !ok {
|
||||
return glyphMetrics{}, fmt.Errorf("ttf face glyphBounds error")
|
||||
}
|
||||
|
||||
gh := int32((gBnd.Max.Y - gBnd.Min.Y) >> 6)
|
||||
gw := int32((gBnd.Max.X - gBnd.Min.X) >> 6)
|
||||
|
||||
// If gylph has no dimensions set to a max value
|
||||
if gw == 0 || gh == 0 {
|
||||
gBnd = ttf.Bounds(fixed.Int26_6(scale))
|
||||
gw = int32((gBnd.Max.X - gBnd.Min.X) >> 6)
|
||||
gh = int32((gBnd.Max.Y - gBnd.Min.Y) >> 6)
|
||||
|
||||
// Above can sometimes yield 0 for font smaller than 48pt, 1 is minimum
|
||||
if gw == 0 || gh == 0 {
|
||||
gw = 1
|
||||
gh = 1
|
||||
}
|
||||
}
|
||||
|
||||
// The glyph's ascent and descent equal -bounds.Min.Y and +bounds.Max.Y.
|
||||
gAscent := int(-gBnd.Min.Y) >> 6
|
||||
gdescent := int(gBnd.Max.Y) >> 6
|
||||
|
||||
return glyphMetrics{
|
||||
bounds: gBnd,
|
||||
advance: gAdv,
|
||||
width: int(gw),
|
||||
height: int(gh),
|
||||
ascent: gAscent,
|
||||
descent: gdescent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func increaseLineHeight(face font.Face, ttf *truetype.Font, ch rune, lineHeight float32, scale int32) float32 {
|
||||
metrics, err := getGlyphMetrics(face, ttf, ch, scale)
|
||||
if err != nil {
|
||||
fmt.Println("ttf face glyphBounds error")
|
||||
return lineHeight
|
||||
}
|
||||
|
||||
return max(lineHeight, float32(metrics.height))
|
||||
}
|
||||
|
||||
func appendGlyph(face font.Face, ttf *truetype.Font, ch rune, x, y *int, lineHeight, atlasWidth float32, atlas *image.RGBA, fg *image.Uniform, scale int32, margin int) (*glyph, error) {
|
||||
char := new(glyph)
|
||||
|
||||
metrics, err := getGlyphMetrics(face, ttf, ch, scale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set w,h and adv, bearing V and bearing H in char
|
||||
char.x = *x
|
||||
char.y = *y
|
||||
char.width = metrics.width
|
||||
char.height = metrics.height
|
||||
char.advance = int(metrics.advance)
|
||||
char.bearingV = metrics.descent
|
||||
char.bearingH = (int(metrics.bounds.Min.X) >> 6)
|
||||
|
||||
clip := image.Rect(*x, *y, *x+metrics.width, *y+metrics.height)
|
||||
|
||||
// Create a freetype context for drawing
|
||||
c := freetype.NewContext()
|
||||
c.SetDPI(72)
|
||||
c.SetFont(ttf)
|
||||
c.SetFontSize(float64(scale))
|
||||
c.SetClip(clip)
|
||||
c.SetDst(atlas)
|
||||
c.SetSrc(fg)
|
||||
c.SetHinting(font.HintingFull)
|
||||
|
||||
// Set the glyph dot
|
||||
px := 0 - (int(metrics.bounds.Min.X) >> 6) + *x
|
||||
py := (metrics.ascent) + *y
|
||||
pt := freetype.Pt(px, py)
|
||||
|
||||
*x += metrics.width + margin
|
||||
if *x+metrics.width+margin > int(atlasWidth) {
|
||||
*x = 0
|
||||
*y += int(lineHeight) + margin
|
||||
}
|
||||
|
||||
// Draw the text from mask to image
|
||||
_, err = c.DrawString(string(ch), pt)
|
||||
return char, err
|
||||
}
|
||||
|
||||
func calculateAtlasSize(face font.Face, ttf *truetype.Font, runes []rune, scale int32, margin int) (float32, float32, float32, error) {
|
||||
var maxTextureSize int32
|
||||
gl.GetIntegerv(gl.MAX_TEXTURE_SIZE, &maxTextureSize)
|
||||
if maxTextureSize == 0 {
|
||||
maxTextureSize = 4096
|
||||
}
|
||||
|
||||
var lineHeight float32
|
||||
for _, r := range runes {
|
||||
lineHeight = increaseLineHeight(face, ttf, r, lineHeight, scale)
|
||||
}
|
||||
|
||||
width := int(maxTextureSize)
|
||||
if width > 4096 {
|
||||
width = 4096
|
||||
}
|
||||
|
||||
for {
|
||||
x := margin
|
||||
y := margin
|
||||
maxY := 0
|
||||
|
||||
for _, r := range runes {
|
||||
metrics, err := getGlyphMetrics(face, ttf, r, scale)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if x+metrics.width+margin > width {
|
||||
x = margin
|
||||
y += int(lineHeight) + margin
|
||||
}
|
||||
|
||||
x += metrics.width + margin
|
||||
|
||||
if y+int(lineHeight) > maxY {
|
||||
maxY = y + int(lineHeight)
|
||||
}
|
||||
}
|
||||
|
||||
height := maxY + margin
|
||||
|
||||
if height <= int(maxTextureSize) || width >= int(maxTextureSize) {
|
||||
if height > int(maxTextureSize) {
|
||||
return 0, 0, 0, fmt.Errorf("glyph atlas %dx%d exceeds max texture size %d", width, height, maxTextureSize)
|
||||
}
|
||||
return float32(width), float32(height), lineHeight, nil
|
||||
}
|
||||
|
||||
width *= 2
|
||||
if width > int(maxTextureSize) {
|
||||
width = int(maxTextureSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LoadTrueTypeFont builds a set of textures based on a ttf files gylphs
|
||||
func LoadTrueTypeFont(program uint32, r io.Reader, scale int32, low, high rune, dir Direction) (*Font, error) {
|
||||
func LoadTrueTypeFont(program uint32, r io.Reader, scale int32, dir Direction) (*Font, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -69,108 +242,42 @@ func LoadTrueTypeFont(program uint32, r io.Reader, scale int32, low, high rune,
|
||||
|
||||
// Make Font stuct type
|
||||
f := new(Font)
|
||||
f.fontChar = make([]*character, 0, high-low+1)
|
||||
f.glyphs = make(map[rune]*glyph)
|
||||
f.program = program // Set shader program
|
||||
f.SetColor(Color{R: 1, G: 1, B: 1, A: 1}) // Set default white
|
||||
|
||||
// Create new face
|
||||
ttfFace := truetype.NewFace(ttf, &truetype.Options{
|
||||
face := truetype.NewFace(ttf, &truetype.Options{
|
||||
Size: float64(scale),
|
||||
DPI: 72,
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
|
||||
margin := 2
|
||||
runes := defaultRunes()
|
||||
var lineHeight float32
|
||||
f.atlasWidth = 1024
|
||||
f.atlasHeight = 1024
|
||||
for ch := low; ch <= high; ch++ {
|
||||
gBnd, _, ok := ttfFace.GlyphBounds(ch)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("ttf face glyphBounds error")
|
||||
}
|
||||
gh := int32((gBnd.Max.Y - gBnd.Min.Y) >> 6)
|
||||
lineHeight = max(lineHeight, float32(gh))
|
||||
|
||||
f.atlasWidth, f.atlasHeight, lineHeight, err = calculateAtlasSize(face, ttf, runes, scale, margin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create image to draw glyph
|
||||
fg, bg := image.White, image.Black
|
||||
rect := image.Rect(0, 0, int(f.atlasWidth), int(f.atlasHeight))
|
||||
rgba := image.NewRGBA(rect)
|
||||
draw.Draw(rgba, rgba.Bounds(), bg, image.Point{}, draw.Src)
|
||||
atlas := image.NewRGBA(rect)
|
||||
draw.Draw(atlas, atlas.Bounds(), bg, image.Point{}, draw.Src)
|
||||
|
||||
margin := 4
|
||||
x := margin
|
||||
y := margin
|
||||
|
||||
// Make each gylph
|
||||
for ch := low; ch <= high; ch++ {
|
||||
char := new(character)
|
||||
|
||||
gBnd, gAdv, ok := ttfFace.GlyphBounds(ch)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("ttf face glyphBounds error")
|
||||
}
|
||||
|
||||
gh := int32((gBnd.Max.Y - gBnd.Min.Y) >> 6)
|
||||
gw := int32((gBnd.Max.X - gBnd.Min.X) >> 6)
|
||||
|
||||
// If gylph has no dimensions set to a max value
|
||||
if gw == 0 || gh == 0 {
|
||||
gBnd = ttf.Bounds(fixed.Int26_6(scale))
|
||||
gw = int32((gBnd.Max.X - gBnd.Min.X) >> 6)
|
||||
gh = int32((gBnd.Max.Y - gBnd.Min.Y) >> 6)
|
||||
|
||||
// Above can sometimes yield 0 for font smaller than 48pt, 1 is minimum
|
||||
if gw == 0 || gh == 0 {
|
||||
gw = 1
|
||||
gh = 1
|
||||
}
|
||||
}
|
||||
|
||||
// The glyph's ascent and descent equal -bounds.Min.Y and +bounds.Max.Y.
|
||||
gAscent := int(-gBnd.Min.Y) >> 6
|
||||
gdescent := int(gBnd.Max.Y) >> 6
|
||||
|
||||
// Set w,h and adv, bearing V and bearing H in char
|
||||
char.x = x
|
||||
char.y = y
|
||||
char.width = int(gw)
|
||||
char.height = int(gh)
|
||||
char.advance = int(gAdv)
|
||||
char.bearingV = gdescent
|
||||
char.bearingH = (int(gBnd.Min.X) >> 6)
|
||||
|
||||
clip := image.Rect(x, y, x+int(gw), y+int(gh))
|
||||
|
||||
// Create a freetype context for drawing
|
||||
c := freetype.NewContext()
|
||||
c.SetDPI(72)
|
||||
c.SetFont(ttf)
|
||||
c.SetFontSize(float64(scale))
|
||||
c.SetClip(clip)
|
||||
c.SetDst(rgba)
|
||||
c.SetSrc(fg)
|
||||
c.SetHinting(font.HintingFull)
|
||||
|
||||
// Set the glyph dot
|
||||
px := 0 - (int(gBnd.Min.X) >> 6) + x
|
||||
py := (gAscent) + y
|
||||
pt := freetype.Pt(px, py)
|
||||
|
||||
x += int(gw) + margin
|
||||
if x+int(gw)+margin > int(f.atlasWidth) {
|
||||
x = 0
|
||||
y += int(lineHeight) + margin
|
||||
}
|
||||
|
||||
// Draw the text from mask to image
|
||||
_, err = c.DrawString(string(ch), pt)
|
||||
for _, r := range runes {
|
||||
glyph, err := appendGlyph(face, ttf, r, &x, &y, lineHeight, f.atlasWidth, atlas, fg, scale, margin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
fmt.Printf("error appending glyph %c: %v\n", r, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Add char to fontChar list
|
||||
f.fontChar = append(f.fontChar, char)
|
||||
f.glyphs[r] = glyph
|
||||
}
|
||||
|
||||
// Generate texture
|
||||
@@ -179,8 +286,10 @@ func LoadTrueTypeFont(program uint32, r io.Reader, scale int32, low, high rune,
|
||||
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
|
||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
|
||||
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, int32(rgba.Rect.Dx()), int32(rgba.Rect.Dy()), 0, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(rgba.Pix))
|
||||
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, int32(atlas.Rect.Dx()), int32(atlas.Rect.Dy()), 0, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(atlas.Pix))
|
||||
|
||||
gl.GenerateMipmap(gl.TEXTURE_2D)
|
||||
gl.BindTexture(gl.TEXTURE_2D, 0)
|
||||
@@ -226,7 +335,7 @@ func LoadFont(file string, scale int32, windowWidth int, windowHeight int) (*Fon
|
||||
resUniform := gl.GetUniformLocation(program, gl.Str("resolution\x00"))
|
||||
gl.Uniform2f(resUniform, float32(windowWidth), float32(windowHeight))
|
||||
|
||||
return LoadTrueTypeFont(program, fd, scale, 32, 256, LeftToRight)
|
||||
return LoadTrueTypeFont(program, fd, scale, LeftToRight)
|
||||
}
|
||||
|
||||
// SetColor allows you to set the text color to be used when you draw the text
|
||||
@@ -250,8 +359,6 @@ func (f *Font) Printf(x, y float32, scale float32, fs string, argv ...interface{
|
||||
return nil
|
||||
}
|
||||
|
||||
lowChar := rune(32)
|
||||
|
||||
// Setup blending mode
|
||||
gl.Enable(gl.BLEND)
|
||||
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
@@ -263,19 +370,17 @@ func (f *Font) Printf(x, y float32, scale float32, fs string, argv ...interface{
|
||||
|
||||
var coords []point
|
||||
|
||||
// Iterate through all characters in string
|
||||
// Iterate through all glyphs in string
|
||||
for i := range indices {
|
||||
// Get rune
|
||||
runeIndex := indices[i]
|
||||
|
||||
var ch *character
|
||||
// Skip runes that are not in font chacter range
|
||||
if int(runeIndex)-int(lowChar) > len(f.fontChar) || runeIndex < lowChar {
|
||||
// Print a ?
|
||||
ch = f.fontChar[int(rune('?'))-int(lowChar)]
|
||||
var ch *glyph
|
||||
// Skip runes that are not in font glyph map
|
||||
if glyph, ok := f.glyphs[runeIndex]; ok {
|
||||
ch = glyph
|
||||
} else {
|
||||
// Find rune in fontChar list
|
||||
ch = f.fontChar[runeIndex-lowChar]
|
||||
ch = f.glyphs['?'] // fallback
|
||||
}
|
||||
|
||||
// Calculate position and size for current rune
|
||||
@@ -325,23 +430,19 @@ func (f *Font) Width(scale float32, fs string, argv ...interface{}) float32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
lowChar := rune(32)
|
||||
|
||||
// Iterate through all characters in string
|
||||
// Iterate through all glyphs in string
|
||||
for i := range indices {
|
||||
|
||||
// Get rune
|
||||
runeIndex := indices[i]
|
||||
|
||||
// Skip runes that are not in font chacter range
|
||||
if int(runeIndex)-int(lowChar) > len(f.fontChar) || runeIndex < lowChar {
|
||||
fmt.Printf("%c %d\n", runeIndex, runeIndex)
|
||||
continue
|
||||
// Find rune in glyphs list
|
||||
var ch *glyph
|
||||
if glyph, ok := f.glyphs[runeIndex]; ok {
|
||||
ch = glyph
|
||||
} else {
|
||||
ch = f.glyphs['?'] // fallback
|
||||
}
|
||||
|
||||
// Find rune in fontChar list
|
||||
ch := f.fontChar[runeIndex-lowChar]
|
||||
|
||||
// Now advance cursors for next glyph (note that advance is number of 1/64 pixels)
|
||||
width += float32((ch.advance >> 6)) * scale // Bitshift by 6 to get value in pixels (2^6 = 64 (divide amount of 1/64th pixels by 64 to get amount of pixels))
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/libretro/ludo/ocr"
|
||||
)
|
||||
|
||||
// OCRCurrentFrame captures the current frame and returns extracted text using the OCR engine.
|
||||
func (video *Video) OCRCurrentFrame() (string, error) {
|
||||
img, err := video.CaptureFrameImage()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("capture frame: %w", err)
|
||||
}
|
||||
|
||||
text, err := ocr.TextFromImage(img)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("run OCR: %w", err)
|
||||
}
|
||||
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// SetSubtitle displays text at the bottom of the screen for a given duration.
|
||||
func (video *Video) SetSubtitle(text string, duration time.Duration) {
|
||||
video.subtitleText = strings.TrimSpace(text)
|
||||
if video.subtitleText == "" {
|
||||
video.subtitleUntil = time.Time{}
|
||||
return
|
||||
}
|
||||
|
||||
video.subtitleUntil = time.Now().Add(duration)
|
||||
}
|
||||
+42
-15
@@ -3,6 +3,7 @@ package video
|
||||
import (
|
||||
"image"
|
||||
"image/png"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -15,41 +16,69 @@ import (
|
||||
|
||||
// During the TakeScreenshot step, we need to render the current game frame at
|
||||
// the right resolution to later capture it using ReadPixels. renderScreenshot
|
||||
// taking care of this.
|
||||
func (video *Video) renderScreenshot() {
|
||||
va := video.vertexArray(0, 0, float32(video.Geom.BaseWidth), float32(video.Geom.BaseHeight), 1.0)
|
||||
// draws with the core aspect ratio and returns the pixel bounds to read back.
|
||||
func (video *Video) renderScreenshot() (int32, int32, int32, int32) {
|
||||
fbw, fbh := video.Window.GetFramebufferSize()
|
||||
w := float32(video.width)
|
||||
h := float32(video.height)
|
||||
if w == 0 || h == 0 {
|
||||
w = float32(video.Geom.BaseWidth)
|
||||
h = float32(video.Geom.BaseHeight)
|
||||
}
|
||||
|
||||
// Center the core-resolution quad in the framebuffer.
|
||||
x := (float32(fbw) - w) / 2
|
||||
y := (float32(fbh) - h) / 2
|
||||
|
||||
va := video.vertexArray(x, y, w, h, 1.0)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, video.vbo)
|
||||
gl.BufferData(gl.ARRAY_BUFFER, len(va)*4, gl.Ptr(va), gl.STATIC_DRAW)
|
||||
|
||||
readX := int32(math.Round(float64(x)))
|
||||
readY := int32(math.Round(float64(fbh) - math.Round(float64(y+h))))
|
||||
readW := int32(math.Round(float64(w)))
|
||||
readH := int32(math.Round(float64(h)))
|
||||
|
||||
bindVertexArray(video.vao)
|
||||
|
||||
gl.BindTexture(gl.TEXTURE_2D, video.texID)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, video.vbo)
|
||||
|
||||
gl.DrawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||
|
||||
return readX, readY, readW, readH
|
||||
}
|
||||
|
||||
// TakeScreenshot captures the ouput of video.Render and writes it to a file
|
||||
func (video *Video) TakeScreenshot(name string) error {
|
||||
// captureFrameImage captures the output of video.Render and returns it as an image.
|
||||
func (video *Video) captureFrameImage() (image.Image, error) {
|
||||
prevMenu := state.MenuActive
|
||||
state.MenuActive = false
|
||||
defer func() { state.MenuActive = true }()
|
||||
defer func() { state.MenuActive = prevMenu }()
|
||||
|
||||
gl.UseProgram(video.defaultProgram)
|
||||
|
||||
video.renderScreenshot()
|
||||
readX, readY, readW, readH := video.renderScreenshot()
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, video.Geom.BaseWidth, video.Geom.BaseHeight))
|
||||
|
||||
_, fbh := video.Window.GetFramebufferSize()
|
||||
img := image.NewRGBA(image.Rect(0, 0, int(readW), int(readH)))
|
||||
|
||||
gl.ReadPixels(
|
||||
0, int32(fbh-video.Geom.BaseHeight),
|
||||
int32(video.Geom.BaseWidth), int32(video.Geom.BaseHeight),
|
||||
readX, readY,
|
||||
readW, readH,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(img.Pix))
|
||||
|
||||
gl.UseProgram(video.program)
|
||||
|
||||
err := os.MkdirAll(settings.Current.ScreenshotsDirectory, os.ModePerm)
|
||||
return imaging.FlipV(img), nil
|
||||
}
|
||||
|
||||
// CaptureFrameImage returns the current frame as an image.
|
||||
func (video *Video) CaptureFrameImage() (image.Image, error) {
|
||||
return video.captureFrameImage()
|
||||
}
|
||||
|
||||
// TakeScreenshot captures the ouput of video.Render and writes it to a file
|
||||
func (video *Video) TakeScreenshot(name string) error {
|
||||
flipped, err := video.captureFrameImage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -60,7 +89,5 @@ func (video *Video) TakeScreenshot(name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
flipped := imaging.FlipV(img)
|
||||
|
||||
return png.Encode(fd, flipped)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RenderSubtitle draws the current subtitle, if any.
|
||||
func (video *Video) RenderSubtitle() {
|
||||
if video == nil || video.Font == nil || video.Window == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if video.subtitleText == "" || time.Now().After(video.subtitleUntil) {
|
||||
video.subtitleText = ""
|
||||
return
|
||||
}
|
||||
|
||||
fbw, fbh := video.Window.GetFramebufferSize()
|
||||
if fbw == 0 || fbh == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
lines := strings.Split(video.subtitleText, "\n")
|
||||
ratio := float32(fbw) / 1920
|
||||
scale := 0.6 * ratio
|
||||
padding := 16 * ratio
|
||||
lineHeight := 64 * ratio
|
||||
margin := 50 * ratio
|
||||
|
||||
video.Font.UpdateResolution(fbw, fbh)
|
||||
|
||||
previousColor := video.Font.color
|
||||
defer video.Font.SetColor(previousColor)
|
||||
|
||||
var maxWidth float32
|
||||
for _, line := range lines {
|
||||
w := video.Font.Width(scale, line)
|
||||
if w > maxWidth {
|
||||
maxWidth = w
|
||||
}
|
||||
}
|
||||
|
||||
bgW := maxWidth + padding*2
|
||||
minBgW := float32(fbw) * 0.75
|
||||
if bgW < minBgW {
|
||||
bgW = minBgW
|
||||
}
|
||||
bgX := (float32(fbw) - bgW) / 2
|
||||
bgH := lineHeight*float32(len(lines)) + padding*2
|
||||
bgY := float32(fbh) - bgH
|
||||
topY := bgY + padding
|
||||
|
||||
bgColor := Color{0, 0, 0, 0.75}
|
||||
|
||||
video.DrawRect(bgX, bgY - margin, bgW, bgH, 0.25, bgColor)
|
||||
video.Font.SetColor(Color{1, 1, 1, 1})
|
||||
|
||||
for i, line := range lines {
|
||||
y := topY + lineHeight*float32(i)
|
||||
x := float32(fbw)/2 - video.Font.Width(scale, line)/2
|
||||
video.Font.Printf(x, y, scale, line)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package video
|
||||
import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/go-gl/gl/v2.1/gl"
|
||||
@@ -40,6 +41,9 @@ type Video struct {
|
||||
width, height int32 // dimensions set by the refresh callback
|
||||
rot uint // rotation index
|
||||
|
||||
subtitleText string
|
||||
subtitleUntil time.Time
|
||||
|
||||
needUpload bool // true when the texture needs to be uploaded to the GPU
|
||||
data unsafe.Pointer
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user