kuji@portfolio:~/projets$ cat huffman.md

Compresseur avec algorithme de Huffman

Outil de compression/décompression sans perte en Python, basé sur les arbres binaires et la manipulation de bits.

Python Algorithmique

Pourquoi ce projet

Je voulais m'entraîner à la manipulation de bits et reprendre un peu de Python avant la rentrée à Télécom Nancy. J'ai repris un algorithme que je connaissais déjà de prépa, pour me concentrer sur l'implémentation plutôt que sur la théorie.

Cas limites & Débogage

Tester l'algorithme sur des cas extrêmes a révélé des bugs intéressants que j'ai dû corriger pour rendre l'outil robuste :

  1. Le fichier vide : Tenter de compresser un fichier sans contenu faisait planter la construction de l'arbre (impossible de dépiler deux nœuds d'un tas vide). Il a fallu ajouter une condition de garde pour court-circuiter la génération et traiter ce cas proprement.
  2. Le fichier à caractère unique : Si un fichier contient des milliers de "A" identiques, l'arbre de Huffman se réduit à une seule racine sans branches. Lors du parcours, le programme ne générait donc aucun code binaire. J'ai corrigé cela en forçant l'attribution du code "0" si l'arbre n'a qu'une seule feuille.

Source

Lien vers le repo GitHub

Principe

L'algorithme de Huffman attribue à chaque octet un code binaire dont la longueur dépend de sa fréquence : un octet très présent reçoit un code court, un octet rare un code long. Aucun code n'étant le préfixe d'un autre, le décodage est sans ambiguïté et le fichier est restitué à l'identique.

  1. Comptage. count_occ compte les occurrences de chaque octet du fichier.
  2. Construction de l'arbre. Chaque octet devient une feuille placée dans un tas-min (heapq). On retire les deux nœuds les plus rares, on les fusionne sous un parent dont la fréquence est la somme des deux, puis on le remet dans le tas. On répète jusqu'à n'avoir plus que la racine.
  3. Génération des codes. dfs_binary parcourt l'arbre : 0 pour aller à gauche, 1 pour aller à droite. Le chemin menant à une feuille est le code de son octet. Si le fichier ne contient qu'un seul octet distinct, l'arbre se réduit à une feuille et reçoit le code 0.
  4. Écriture des bits. Les codes sont mis bout à bout puis regroupés par paquets de 8 grâce aux décalages << et au OU binaire |. Le dernier octet est complété par des zéros, et leur nombre est mémorisé (le padding).

Format du fichier .huff

La première ligne contient la table des codes au format JSON, avec en plus le nombre de bits de padding. Les octets compressés suivent après le saut de ligne. Les clés sont les valeurs des octets en décimal.

{"97": "0", "98": "10", "99": "11", "_PADDING_": 5}
[octets compressés]

Exemple avec a = 97, b = 98 et c = 99.

À la décompression, decompress lit cette ligne, inverse la table (code vers octet), reconvertit chaque octet en 8 bits, retire le padding, puis lit les bits un par un jusqu'à reconnaître un code.

Utilisation

$ python3 compress.py c mon_fichier.txt
# crée mon_fichier.txt.huff
$ python3 compress.py d mon_fichier.txt.huff
# crée mon_fichier.txt_restaure

Code source

compress.py
import heapq, json, sys

class Node:
    def __init__(self, value, freq, c_l, c_r):
        self.value = value
        self.freq = freq
        self.c_l = c_l
        self.c_r = c_r

    def __lt__(self, other):
        return self.freq < other.freq

def count_occ(text):
    dic = {}
    for c in text:
        if c not in dic:
            dic[c] = 1
        else:
            dic[c] += 1
    return dic


def dic_to_heap(dic):
    heap = []
    for k in dic:
        node = Node(k, dic[k], None, None)
        heap.append(node)
    heapq.heapify(heap)
    return heap

def dfs_binary(node, pre, dic):
    if node.value is not None:
        dic[node.value] = pre or "0"  # "0" si l'arbre n'a qu'une feuille
    else:
        l_pre = pre + "0"
        r_pre = pre + "1"
        dfs_binary(node.c_l, l_pre, dic)
        dfs_binary(node.c_r, r_pre, dic)

def huffman(file):
    with open(file, 'rb') as f:
        data = f.read()
        heap_occ = dic_to_heap(count_occ(data))
        while len(heap_occ) > 1:
            left = heapq.heappop(heap_occ)
            right = heapq.heappop(heap_occ)
            parent = Node(None, left.freq+right.freq, left, right)
            heapq.heappush(heap_occ, parent)
        huffman_code = {}
        if heap_occ:  # fichier vide : pas d'arbre
            dfs_binary(heap_occ[0], "", huffman_code)
        current_octet = 0
        byte = bytearray()
        cpt_bytes = 0
        for c in data:
            code = huffman_code[c]
            for bit in code:
                current_octet <<= 1
                if bit == '1':
                    current_octet |= 1
                cpt_bytes +=1

                if cpt_bytes == 8:
                    byte.append(current_octet)
                    current_octet = 0
                    cpt_bytes = 0
        padding_length =0
        if cpt_bytes > 0 :
            padding_length = 8 - cpt_bytes
            current_octet <<= padding_length
            byte.append(current_octet)
        huffman_code["_PADDING_"] = padding_length
        json_dic = json.dumps(huffman_code)

        with open(file + ".huff", 'wb') as o:
            o.write(json_dic.encode("utf-8"))
            o.write(b'\n')
            o.write(byte)


def decompress(huff_file) :
    with open(huff_file, 'rb') as inp:
        header_str = inp.readline().decode('utf-8')
        header_dic = json.loads(header_str)
        padding = header_dic.pop("_PADDING_")
        inversed_dico = {valeur: int(cle) for cle, valeur in header_dic.items()}
        texte = ""
        data = inp.read()
        for octet in data:
            octet_trad = f"{octet:08b}"
            texte += octet_trad
        if padding > 0:
            texte = texte[:-padding] #enleve les zéros artificiels
        byte = bytearray()
        tamp = ""
        for bit in texte:
            tamp += bit
            if tamp in inversed_dico:
                byte.append(inversed_dico[tamp])
                tamp = ""

        output_name = huff_file.replace(".huff", "_restaure")
        with open(output_name, 'wb') as out:
            out.write(byte)


if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("Utilisation : python3 compress.py <c ou d> <nom_du_fichier>")
    else:
        fichier = sys.argv[2]
        action = sys.argv[1]

        if action == "c":
            if not fichier.endswith(".huff"):
                huffman(fichier)
                print("Compression terminée.")
            else:
                print("Le fichier est déjà compressé (.huff).")

        elif action == "d":
            if fichier.endswith(".huff"):
                decompress(fichier)
                print("Décompression terminée.")
            else:
                print("Fichier non compressé ou extension invalide (attendue: .huff).")

        else:
            print("Mauvais paramètre : entrez c pour compresser ou d pour décompresser.")