| 
 New AutoColor adjustment filter - Dan64 -  13.04.2025
 
 Hello Selur,
 
 given that currently is missing a porting of Avisynth functions: ColorYUV and Autolevels. I decided to write a filter that implement them.
 The filter is included in the file: autoadjust.py (see attachment)
 The porting is limited to the following functions:
 
 
 ColorYUV(autogain=True) -> AutoGain(clip)ColorYUV(autowhite=True) -> AutoWhite(clip)
 Autolevels() -> AutoLevels(clip, method, limit, gridsize, weight)
Actually AutoWhite was already available but I included it to complete the auto adjustment (*).
 
 While AutoGain is a simple porting of Avisynth implementation. For the implementation of Autolevels I decided to leverage on OpenCV.
 I don't think that is always necessary to reinvent the Wheel so I decided to use OpenCV for implementing AutoLevel, since in effect this filter should implement some kind of histogram equalization, which in OpenCV is fully implemented.
 It is not easy to implement a good autolevels filter, the implementation in Avisynth is very good. I was not able to match the same results and I had to implement 5 methods to cover different needs. To me the method=3 (default) is the one that provides overall the best results. I added also the parameter weight to mitigate the filter effects.
 
 The script should be put in ".\Hybrid\64bit\vsscripts".
 
 In the attachment there are also 3 test script to compare the Avisynth version with this implementation.
 
 Let me know what do you think.
 
 Dan
 
 EDIT: In the attachment there is also unsharpmask.py even this Avisynth filter is not available in Vapoursynth
 
 (*) I implemented inside the filters the conersion YUV -> RGB24, but it does not work, it works only if is applied in the main script
   
 
 RE: New AutoColor adjustment filter - Selur -  13.04.2025
 
 Will test tomorrow and report back.
 
 Cu Selur
 
 
 RE: New AutoColor adjustment filter - Selur -  14.04.2025
 
 AutoWhite: I got that approach already in Hybrid: https://github.com/Selur/VapoursynthScriptsInHybrid/blob/master/autowhite.py
 AutoLevels: I would prefer the Vapoursynth version, but it bombs at the end of test.avi.
 AutoGain: Vapoursynth version causes flickering, but isn't as aggressive.
 Note that ColorYUV2 and ColorYUV are not the same.
 
 Neither AutoLevels or AutoGain seems usable atm.
 
 Cu Selur
 
 
 RE: New AutoColor adjustment filter - Dan64 -  14.04.2025
 
 Introduced an untested last minute change to AutoLevels.
 
 This version works.
 
 Dan
 
 
 RE: New AutoColor adjustment filter - Selur -  14.04.2025
 
 Yes, it works, but it doesn't fix the dancing shadows.
  https://www.mediafire.com/file/pjzfrl0eusy1zd0/test.265/file
 
 Cu Selur
 
 
 RE: New AutoColor adjustment filter - Dan64 -  14.04.2025
 
 New version.
 
 AutoGain -> added parameter clip_limit, strength
 AutoLeveles -> changed default model to 4 and added parameter strength
 
 With these changes
 
 AutoGain : there is still a little flickering when there is a scene change, but in my opinion, in this case Avisynth is even worse.
 AutoLevels: no-dancing shadows using method=4 (see attached clip).
 
 In AutoLevels are implemented 5 methods because I was unable to find a method working well in every condition.
 
 Dan
 
 
 RE: New AutoColor adjustment filter - Selur -  14.04.2025
 
 Nice, sadly I'll be away from my main system till Thursday evening so can't test atm.
 
 Cu Selur
 
 
 RE: New AutoColor adjustment filter - djilayeden -  14.04.2025
 
 Just wanted to share some thoughts on GamMac() — I’ve been testing it again recently as part of an attempt to reproduce Fred’s 2017 workflow
 
 
 GamMac
 
 I must say, GamMac remains incredibly powerful when it comes to color correction, especially in old B&W films that have been colorized or partially faded. It’s impressive how it manages to rebalance hues in a subtle and filmic way — much more natural than some modern machine learning solutions which tend to oversaturate or skew color tones.
 Combined with
 ,
 , and
 , it really helps to get close to the visual quality Fred was achieving. I even tried some of the sharpening techniques he described (UnsharpMask + blur passes), and the results are surprisingly close to his style.
 Thanks again for keeping these filters alive and accessible — they still do magic in 2025!
 Best regards,
 
 
 RE: New AutoColor adjustment filter - Selur -  15.04.2025
 
 @Dan64:
 Had a quick look at the new autoadjust.
 I like the new versions. (just a bit slow)
 
 a. Do they all have to be RGB24 only? iirc. cv2 should be able to also use RGB48
  b. wouldn't it be better to not use PIL ?
 c. have you tried cupy?
 d. It seems wrong to use range_s="full" and later use tv range restrictions (16,235) for the limits
 
 
 Here's a quick try for a high bit depth version without PIL:
 
 I'm not sure about the luma range stuff.def AutoGainTest(clip: vs.VideoNode, clip_limit: float = 1.0, strength: float = 0.5) -> vs.VideoNode:if not isinstance(clip, vs.VideoNode):
 raise vs.Error("AutoGain: This is not a clip")
 
 # Convert to RGB if needed (supporting high bit depth)
 rgb_format = vs.RGBS if clip.format.bits_per_sample > 8 else vs.RGB24
 if clip.format.id != rgb_format:
 if clip.format.color_family == vs.YUV:
 rgb_clip = clip.resize.Bicubic(format=rgb_format, matrix_in_s="709", range_s="full")
 else:
 rgb_clip = clip.resize.Bicubic(format=rgb_format, range_s="full")
 else:
 rgb_clip = clip
 
 weight = max(min(1.0 - strength, 1.0), 0.0)
 bits = clip.format.bits_per_sample
 max_val = (1 << bits) - 1
 loose_max_limit = (235 + 1) << (bits - 8) if bits <= 16 else (235 + 1) * 256
 loose_min_limit = 16 << (bits - 8) if bits <= 16 else 16 * 256
 clip_limit_factor = (1 - clip_limit/100.0) if clip_limit > 0 else 1.0
 
 def frame_autogain(n, f):
 # Create numpy array from frame planes (handles both 8-bit and high bit depth)
 if rgb_format == vs.RGB24:
 img_np = np.stack([
 np.asarray(f[0]),
 np.asarray(f[1]),
 np.asarray(f[2])
 ], axis=2).astype(np.float32)
 else:
 # For high bit depth (RGBS)
 img_np = np.stack([
 np.asarray(f[0]) * 255,
 np.asarray(f[1]) * 255,
 np.asarray(f[2]) * 255
 ], axis=2).astype(np.float32)
 
 # Process image
 yuv = cv2.cvtColor(img_np, cv2.COLOR_RGB2YUV)
 dY = yuv[:, :, 0]
 
 maxY = min(dY.max(), loose_max_limit)
 minY = max(dY.min(), loose_min_limit)
 y_range = maxY - minY
 
 if y_range > 0:
 scale = (loose_max_limit - loose_min_limit) / y_range
 y_offset = (loose_min_limit - scale * minY)
 y_gain = (scale - 1.0)
 
 # Apply gain and offset
 yuv[:, :, 0] = np.clip((dY + y_offset) * (y_gain + 1), 0, 255)
 
 # Convert back to RGB
 rgb_new = cv2.cvtColor(yuv, cv2.COLOR_YUV2RGB)
 
 # Create new frame
 new_frame = f.copy()
 if rgb_format == vs.RGB24:
 for i in range(3):
 np.copyto(np.asarray(new_frame[i]), rgb_new[:, :, i].astype(np.uint8))
 else:
 # For high bit depth (RGBS)
 for i in range(3):
 np.copyto(np.asarray(new_frame[i]), (rgb_new[:, :, i] / 255).astype(np.float32))
 
 return new_frame
 
 clip_a = rgb_clip.std.ModifyFrame(clips=[rgb_clip], selector=frame_autogain)
 clip_rgb = core.std.Merge(clip_a, rgb_clip, weight) if weight > 0 else clip_a
 
 # Convert back to original format if needed
 if clip.format.id != rgb_format:
 if clip.format.color_family == vs.YUV:
 return clip_rgb.resize.Bicubic(format=clip.format.id, matrix_s="709", range_s="limited")
 else:
 return clip_rgb.resize.Bicubic(format=clip.format.id, range_s=clip.get_frame(0).props._ColorRange)
 
 return clip_rgb
 
 def AutoGainFull(clip: vs.VideoNode, clip_limit: float = 1.0, strength: float = 0.5) -> vs.VideoNode:"""
 AutoGain filter for full-range RGB input/output only.
 Uses RGB limits (0-max) instead of YUV TV range (16-235).
 
 Args:
 clip: RGB input clip (must be full range)
 clip_limit: Threshold for contrast limiting (0-50)
 strength: Filter strength (0-1)
 """
 if not isinstance(clip, vs.VideoNode):
 raise vs.Error("AutoGain: Input must be a clip")
 
 # Verify input is RGB and full range
 if clip.format.color_family != vs.RGB:
 raise vs.Error("AutoGain: Input must be RGB format")
 
 if hasattr(clip.get_frame(0).props, "_ColorRange") and clip.get_frame(0).props._ColorRange != 0:
 raise vs.Error("AutoGain: Input must be full range (0-255/1023/etc.)")
 
 bits = clip.format.bits_per_sample
 max_val = (1 << bits) - 1
 scale_factor = 255 / max_val if bits > 8 else 1
 
 # Parameters
 weight = max(min(1.0 - strength, 1.0), 0.0)
 clip_limit_factor = (1 - clip_limit/100.0) if clip_limit > 0 else 1.0
 
 def frame_autogain(n, f):
 # Create numpy array from frame planes (handles all bit depths)
 if clip.format.sample_type == vs.INTEGER:
 img_np = np.stack([
 np.asarray(f[0]),
 np.asarray(f[1]),
 np.asarray(f[2])
 ], axis=2).astype(np.float32) * scale_factor
 else:
 # Floating point format (RGBS)
 img_np = np.stack([
 np.asarray(f[0]) * 255,
 np.asarray(f[1]) * 255,
 np.asarray(f[2]) * 255
 ], axis=2).astype(np.float32)
 
 # Convert to YUV for processing (still using full range)
 yuv = cv2.cvtColor(img_np, cv2.COLOR_RGB2YUV)
 dY = yuv[:, :, 0]
 
 # Calculate using full range (0-255)
 maxY = dY.max()
 minY = dY.min()
 y_range = maxY - minY
 
 if y_range > 0:
 scale = 255 / y_range  # Full range scaling
 y_offset = -scale * minY
 y_gain = (scale - 1.0)
 
 # Apply with clip_limit factor
 y_offset *= clip_limit_factor
 y_gain *= clip_limit_factor
 
 # Apply gain and offset
 yuv[:, :, 0] = np.clip((dY + y_offset) * (y_gain + 1), 0, 255)
 
 # Convert back to RGB
 rgb_new = cv2.cvtColor(yuv, cv2.COLOR_YUV2RGB)
 
 # Create new frame with proper bit depth
 new_frame = f.copy()
 if clip.format.sample_type == vs.INTEGER:
 output = np.clip(rgb_new / scale_factor, 0, max_val).astype(np.uint16 if bits > 8 else np.uint8)
 for i in range(3):
 np.copyto(np.asarray(new_frame[i]), output[:, :, i])
 else:
 # Floating point format (RGBS)
 for i in range(3):
 np.copyto(np.asarray(new_frame[i]), (rgb_new / 255).astype(np.float32))
 
 return new_frame
 
 # Process and optionally blend with original
 clip_a = clip.std.ModifyFrame(clips=[clip], selector=frame_autogain)
 return core.std.Merge(clip_a, clip, weight) if weight > 0 else clip_a
