gokaygokay commited on
Commit
c1087b7
·
verified ·
1 Parent(s): ed4c73f

Preserve original hue, saturation and brightness through restoration and finishing

Browse files

Replace 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.

Files changed (4) hide show
  1. README.md +1 -1
  2. app.py +4 -3
  3. pipeline.py +46 -9
  4. 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 uses low-frequency alignment. 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
 
 
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=.85,
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=.85,
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=.85, step=.05, label='Original color preservation')
 
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, .85, 128, 1, 10, .5, .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, .8, 128, 1, 15, .5, .1),
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
- return np.clip(image + float(strength) * (cv2.GaussianBlur(reference, (0, 0), 5)
79
- - cv2.GaussianBlur(image, (0, 0), 5)), 0, 255)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=.85, batch_size=4, passes=3, pass2_strength=.5,
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 = ImageOps.exif_transpose(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.save(reference_path, compress_level=3)
 
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=3,steps=25,overlap=192,batch_size=1)
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