I wrote a short Python program that can automatically do the shift and color-correction, or at least make a best-effort attempt:
$ ./FixJpeg.py mNQdX.jpg
Opening mNQdX.jpg.
Found color corruption discontinuity at y=235.
Found image shift discontinuity at x=484.
Unshifted image.
Using 'AFFINE' mode to correct colors.
Found colorspace correction matrix:
array([[ 1.0936184 , 0.11829611, -0.21800563, 0.10399387],
[-0.11286427, 1.11502151, 0.03886265, 0.09807938],
[ 0.22086667, -0.01595861, 1.09931108, 0.04676938],
[ 0. , 0. , 0. , 1. ]])
Fixed colors.
Filled in missing pixels.
Saving to 'mNQdX.jpg.fixed.png'.
Saved!
Result:
Compared to "JPEG-Repair Tookit", this works on Linux and is fully automatic so you can run it in a Shell script, but it's maybe less accurate than you could get by hand.
Compared to "JPEG Repair Shop", this has no awareness of the encoding structure of JPEG. It only works on the pixel color values.
Copy the code below and save it as "FixJpeg.py":
#!/usr/bin/env python3
"""
De-rotate and fix colors in a partially corrupted image file.
See: https://superuser.com/questions/611058/repair-broken-jpg-files
"""
from PIL import Image
import numpy as np
import argparse, textwrap, sys
def parser():
_parser = argparse.ArgumentParser(
formatter_class=type('ArgFormatter', (argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter), {}),
description=__doc__,
epilog=textwrap.dedent("""
Spacial distances are [0.0-1.0], relative to the image dimensions.
Color values are normalized to [0.0-1.0].
Copyright © 2024 Will Chen
Usage of the works is permitted provided that this
instrument is retained with the works, so that any entity
that uses the works is notified of this instrument.
DISCLAIMER: THE WORKS ARE WITHOUT WARRANTY.
""")
)
_parser.add_argument('--glitch-vertical-search-radius', metavar='(0.0-1.0]', default=0.05, type=float,
help="Look for the glitch boundary within this distance.")
_parser.add_argument('--glitch-horizontal-smooth-radius', metavar='[0.0-1.0]', default=0.03, type=float,
help="Smooth out the discovered glitch boundary.")
_parser.add_argument('--color-low-quantile', metavar='[0.0-1.0]', default=0.1, type=float,
help="With --color-fix-mode RGB_*, fix color using this low quantile in last uncorrupted pixel row.")
_parser.add_argument('--color-middle-quantile', metavar='[0.0-1.0]', default=0.5, type=float,
help="With --color-fix-mode RGB_*, fix color using this middle quantile in last uncorrupted pixel row.")
_parser.add_argument('--color-high-quantile', metavar='[0.0-1.0]', default=0.9, type=float,
help="With --color-fix-mode RGB_*, fix color using this high quantile in last uncorrupted pixel row.")
_parser.add_argument('--color-check-hsmear', metavar='[0.0-1.0]', default=0.02, type=float,
help="Smooth out reference values for fixing colors.")
_parser.add_argument('--color-match-dist', metavar='[0.0-1.0]', default=0.015, type=float,
help="With --color-fix-mode RGB_*, sample all pixels within this difference when computing the inverse transform.")
_parser.add_argument('--color-check-degamma', metavar='~', default=1.0, type=float,
help="With --color-fix-mode AFFINE, account for a naïve exponential gamma transform when fixing colors.")
_parser.add_argument('--color-fix-mode', default='AFFINE', choices=['AFFINE', 'RGB_EXPO', 'RGB_LINEAR'],
help="Algorithm to use for fixing colors.")
_parser.add_argument('--color-space', default='RGB', choices=['RGB', 'YCbCr', 'LAB', 'HSV'],
help="Image colorspace to perform all operations in.")
_parser.add_argument('-o', '--outfile', default=None,
help="Manually specify output file. This also enables clobbering.")
_parser.add_argument('-n', '--dont-save', action='store_true',
help="Don't save the fixed image file.")
_parser.add_argument('--show', action='store_true',
help="Open the fixed image.")
_parser.add_argument('--debug', action='store_true',
help="Expose internal data at the Python module level. This is intended to be used with `python3 -i`.")
_parser.add_argument('filename',
help="Image filepath to fix.")
return _parser
def report(*a, **kw):
print(*a, file=sys.stderr, **kw)
def colordist(a1, a2):
return np.sqrt(np.sum(np.fabs(a1 - a2) ** 2, -1))
def extrude(a):
return np.repeat(a[:,:,np.newaxis], 3, 2)
def h_smear(a, radius, *, axis=1):
out = np.zeros_like(a)
for x_offset in range(-radius, radius+1):
out += np.roll(a, x_offset, axis)
return out / (radius*2+1)
def clamp(a):
return np.maximum(np.minimum(a, 1), 0)
def merge(a1, a2):
return np.where(np.logical_not(np.isnan(a1)), a1, np.where(np.logical_not(np.isnan(a2)), a2, np.NaN))
def v_infill(a):
return np.where(np.isnan(a), (np.roll(a, 1, 0) + np.roll(a, -1, 0)) / 2, a)
def fix_image(arr: np.ndarray[float], ARGS: object):
a_vdist = np.pad(colordist(arr[:-1], arr[1:]), ((0,1), (0,0)))
a_row = lambda: np.repeat(np.arange(arr.shape[0])[:,np.newaxis], arr.shape[1], 1)
sharpest_horizontal_y = np.argmax(np.sum(a_vdist, 1))
report(f"Found color corruption discontinuity at y={sharpest_horizontal_y}.")
a_glitchzone = clamp(1 - (np.fabs(a_row() - sharpest_horizontal_y) / (arr.shape[0] * ARGS.glitch_vertical_search_radius)))
glitch_y = np.argmax(h_smear(a_vdist, round(arr.shape[1] * ARGS.glitch_horizontal_smooth_radius)) * a_glitchzone, 0)
del a_glitchzone
a_glitch_ys = np.tile(glitch_y, arr.shape[0]).reshape(a_vdist.shape)
glitched_bottom = arr[sharpest_horizontal_y:]
a_hdist = colordist(glitched_bottom, np.roll(glitched_bottom, 1, 1))
rotation = np.argmax(np.sum(a_hdist, 0))
report(f"Found image shift discontinuity at x={rotation}.")
a_output_top = np.where(extrude(a_row() <= a_glitch_ys), arr, np.NaN)
a_output_bottom = np.where(extrude(a_row() > a_glitch_ys), arr, np.NaN)
a_output_bottom = np.roll(a_output_bottom, -rotation, 1)
report(f"Unshifted image.")
report(f"Using {ARGS.color_fix_mode!r} mode to correct colors.")
if ARGS.color_fix_mode == 'AFFINE':
try:
from transforms3d import _gohlketransforms as ghk
except ModuleNotFoundError as e:
report("ERROR: Please install transforms3d in order to use affine transform mode.")
raise e
else:
last_good_row = h_smear(np.sum(extrude((a_row() == a_glitch_ys)) * arr, 0) ** (1/ARGS.color_check_degamma), round(arr.shape[1] * ARGS.color_check_hsmear), axis=0)
first_bad_row = h_smear(np.roll(np.sum(extrude((a_row() == a_glitch_ys + 1)) * arr, 0), -rotation, 0) ** (1/ARGS.color_check_degamma), round(arr.shape[1] * ARGS.color_check_hsmear), axis=0)
correction_matrix = ghk.affine_matrix_from_points(first_bad_row.transpose(), last_good_row.transpose(), shear=False, scale=True, usesvd=True)
report(f"Found colorspace correction matrix:\n{correction_matrix!r}")
def fixelstream(rgbs):
bad_pixels = np.pad(rgbs.transpose(), ((0,1),(0,0)), mode='constant', constant_values=1)
fixed_pixels = correction_matrix @ bad_pixels
return fixed_pixels[:3].transpose()
a_output_bottom = fixelstream((a_output_bottom**(1/ARGS.color_check_degamma)).reshape([a_output_bottom.shape[0] * a_output_bottom.shape[1], a_output_bottom.shape[2]])).reshape(a_output_bottom.shape)**ARGS.color_check_degamma
else:
last_good_row = h_smear(np.sum(extrude((a_row() == a_glitch_ys)) * arr, 0), round(arr.shape[1] * ARGS.color_check_hsmear), axis=0)
first_bad_row = h_smear(np.roll(np.sum(extrude((a_row() == a_glitch_ys + 1)) * arr, 0), -rotation, 0), round(arr.shape[1] * ARGS.color_check_hsmear), axis=0)
color_targets = np.array([np.quantile(last_good_row[:,i], [ARGS.color_low_quantile, ARGS.color_middle_quantile, ARGS.color_high_quantile]) for i in range(arr.shape[2])]).transpose()
color_target_a_pixels = np.array([np.isclose(last_good_row, quantile, atol=ARGS.color_match_dist) for quantile in color_targets])
color_corrupteds = np.array([np.where(quantile, first_bad_row, np.NaN) for quantile in color_target_a_pixels])
color_corrupteds = np.nanmean(color_corrupteds, 1)
_minus = color_corrupteds[0]
_div = (color_corrupteds[2]-color_corrupteds[0])
_expo = np.log(color_targets[1]) / np.log(color_corrupteds[1]) if ARGS.color_fix_mode == 'RGB_EXPO' else 1.0
_times = (color_targets[2]-color_targets[0])
_plus = color_targets[0]
report(f"Found colorspace correction values:\n\t-= {_minus}\n\t/= {_div}\n\t**={_expo}\n\t*= {_times}\n\t+= {_plus}")
if ARGS.color_fix_mode in ('RGB_EXPO', 'RGB_LINEAR'):
a_output_bottom -= _minus
a_output_bottom /= _div
a_output_bottom **= _expo
a_output_bottom *= _times
a_output_bottom += _plus
elif ARGS.color_fix_mode == 'RGB_CIRC':
raise NotImplementedError
else:
raise ValueError(ARGS.color_fix_mode)
report(f"Fixed colors.")
a_output = merge(a_output_top, a_output_bottom)
a_output = v_infill(a_output)
report(f"Filled in missing pixels.")
if ARGS.debug:
global r
r = lambda: None
report(f"Stored internal variables in the module variable `r`. Inspect in an interactive Python interpreter.")
r.__dict__.update(**locals())
return a_output
def fix_file(image: str, ARGS: object):
report(f"Opening {ARGS.filename}.")
with Image.open(ARGS.filename) as img:
orig_mode = img.mode
if img.mode != ARGS.color_space:
from PIL import ImageMode
assert ImageMode.getmode(ARGS.color_space).typestr == '|u1'
assert len(ImageMode.getmode(ARGS.color_space).bands) == 3
report(f"Converting from {img.mode!r} to {ARGS.color_space!r}.")
img = img.convert(ARGS.color_space)
arr = np.array(img).astype(np.float32) / 255
img = Image.fromarray(np.round(clamp(fix_image(arr, ARGS)) * 255).astype(np.uint8), ARGS.color_space)
if img.mode != orig_mode:
report(f"Converting from {img.mode!r} to {orig_mode!r}.")
img = img.convert(orig_mode)
return img
if __name__ == '__main__':
ARGS = parser().parse_args()
img = fix_file(ARGS.filename, ARGS)
if ARGS.dont_save:
report(f"Skipping saving image.")
else:
if ARGS.outfile is None:
import os
outfile = f'{ARGS.filename!s}.fixed.png'
i = 0
while os.path.exists(outfile):
i += 1
outfile = f'{ARGS.filename!s}.fixed-{i!s}.png'
else:
outfile = ARGS.outfile
report(f"Saving to {outfile!r}.")
img.save(outfile)
report(f"Saved!")
if ARGS.show:
img.show()
Requires:
numpy
.
PIL
/Pillow
.
transform3d
(Optional, for 'AFFINE'
colour repair mode).
$ pip3 install --user numpy Pillow transform3d