I think it would be better to: Require full range RGB input and only work with full range luma.
 Just for inspiration (probably still got some errors in it):
 
 def AutoGainYUV(clip: vs.VideoNode, clip_limit: float = 1.0, strength: float = 0.5, planes: tuple = (0,)) -> vs.VideoNode:"""
 YUV-only AutoGain that processes luma (and optionally chroma) directly
 Requires YUV input, avoids all RGB conversions
 
 Args:
 clip: YUV input clip
 clip_limit: Threshold for contrast limiting (0-50)
 strength: Filter strength (0-1)
 planes: Which planes to process (default=(0,) for luma only)
 """
 if not isinstance(clip, vs.VideoNode):
 raise vs.Error("AutoGainYUV: Input must be a clip")
 
 if clip.format.color_family != vs.YUV:
 raise vs.Error("AutoGainYUV: Input must be YUV format")
 
 bits = clip.format.bits_per_sample
 is_float = clip.format.sample_type == vs.FLOAT
 weight = max(min(1.0 - strength, 1.0), 0.0)
 clip_limit_factor = (1 - clip_limit/100.0) if clip_limit > 0 else 1.0
 
 def frame_autogain(n, f):
 # Get frame properties to determine data type
 sample_type = clip.format.sample_type
 bytes_per_sample = clip.format.bytes_per_sample
 
 # Process each plane
 processed_planes = []
 for i in range(clip.format.num_planes):
 if i not in planes:
 processed_planes.append(np.asarray(f[i]))
 continue
 
 # Convert plane to numpy array with correct dtype
 if sample_type == vs.FLOAT:
 plane = np.asarray(f[i], dtype=np.float32)
 processing_plane = plane * 255
 elif bytes_per_sample == 1:
 plane = np.asarray(f[i], dtype=np.uint8)
 processing_plane = plane.astype(np.float32)
 else:
 plane = np.asarray(f[i], dtype=np.uint16)
 processing_plane = plane.astype(np.float32)
 
 # Calculate statistics
 maxY = processing_plane.max()
 minY = processing_plane.min()
 y_range = maxY - minY
 
 if y_range > 0:
 scale = 255 / y_range
 y_offset = -scale * minY
 y_gain = (scale - 1.0)
 
 # Apply with clip_limit factor
 y_offset *= clip_limit_factor
 y_gain *= clip_limit_factor
 
 # Apply gain and offset
 processing_plane = np.clip((processing_plane + y_offset) * (y_gain + 1), 0, 255)
 
 # Convert back to original format
 if sample_type == vs.FLOAT:
 result = processing_plane / 255
 elif bytes_per_sample == 1:
 result = np.clip(processing_plane, 0, 255).astype(np.uint8)
 else:
 result = np.clip(processing_plane, 0, 65535).astype(np.uint16)
 
 processed_planes.append(result)
 
 # Create new frame with processed planes
 new_frame = f.copy()
 for i in range(clip.format.num_planes):
 np.copyto(np.asarray(new_frame[i]), processed_planes[i])
 
 return new_frame
 
 # Process and optionally blend with original
 clip_a = clip.std.ModifyFrame(clips=[clip], selector=frame_autogain)
 return core.std.Merge(clip_a, clip, weight) if weight > 0 else clip_a
Cu Selur
 
 
 RE: New AutoColor adjustment filter - Dan64 -  15.04.2025
 
 On my tests the AviSynth script is encoded at about 40fps (using avs import in Vapoursynth), while the Vapoursynth version is about 2X faster.
 
 Regarding the YUV color space, the problem is that if is necessary to elaborate the single frames using python libraries is necessary to convert the frames is np.ndarray.
 In my knowledge the frame conversion is possible only using RGB24/RGBS (*).
 To be sure I checked all vs-filters developed by HolyWu and they are all using  RGB24/RGBS.
 
 
 I'm interested in this project because some adjustments could be useful also to adjust the colors of HAVC clips.
 
 For sure there is room of improvement...
 
 I think that could be possible to convert in Python also the attached avs script (very preliminary version of the Gamma Machine).
 
 During the Easter holidays I won't be at home, but I think to resume work on this project towards the end of May.
 
 Thanks,
 Dan
 
 (*) the official conversion of Vapoursynth frames in np.ndarray has never been released.
 
 
 
 |