feat(images): add ESRGAN upscaling and Studio workflow (#12620)

* feat(images): add upscale model capabilities

* fix(config): keep SD GGML upscale opt-in

* feat(stablediffusion): add lazy ESRGAN upscale bridge

Keep native image ownership in C++ and serialize context creation, inference and teardown. Use upscale_scale in backend options as the explicit upscale-only marker because ModelOptions does not carry usecases.

Assisted-by: Codex:gpt-5

* fix(stablediffusion): validate ESRGAN settings

* fix(upscale): forward typed settings and contain resolved model paths

Typed settings replace legacy options without duplicates. Resolve model and trusted root symlinks before caching the model path, rejecting missing files and escapes.

Assisted-by: Codex:gpt-5

* fix(upscale): validate scale and clean failed HTTP outputs

Require an explicit scale bounded to 1-16, check backend results, and keep destination cleanup owned by the allocating endpoint. Cover validation, result failures, cleanup, and successful URL responses.

Assisted-by: Codex:gpt-5

* feat(studio): add inline image upscale workflow

Assisted-by: OpenAI:gpt-5

* test(studio): serve upscale capability fixtures

* fix(stablediffusion): default ESRGAN tiling to full frame

* feat(gallery): add RealESRGAN anime upscaler

* fix(stablediffusion): allow full-frame ESRGAN tiles

* fix(routes): select explicit upscale models

Give image upscaling dedicated usecase-filtered middleware and keep ordinary image models out of the endpoint. Mark and verify the legacy Diffusers x4 gallery model as an explicit upscaler.

Assisted-by: OpenAI:gpt-5 [shell]

* refactor(upscale): keep settings in backend options

* test(config): keep upscale options backend-specific

* fix: keep upscale routing metadata out of backend options

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-11 00:30:09 +02:00
1 parent a8ee0d95ec
commit 0177e4988c
43 files changed
+1660 -75

No files matched your search

@@ -1,5 +1,7 @@
#include "stable-diffusion.h"
#include <cmath>
#include <memory>
#include <climits>
#include <cstdint>
#define GGML_MAX_NAME 128
@@ -1452,3 +1454,66 @@ int unload() {
free_sd_ctx(sd_c);
return 0;
}
// Own copies even if upstream retains constructor arguments: never retain a Go
// string pointer across the ABI call.
struct gosd_upscaler {
std::string path, backend, params_backend;
upscaler_ctx_t* ctx = nullptr;
~gosd_upscaler() { if (ctx) free_upscaler_ctx(ctx); }
};
void* gosd_upscaler_create(const char* path, bool direct, int threads, int tile_size,
const char* backend, const char* params_backend) {
if (!path || !*path || tile_size < 0) return nullptr;
try {
auto h = std::make_unique<gosd_upscaler>();
h->path = path;
h->backend = backend ? backend : "";
h->params_backend = params_backend ? params_backend : "";
h->ctx = new_upscaler_ctx(h->path.c_str(), direct, threads, tile_size,
h->backend.c_str(), h->params_backend.c_str());
if (!h->ctx) return nullptr;
return h.release();
} catch (...) { return nullptr; }
}
int gosd_upscaler_scale(void* handle) {
if (!handle) return 0;
try { return get_upscale_factor(static_cast<gosd_upscaler*>(handle)->ctx); }
catch (...) { return 0; }
}
void gosd_upscaler_destroy(void* handle) {
delete static_cast<gosd_upscaler*>(handle);
}
int gosd_upscaler_run(void* handle, const char* src, const char* dst, int scale) {
if (!handle || !src || !*src || !dst || !*dst || scale <= 0) return 1;
// RAII also covers exceptions from native inference.
struct images {
sd_image_t* data = nullptr;
int count = 0;
~images() { if (data) free_sd_images(data, count); }
} output;
int width = 0, height = 0, channels = 0;
std::unique_ptr<unsigned char, decltype(&stbi_image_free)> input(
stbi_load(src, &width, &height, &channels, 3), stbi_image_free);
if (!input || width <= 0 || height <= 0) return 2;
const uint64_t out_width = uint64_t(width) * scale;
const uint64_t out_height = uint64_t(height) * scale;
if (out_width > INT_MAX / 3 || out_height > INT_MAX ||
out_width * out_height > SIZE_MAX / 3) return 4;
sd_image_t image = {uint32_t(width), uint32_t(height), 3, input.get()};
try {
if (!upscale(static_cast<gosd_upscaler*>(handle)->ctx, image, scale,
&output.data, &output.count)) return 3;
if (!output.data || output.count != 1 || !output.data[0].data ||
output.data[0].width != out_width || output.data[0].height != out_height ||
output.data[0].channel != 3) return 4;
if (!stbi_write_png(dst, int(out_width), int(out_height), 3,
output.data[0].data, int(out_width) * 3)) return 5;
return 0;
} catch (...) { return 3; }
}
@@ -5,6 +5,13 @@
extern "C" {
#endif
// Opaque ESRGAN context. Run returns 0, or argument/decode/inference/output/write
// errors (1..5); all image allocations remain native.
void* gosd_upscaler_create(const char* path, bool direct, int threads, int tile_size, const char* backend, const char* params_backend);
int gosd_upscaler_scale(void* handle);
int gosd_upscaler_run(void* handle, const char* src, const char* dst, int scale);
void gosd_upscaler_destroy(void* handle);
void sd_tiling_params_set_enabled(sd_tiling_params_t *params, bool enabled);
void sd_tiling_params_set_tile_sizes(sd_tiling_params_t *params, int tile_size_w, int tile_size_h);
void sd_tiling_params_set_rel_sizes(sd_tiling_params_t *params, float rel_size_w, float rel_size_h);
+199 -3
View File
@@ -8,6 +8,7 @@ import (
"runtime"
"strconv"
"strings"
"sync"
"unsafe"
"github.com/mudler/LocalAI/pkg/grpc/base"
@@ -17,6 +18,9 @@ import (
type SDGGML struct {
base.SingleThread
upscaleMu sync.Mutex
upscaler uintptr
upscale upscaleSettings
threads int
sampleMethod string
cfgScale float32
@@ -25,9 +29,13 @@ type SDGGML struct {
}
var (
LoadModel func(model, model_apth string, options []uintptr, threads int32, diff int) int
GenImage func(params uintptr, steps int, dst string, cfgScale float32, srcImage string, strength float32, maskImage string, refImages []uintptr, refImagesCount int) int
GenVideo func(params uintptr, steps int, dst string, cfgScale float32, fps int, initImage string, endImage string) int
UpscalerCreate func(string, bool, int32, int32, string, string) uintptr
UpscalerScale func(uintptr) int32
UpscalerRun func(uintptr, string, string, int32) int32
UpscalerDestroy func(uintptr)
LoadModel func(model, model_apth string, options []uintptr, threads int32, diff int) int
GenImage func(params uintptr, steps int, dst string, cfgScale float32, srcImage string, strength float32, maskImage string, refImages []uintptr, refImagesCount int) int
GenVideo func(params uintptr, steps int, dst string, cfgScale float32, fps int, initImage string, endImage string) int
TilingParamsSetEnabled func(params uintptr, enabled bool)
TilingParamsSetTileSizes func(params uintptr, tileSizeX int, tileSizeY int)
@@ -154,6 +162,22 @@ func CString(name string) *byte {
}
func (sd *SDGGML) Load(opts *pb.ModelOptions) error {
sd.upscaleMu.Lock()
defer sd.upscaleMu.Unlock()
if opts == nil {
return fmt.Errorf("missing model options")
}
settings, err := parseUpscaleSettings(opts)
if err != nil {
return err
}
if sd.upscaler != 0 {
return fmt.Errorf("free the existing upscaler before loading another model")
}
sd.upscale = settings
if settings.enabled {
return nil
}
sd.threads = int(opts.Threads)
@@ -336,3 +360,175 @@ func (sd *SDGGML) GenerateVideo(opts *pb.GenerateVideoRequest) error {
}
return nil
}
// Presence of the backend option upscale_scale selects upscale mode.
type upscaleSettings struct {
enabled bool
path string
scale, tile, threads int32
direct bool
backend, paramsBackend string
}
func parseUpscaleSettings(opts *pb.ModelOptions) (upscaleSettings, error) {
s := upscaleSettings{threads: opts.Threads}
image := false
for _, op := range opts.Options {
k, _, _ := strings.Cut(op, ":")
if k == "upscale_scale" {
s.enabled = true
}
if k == "diffusion_model" {
image = true
}
}
if !s.enabled {
return s, nil
}
if image {
return s, fmt.Errorf("upscale_scale cannot be combined with diffusion_model")
}
seen := make(map[string]bool)
for _, op := range opts.Options {
k, v, _ := strings.Cut(op, ":")
switch k {
case "upscale_scale", "upscale_tile_size", "upscale_direct", "backend", "params_backend":
if seen[k] {
return s, fmt.Errorf("duplicate upscale setting %q", k)
}
seen[k] = true
}
switch k {
case "upscale_scale", "upscale_tile_size":
n, err := strconv.ParseInt(v, 10, 32)
if err != nil || (k == "upscale_scale" && n <= 0) || (k == "upscale_tile_size" && n < 0) {
if k == "upscale_tile_size" {
return s, fmt.Errorf("%s must be a non-negative integer", k)
}
return s, fmt.Errorf("%s must be a positive integer", k)
}
if k == "upscale_scale" {
s.scale = int32(n)
} else {
s.tile = int32(n)
}
case "upscale_direct":
b, err := strconv.ParseBool(v)
if err != nil {
return s, fmt.Errorf("invalid upscale_direct: %w", err)
}
s.direct = b
case "backend":
s.backend = v
case "params_backend":
s.paramsBackend = v
}
}
if s.scale <= 0 {
return s, fmt.Errorf("upscale_scale must be configured and positive")
}
modelFile := opts.ModelFile
if modelFile == "" {
return s, fmt.Errorf("upscale model path is empty")
}
if filepath.IsAbs(modelFile) {
s.path = filepath.Clean(modelFile)
} else {
// Verify the untrusted relative name before joining, then verify the
// resolved result as well. VerifyPath intentionally treats absolute
// inputs as relative, so absolute model files use VerifyResolvedPath.
if err := utils.VerifyPath(modelFile, opts.ModelPath); err != nil {
return s, fmt.Errorf("upscale model path is outside model path: %w", err)
}
s.path = filepath.Join(opts.ModelPath, modelFile)
}
if err := utils.VerifyResolvedPath(s.path, opts.ModelPath); err != nil {
return s, fmt.Errorf("upscale model path is outside model path: %w", err)
}
// Lexical containment alone does not prevent a model symlink (or a
// symlinked parent directory) from escaping the trusted root. Resolve
// both, requiring an existing target, and cache only the real model path.
realRoot, err := filepath.EvalSymlinks(opts.ModelPath)
if err != nil {
return s, fmt.Errorf("resolve upscale model root: %w", err)
}
realPath, err := filepath.EvalSymlinks(s.path)
if err != nil {
return s, fmt.Errorf("resolve upscale model file: %w", err)
}
if err := utils.VerifyResolvedPath(realPath, realRoot); err != nil {
return s, fmt.Errorf("upscale model path is outside real model path: %w", err)
}
info, err := os.Stat(realPath)
if err != nil {
return s, fmt.Errorf("stat upscale model file: %w", err)
}
if !info.Mode().IsRegular() {
return s, fmt.Errorf("upscale model path must be a regular file")
}
s.path = realPath
for _, v := range []string{s.path, s.backend, s.paramsBackend} {
if strings.ContainsRune(v, 0) {
return s, fmt.Errorf("upscale settings contain NUL")
}
}
return s, nil
}
func (sd *SDGGML) UpscaleImage(opts *pb.UpscaleImageRequest) error {
sd.upscaleMu.Lock()
defer sd.upscaleMu.Unlock()
s := sd.upscale
if !s.enabled {
return fmt.Errorf("no upscale-only model loaded (configure upscale_scale)")
}
if opts == nil || opts.Scale <= 0 || opts.Src == "" || opts.Dst == "" || strings.ContainsRune(opts.Src, 0) || strings.ContainsRune(opts.Dst, 0) {
return fmt.Errorf("upscale requires source, destination and positive requested scale")
}
if sd.upscaler == 0 {
h := UpscalerCreate(s.path, s.direct, s.threads, s.tile, s.backend, s.paramsBackend)
runtime.KeepAlive(s)
if h == 0 {
return fmt.Errorf("could not initialize ESRGAN upscaler")
}
detected := UpscalerScale(h)
if detected <= 0 || detected != s.scale {
UpscalerDestroy(h)
return fmt.Errorf("configured upscale scale %d differs from detected native scale %d", s.scale, detected)
}
sd.upscaler = h
}
if opts.Scale != s.scale {
return fmt.Errorf("requested upscale scale %d differs from native scale %d", opts.Scale, s.scale)
}
code := UpscalerRun(sd.upscaler, opts.Src, opts.Dst, opts.Scale)
runtime.KeepAlive(opts)
switch code {
case 0:
return nil
case 1:
return fmt.Errorf("invalid native upscale arguments")
case 2:
return fmt.Errorf("upscale source decode failed")
case 3:
return fmt.Errorf("upscale inference failed")
case 4:
return fmt.Errorf("upscale output validation failed")
case 5:
return fmt.Errorf("upscale PNG write failed")
default:
return fmt.Errorf("upscale failed (native code %d)", code)
}
}
func (sd *SDGGML) Free() error {
sd.upscaleMu.Lock()
defer sd.upscaleMu.Unlock()
if sd.upscaler != 0 {
UpscalerDestroy(sd.upscaler)
sd.upscaler = 0
}
sd.upscale = upscaleSettings{}
// Deliberately leave the existing diffusion sd_c lifecycle unchanged.
return nil
}
@@ -1,6 +1,8 @@
package main
import (
"os"
"path/filepath"
"testing"
"unsafe"
@@ -222,3 +224,313 @@ var _ = Describe("GenerateImage VAE tiling", func() {
Expect(fake.overlapCalls).To(BeZero())
})
})
var _ = Describe("ESRGAN lifecycle", func() {
var sd *SDGGML
var root, modelPath string
var creates, destroys, runs, loads int
var scale, code int32
var fail bool
var entered, release chan struct{}
BeforeEach(func() {
sd = &SDGGML{}
root = GinkgoT().TempDir()
var err error
root, err = filepath.EvalSymlinks(root)
Expect(err).NotTo(HaveOccurred())
modelPath = filepath.Join(root, "esrgan.gguf")
Expect(os.WriteFile(modelPath, []byte("model"), 0600)).To(Succeed())
creates, destroys, runs, loads = 0, 0, 0, 0
scale, code, fail = 4, 0, false
entered, release = nil, nil
oldCreate, oldScale, oldRun, oldDestroy, oldLoad := UpscalerCreate, UpscalerScale, UpscalerRun, UpscalerDestroy, LoadModel
DeferCleanup(func() {
UpscalerCreate, UpscalerScale, UpscalerRun, UpscalerDestroy, LoadModel = oldCreate, oldScale, oldRun, oldDestroy, oldLoad
})
UpscalerCreate = func(path string, direct bool, threads, tile int32, backend, params string) uintptr {
creates++
Expect(path).To(Equal(modelPath))
Expect(tile).To(Equal(int32(128)))
if fail {
return 0
}
return 42
}
UpscalerScale = func(uintptr) int32 { return scale }
UpscalerRun = func(uintptr, string, string, int32) int32 {
runs++
if entered != nil {
close(entered)
<-release
}
return code
}
UpscalerDestroy = func(h uintptr) { Expect(h).To(Equal(uintptr(42))); destroys++ }
LoadModel = func(string, string, []uintptr, int32, int) int { loads++; return 0 }
})
load := func(extra ...string) error {
return sd.Load(&pb.ModelOptions{ModelPath: root, ModelFile: "esrgan.gguf", Options: append([]string{"upscale_scale:4", "upscale_tile_size:128"}, extra...)})
}
run := func(s int32) error {
return sd.UpscaleImage(&pb.UpscaleImageRequest{Src: "in.png", Dst: "out.png", Scale: s})
}
It("skips diffusion, creates lazily, reuses and frees once", func() {
Expect(load()).To(Succeed())
Expect(loads).To(BeZero())
Expect(creates).To(BeZero())
Expect(run(4)).To(Succeed())
Expect(run(4)).To(Succeed())
Expect(creates).To(Equal(1))
Expect(runs).To(Equal(2))
Expect(sd.Free()).To(Succeed())
Expect(sd.Free()).To(Succeed())
Expect(destroys).To(Equal(1))
Expect(run(4)).NotTo(Succeed())
})
It("rejects invalid configuration", func() {
Expect(load("upscale_scale:0")).NotTo(Succeed())
Expect(load("upscale_tile_size:-1")).NotTo(Succeed())
})
It("rejects detected scale mismatch without caching", func() {
Expect(load()).To(Succeed())
scale = 2
Expect(run(4)).To(MatchError(ContainSubstring("configured")))
Expect(destroys).To(Equal(1))
scale = 4
Expect(run(4)).To(Succeed())
Expect(creates).To(Equal(2))
})
It("rejects requested scale mismatch", func() {
Expect(load()).To(Succeed())
Expect(run(2)).To(MatchError(ContainSubstring("requested")))
Expect(runs).To(BeZero())
})
It("does not cache failed initialization", func() {
Expect(load()).To(Succeed())
fail = true
Expect(run(4)).NotTo(Succeed())
fail = false
Expect(run(4)).To(Succeed())
Expect(creates).To(Equal(2))
})
It("rejects undetected scales", func() {
Expect(load()).To(Succeed())
scale = 0
Expect(run(4)).NotTo(Succeed())
Expect(destroys).To(Equal(1))
})
It("reports native failures", func() {
Expect(load()).To(Succeed())
for _, c := range []int32{1, 2, 3, 4, 5} {
code = c
Expect(run(4)).NotTo(Succeed())
}
})
It("waits for inference before Free", func() {
Expect(load()).To(Succeed())
Expect(run(4)).To(Succeed())
entered, release = make(chan struct{}), make(chan struct{})
done := make(chan error, 1)
freed := make(chan error, 1)
go func() { done <- run(4) }()
<-entered
go func() { freed <- sd.Free() }()
Consistently(freed, "50ms").ShouldNot(Receive())
close(release)
Eventually(done).Should(Receive(BeNil()))
Eventually(freed).Should(Receive(BeNil()))
Expect(destroys).To(Equal(1))
})
})
func TestUpscaleSettings(t *testing.T) {
root := upscaleTestRoot(t)
opts := &pb.ModelOptions{ModelPath: root, ModelFile: filepath.Join(root, "model.gguf"), Threads: 7, Options: []string{
"upscale_scale:4", "upscale_tile_size:256",
"upscale_direct:true", "backend:Vulkan0", "params_backend:CPU",
}}
got, err := parseUpscaleSettings(opts)
if err != nil {
t.Fatal(err)
}
if !got.enabled || got.path != opts.ModelFile || got.scale != 4 || got.tile != 256 || got.threads != 7 || !got.direct || got.backend != "Vulkan0" || got.paramsBackend != "CPU" {
t.Fatalf("wrong settings: %+v", got)
}
for _, options := range [][]string{
{"upscale_scale:banana"}, {"upscale_scale:2147483648"},
{"upscale_scale:4", "upscale_tile_size:-1"},
{"upscale_scale:4", "diffusion_model"}, {"upscale_scale:4", "upscale_direct:maybe"},
} {
opts.Options = options
if _, err := parseUpscaleSettings(opts); err == nil {
t.Fatalf("accepted invalid options %v", options)
}
}
}
func TestUpscaleModeSelection(t *testing.T) {
root := upscaleTestRoot(t)
upscale, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: "model.gguf", Options: []string{"upscale_scale:4"}})
if err != nil || !upscale.enabled {
t.Fatalf("upscale_scale did not enable upscale mode: settings=%+v err=%v", upscale, err)
}
knownUsecases, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: "model.gguf", Options: []string{"known_usecases:upscale"}})
if err != nil || knownUsecases.enabled {
t.Fatalf("known_usecases must not enable upscale mode: settings=%+v err=%v", knownUsecases, err)
}
if _, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: "model.gguf", Options: []string{"upscale_scale:4", "diffusion_model"}}); err == nil {
t.Fatal("accepted upscale_scale combined with diffusion_model")
}
savedLoadModel := LoadModel
defer func() { LoadModel = savedLoadModel }()
loads := 0
diffusion := -1
LoadModel = func(_ string, _ string, _ []uintptr, _ int32, diffusionModel int) int {
loads++
diffusion = diffusionModel
return 0
}
if err := (&SDGGML{}).Load(&pb.ModelOptions{ModelPath: root, ModelFile: "model.gguf", Options: []string{"diffusion_model"}}); err != nil {
t.Fatalf("Load without upscale_scale: %v", err)
}
if loads != 1 || diffusion != 1 {
t.Fatalf("Load without upscale_scale did not use diffusion path: loads=%d diffusion=%d", loads, diffusion)
}
}
func TestUpscaleSettingsFullFrameTile(t *testing.T) {
root := upscaleTestRoot(t)
for _, options := range [][]string{
{"upscale_scale:4"},
{"upscale_scale:4", "upscale_tile_size:0"},
} {
got, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: "model.gguf", Options: options})
if err != nil {
t.Fatalf("parseUpscaleSettings(%v): %v", options, err)
}
if got.tile != 0 {
t.Fatalf("tile for %v = %d, want full-frame 0", options, got.tile)
}
}
}
func TestUpscaleSettingsPathContainment(t *testing.T) {
root := upscaleTestRoot(t)
if err := os.Mkdir(filepath.Join(root, "nested"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "nested/model.gguf"), []byte("model"), 0600); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name, modelFile, want string
wantErr bool
}{
{"safe nested relative path", "nested/model.gguf", filepath.Join(root, "nested/model.gguf"), false},
{"relative traversal", "../outside.gguf", "", true},
{"allowed in-root absolute path", filepath.Join(root, "model.gguf"), filepath.Join(root, "model.gguf"), false},
{"outside absolute path", "/other/model.gguf", "", true},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: tc.modelFile, Options: []string{"upscale_scale:4"}})
if tc.wantErr {
if err == nil {
t.Fatal("accepted path outside model path")
}
return
}
if err != nil {
t.Fatal(err)
}
if got.path != tc.want {
t.Fatalf("path = %q, want %q", got.path, tc.want)
}
})
}
}
func TestUpscaleSettingsRejectsDuplicateSettings(t *testing.T) {
root := upscaleTestRoot(t)
for _, setting := range []string{"upscale_scale:4", "upscale_tile_size:128", "upscale_direct:true", "backend:Vulkan", "params_backend:CPU"} {
t.Run(setting, func(t *testing.T) {
_, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: "model.gguf", Options: []string{"upscale_scale:4", setting, setting}})
if err == nil {
t.Fatalf("accepted duplicate %s", setting)
}
})
}
}
func upscaleTestRoot(t *testing.T) string {
t.Helper()
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "model.gguf"), []byte("model"), 0600); err != nil {
t.Fatal(err)
}
return root
}
func TestUpscaleSettingsRealPathContainment(t *testing.T) {
root := upscaleTestRoot(t)
outside := upscaleTestRoot(t)
for _, tc := range []struct {
name, target string
wantErr bool
}{
{"in-root symlink", filepath.Join(root, "model.gguf"), false},
{"outside symlink", filepath.Join(outside, "model.gguf"), true},
{"dangling symlink", filepath.Join(root, "missing.gguf"), true},
{"outside directory symlink", outside, true},
} {
t.Run(tc.name, func(t *testing.T) {
link := filepath.Join(root, tc.name)
if err := os.Symlink(tc.target, link); err != nil {
t.Skipf("symlink creation unsupported: %v", err)
}
file := link
if tc.name == "outside directory symlink" {
file = filepath.Join(link, "model.gguf")
}
got, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: file, Options: []string{"upscale_scale:4"}})
if tc.wantErr {
if err == nil {
t.Fatal("accepted unsafe symlink")
}
return
}
if err != nil {
t.Fatal(err)
}
if got.path != tc.target {
t.Fatalf("cached path %q, want real path %q", got.path, tc.target)
}
})
}
t.Run("symlinked trusted root", func(t *testing.T) {
link := filepath.Join(t.TempDir(), "root")
if err := os.Symlink(root, link); err != nil {
t.Skipf("symlink creation unsupported: %v", err)
}
got, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: link, ModelFile: "model.gguf", Options: []string{"upscale_scale:4"}})
if err != nil {
t.Fatal(err)
}
if got.path != filepath.Join(root, "model.gguf") {
t.Fatalf("not resolved: %q", got.path)
}
})
for _, file := range []string{"missing.gguf", "."} {
t.Run(file, func(t *testing.T) {
if _, err := parseUpscaleSettings(&pb.ModelOptions{ModelPath: root, ModelFile: file, Options: []string{"upscale_scale:4"}}); err == nil {
t.Fatal("accepted missing or non-file model")
}
})
}
}
+4
View File
@@ -35,6 +35,10 @@ func main() {
}
libFuncs := []LibFuncs{
{&UpscalerCreate, "gosd_upscaler_create"},
{&UpscalerScale, "gosd_upscaler_scale"},
{&UpscalerRun, "gosd_upscaler_run"},
{&UpscalerDestroy, "gosd_upscaler_destroy"},
{&LoadModel, "load_model"},
{&GenImage, "gen_image"},
{&GenVideo, "gen_video"},
+18
View File
@@ -451,3 +451,21 @@ var _ = Describe("effectiveThreads", func() {
Expect(effectiveThreads(config.ModelConfig{}, 0)).To(Equal(1))
})
})
var _ = Describe("grpcModelOpts option forwarding", func() {
It("preserves explicit upscale backend options exactly", func() {
threads := 1
options := []string{"upscale_scale:2", "upscale_scale:8", "upscale_tile_size:-1", "backend:CPU"}
cfg := config.ModelConfig{Threads: &threads, Backend: "stablediffusion-ggml", KnownUsecases: config.GetUsecasesFromYAML([]string{"upscale", "image"}), Options: options}
Expect(grpcModelOpts(cfg, "/models").Options).To(Equal(options))
Expect(cfg.Options).To(Equal(options))
})
It("leaves generic SD options unchanged", func() {
threads := 1
cfg := config.ModelConfig{Threads: &threads, Backend: "stablediffusion-ggml", Options: []string{"backend:CPU", "diffusion_model:model.gguf"}}
Expect(grpcModelOpts(cfg, "/models").Options).To(Equal(cfg.Options))
cfg.KnownUsecases = config.GetUsecasesFromYAML([]string{"image"})
Expect(grpcModelOpts(cfg, "/models").Options).To(Equal(cfg.Options))
})
})
+15 -2
View File
@@ -2,6 +2,7 @@ package backend
import (
"context"
"fmt"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/grpc/proto"
@@ -24,7 +25,7 @@ func ImageUpscale(ctx context.Context, src, dst string, scale int, loader *model
return err
}
defer release()
_, err = inferenceModel.UpscaleImage(
result, err := inferenceModel.UpscaleImage(
ctx,
&proto.UpscaleImageRequest{
Src: src,
@@ -32,7 +33,19 @@ func ImageUpscale(ctx context.Context, src, dst string, scale int, loader *model
Scale: int32(scale),
},
)
return err
if err != nil {
return err
}
if result == nil {
return fmt.Errorf("image upscale returned nil result")
}
if !result.Success {
if result.Message != "" {
return fmt.Errorf("image upscale failed: %s", result.Message)
}
return fmt.Errorf("image upscale failed")
}
return nil
}
return fn, nil
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: MIT
package backend_test
import (
"context"
"errors"
"strings"
"testing"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
grpcPkg "github.com/mudler/LocalAI/pkg/grpc"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
ggrpc "google.golang.org/grpc"
)
type upscaleBackend struct {
grpcPkg.Backend
result *pb.Result
err error
request *pb.UpscaleImageRequest
}
func (*upscaleBackend) HealthCheck(context.Context) (bool, error) { return true, nil }
func (*upscaleBackend) IsBusy() bool { return false }
func (b *upscaleBackend) UpscaleImage(_ context.Context, in *pb.UpscaleImageRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
b.request = in
return b.result, b.err
}
func TestImageUpscaleResult(t *testing.T) {
transportErr := errors.New("transport failed")
for _, tc := range []struct {
name string
result *pb.Result
err error
want string
}{
{name: "nil", want: "nil"},
{name: "unsuccessful", result: &pb.Result{}, want: "failed"},
{name: "message", result: &pb.Result{Message: "native mismatch"}, want: "native mismatch"},
{name: "transport", err: transportErr, want: "transport failed"},
{name: "success", result: &pb.Result{Success: true}},
} {
t.Run(tc.name, func(t *testing.T) {
fake := &upscaleBackend{result: tc.result, err: tc.err}
loader := model.NewModelLoader(&system.SystemState{})
loader.SetModelRouter(func(_ context.Context, id string, _, _, _, _ string, _ *pb.ModelOptions, _ bool) (*model.Model, error) {
return model.NewModelWithClient(id, "test://upscale", fake), nil
})
cfg := config.ModelConfig{Name: "upscale", Backend: "stub"}
cfg.SetDefaults()
fn, err := backend.ImageUpscale(context.Background(), "source", "destination", 3, loader, cfg, config.NewApplicationConfig(config.WithSystemState(&system.SystemState{})))
if err != nil {
t.Fatal(err)
}
err = fn()
if tc.want == "" {
if err != nil {
t.Fatal(err)
}
} else if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want %q", err, tc.want)
}
if tc.err != nil && !errors.Is(err, tc.err) {
t.Fatalf("lost transport error: %v", err)
}
if fake.request == nil || fake.request.Scale != 3 || fake.request.Src != "source" || fake.request.Dst != "destination" {
t.Fatalf("unexpected request: %v", fake.request)
}
})
}
}
+9 -3
View File
@@ -17,6 +17,7 @@ const (
UsecaseEmbeddings = "embeddings"
UsecaseTokenize = "tokenize"
UsecaseImage = "image"
UsecaseUpscale = "upscale"
UsecaseVideo = "video"
Usecase3D = "3d"
Usecase3DAnimation = "3d_animation"
@@ -122,6 +123,11 @@ var UsecaseInfoMap = map[string]UsecaseInfo{
GRPCMethod: MethodTokenizeString,
Description: "Tokenization via the TokenizeString RPC without running inference.",
},
UsecaseUpscale: {
Flag: FLAG_UPSCALE,
GRPCMethod: MethodUpscaleImage,
Description: "Image upscaling via the UpscaleImage RPC.",
},
UsecaseImage: {
Flag: FLAG_IMAGE,
GRPCMethod: MethodGenerateImage,
@@ -406,7 +412,7 @@ var BackendCapabilities = map[string]BackendCapability{
// --- Image/video generation backends ---
"diffusers": {
GRPCMethods: []GRPCMethod{MethodGenerateImage, MethodUpscaleImage, MethodGenerateVideo, MethodSoundGeneration},
PossibleUsecases: []string{UsecaseImage, UsecaseVideo, UsecaseSoundGeneration},
PossibleUsecases: []string{UsecaseImage, UsecaseUpscale, UsecaseVideo, UsecaseSoundGeneration},
DefaultUsecases: []string{UsecaseImage},
Description: "HuggingFace diffusers — image, video, and sound generation",
},
@@ -432,8 +438,8 @@ var BackendCapabilities = map[string]BackendCapability{
Description: "Stable Diffusion native backend",
},
"stablediffusion-ggml": {
GRPCMethods: []GRPCMethod{MethodGenerateImage},
PossibleUsecases: []string{UsecaseImage},
GRPCMethods: []GRPCMethod{MethodGenerateImage, MethodUpscaleImage},
PossibleUsecases: []string{UsecaseImage, UsecaseUpscale},
DefaultUsecases: []string{UsecaseImage},
Description: "Stable Diffusion via GGML quantized models",
},
+12
View File
@@ -445,3 +445,15 @@ var _ = Describe("AllBackendNames", func() {
Expect(slices.IsSorted(names)).To(BeTrue())
})
})
var _ = Describe("Upscale backend declaration", func() {
It("maps upscale to its dedicated RPC and advertises SD GGML support", func() {
info := UsecaseInfoMap[UsecaseUpscale]
Expect(info.Flag).To(Equal(FLAG_UPSCALE))
Expect(info.GRPCMethod).To(Equal(MethodUpscaleImage))
cap := GetBackendCapability("stablediffusion-ggml")
Expect(cap.GRPCMethods).To(ContainElement(MethodUpscaleImage))
Expect(cap.PossibleUsecases).To(ContainElement(UsecaseUpscale))
Expect(cap.DefaultUsecases).NotTo(ContainElement(UsecaseUpscale))
})
})
+1
View File
@@ -62,6 +62,7 @@ var UsecaseOptions = []FieldOption{
{Value: "rerank", Label: "Rerank"},
{Value: "score", Label: "Score (Router Classifier)"},
{Value: "image", Label: "Image"},
{Value: "upscale", Label: "Image upscaling"},
{Value: "vision", Label: "Vision"},
{Value: "detection", Label: "Detection"},
{Value: "depth", Label: "Depth"},
+12
View File
@@ -85,3 +85,15 @@ var _ = Describe("Decisions router metadata", func() {
}
})
})
var _ = Describe("backend-only upscale options", func() {
It("does not expose scale or tile size as generic root configuration", func() {
reg := meta.DefaultRegistry()
Expect(reg).NotTo(HaveKey("upscale_scale"))
Expect(reg).NotTo(HaveKey("upscale_tile_size"))
md := meta.BuildForTest(reflect.TypeOf(config.ModelConfig{}), reg)
for _, field := range md.Fields {
Expect(field.Path).NotTo(BeElementOf("upscale_scale", "upscale_tile_size"))
}
})
})
+3 -2
View File
@@ -162,6 +162,7 @@ func (c *ModelConfig) Capabilities() []string {
add(c.HasUsecases(FLAG_TTS), UsecaseTTS)
add(c.HasUsecases(FLAG_SOUND_GENERATION), UsecaseSoundGeneration)
add(c.HasUsecases(FLAG_IMAGE), UsecaseImage)
add(c.HasUsecases(FLAG_UPSCALE), UsecaseUpscale)
add(c.HasUsecases(FLAG_VIDEO), UsecaseVideo)
add(c.HasUsecases(FLAG_3D), Usecase3D)
add(c.HasUsecases(FLAG_3D_ANIMATION), Usecase3DAnimation)
@@ -207,7 +208,7 @@ func (c *ModelConfig) InputModalities() []string {
// Ollama surface); detection/depth/face/3D models consume images directly.
imageIn := (chatish && c.VisionSupported()) || c.LimitMMPerPrompt.LimitImagePerPrompt > 0 ||
c.HasUsecases(FLAG_DETECTION) || c.HasUsecases(FLAG_DEPTH) || c.HasUsecases(FLAG_FACE_RECOGNITION) ||
c.HasUsecases(FLAG_3D)
c.HasUsecases(FLAG_3D) || c.HasUsecases(FLAG_UPSCALE)
audioIn := c.AudioInputSupported() || c.HasUsecases(FLAG_TRANSCRIPT) || c.HasUsecases(FLAG_AUDIO_TRANSFORM) ||
c.HasUsecases(FLAG_REALTIME_AUDIO) || c.HasUsecases(FLAG_VAD) || c.HasUsecases(FLAG_DIARIZATION) ||
@@ -228,7 +229,7 @@ func (c *ModelConfig) OutputModalities() []string {
modalities := declaredModalities(c.KnownOutputModalities)
textOut := c.HasUsecases(FLAG_CHAT) || c.HasUsecases(FLAG_COMPLETION) || c.HasUsecases(FLAG_EDIT) ||
c.HasUsecases(FLAG_TRANSCRIPT)
imageOut := c.HasUsecases(FLAG_IMAGE)
imageOut := c.HasUsecases(FLAG_IMAGE) || c.HasUsecases(FLAG_UPSCALE)
audioOut := c.HasUsecases(FLAG_TTS) || c.HasUsecases(FLAG_SOUND_GENERATION) ||
c.HasUsecases(FLAG_AUDIO_TRANSFORM) || c.HasUsecases(FLAG_REALTIME_AUDIO)
videoOut := c.HasUsecases(FLAG_VIDEO)
+14
View File
@@ -207,3 +207,17 @@ var _ = Describe("Model capabilities derivation", func() {
})
})
})
var _ = Describe("Upscale capabilities", func() {
It("keeps dedicated upscalers image-only without guessing generation", func() {
for _, backend := range []string{"stablediffusion-ggml", "llama-cpp", ""} {
cfg := &ModelConfig{Backend: backend, KnownUsecases: GetUsecasesFromYAML([]string{"upscale"})}
Expect(cfg.Capabilities()).To(Equal([]string{UsecaseUpscale}))
Expect(cfg.InputModalities()).To(Equal([]string{ModalityImage}))
Expect(cfg.OutputModalities()).To(Equal([]string{ModalityImage}))
Expect(cfg.HasUsecases(FLAG_IMAGE)).To(BeFalse())
Expect((&ModelConfig{Backend: backend}).HasUsecases(FLAG_UPSCALE)).To(BeFalse())
}
Expect(*GetUsecasesFromYAML([]string{"FLAG_UPSCALE"})).To(Equal(FLAG_UPSCALE))
})
})
+10 -2
View File
@@ -2088,6 +2088,9 @@ const (
// pickers it cannot serve.
FLAG_DECISIONS ModelConfigUsecase = 1 << 25
// Marks a model as an image upscaler served by the UpscaleImage RPC.
FLAG_UPSCALE ModelConfigUsecase = 1 << 26
// Common Subsets
FLAG_LLM ModelConfigUsecase = FLAG_CHAT | FLAG_COMPLETION | FLAG_EDIT
)
@@ -2151,6 +2154,7 @@ func GetAllModelConfigUsecases() map[string]ModelConfigUsecase {
"FLAG_3D": FLAG_3D,
"FLAG_3D_ANIMATION": FLAG_3D_ANIMATION,
"FLAG_DECISIONS": FLAG_DECISIONS,
"FLAG_UPSCALE": FLAG_UPSCALE,
}
}
@@ -2179,7 +2183,7 @@ func GetUsecasesFromYAML(input []string) *ModelConfigUsecase {
//
// Declared known_usecases are normally additive — the guessing heuristic
// still adds whatever it can infer from backend/templates. The exceptions
// are FLAG_SCORE, FLAG_TOKEN_CLASSIFY and FLAG_DECISIONS: when the operator
// are FLAG_SCORE, FLAG_TOKEN_CLASSIFY, FLAG_DECISIONS and FLAG_UPSCALE: when the operator
// declared any of them, they reserved the model for a direct-decode primitive
// (the router classifier, the PII NER tier, or a decision head). Letting GuessUsecases
// paint chat/completion/embeddings on top would surface it in pickers it
@@ -2191,7 +2195,7 @@ func (c *ModelConfig) HasUsecases(u ModelConfigUsecase) bool {
if (u & *c.KnownUsecases) == u {
return true
}
if (*c.KnownUsecases & (FLAG_SCORE | FLAG_TOKEN_CLASSIFY | FLAG_DECISIONS)) != 0 {
if (*c.KnownUsecases & (FLAG_SCORE | FLAG_TOKEN_CLASSIFY | FLAG_DECISIONS | FLAG_UPSCALE)) != 0 {
return false
}
}
@@ -2202,6 +2206,10 @@ func (c *ModelConfig) HasUsecases(u ModelConfigUsecase) bool {
// In its current state, this function should ideally check for properties of the config like templates, rather than the direct backend name checks for the lower half.
// This avoids the maintenance burden of updating this list for each new backend - but unfortunately, that's the best option for some services currently.
func (c *ModelConfig) GuessUsecases(u ModelConfigUsecase) bool {
// Upscaling requires a dedicated model declaration, not just an image backend.
if u&FLAG_UPSCALE != 0 {
return false
}
// Backends that are clearly not text-generation
nonTextGenBackends := []string{
"whisper", "piper", "kokoro",
+19
View File
@@ -52,6 +52,25 @@ parameters:
Expect(valid).To(BeTrue())
})
It("parses upscale backend options and explicit usecase from YAML", func() {
raw := []byte(`
name: realesrgan
backend: stablediffusion-ggml
known_usecases: [upscale]
options:
- upscale_scale:4
- upscale_tile_size:128
parameters:
model: realesrgan-x4plus.gguf
`)
var cfg ModelConfig
Expect(yaml.Unmarshal(raw, &cfg)).To(Succeed())
Expect(cfg.Options).To(Equal([]string{"upscale_scale:4", "upscale_tile_size:128"}))
Expect(cfg.KnownUsecases).NotTo(BeNil())
Expect(*cfg.KnownUsecases & FLAG_UPSCALE).To(Equal(FLAG_UPSCALE))
Expect(cfg.KnownUsecaseStrings).To(ContainElement("FLAG_UPSCALE"))
})
It("round-trips context compression settings", func() {
raw := []byte(`
name: compressed-chat
+31
View File
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
package config
import (
"strconv"
"strings"
)
// UpscaleScaleFromOptions reports the declared native scale for client metadata.
// Match the backend's int32 range and reject duplicates rather than advertising
// a value that the backend will reject. This does not replace backend validation.
func UpscaleScaleFromOptions(c ModelConfig) int {
var scale int
seen := false
for _, option := range c.Options {
key, value, _ := strings.Cut(option, ":")
if key != "upscale_scale" {
continue
}
if seen {
return 0
}
seen = true
n, err := strconv.ParseInt(value, 10, 32)
if err != nil || n <= 0 {
return 0
}
scale = int(n)
}
return scale
}
+34
View File
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT
package config
import "testing"
func TestUpscaleScaleFromOptions(t *testing.T) {
for _, tc := range []struct {
name string
options []string
want int
}{
{"absent", nil, 0},
{"unrelated", []string{"backend:CPU", "upscale_tile_size:128"}, 0},
{"valid", []string{"upscale_scale:4"}, 4},
{"malformed", []string{"upscale_scale:banana"}, 0},
{"missing value", []string{"upscale_scale"}, 0},
{"empty", []string{"upscale_scale:"}, 0},
{"zero", []string{"upscale_scale:0"}, 0},
{"negative", []string{"upscale_scale:-1"}, 0},
{"overflow", []string{"upscale_scale:2147483648"}, 0},
{"huge", []string{"upscale_scale:999999999999999999999999999"}, 0},
{"duplicate", []string{"upscale_scale:4", "upscale_scale:4"}, 0},
{"invalid duplicate", []string{"upscale_scale:4", "upscale_scale:bad"}, 0},
{"extra separator", []string{"upscale_scale:4:2"}, 0},
{"whitespace", []string{"upscale_scale: 4"}, 0},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := ModelConfig{Options: tc.options}
if got := UpscaleScaleFromOptions(cfg); got != tc.want {
t.Fatalf("got %d, want %d", got, tc.want)
}
})
}
}
+63
View File
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: MIT
package gallery_test
import (
"github.com/mudler/LocalAI/core/config"
"os"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v3"
)
var _ = Describe("gallery/index.yaml ESRGAN entry", func() {
It("installs the official native x4 checkpoint as an upscale-only model", func() {
entries, err := loadGalleryIndex()
Expect(err).NotTo(HaveOccurred())
entry, ok := indexEntriesByName(entries)["realesrgan-x4plus-anime-6b"]
Expect(ok).To(BeTrue())
Expect(entry.URL).To(Equal("github:mudler/LocalAI/gallery/virtual.yaml@master"))
Expect(entry.License).To(Equal("BSD-3-Clause"))
Expect(entry.Overrides["backend"]).To(Equal("stablediffusion-ggml"))
Expect(entry.Overrides["known_usecases"]).To(ConsistOf("upscale"))
Expect(entry.Overrides).NotTo(HaveKey("upscale_scale"))
Expect(entry.Overrides).NotTo(HaveKey("upscale_tile_size"))
Expect(entry.Overrides["options"]).To(ConsistOf("upscale_scale:4", "upscale_tile_size:128"))
Expect(entry.AdditionalFiles).To(HaveLen(1))
file := entry.AdditionalFiles[0]
Expect(file.Filename).To(Equal("RealESRGAN_x4plus_anime_6B.pth"))
Expect(entry.Overrides["parameters"]).To(HaveKeyWithValue("model", file.Filename))
Expect(file.URI).To(Equal("https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.2.4/" + file.Filename))
Expect(file.SHA256).To(Equal("f872d837d3c90ed2e05227bed711af5671a6fd1c9f7d7e91c911a61f155e99da"))
// Keep the copy-paste configuration in the documentation aligned with the
// installable entry, including YAML scalar types.
doc, err := os.ReadFile("../../docs/content/features/image-generation.md")
Expect(err).NotTo(HaveOccurred())
const start = "```yaml\nname: realesrgan-x4plus-anime-6b\n"
_, example, found := strings.Cut(string(doc), start)
Expect(found).To(BeTrue())
example, _, found = strings.Cut(example, "```")
Expect(found).To(BeTrue())
var settings map[string]any
Expect(yaml.Unmarshal([]byte(example), &settings)).To(Succeed())
Expect(settings).To(Equal(entry.Overrides))
})
})
var _ = Describe("Diffusers upscaler gallery capability", func() {
It("explicitly declares upscaling rather than image generation", func() {
entries, err := loadGalleryIndex()
Expect(err).NotTo(HaveOccurred())
entry, ok := indexEntriesByName(entries)["stable-diffusion-x4-upscaler"]
Expect(ok).To(BeTrue())
data, err := yaml.Marshal(entry.Overrides)
Expect(err).NotTo(HaveOccurred())
var cfg config.ModelConfig
Expect(yaml.Unmarshal(data, &cfg)).To(Succeed())
Expect(cfg.HasUsecases(config.FLAG_UPSCALE)).To(BeTrue())
Expect(cfg.HasUsecases(config.FLAG_IMAGE)).To(BeFalse())
Expect(config.GetBackendCapability(cfg.Backend).GRPCMethods).To(ContainElement(config.MethodUpscaleImage))
})
})
@@ -44,6 +44,7 @@ func ListModelCapabilitiesEndpoint(bcl *config.ModelConfigLoader, ml *model.Mode
cfg.ContextSize = &appConfig.ContextSize
}
entry.Capabilities = cfg.Capabilities()
entry.UpscaleScale = config.UpscaleScaleFromOptions(cfg)
// Generation aliases inherit target capabilities, but the router's
// native classifier loads the named config directly (no alias resolution).
original, exists := bcl.GetModelConfig(m)
@@ -98,6 +98,31 @@ var _ = Describe("ListModelCapabilitiesEndpoint", func() {
}
})
It("exposes lowercase upscale and backend option scale without text modalities", func() {
writeConfig("upscaler", "name: upscaler\nbackend: stablediffusion-ggml\nknown_usecases: [upscale]\noptions:\n - upscale_scale:4\n")
entry := entryFor(call(), "upscaler")
Expect(entry).NotTo(BeNil())
Expect(entry.Capabilities).To(Equal([]string{"upscale"}))
Expect(entry.InputModalities).To(Equal([]string{"image"}))
Expect(entry.OutputModalities).To(Equal([]string{"image"}))
Expect(entry.UpscaleScale).To(Equal(4))
raw, err := json.Marshal(entry)
Expect(err).NotTo(HaveOccurred())
Expect(string(raw)).To(ContainSubstring(`"upscale_scale":4`))
})
It("omits absent or invalid backend scale metadata", func() {
for _, options := range []string{"", "options: [upscale_scale:bad]\n", "options: [upscale_scale:0]\n", "options: [upscale_scale:-1]\n", "options: [upscale_scale:2147483648]\n", "options: [upscale_scale:4, upscale_scale:4]\n"} {
writeConfig("upscaler", "name: upscaler\nbackend: stablediffusion-ggml\nknown_usecases: [upscale]\n"+options)
entry := entryFor(call(), "upscaler")
Expect(entry).NotTo(BeNil())
Expect(entry.UpscaleScale).To(BeZero())
raw, err := json.Marshal(entry)
Expect(err).NotTo(HaveOccurred())
Expect(string(raw)).NotTo(ContainSubstring(`"upscale_scale"`))
}
})
It("returns the list envelope even with no models", func() {
resp := call()
Expect(resp.Object).To(Equal("list"))
+25 -11
View File
@@ -21,6 +21,10 @@ import (
model "github.com/mudler/LocalAI/pkg/model"
)
// maxUpscaleScale limits output area amplification to 256x and fits the int32
// protobuf field. The backend still enforces the model's exact native scale.
const maxUpscaleScale = 16
// UpscaleEndpoint handles POST /v1/images/upscale
//
// @Summary Image upscaling
@@ -30,7 +34,7 @@ import (
// @Produce application/json
// @Param model formData string true "Upscaler model identifier (e.g. stable-diffusion-x4-upscaler)"
// @Param image formData file true "Input image file"
// @Param scale formData int false "Upscale factor: 2 or 4 (default 2)"
// @Param scale formData int true "Upscale factor: integer from 1 to 16; must match the model native scale"
// @Success 200 {object} schema.OpenAIResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
@@ -45,13 +49,12 @@ func UpscaleEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appCon
return echo.NewHTTPError(http.StatusBadRequest, "missing model")
}
scale := 2
if scaleStr != "" {
v, err := strconv.Atoi(scaleStr)
if err != nil || (v != 2 && v != 4) {
return echo.NewHTTPError(http.StatusBadRequest, "scale must be 2 or 4")
}
scale = v
if scaleStr == "" {
return echo.NewHTTPError(http.StatusBadRequest, "missing scale")
}
scale, err := strconv.Atoi(scaleStr)
if err != nil || scale < 1 || scale > maxUpscaleScale {
return echo.NewHTTPError(http.StatusBadRequest, "scale must be an integer from 1 to 16")
}
// Read uploaded image
@@ -103,19 +106,26 @@ func UpscaleEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appCon
id := uuid.New().String()
dstPath := filepath.Join(tmpDir, fmt.Sprintf("upscale_%s.png", id))
// Only this endpoint owns the generated destination; the generic backend
// must not delete arbitrary caller-supplied paths on failure.
completed := false
defer func() {
if !completed {
_ = os.Remove(dstPath)
}
}()
fn, err := backend.ImageUpscaleFunc(c.Request().Context(), srcPath, dstPath, scale, ml, *cfg, appConfig)
if err != nil {
return err
}
if err := fn(); err != nil {
_ = os.Remove(dstPath)
return err
}
baseURL := middleware.BaseURL(c)
imgURL, err := url.JoinPath(baseURL, "generated-images", filepath.Base(dstPath))
if err != nil {
_ = os.Remove(dstPath)
return err
}
@@ -129,6 +139,10 @@ func UpscaleEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appCon
},
}
return c.JSON(http.StatusOK, resp)
if err := c.JSON(http.StatusOK, resp); err != nil {
return err
}
completed = true
return nil
}
}
+93 -20
View File
@@ -1,13 +1,18 @@
package openai
import (
"bytes"
"context"
"encoding/json"
"errors"
grpcPkg "github.com/mudler/LocalAI/pkg/grpc"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/system"
ggrpc "google.golang.org/grpc"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
@@ -29,7 +34,7 @@ var _ = Describe("Image upscaling", func() {
var err error
tmpDir, err = os.MkdirTemp("", "upscale")
Expect(err).ToNot(HaveOccurred())
appConfig = config.NewApplicationConfig(config.WithGeneratedContentDir(tmpDir))
appConfig = config.NewApplicationConfig(config.WithGeneratedContentDir(tmpDir), config.WithSystemState(&system.SystemState{}))
})
AfterEach(func() {
@@ -39,7 +44,7 @@ var _ = Describe("Image upscaling", func() {
It("stores the result in the directory served by /generated-images", func() {
original := backend.ImageUpscaleFunc
backend.ImageUpscaleFunc = func(_ context.Context, _, dst string, scale int, _ *model.ModelLoader, _ config.ModelConfig, _ *config.ApplicationConfig) (func() error, error) {
Expect(scale).To(Equal(4))
Expect(scale).To(Equal(3))
return func() error {
return os.WriteFile(dst, []byte("PNGDATA"), 0o644)
}, nil
@@ -47,7 +52,7 @@ var _ = Describe("Image upscaling", func() {
DeferCleanup(func() { backend.ImageUpscaleFunc = original })
req, _ := makeMultipartRequest(
map[string]string{"model": "stable-diffusion-x4-upscaler", "scale": "4"},
map[string]string{"model": "stable-diffusion-x4-upscaler", "scale": "3"},
map[string][]byte{"image": []byte("IMAGEDATA")},
)
rec := httptest.NewRecorder()
@@ -66,24 +71,92 @@ var _ = Describe("Image upscaling", func() {
contents, err := os.ReadFile(filepath.Join(tmpDir, "images", filename))
Expect(err).ToNot(HaveOccurred())
Expect(contents).To(Equal([]byte("PNGDATA")))
entries, err := os.ReadDir(filepath.Join(tmpDir, "images"))
Expect(err).NotTo(HaveOccurred())
Expect(entries).To(HaveLen(1))
})
It("rejects unsupported scale factors", func() {
req, _ := makeMultipartRequest(
map[string]string{"model": "stable-diffusion-x4-upscaler", "scale": "3"},
map[string][]byte{"image": []byte("IMAGEDATA")},
)
DescribeTable("rejects missing or invalid scales", func(scale string) {
fields := map[string]string{"model": "upscaler"}
if scale != "missing" {
fields["scale"] = scale
}
req, _ := makeMultipartRequest(fields, map[string][]byte{"image": []byte("IMAGE")})
rec := httptest.NewRecorder()
ctx := echo.New().NewContext(req, rec)
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{Backend: "diffusers"})
e := echo.New()
ctx := e.NewContext(req, rec)
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, &config.ModelConfig{})
err := UpscaleEndpoint(nil, nil, appConfig)(ctx)
var httpErr *echo.HTTPError
Expect(err).To(MatchError(ContainSubstring("scale must be 2 or 4")))
Expect(err).To(BeAssignableToTypeOf(httpErr))
httpErr = err.(*echo.HTTPError)
Expect(httpErr.Code).To(Equal(http.StatusBadRequest))
Expect(httpErr.Message).To(Equal("scale must be 2 or 4"))
Expect(bytes.TrimSpace(rec.Body.Bytes())).To(BeEmpty())
})
Expect(err).To(HaveOccurred())
e.HTTPErrorHandler(err, ctx)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
},
Entry("missing", "missing"), Entry("empty", ""), Entry("text", "abc"),
Entry("fraction", "2.5"), Entry("zero", "0"), Entry("negative", "-1"),
// 16 caps area amplification at 256x; int32 overflow must also fail.
Entry("above resource bound", "17"), Entry("int32 overflow", "2147483648"),
Entry("integer overflow", "999999999999999999999999"))
DescribeTable("cleans only request files on failure", func(outcome string) {
sentinel := filepath.Join(tmpDir, "unrelated.png")
Expect(os.WriteFile(sentinel, []byte("keep"), 0600)).To(Succeed())
var srcPath, dstPath string
original := backend.ImageUpscaleFunc
DeferCleanup(func() { backend.ImageUpscaleFunc = original })
backend.ImageUpscaleFunc = func(ctx context.Context, src, dst string, scale int, _ *model.ModelLoader, cfg config.ModelConfig, app *config.ApplicationConfig) (func() error, error) {
srcPath, dstPath = src, dst
Expect(os.WriteFile(dst, []byte("partial"), 0600)).To(Succeed())
if outcome == "load" {
return nil, errors.New("load failed")
}
fake := &upscaleTestBackend{outcome: outcome}
loader := model.NewModelLoader(&system.SystemState{})
loader.SetModelRouter(func(_ context.Context, id string, _, _, _, _ string, _ *pb.ModelOptions, _ bool) (*model.Model, error) {
return model.NewModelWithClient(id, "test://upscale", fake), nil
})
return backend.ImageUpscale(ctx, src, dst, scale, loader, cfg, app)
}
req, _ := makeMultipartRequest(map[string]string{"model": "upscaler", "scale": strconv.Itoa(16)}, map[string][]byte{"image": []byte("IMAGE")})
rec := httptest.NewRecorder()
e := echo.New()
ctx := e.NewContext(req, rec)
cfg := &config.ModelConfig{Name: "upscaler", Backend: "stub"}
cfg.SetDefaults()
ctx.Set(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG, cfg)
err := UpscaleEndpoint(nil, nil, appConfig)(ctx)
Expect(err).To(HaveOccurred())
if outcome == "unsuccessful" {
Expect(err.Error()).To(ContainSubstring("native scale mismatch"))
}
e.HTTPErrorHandler(err, ctx)
Expect(rec.Code).To(Equal(http.StatusInternalServerError))
Expect(srcPath).NotTo(BeEmpty())
Expect(dstPath).NotTo(BeEmpty())
_, err = os.Stat(srcPath)
Expect(os.IsNotExist(err)).To(BeTrue())
_, err = os.Stat(dstPath)
Expect(os.IsNotExist(err)).To(BeTrue())
contents, err := os.ReadFile(sentinel)
Expect(err).NotTo(HaveOccurred())
Expect(string(contents)).To(Equal("keep"))
}, Entry("model loading", "load"), Entry("transport", "transport"), Entry("nil result", "nil"), Entry("unsuccessful result", "unsuccessful"))
})
type upscaleTestBackend struct {
grpcPkg.Backend
outcome string
}
func (*upscaleTestBackend) HealthCheck(context.Context) (bool, error) { return true, nil }
func (*upscaleTestBackend) IsBusy() bool { return false }
func (b *upscaleTestBackend) UpscaleImage(_ context.Context, in *pb.UpscaleImageRequest, _ ...ggrpc.CallOption) (*pb.Result, error) {
Expect(in.Scale).To(Equal(int32(16)))
switch b.outcome {
case "transport":
return nil, errors.New("transport failed")
case "nil":
return nil, nil
default:
return &pb.Result{Success: false, Message: "native scale mismatch"}, nil
}
}
+6 -4
View File
@@ -54,11 +54,13 @@ export const GPU_RESOURCES = {
}
// Stub everything the front page reads. `types` are the types that have a
// model. `installs` collects the names POSTed to the install endpoint; after an
// install the type gains its model on the next capabilities read when
// `installOnPost` is set.
// model; `capabilityModels` supplies complete capability records when a test
// needs fields beyond a model type. `installs` collects the names POSTed to the
// install endpoint; after an install the type gains its model on the next
// capabilities read when `installOnPost` is set.
export async function mockStudio(page, {
types = ALL_TYPES,
capabilityModels,
resources = GPU_RESOURCES,
operations = [],
installOnPost = false,
@@ -69,7 +71,7 @@ export async function mockStudio(page, {
await page.route('**/api/models/capabilities', route => {
state.capabilityCalls += 1
if (capabilitiesStatus !== 200) return route.fulfill({ status: capabilitiesStatus, json: { error: 'down' } })
return route.fulfill({ json: capabilities(state.types) })
return route.fulfill({ json: { data: capabilityModels || capabilities(state.types).data } })
})
// The v1 list is the fallback useModels takes when the capabilities call
// fails; make it fail too so an error state is reachable.
@@ -243,3 +243,81 @@ test.describe('Lineage view: phone', () => {
}
})
})
test.describe('Inline upscale', () => {
async function setup(page, { models = true, failure } = {}) {
await mockStudio(page, { capabilityModels: models ? [
{ id: 'up-4', capabilities: ['FLAG_UPSCALE'], upscaleScale: 4 },
{ id: 'up-2', capabilities: ['FLAG_UPSCALE'], upscaleScale: 2 },
{ id: 'invalid', capabilities: ['FLAG_UPSCALE'], upscaleScale: 0 },
{ id: 'text-image', capabilities: ['FLAG_IMAGE'] },
] : [] })
await stubMedia(page)
const history = sampleHistory()
if (failure === 'cross-origin') history.image.find(e => e.id === 'img-bowls').results[0].url = 'https://other.test/source.png'
await seedHistory(page, history)
if (['fetch', 'non-image', 'source-decode'].includes(failure)) {
await page.route('**/generated-images/bowls.png', route => route.fulfill({
status: failure === 'fetch' ? 500 : 200,
contentType: failure === 'non-image' ? 'text/html' : 'image/png', body: 'bad image',
}))
}
let requests = 0
await page.route('**/v1/images/upscale', async route => {
requests++
const request = route.request()
expect(request.headers()['content-type']).toContain('multipart/form-data; boundary=')
const body = request.postDataBuffer().toString()
expect(body).toContain('name="model"\r\n\r\nup-2')
expect(body).toContain('name="scale"\r\n\r\n2')
expect(body).toContain('name="image"; filename="bowls.png"')
await route.fulfill({ status: failure === 'api' ? 500 : 200, json: failure === 'api'
? { error: 'upscale unavailable' }
: { data: [{ url: '/generated-images/upscaled.png', width: 99999, height: 99999 }] } })
})
if (failure === 'result-decode') await page.route('**/generated-images/upscaled.png', route => route.fulfill({ contentType: 'image/png', body: 'broken' }))
await page.goto('/app/studio?work=img-bowls')
await page.getByTestId('studio-dock-toggle').click()
return () => requests
}
test('absent without an eligible model', async ({ page }) => {
await setup(page, { models: false })
await expect(page.getByTestId('step-upscale')).toHaveCount(0)
})
test('filtered models, fixed scale, multipart and a persisted image child with repeat suggestions', async ({ page }) => {
await setup(page)
await page.getByTestId('step-upscale').click()
const composer = page.getByTestId('upscale-composer')
await expect(composer.locator('img')).toBeVisible()
await expect(composer.locator('option')).toHaveText(['up-4', 'up-2'])
await composer.getByLabel('Upscaling model').selectOption('up-2')
await expect(composer.getByLabel('Scale', { exact: true })).toHaveValue('2×')
await expect(composer.getByLabel('Scale', { exact: true })).toHaveAttribute('readonly', '')
await composer.getByRole('button', { name: 'Upscale', exact: true }).click()
await expect(page.getByTestId('lineage-node')).toHaveCount(2)
for (const edge of ['animate', 'to-3d', 'variation', 'upscale']) await expect(page.getByTestId(`step-${edge}`)).toBeVisible()
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('localai_image_history')).find(e => e.edge === 'upscale'))).toMatchObject({
parentId: 'img-bowls', model: 'up-2', params: { scale: 2, sourceDimensions: { width: 640, height: 420 }, outputDimensions: { width: 640, height: 420 } },
})
await expect(page.locator('.studio-edge-label')).toContainText(['upscale'])
await page.getByTestId('step-upscale').click()
await expect(page.getByTestId('upscale-composer').locator('img')).toHaveAttribute('src', '/generated-images/upscaled.png')
})
for (const failure of ['cross-origin', 'non-image', 'fetch', 'api', 'source-decode', 'result-decode']) {
test(`${failure} failure preserves selection and creates no child`, async ({ page }) => {
const requests = await setup(page, { failure })
await page.getByTestId('step-upscale').click()
const composer = page.getByTestId('upscale-composer')
await composer.getByLabel('Upscaling model').selectOption('up-2')
await composer.getByRole('button', { name: 'Upscale', exact: true }).click()
await expect(composer.getByRole('alert')).toContainText('Upscale failed')
await expect(page.getByTestId('lineage-node')).toHaveCount(1)
await expect(node(page, 'img-bowls')).toHaveAttribute('data-selected', 'true')
expect(requests()).toBe(['api', 'result-decode'].includes(failure) ? 1 : 0)
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('localai_image_history')).some(e => e.edge === 'upscale'))).toBe(false)
})
}
})
@@ -206,7 +206,8 @@
"transform": "transform",
"diarize": "who spoke",
"soundtrack": "soundtrack",
"turntable": "turntable"
"turntable": "turntable",
"upscale": "upscale"
},
"steps": {
"animate": {
@@ -240,6 +241,10 @@
"turntable": {
"title": "Turntable clip",
"why": "Show the object from every side"
},
"upscale": {
"title": "Upscale",
"why": "Increase the resolution of this image."
}
},
"kinds": {
@@ -364,6 +369,14 @@
"notKept": "The recording and the transcript are not kept, so only the summary of this run is shown. Run it again with the file to see the timeline.",
"empty": "Choose a recording to see who spoke and when"
}
},
"upscale": {
"model": "Upscaling model",
"scale": "Scale",
"source": "Source image",
"loading": "Upscaling…",
"failed": "Upscale failed: {{message}}",
"noResult": "No output image was returned."
}
},
"image": {
@@ -1,5 +1,7 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
// eslint-disable-next-line no-unused-vars
import UpscaleComposer from './UpscaleComposer'
import Icon from '../Icon'
// eslint-disable-next-line no-unused-vars
import InstallNote from './InstallNote'
@@ -31,10 +33,10 @@ function isTyping(el) {
// are installed.
//
// "Run as a new take" and "Branch from here" open the workspace with the source
// and the words filled in. They do not run anything here. A step the destination
// and the words filled in. Upscale instead runs in the inline composer. A step the destination
// workspace cannot start from yet is listed but disabled, with the reason.
export default function LineageView({
items, workId, modalities, defaultModel, onClose, onToggleFavourite, onHandoff, onModelsChanged,
items, workId, modalities, upscalers = [], onAddImage, defaultModel, onClose, onToggleFavourite, onHandoff, onModelsChanged,
}) {
const { t } = useTranslation('media')
const members = useMemo(() => projectOf(items, workId), [items, workId])
@@ -137,7 +139,7 @@ export default function LineageView({
}
const root = members[0]
const steps = NEXT_STEPS[selected.type] || []
const steps = [...(NEXT_STEPS[selected.type] || []), ...(selected.type === 'images' && upscalers.length ? [{ to: 'images', edge: 'upscale', supported: true }] : [])]
const takeOk = canTake(selected)
const branchStep = suggestion || steps.find(s => s.supported) || null
const draftInstalled = draft ? installed.has(draft.to) : true
@@ -189,7 +191,7 @@ export default function LineageView({
)
}
if (n.kind === 'ghost') {
const missing = !installed.has(n.ghost.to)
const missing = n.ghost.edge !== 'upscale' && !installed.has(n.ghost.to)
return (
<button
key={n.id}
@@ -257,9 +259,9 @@ export default function LineageView({
{draft ? (
<>
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => setDraft(null)} data-testid="studio-draft-cancel">{t('studio.lineage.cancel')}</button>
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={!canOpenDraft} onClick={openDraft} data-testid="studio-draft-open">
{draft.edge !== 'upscale' && <button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={!canOpenDraft} onClick={openDraft} data-testid="studio-draft-open">
<Icon name="sparkles" /> {t('studio.lineage.openIn', { type: label(draft.to) })}
</button>
</button>}
</>
) : (
<>
@@ -284,7 +286,13 @@ export default function LineageView({
</p>
)}
{expanded && (draft ? (
{expanded && (draft?.edge === 'upscale' ? (
<UpscaleComposer key={selected.id} source={selected} upscalers={upscalers} onSuccess={entry => {
const id = onAddImage(entry)
setSelectedId(id)
setDraft(null)
}} />
) : draft ? (
<div className="studio-dock__body studio-draft" data-testid="studio-draft">
<span className="studio-eyebrow">{t('studio.lineage.makeFrom')}</span>
<div className="studio-draft__targets" role="group" aria-label={t('studio.lineage.targets')}>
@@ -296,7 +304,7 @@ export default function LineageView({
type="button"
className="dk-chip studio-type"
aria-pressed={draft.edge === step.edge}
data-missing={(!installed.has(step.to)) || undefined}
data-missing={(step.edge !== 'upscale' && !installed.has(step.to)) || undefined}
disabled={bad}
data-testid={`draft-target-${step.edge}`}
onClick={() => startDraft(step)}
@@ -330,7 +338,7 @@ export default function LineageView({
{steps.length === 0 && <li className="studio-steps__none">{t('studio.lineage.noBranch', { type: label(selected.type) })}</li>}
{steps.map(step => {
const bad = !step.supported
const missing = !bad && !installed.has(step.to)
const missing = !bad && step.edge !== 'upscale' && !installed.has(step.to)
return (
<li key={step.edge}>
<button type="button" className="studio-step" disabled={bad} onClick={() => startDraft(step)} data-testid={`step-${step.edge}`} data-state={bad ? 'unsupported' : missing ? 'missing' : 'ready'}>
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: MIT
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageApi } from '../../utils/api'
import { imageDimensions, sourceImageFile } from '../../utils/upscale'
export default function UpscaleComposer({ source, upscalers, onSuccess }) {
const { t } = useTranslation('media')
const [model, setModel] = useState(upscalers[0]?.id || '')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const active = useRef(true)
const running = useRef(false)
useEffect(() => { active.current = true; return () => { active.current = false } }, [])
const selected = upscalers.find(m => m.id === model) || upscalers[0]
const submit = async (event) => {
event.preventDefault()
if (!selected || running.current) return
running.current = true
setLoading(true)
setError('')
try {
const image = await sourceImageFile(source.url)
const sourceDimensions = await imageDimensions(image)
if (!active.current) return
const response = await imageApi.upscale({ model: selected.id, image, scale: selected.upscaleScale })
const results = (response?.data || []).map(r => ({ url: r.url || (r.b64_json ? `data:image/png;base64,${r.b64_json}` : '') }))
if (!results.length || results.some(r => !r.url)) throw new Error(t('studio.upscale.noResult'))
// A result that cannot be decoded is a failed run: never record guessed
// dimensions or an unusable child. The endpoint's dimensions are ignored.
const dimensions = await Promise.all(results.map(r => imageDimensions(r.url)))
if (!active.current) return
onSuccess({ model: selected.id, params: { scale: selected.upscaleScale, sourceDimensions, outputDimensions: dimensions[0] }, results, parentId: source.id, edge: 'upscale' })
} catch (err) {
if (active.current) setError(t('studio.upscale.failed', { message: err.message }))
} finally {
running.current = false
if (active.current) setLoading(false)
}
}
return (
<form className="studio-dock__body studio-detail" onSubmit={submit} data-testid="upscale-composer" aria-busy={loading}>
<div className="studio-detail__preview"><img src={source.url} alt={source.title || t('studio.upscale.source')} /></div>
<div className="studio-detail__info">
<label className="studio-eyebrow">{t('studio.upscale.model')}
<select className="dk-input" value={selected?.id || ''} onChange={e => setModel(e.target.value)} disabled={loading}>
{upscalers.map(m => <option key={m.id} value={m.id}>{m.id}</option>)}
</select>
</label>
<label className="studio-eyebrow">{t('studio.upscale.scale')}
<input className="dk-input" value={selected ? `${selected.upscaleScale}×` : ''} readOnly />
</label>
{error && <p role="alert">{error}</p>}
<button className="dk-btn dk-btn--primary" type="submit" disabled={loading || !selected}>
{t(loading ? 'studio.upscale.loading' : 'studio.steps.upscale.title')}
</button>
</div>
</form>
)
}
+10 -7
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { clearAllMediaHistory, FAVOURITES_KEY, readAllMediaHistory } from './useMediaHistory'
import { clearAllMediaHistory, FAVOURITES_KEY, readAllMediaHistory, useMediaHistory } from './useMediaHistory'
import { use3DHistory } from './use3DHistory'
import { collectWork, pruneIds, toggleId } from '../utils/studioWork'
@@ -21,12 +21,14 @@ function writeFavourites(ids) {
// Everything the Studio front page shows as "your work": the entries each
// workspace stored, in one list, with favourites. The browser-storage lists are
// read once on mount (they change only while a workspace is open, and this page
// mounts fresh when you come back); 3D arrives asynchronously from IndexedDB.
// read once on mount, except images: inline upscaling writes through the same
// image history hook and merges its live entries. 3D arrives from IndexedDB.
//
// `ready` is false until 3D has answered, so the page can show a skeleton
// instead of flashing an empty state for work that is a moment away.
export function useStudioWork() {
const { addEntry: addImageEntry, historyProps: imageHistory } = useMediaHistory('image')
const clearImages = imageHistory.onClearAll
const [media, setMedia] = useState(() => readAllMediaHistory())
const [favourites, setFavourites] = useState(readFavourites)
const { entries: threeD, clearAll: clearThreeD } = use3DHistory()
@@ -41,8 +43,8 @@ export function useStudioWork() {
useEffect(() => { if (threeD.length > 0) setReady(true) }, [threeD])
const items = useMemo(
() => collectWork(media, threeD, { favourites }),
[media, threeD, favourites],
() => collectWork({ ...media, image: imageHistory.entries }, threeD, { favourites }),
[media, imageHistory.entries, threeD, favourites],
)
const toggleFavourite = useCallback((id) => {
@@ -54,11 +56,12 @@ export function useStudioWork() {
}, [items])
const clearHistory = useCallback(async () => {
clearImages()
clearAllMediaHistory()
setMedia(readAllMediaHistory())
setFavourites([])
await clearThreeD()
}, [clearThreeD])
}, [clearThreeD, clearImages])
return { items, ready, toggleFavourite, clearHistory }
return { items, ready, addImageEntry, toggleFavourite, clearHistory }
}
+2
View File
@@ -14,6 +14,7 @@ import { useModels } from '../hooks/useModels'
import {
CAP_DIARIZATION, CAP_IMAGE, CAP_VIDEO, CAP_3D, CAP_3D_ANIMATION, CAP_TTS, CAP_SOUND_GENERATION, CAP_AUDIO_TRANSFORM,
} from '../utils/capabilities'
import { upscaleModels } from '../utils/upscale'
import Icon from '../components/Icon'
import '../components/studio/studio.css'
@@ -132,6 +133,7 @@ export default function Studio() {
) : (
<StudioOverview
modalities={modalities}
upscalers={upscaleModels(models)}
modelsLoading={modelsLoading}
modelsError={modelsError}
refetchModels={refetchModels}
@@ -28,11 +28,11 @@ import '../components/studio/studio.css'
// modelsLoading true until the first answer about installed models
// modelsError set when that answer could not be read
// refetchModels ask again, used while a model installs
export default function StudioOverview({ modalities, modelsLoading, modelsError, refetchModels }) {
export default function StudioOverview({ modalities, upscalers, modelsLoading, modelsError, refetchModels }) {
const { t } = useTranslation('media')
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const { items, ready, toggleFavourite, clearHistory } = useStudioWork()
const { items, ready, addImageEntry, toggleFavourite, clearHistory } = useStudioWork()
const [filter, setFilter] = useState('all')
const [group, setGroup] = useState(true)
// The composer's state lives here, so opening a lineage and coming back, or a
@@ -63,6 +63,8 @@ export default function StudioOverview({ modalities, modelsLoading, modelsError,
<LineageView
key={workId}
items={items}
upscalers={upscalers}
onAddImage={addImageEntry}
workId={workId}
modalities={modalities}
defaultModel={defaultModel}
+7
View File
@@ -296,6 +296,13 @@ export const agentJobsApi = {
// Image generation
export const imageApi = {
upscale: async ({ model, image, scale }) => {
const form = new FormData()
form.append('model', model)
form.append('image', image)
form.append('scale', String(scale))
return handleResponse(await fetch(apiUrl('/v1/images/upscale'), { method: 'POST', body: form }))
},
generate: (body) => postJSON(API_CONFIG.endpoints.imageGenerations, body),
}
+1
View File
@@ -9,6 +9,7 @@ export const CAP_EDIT = 'FLAG_EDIT'
export const CAP_EMBEDDINGS = 'FLAG_EMBEDDINGS'
export const CAP_RERANK = 'FLAG_RERANK'
export const CAP_IMAGE = 'FLAG_IMAGE'
export const CAP_UPSCALE = 'FLAG_UPSCALE'
export const CAP_TRANSCRIPT = 'FLAG_TRANSCRIPT'
export const CAP_TTS = 'FLAG_TTS'
export const CAP_SOUND_GENERATION = 'FLAG_SOUND_GENERATION'
+3 -2
View File
@@ -39,7 +39,7 @@ const TYPE_BY_MEDIA = Object.fromEntries(
Object.entries(TYPE_INFO).filter(([, v]) => v.media).map(([key, v]) => [v.media, key]),
)
export const EDGE_KINDS = ['take', 'animate', 'to-3d', 'variation', 'transform', 'diarize']
export const EDGE_KINDS = ['upscale', 'take', 'animate', 'to-3d', 'variation', 'transform', 'diarize']
// What each kind of result can be sent on to. `supported` is whether the
// destination workspace accepts that result as a starting point today. The
@@ -121,7 +121,8 @@ export function parseSize(size) {
// Width over height for a tile. Only what the entry recorded: an entry with no
// size is drawn square rather than guessed.
export function aspectOf(item) {
const size = parseSize(item?.params?.size)
const output = item?.params?.outputDimensions
const size = output?.width > 0 && output?.height > 0 ? output : parseSize(item?.params?.size)
return size ? size.width / size.height : 1
}
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: MIT
import { CAP_UPSCALE } from './capabilities.js'
export function upscaleModels(models) {
return models.filter(m => !m.disabled && m.capabilities?.includes(CAP_UPSCALE) &&
Number.isFinite(m.upscaleScale) && m.upscaleScale > 0)
.map(({ id, upscaleScale }) => ({ id, upscaleScale }))
}
export async function sourceImageFile(source, base = window.location.href, fetcher = fetch) {
if (source instanceof File) return source
const url = new URL(source, base)
if (url.origin !== new URL(base).origin || !['http:', 'https:'].includes(url.protocol)) {
throw new Error('Source image must be same-origin')
}
const response = await fetcher(url.href, { credentials: 'same-origin', mode: 'same-origin', redirect: 'error' })
if (!response.ok) throw new Error(`Image fetch failed (HTTP ${response.status})`)
if (!response.headers.get('content-type')?.toLowerCase().startsWith('image/')) throw new Error('Source is not an image')
const blob = await response.blob()
return new File([blob], url.pathname.split('/').pop() || 'source.png', { type: blob.type })
}
export function imageDimensions(source) {
return new Promise((resolve, reject) => {
const objectUrl = source instanceof Blob ? URL.createObjectURL(source) : null
const image = new Image()
const finish = (error) => {
clearTimeout(timer)
image.onload = image.onerror = null
if (objectUrl) URL.revokeObjectURL(objectUrl)
if (error || !image.naturalWidth || !image.naturalHeight) reject(new Error('Could not decode image dimensions'))
else resolve({ width: image.naturalWidth, height: image.naturalHeight })
}
const timer = setTimeout(() => finish(true), 30000)
image.onload = () => finish(false)
image.onerror = () => finish(true)
image.src = objectUrl || source
})
}
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: MIT
import test from 'node:test'
import assert from 'node:assert/strict'
import { upscaleModels, sourceImageFile } from './upscale.js'
import { toWorkItem, EDGE_KINDS } from './studioWork.js'
test('only enabled upscalers with positive finite scale are offered', () => {
assert.deepEqual(upscaleModels([
{ id: 'yes', capabilities: ['FLAG_UPSCALE'], upscaleScale: 4 },
{ id: 'image', capabilities: ['FLAG_IMAGE'], upscaleScale: 4 },
...[0, -1, undefined, Infinity].map(upscaleScale => ({ capabilities: ['FLAG_UPSCALE'], upscaleScale })),
{ capabilities: ['FLAG_UPSCALE'], upscaleScale: 2, disabled: true },
]), [{ id: 'yes', upscaleScale: 4 }])
})
test('URL source is same-origin, checked and named', async () => {
let called = false
const fetcher = async (url, options) => {
called = true
assert.equal(url, 'https://local.test/generated/source.png')
assert.equal(options.credentials, 'same-origin')
return new Response('png', { headers: { 'Content-Type': 'image/png' } })
}
const file = await sourceImageFile('/generated/source.png', 'https://local.test/app/studio', fetcher)
assert.equal(file.name, 'source.png')
assert.equal(file.type, 'image/png')
called = false
await assert.rejects(sourceImageFile('https://other.test/a.png', 'https://local.test/', fetcher), /origin/)
assert.equal(called, false)
for (const response of [new Response('bad', { status: 500 }), new Response('html', { headers: { 'Content-Type': 'text/html' } })]) {
await assert.rejects(sourceImageFile('/a', 'https://local.test/', async () => response))
}
await assert.rejects(sourceImageFile('/a', 'https://local.test/', async () => { throw new Error('offline') }))
})
test('upscale history normalizes as a child image', () => {
assert.ok(EDGE_KINDS.includes('upscale'))
const item = toWorkItem('images', { id: 'child', parentId: 'source', edge: 'upscale', results: [{ url: '/out.png' }] })
assert.equal(item.type, 'images')
assert.equal(item.parentId, 'source')
assert.equal(item.edge, 'upscale')
})
+26 -3
View File
@@ -1,6 +1,8 @@
package routes
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
@@ -267,10 +269,31 @@ func RegisterOpenAIRoutes(app *echo.Echo,
app.POST("/v1/images/inpainting", inpaintingHandler, imageMiddleware...)
app.POST("/images/inpainting", inpaintingHandler, imageMiddleware...)
// upscale endpoint - reuse same middleware config as images
// Upscaling has its own model usecase; generation models are not defaults.
upscaleMiddleware := []echo.MiddlewareFunc{
nodeHeaderMiddleware,
traceMiddleware,
// Default: use the first available upscaling model
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_UPSCALE)),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }),
func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// The default-model filter does not validate explicitly named models.
cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || cfg == nil || !cfg.HasUsecases(config.FLAG_UPSCALE) {
return echo.NewHTTPError(http.StatusBadRequest, "model does not support image upscaling")
}
if err := re.SetOpenAIRequest(c); err != nil {
return err
}
return next(c)
}
},
}
upscaleHandler := openai.UpscaleEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
app.POST("/v1/images/upscale", upscaleHandler, imageMiddleware...)
app.POST("/images/upscale", upscaleHandler, imageMiddleware...)
app.POST("/v1/images/upscale", upscaleHandler, upscaleMiddleware...)
app.POST("/images/upscale", upscaleHandler, upscaleMiddleware...)
// List models
app.GET("/v1/models", openai.ListModelsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.AuthDB()))
+2
View File
@@ -822,6 +822,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
NodeStatus string `json:"node_status"`
}
type modelCapability struct {
UpscaleScale int `json:"upscaleScale,omitempty"`
ThreeDOperations []schema.ThreeDOperation `json:"three_d_operations,omitempty"`
ID string `json:"id"`
Capabilities []string `json:"capabilities"`
@@ -873,6 +874,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
result = append(result, modelCapability{
ID: cfg.Name,
Capabilities: cfg.KnownUsecaseStrings,
UpscaleScale: config.UpscaleScaleFromOptions(cfg),
ThreeDOperations: cfg.ThreeDOperations(),
Backend: cfg.Backend,
Disabled: cfg.IsDisabled(),
@@ -0,0 +1,80 @@
package routes_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/routes"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
)
func TestUIUpscaleCapabilities(t *testing.T) {
dir := t.TempDir()
st, err := system.GetSystemState(system.WithModelPath(dir))
if err != nil {
t.Fatal(err)
}
cl := config.NewModelConfigLoader(dir)
for name, options := range map[string]string{
"upscaler": "options: [upscale_scale:4]\n",
"absent": "",
"malformed": "options: [upscale_scale:bad]\n",
"nonpositive": "options: [upscale_scale:0]\n",
"negative": "options: [upscale_scale:-1]\n",
"overflow": "options: [upscale_scale:2147483648]\n",
"duplicate": "options: [upscale_scale:4, upscale_scale:4]\n",
} {
path := filepath.Join(dir, name+".yaml")
if err := os.WriteFile(path, []byte("name: "+name+"\nbackend: stablediffusion-ggml\nknown_usecases: [upscale]\n"+options), 0600); err != nil {
t.Fatal(err)
}
if err := cl.ReadModelConfig(path); err != nil {
t.Fatal(err)
}
}
ac := config.NewApplicationConfig()
ac.SystemState = st
ml := model.NewModelLoader(st)
gs := galleryop.NewGalleryService(ac, ml)
app := echo.New()
routes.RegisterUIAPIRoutes(app, cl, ml, ac, gs, galleryop.NewOpCache(gs), &application.Application{}, func(next echo.HandlerFunc) echo.HandlerFunc { return next })
rec := httptest.NewRecorder()
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/models/capabilities", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var response struct {
Data []struct {
ID string `json:"id"`
Capabilities []string `json:"capabilities"`
Scale *int `json:"upscaleScale"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Data) != 7 {
t.Fatalf("unexpected model count: %d", len(response.Data))
}
for _, entry := range response.Data {
if len(entry.Capabilities) != 1 || entry.Capabilities[0] != "FLAG_UPSCALE" {
t.Fatalf("unexpected capabilities: %+v", entry)
}
if entry.ID == "upscaler" {
if entry.Scale == nil || *entry.Scale != 4 {
t.Fatalf("unexpected scale: %+v", entry)
}
} else if entry.Scale != nil {
t.Fatalf("invalid scale exposed: %+v", entry)
}
}
}
+100
View File
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: MIT
package routes_test
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/http/routes"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Upscale route model selection", func() {
It("uses upscale-only models on both aliases without admitting generation models", func() {
root := GinkgoT().TempDir()
app, err := application.New(config.WithDataPath(root), config.WithGeneratedContentDir(root), config.WithDisableLocalAIAssistant(true), config.WithSystemState(&system.SystemState{Model: system.Model{ModelsPath: root}, Backend: system.Backend{BackendsPath: root}}))
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { Expect(app.Shutdown()).To(Succeed()) })
upscale, image := config.FLAG_UPSCALE, config.FLAG_IMAGE
configs := []config.ModelConfig{
{Name: "esrgan", Backend: "stablediffusion-ggml", KnownUsecases: &upscale},
{Name: "stable-diffusion-x4-upscaler", Backend: "diffusers", KnownUsecases: &upscale},
{Name: "ordinary-image", Backend: "diffusers", KnownUsecases: &image},
{Name: "incompatible", Backend: "whisper"},
}
for i := range configs {
configs[i].SetDefaults()
configs[i].Model = configs[i].Name
}
original := backend.ImageUpscaleFunc
DeferCleanup(func() { backend.ImageUpscaleFunc = original })
served := ""
backend.ImageUpscaleFunc = func(_ context.Context, _, dst string, _ int, _ *model.ModelLoader, cfg config.ModelConfig, _ *config.ApplicationConfig) (func() error, error) {
served = cfg.Name
return func() error { return os.WriteFile(dst, []byte("PNG"), 0600) }, nil
}
e := echo.New()
selected := ""
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
err := next(c)
if cfg, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig); ok && cfg != nil {
selected = cfg.Name
}
return err
}
})
routes.RegisterOpenAIRoutes(e, middleware.NewRequestExtractor(app.ModelConfigLoader(), app.ModelLoader(), app.ApplicationConfig()), app)
for _, path := range []string{"/v1/images/upscale", "/images/upscale"} {
for _, cfg := range configs {
app.ModelConfigLoader().ReplaceModelConfigs([]config.ModelConfig{cfg})
for _, explicit := range []bool{true, false} {
served, selected = "", ""
var body bytes.Buffer
form := multipart.NewWriter(&body)
if explicit {
Expect(form.WriteField("model", cfg.Name)).To(Succeed())
}
Expect(form.WriteField("scale", "4")).To(Succeed())
file, err := form.CreateFormFile("image", "image.png")
Expect(err).NotTo(HaveOccurred())
_, err = file.Write([]byte("IMAGE"))
Expect(err).NotTo(HaveOccurred())
Expect(form.Close()).To(Succeed())
req := httptest.NewRequest(http.MethodPost, path, &body)
req.Header.Set("Content-Type", form.FormDataContentType())
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
eligible := cfg.HasUsecases(config.FLAG_UPSCALE)
if explicit && eligible {
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
Expect(served).To(Equal(cfg.Name))
} else {
// The endpoint still requires a form model even when middleware selects a default.
Expect(rec.Code).To(Equal(http.StatusBadRequest), rec.Body.String())
Expect(served).To(BeEmpty())
}
if !explicit {
if eligible {
Expect(selected).To(Equal(cfg.Name))
} else {
Expect(selected).NotTo(Equal(cfg.Name))
}
}
}
}
}
})
})
+1
View File
@@ -275,6 +275,7 @@ type ModelsDataResponse struct {
// served by the LocalAI-specific /v1/models/capabilities endpoint so clients can
// route attachments (image/audio/video) to a model only when it can handle them.
type ModelCapabilities struct {
UpscaleScale int `json:"upscale_scale,omitempty"`
ThreeDOperations []ThreeDOperation `json:"three_d_operations,omitempty"`
ID string `json:"id"`
Object string `json:"object"`
+87 -3
View File
@@ -135,6 +135,83 @@ options:
Reach for this when the decode fails but sampling completed, which the backend log shows as `sampling completed` followed by a VAE allocation error. Two cases hit it: cards without the VRAM for a full-frame decode, and drivers that cap a single allocation regardless of free memory (Mesa RADV reports a 4 GiB `maxMemoryAllocationSize`, so a large decode fails there even with tens of GB free). Lowering the output resolution avoids it too, at the cost of the resolution.
#### ESRGAN image upscaling
The `stablediffusion-ggml` backend supports standalone ESRGAN upscaling through
`POST /v1/images/upscale`. This is a separate, upscale-only model, not a diffusion
pipeline. Image generation does **not** automatically upscale its results.
Install the gallery model:
```bash
local-ai models install realesrgan-x4plus-anime-6b
```
This uses the official [RealESRGAN_x4plus_anime_6B.pth release](https://github.com/xinntao/Real-ESRGAN/releases/tag/v0.2.2.4),
a six-block model for anime images with a native **4x** scale. The PyTorch `.pth`
checkpoint is supported directly by [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp/blob/f89d9b13d730eabeede7314ce49dacd18d3c90c2/docs/esrgan.md);
no GGUF conversion is needed. The upstream project is licensed under
[BSD-3-Clause](https://github.com/xinntao/Real-ESRGAN/blob/v0.2.2.4/LICENSE).
For manual installation, download that checkpoint into your models directory and
save the following as `realesrgan-x4plus-anime-6b.yaml` alongside it (the gallery
installer configures these settings for you):
```yaml
name: realesrgan-x4plus-anime-6b
backend: stablediffusion-ggml
parameters:
model: RealESRGAN_x4plus_anime_6B.pth
known_usecases: [upscale]
options:
- upscale_scale:4
- upscale_tile_size:128
```
`upscale_scale` and `upscale_tile_size` are backend options, expressed as
`key:value` strings in `options`, not root-level configuration fields.
`upscale_scale` must be a positive integer. Keep `known_usecases` explicitly
set to `[upscale]`: do not add `image` or `diffusion_model` options to this
configuration. Options are forwarded unchanged; the backend rejects invalid
or duplicate recognized settings.
The multipart fields `model`, `image`, and **`scale` are required**. The HTTP
endpoint accepts only integer scales from **1 through 16**, rejecting missing,
malformed, zero, negative, or larger values with HTTP 400. This range is not a
promise that each model supports every scale: for ESRGAN, the configured
`upscale_scale`, the loaded model's detected native scale, and the request's
`scale` must all match exactly. Mismatches fail rather than silently resizing or
running multiple passes. For this model, always request `scale=4`.
```bash
curl http://localhost:8080/v1/images/upscale \
-F model=realesrgan-x4plus-anime-6b \
-F scale=4 \
-F image=@input.png
```
The response follows the image-generation response format, with the output URL
in `data[0].url` under `/generated-images`. A successful 4x upscale multiplies
both width and height by four.
`upscale_tile_size` controls ESRGAN tiling in pixels: omit it or set it to
`0` for stable-diffusion.cpp full-frame processing; set a positive value (such
as the gallery entry's `128`) to enable tiling. Smaller tiles reduce working
memory at the cost of more tile work. Full-frame processing can use much more
memory. `upscale_direct` is a separate convolution-algorithm flag, not a tile
control. Tile settings apply to ESRGAN, not the diffusion model's separate VAE
tiling settings.
In **Studio**, open an existing image, choose **Upscale**, select an installed
upscaler, and submit. The scale is read-only and comes from the selected model's
configured scale. A successful result becomes an **Upscale child node** of the
source image, leaving the original intact. Open that child to continue the
workflow with the available image actions, including another upscale or image
editing; each upscale is an explicit operation, not a post-generation default.
Unlike the Diffusers upscaling described below, this path uses ESRGAN inference
and does not fall back to Lanczos resizing.
#### Distributed inference (RPC workers)
The `stablediffusion-ggml` backend can offload computation to remote `ggml` RPC workers, sharding a model that does not fit on a single machine. It reuses the **same backend-agnostic `rpc-server` workers as the llama.cpp backend**, so one worker pool can serve both.
@@ -166,8 +243,10 @@ By default the RPC devices join the pool and participate in placement; combine w
#### Image upscaling
LocalAI can upscale an uploaded image by a factor of 2 or 4 through
`POST /v1/images/upscale`. Install the included Stable Diffusion x4 upscaler
The Diffusers backend can upscale an uploaded image through
`POST /v1/images/upscale`. The multipart `scale` field is required and must be an
integer from 1 through 16 at the HTTP boundary; actual scaling behavior depends
on the selected pipeline. Install the included Stable Diffusion x4 upscaler
gallery model first:
```bash
@@ -186,7 +265,12 @@ curl http://localhost:8080/v1/images/upscale \
The response uses the same format as image generation and returns the generated
image under `/generated-images`. The `diffusers` backend uses a loaded
`StableDiffusionUpscalePipeline` or `StableDiffusionLatentUpscalePipeline` when
configured. Other diffusers pipelines fall back to Lanczos resizing.
configured. Other diffusers pipelines fall back to Lanczos resizing. Upscale
routes select models with `known_usecases: [upscale]`, not ordinary image
generation models. If you installed the x4 upscaler before it declared this
usecase, update its model YAML to `known_usecases: [upscale]` or reinstall the
gallery entry. Custom Diffusers configurations intended for upscaling should
also explicitly declare the `upscale` usecase.
#### Model setup
+31 -1
View File
@@ -21331,6 +21331,36 @@
- filename: llama-cpp/models/deepseek-ai.DeepSeek-V3.2.Q4_K_M-00029-of-00029.gguf
sha256: 013af4e9d2f84e484f77c7bae2a02652607f0f0179bd2815ffdf401c3ada5184
uri: https://huggingface.co/DevQuasar/deepseek-ai.DeepSeek-V3.2-GGUF/resolve/main/deepseek-ai.DeepSeek-V3.2.Q4_K_M-00029-of-00029.gguf
# Official PyTorch checkpoint recommended by stable-diffusion.cpp docs/esrgan.md.
# SHA256 measured from the release download (17,938,799 bytes).
- name: realesrgan-x4plus-anime-6b
url: github:mudler/LocalAI/gallery/virtual.yaml@master
urls:
- https://github.com/xinntao/Real-ESRGAN
- https://github.com/xinntao/Real-ESRGAN/releases/tag/v0.2.2.4
- https://github.com/xinntao/Real-ESRGAN/blob/v0.2.2.4/LICENSE
- https://github.com/leejet/stable-diffusion.cpp/blob/f89d9b13d730eabeede7314ce49dacd18d3c90c2/docs/esrgan.md
description: |
RealESRGAN x4plus anime 6B is a six-block ESRGAN model for anime images,
with a native 4x scale. Uses the official PyTorch .pth checkpoint through
stable-diffusion.cpp, without conversion. Upscale-only: not a text-to-image model.
license: BSD-3-Clause
tags:
- image-upscaling
- super-resolution
- stablediffusion-ggml
overrides:
backend: stablediffusion-ggml
known_usecases: [upscale]
options:
- upscale_scale:4
- upscale_tile_size:128
parameters:
model: RealESRGAN_x4plus_anime_6B.pth
files:
- filename: RealESRGAN_x4plus_anime_6B.pth
uri: https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.2.4/RealESRGAN_x4plus_anime_6B.pth
sha256: f872d837d3c90ed2e05227bed711af5671a6fd1c9f7d7e91c911a61f155e99da
- name: stable-diffusion-x4-upscaler
url: github:mudler/LocalAI/gallery/virtual.yaml@master
urls:
@@ -21350,7 +21380,7 @@
diffusers:
pipeline_type: StableDiffusionUpscalePipeline
known_usecases:
- image
- upscale
parameters:
model: stabilityai/stable-diffusion-x4-upscaler
artifacts: