Spaces:
Running on Zero
Running on Zero
Preserve original hue, saturation and brightness through restoration and finishing
Browse filesReplace blurred RGB matching with per-pixel hue/saturation anchoring and broad value alignment. Reapply after finishing and default color preservation to 1.0. Convert embedded RGB ICC profiles to sRGB when available and tag PNG exports. All 22 automated checks pass, including vivid color recovery with restored texture retained.
- README.md +1 -1
- app.py +4 -3
- pipeline.py +46 -9
- test_pipeline.py +35 -1
README.md
CHANGED
|
@@ -38,7 +38,7 @@ is recoverable at commit `af15c36a9052b361c7a69f1bd210290125a9b07c`.
|
|
| 38 |
Gaussian overlap blending, padded borders, and separately tiled VAE inference.
|
| 39 |
- PNG downloads preserve alpha. The comparison uses RGB; the original alpha
|
| 40 |
channel is resized separately and attached to the download.
|
| 41 |
-
- Color preservation
|
| 42 |
with bicubic input resizing; it does not change the underlying model conditioning.
|
| 43 |
- UI reports inference and processing time separately; ZeroGPU queue wait is additional.
|
| 44 |
|
|
|
|
| 38 |
Gaussian overlap blending, padded borders, and separately tiled VAE inference.
|
| 39 |
- PNG downloads preserve alpha. The comparison uses RGB; the original alpha
|
| 40 |
channel is resized separately and attached to the download.
|
| 41 |
+
- Color preservation defaults to 1.0: original hue and saturation are anchored per pixel, broad brightness is aligned, and restored fine texture is retained. The anchor is reapplied after finishing. Embedded RGB ICC profiles are converted to sRGB when available, and exported PNGs are tagged sRGB. Restoration amount blends
|
| 42 |
with bicubic input resizing; it does not change the underlying model conditioning.
|
| 43 |
- UI reports inference and processing time separately; ZeroGPU queue wait is additional.
|
| 44 |
|
app.py
CHANGED
|
@@ -18,7 +18,7 @@ if os.environ.get('TILE_V3_UI_ONLY') != '1':
|
|
| 18 |
MODELS['Detail'] = RestorationModel('Detail', shared=MODELS['Restore'])
|
| 19 |
|
| 20 |
|
| 21 |
-
def duration(image, mode='Restore', size='2×', seed=42, restoration=1., color_strength=.
|
| 22 |
batch_size=4, passes=3, pass2_strength=.5, pass3_strength=.3, texture_only=True,
|
| 23 |
detail_boost=.25, local_contrast=.1, overlap=128, variations=1, steps=10,
|
| 24 |
guidance=.5, weak_condition=.1, preset='Maximum detail', progress=None, **kwargs):
|
|
@@ -30,7 +30,7 @@ def duration(image, mode='Restore', size='2×', seed=42, restoration=1., color_s
|
|
| 30 |
|
| 31 |
|
| 32 |
@spaces.GPU(duration=duration)
|
| 33 |
-
def upscale(image, mode='Restore', size='2×', seed=42, restoration=1., color_strength=.
|
| 34 |
batch_size=4, passes=3, pass2_strength=.5, pass3_strength=.3, texture_only=True,
|
| 35 |
detail_boost=.25, local_contrast=.1, overlap=128, variations=1, steps=10,
|
| 36 |
guidance=.5, weak_condition=.1, preset='Maximum detail', progress=gr.Progress()):
|
|
@@ -95,7 +95,8 @@ with gr.Blocks(title='Tile Upscaler v3', theme=gr.themes.Soft(primary_hue='indig
|
|
| 95 |
with gr.Row():
|
| 96 |
texture_only = gr.Checkbox(value=True, label='Texture-only refinements',
|
| 97 |
info='Later passes add small detail while retaining broader shapes.')
|
| 98 |
-
color_strength = gr.Slider(0, 1, value=.
|
|
|
|
| 99 |
with gr.Row():
|
| 100 |
detail_boost = gr.Slider(0, 1, value=.25, step=.05, label='Fine-detail boost',
|
| 101 |
info='Luminance sharpening with local halo limits; increases crispness.')
|
|
|
|
| 18 |
MODELS['Detail'] = RestorationModel('Detail', shared=MODELS['Restore'])
|
| 19 |
|
| 20 |
|
| 21 |
+
def duration(image, mode='Restore', size='2×', seed=42, restoration=1., color_strength=1.,
|
| 22 |
batch_size=4, passes=3, pass2_strength=.5, pass3_strength=.3, texture_only=True,
|
| 23 |
detail_boost=.25, local_contrast=.1, overlap=128, variations=1, steps=10,
|
| 24 |
guidance=.5, weak_condition=.1, preset='Maximum detail', progress=None, **kwargs):
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
@spaces.GPU(duration=duration)
|
| 33 |
+
def upscale(image, mode='Restore', size='2×', seed=42, restoration=1., color_strength=1.,
|
| 34 |
batch_size=4, passes=3, pass2_strength=.5, pass3_strength=.3, texture_only=True,
|
| 35 |
detail_boost=.25, local_contrast=.1, overlap=128, variations=1, steps=10,
|
| 36 |
guidance=.5, weak_condition=.1, preset='Maximum detail', progress=gr.Progress()):
|
|
|
|
| 95 |
with gr.Row():
|
| 96 |
texture_only = gr.Checkbox(value=True, label='Texture-only refinements',
|
| 97 |
info='Later passes add small detail while retaining broader shapes.')
|
| 98 |
+
color_strength = gr.Slider(0, 1, value=1., step=.05, label='Original color preservation',
|
| 99 |
+
info='At 1, keeps original hue, saturation and broad brightness while retaining restored texture.')
|
| 100 |
with gr.Row():
|
| 101 |
detail_boost = gr.Slider(0, 1, value=.25, step=.05, label='Fine-detail boost',
|
| 102 |
info='Luminance sharpening with local halo limits; increases crispness.')
|
pipeline.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""A single request: restore, refine, finish, and export distinct seed variants."""
|
| 2 |
import json
|
|
|
|
| 3 |
import math
|
| 4 |
import shutil
|
| 5 |
import tempfile
|
|
@@ -9,7 +10,7 @@ from pathlib import Path
|
|
| 9 |
|
| 10 |
import cv2
|
| 11 |
import numpy as np
|
| 12 |
-
from PIL import Image, ImageOps
|
| 13 |
from geometry import output_size, padded_size, tile_coordinates
|
| 14 |
|
| 15 |
cv2.setNumThreads(2)
|
|
@@ -17,11 +18,11 @@ PRESET_FIELDS = ('mode', 'passes', 'restoration', 'pass2_strength', 'pass3_stren
|
|
| 17 |
'texture_only', 'detail_boost', 'local_contrast', 'color_strength',
|
| 18 |
'overlap', 'variations', 'steps', 'guidance', 'weak_condition')
|
| 19 |
PRESETS = {
|
| 20 |
-
'Maximum detail': ('Restore', 3, 1., .5, .3, True, .25, .1, .
|
| 21 |
'Balanced': ('Restore', 2, 1., .4, .25, True, .15, .05, 1., 64, 1, 10, .5, .1),
|
| 22 |
'Natural': ('Restore', 1, .8, .3, .2, True, .08, 0., 1., 64, 1, 10, 1., .2),
|
| 23 |
'Fast': ('Fast', 1, 1., .4, .25, True, .12, .05, 1., 64, 1, 5, .5, .1),
|
| 24 |
-
'Creative detail': ('Detail', 2, 1., .35, .2, True, .15, .05, .
|
| 25 |
}
|
| 26 |
DEFAULTS = dict(zip(PRESET_FIELDS, PRESETS['Maximum detail']))
|
| 27 |
MODEL_REVISION = 'f24b3061b7f350b81e1907bdcfc27f71fb4ff3f3'
|
|
@@ -73,10 +74,43 @@ def blend_pass(base, prediction, strength, texture_only):
|
|
| 73 |
|
| 74 |
|
| 75 |
def align_color(image, reference, strength):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
if not strength:
|
| 77 |
return image
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
|
| 82 |
def finish_detail(image, boost, contrast):
|
|
@@ -105,7 +139,7 @@ def output_directory():
|
|
| 105 |
|
| 106 |
|
| 107 |
def run_pipeline(models, image, mode='Restore', size='2×', seed=42, restoration=1.,
|
| 108 |
-
color_strength=.
|
| 109 |
pass3_strength=.3, texture_only=True, detail_boost=.25, local_contrast=.1,
|
| 110 |
overlap=128, variations=1, steps=10, guidance=.5, weak_condition=.1,
|
| 111 |
preset='Maximum detail', progress=None):
|
|
@@ -116,7 +150,7 @@ def run_pipeline(models, image, mode='Restore', size='2×', seed=42, restoration
|
|
| 116 |
guidance, weak_condition, batch_size, seed)
|
| 117 |
if mode not in models:
|
| 118 |
raise ValueError('Model inference is unavailable in the local UI preview.')
|
| 119 |
-
image =
|
| 120 |
target = output_size(*image.size, size)
|
| 121 |
_, estimate = work_estimate(target, mode, passes, overlap, variations, steps, batch_size)
|
| 122 |
if estimate > 165:
|
|
@@ -129,7 +163,8 @@ def run_pipeline(models, image, mode='Restore', size='2×', seed=42, restoration
|
|
| 129 |
pw, ph = padded_size(*target)
|
| 130 |
folder = output_directory()
|
| 131 |
reference_path = folder / 'before.png'
|
| 132 |
-
reference.
|
|
|
|
| 133 |
settings = dict(mode=mode, size=size, seed=int(seed), restoration=restoration, color_strength=color_strength,
|
| 134 |
batch_size=batch_size, passes=passes, pass2_strength=pass2_strength, pass3_strength=pass3_strength,
|
| 135 |
texture_only=texture_only, detail_boost=detail_boost, local_contrast=local_contrast,
|
|
@@ -162,11 +197,13 @@ def run_pipeline(models, image, mode='Restore', size='2×', seed=42, restoration
|
|
| 162 |
current = result.round().astype(np.uint8)
|
| 163 |
timings.append(dict(pass_number=pass_index + 1, model=pass_mode, seed=(variant_seed + pass_index) % 2147483648, **timing))
|
| 164 |
current = finish_detail(current, detail_boost, local_contrast)
|
|
|
|
|
|
|
| 165 |
output = Image.fromarray(current)
|
| 166 |
if alpha is not None:
|
| 167 |
output.putalpha(alpha)
|
| 168 |
path = folder / f'tile-v3-{mode.lower()}-{target[0]}x{target[1]}-{passes}pass-seed{variant_seed}.png'
|
| 169 |
-
output.save(path, compress_level=3)
|
| 170 |
gpu_seconds = sum(t['gpu_seconds'] for t in timings)
|
| 171 |
record = dict(variant=variant + 1, seed=variant_seed, gpu_seconds=gpu_seconds, passes=timings, file=path.name)
|
| 172 |
records.append(record)
|
|
|
|
| 1 |
"""A single request: restore, refine, finish, and export distinct seed variants."""
|
| 2 |
import json
|
| 3 |
+
from io import BytesIO
|
| 4 |
import math
|
| 5 |
import shutil
|
| 6 |
import tempfile
|
|
|
|
| 10 |
|
| 11 |
import cv2
|
| 12 |
import numpy as np
|
| 13 |
+
from PIL import Image, ImageOps, ImageCms
|
| 14 |
from geometry import output_size, padded_size, tile_coordinates
|
| 15 |
|
| 16 |
cv2.setNumThreads(2)
|
|
|
|
| 18 |
'texture_only', 'detail_boost', 'local_contrast', 'color_strength',
|
| 19 |
'overlap', 'variations', 'steps', 'guidance', 'weak_condition')
|
| 20 |
PRESETS = {
|
| 21 |
+
'Maximum detail': ('Restore', 3, 1., .5, .3, True, .25, .1, 1., 128, 1, 10, .5, .1),
|
| 22 |
'Balanced': ('Restore', 2, 1., .4, .25, True, .15, .05, 1., 64, 1, 10, .5, .1),
|
| 23 |
'Natural': ('Restore', 1, .8, .3, .2, True, .08, 0., 1., 64, 1, 10, 1., .2),
|
| 24 |
'Fast': ('Fast', 1, 1., .4, .25, True, .12, .05, 1., 64, 1, 5, .5, .1),
|
| 25 |
+
'Creative detail': ('Detail', 2, 1., .35, .2, True, .15, .05, 1., 128, 1, 15, .5, .1),
|
| 26 |
}
|
| 27 |
DEFAULTS = dict(zip(PRESET_FIELDS, PRESETS['Maximum detail']))
|
| 28 |
MODEL_REVISION = 'f24b3061b7f350b81e1907bdcfc27f71fb4ff3f3'
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
def align_color(image, reference, strength):
|
| 77 |
+
"""Anchor original hue/saturation and broad brightness; retain restored texture.
|
| 78 |
+
|
| 79 |
+
Matching blurred RGB alone cannot recover small saturated objects or color
|
| 80 |
+
edges. RGB ratios preserve original hue and saturation without histogram
|
| 81 |
+
matching, while a high-pass value residual retains the restored fine detail.
|
| 82 |
+
"""
|
| 83 |
+
image = image.astype(np.float32)
|
| 84 |
if not strength:
|
| 85 |
return image
|
| 86 |
+
reference = reference.astype(np.float32)
|
| 87 |
+
value = image.max(axis=2)
|
| 88 |
+
source_value = reference.max(axis=2)
|
| 89 |
+
target_value = np.clip(value + cv2.GaussianBlur(source_value - value, (0, 0), 5), 0, 255)
|
| 90 |
+
ratios = np.divide(reference, source_value[:, :, None],
|
| 91 |
+
out=np.ones_like(reference), where=source_value[:, :, None] > 1e-6)
|
| 92 |
+
anchored = ratios * target_value[:, :, None]
|
| 93 |
+
return np.clip(image + float(strength) * (anchored - image), 0, 255)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def normalize_color(image):
|
| 97 |
+
"""Convert embedded RGB profiles to sRGB before processing and PNG export."""
|
| 98 |
+
image = ImageOps.exif_transpose(image)
|
| 99 |
+
profile = image.info.get('icc_profile')
|
| 100 |
+
if profile:
|
| 101 |
+
try:
|
| 102 |
+
converted = ImageCms.profileToProfile(image.convert('RGB'),
|
| 103 |
+
ImageCms.ImageCmsProfile(BytesIO(profile)), ImageCms.createProfile('sRGB'), outputMode='RGB')
|
| 104 |
+
if 'A' in image.getbands() or 'transparency' in image.info:
|
| 105 |
+
converted.putalpha(image.convert('RGBA').getchannel('A'))
|
| 106 |
+
return converted
|
| 107 |
+
except (ImageCms.PyCMSError, ValueError, OSError):
|
| 108 |
+
# Malformed profiles must not make an otherwise readable image fail.
|
| 109 |
+
print('Ignoring unreadable input ICC profile', flush=True)
|
| 110 |
+
return image
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
SRGB_PROFILE = ImageCms.ImageCmsProfile(ImageCms.createProfile('sRGB')).tobytes()
|
| 114 |
|
| 115 |
|
| 116 |
def finish_detail(image, boost, contrast):
|
|
|
|
| 139 |
|
| 140 |
|
| 141 |
def run_pipeline(models, image, mode='Restore', size='2×', seed=42, restoration=1.,
|
| 142 |
+
color_strength=1., batch_size=4, passes=3, pass2_strength=.5,
|
| 143 |
pass3_strength=.3, texture_only=True, detail_boost=.25, local_contrast=.1,
|
| 144 |
overlap=128, variations=1, steps=10, guidance=.5, weak_condition=.1,
|
| 145 |
preset='Maximum detail', progress=None):
|
|
|
|
| 150 |
guidance, weak_condition, batch_size, seed)
|
| 151 |
if mode not in models:
|
| 152 |
raise ValueError('Model inference is unavailable in the local UI preview.')
|
| 153 |
+
image = normalize_color(image)
|
| 154 |
target = output_size(*image.size, size)
|
| 155 |
_, estimate = work_estimate(target, mode, passes, overlap, variations, steps, batch_size)
|
| 156 |
if estimate > 165:
|
|
|
|
| 163 |
pw, ph = padded_size(*target)
|
| 164 |
folder = output_directory()
|
| 165 |
reference_path = folder / 'before.png'
|
| 166 |
+
reference.info.pop("transparency", None)
|
| 167 |
+
reference.save(reference_path, compress_level=3, icc_profile=SRGB_PROFILE)
|
| 168 |
settings = dict(mode=mode, size=size, seed=int(seed), restoration=restoration, color_strength=color_strength,
|
| 169 |
batch_size=batch_size, passes=passes, pass2_strength=pass2_strength, pass3_strength=pass3_strength,
|
| 170 |
texture_only=texture_only, detail_boost=detail_boost, local_contrast=local_contrast,
|
|
|
|
| 197 |
current = result.round().astype(np.uint8)
|
| 198 |
timings.append(dict(pass_number=pass_index + 1, model=pass_mode, seed=(variant_seed + pass_index) % 2147483648, **timing))
|
| 199 |
current = finish_detail(current, detail_boost, local_contrast)
|
| 200 |
+
# Finishing must not undo the color anchor through additive sharpening.
|
| 201 |
+
current = align_color(current, source, color_strength).round().astype(np.uint8)
|
| 202 |
output = Image.fromarray(current)
|
| 203 |
if alpha is not None:
|
| 204 |
output.putalpha(alpha)
|
| 205 |
path = folder / f'tile-v3-{mode.lower()}-{target[0]}x{target[1]}-{passes}pass-seed{variant_seed}.png'
|
| 206 |
+
output.save(path, compress_level=3, icc_profile=SRGB_PROFILE)
|
| 207 |
gpu_seconds = sum(t['gpu_seconds'] for t in timings)
|
| 208 |
record = dict(variant=variant + 1, seed=variant_seed, gpu_seconds=gpu_seconds, passes=timings, file=path.name)
|
| 209 |
records.append(record)
|
test_pipeline.py
CHANGED
|
@@ -28,6 +28,10 @@ def test_variants_passes_alpha_and_reproducible_archive():
|
|
| 28 |
metadata=json.loads(z.read('settings.json'))
|
| 29 |
assert metadata['settings']['pass3_strength']==.3
|
| 30 |
assert len(metadata['variants'][0]['passes'])==3
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
again=run_pipeline({'Restore':Fake()},image,variations=2)
|
| 32 |
np.testing.assert_array_equal(first,Image.open(again[1]))
|
| 33 |
|
|
@@ -49,6 +53,36 @@ def test_settings_validation_and_capacity():
|
|
| 49 |
for kwargs in ({'seed':float('nan')},{'guidance':3},{'overlap':100},{'detail_boost':float('nan')}):
|
| 50 |
with pytest.raises(ValueError): run_pipeline({'Restore':Fake()},image,**kwargs)
|
| 51 |
with pytest.raises(ValueError,match='exceeds'):
|
| 52 |
-
run_pipeline({'Detail':Fake()},image,mode='Detail',size='4096 px long edge',passes=3,variations=
|
| 53 |
assert DEFAULTS['mode']=='Restore' and DEFAULTS['passes']==3
|
| 54 |
assert all(len(v)==len(PRESET_FIELDS) for v in PRESETS.values())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
metadata=json.loads(z.read('settings.json'))
|
| 29 |
assert metadata['settings']['pass3_strength']==.3
|
| 30 |
assert len(metadata['variants'][0]['passes'])==3
|
| 31 |
+
for transparent in [Image.new('LA',(40,24),(90,128)), Image.new('P',(40,24),0)]:
|
| 32 |
+
if transparent.mode=='P': transparent.info['transparency']=0
|
| 33 |
+
exported=run_pipeline({'Restore':Fake()},transparent,passes=1)
|
| 34 |
+
assert Image.open(exported[1]).mode=='RGBA'
|
| 35 |
again=run_pipeline({'Restore':Fake()},image,variations=2)
|
| 36 |
np.testing.assert_array_equal(first,Image.open(again[1]))
|
| 37 |
|
|
|
|
| 53 |
for kwargs in ({'seed':float('nan')},{'guidance':3},{'overlap':100},{'detail_boost':float('nan')}):
|
| 54 |
with pytest.raises(ValueError): run_pipeline({'Restore':Fake()},image,**kwargs)
|
| 55 |
with pytest.raises(ValueError,match='exceeds'):
|
| 56 |
+
run_pipeline({'Detail':Fake()},image,mode='Detail',size='4096 px long edge',passes=3,variations=1,steps=25,overlap=128,batch_size=4)
|
| 57 |
assert DEFAULTS['mode']=='Restore' and DEFAULTS['passes']==3
|
| 58 |
assert all(len(v)==len(PRESET_FIELDS) for v in PRESETS.values())
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_color_anchor_recovers_vivid_colors_and_keeps_restored_detail():
|
| 62 |
+
from pipeline import align_color
|
| 63 |
+
import cv2
|
| 64 |
+
reference=np.zeros((64,96,3),np.float32)
|
| 65 |
+
reference[:,:32]=[230,30,20]
|
| 66 |
+
reference[:,32:64]=[20,210,50]
|
| 67 |
+
reference[:,64:]=[30,50,240]
|
| 68 |
+
gray=reference.mean(2,keepdims=True)*.65+30
|
| 69 |
+
prediction=np.repeat(gray,3,axis=2)
|
| 70 |
+
# Add texture the source does not have: it should survive the color correction.
|
| 71 |
+
prediction[::2]+=8
|
| 72 |
+
corrected=align_color(prediction,reference,1.)
|
| 73 |
+
src_hsv=cv2.cvtColor(reference/255,cv2.COLOR_RGB2HSV)
|
| 74 |
+
dst_hsv=cv2.cvtColor(corrected/255,cv2.COLOR_RGB2HSV)
|
| 75 |
+
np.testing.assert_allclose(src_hsv[:,:,:2],dst_hsv[:,:,:2],atol=.001)
|
| 76 |
+
assert corrected[::2,16,0].mean()>corrected[1::2,16,0].mean()+5
|
| 77 |
+
np.testing.assert_array_equal(align_color(prediction,reference,0),prediction)
|
| 78 |
+
flat=np.broadcast_to([230,30,20],(32,32,3)).astype('float32')
|
| 79 |
+
np.testing.assert_allclose(align_color(np.full_like(flat,100),flat,1),flat,atol=.0001)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_profile_normalization_and_export_tag():
|
| 83 |
+
from pipeline import normalize_color,SRGB_PROFILE
|
| 84 |
+
image=Image.new('RGBA',(16,12),(230,30,20,128));image.info['icc_profile']=SRGB_PROFILE
|
| 85 |
+
converted=normalize_color(image)
|
| 86 |
+
np.testing.assert_array_equal(image,converted)
|
| 87 |
+
r=run_pipeline({'Restore':Fake()},image,passes=1)
|
| 88 |
+
assert Image.open(r[1]).info.get('icc_profile')==SRGB_PROFILE
|