Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions deltatech_image_optimize/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
===============
Image Optimizer
===============

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:52431cbd7b6fd0fc59d3bda1dabac2b5135bb31f428e09674eea8f8fe8b3af2c
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/github-dhongu%2Fdeltatech-lightgray.png?logo=github
:target: https://github.com/dhongu/deltatech/tree/18.0/deltatech_image_optimize
:alt: dhongu/deltatech

|badge1| |badge2|

Image Optimizer
===============

Recompresses oversized **original** image attachments (``image_1920``
and ``image_variant_1920`` by default) to reclaim filestore space.

For each targeted image the module:

- downscales it to a maximum side of ``max_dim`` pixels (default 1920);
- re-encodes photos without transparency as progressive **JPEG** at the
configured quality (default 85);
- keeps images with transparency as optimized **PNG** (alpha preserved);
- skips animated GIFs (never flattens the animation);
- keeps the result only when it is actually smaller.

The optimized image is written back **through the owning record**, so
Odoo regenerates the resized variants (``image_1024/512/256/128``) from
the new, smaller original.

Processed attachments are flagged (``deltatech_image_optimized``) and
skipped on the next run. Because Odoo creates a fresh attachment
whenever an image field is updated, newly uploaded or changed images are
picked up automatically.

Configuration
-------------

System Parameters (Settings → Technical → System Parameters):

+--------------------------------------------+-------------------------------+----------------------+
| Key | Default | Meaning |
+============================================+===============================+======================+
| ``deltatech_image_optimize.quality`` | 85 | JPEG quality (1..95) |
+--------------------------------------------+-------------------------------+----------------------+
| ``deltatech_image_optimize.max_dim`` | 1920 | max side in pixels |
+--------------------------------------------+-------------------------------+----------------------+
| ``deltatech_image_optimize.min_size`` | 102400 | only images larger |
| | | than this (bytes) |
+--------------------------------------------+-------------------------------+----------------------+
| ``deltatech_image_optimize.batch`` | 1000 | images per cron run |
+--------------------------------------------+-------------------------------+----------------------+
| ``deltatech_image_optimize.target_fields`` | image_1920,image_variant_1920 | fields to optimize |
+--------------------------------------------+-------------------------------+----------------------+

Scheduled action
----------------

``Image Optimizer: recompress oversized images`` runs daily. It is
**disabled by default** — review the configuration, test on staging,
then enable it.

For a large one-time backlog you can loop the batch method from the
shell:

.. code:: python

while env["ir.attachment"]._dt_image_optimize_run(limit=2000)["scanned"]:
env.cr.commit()

**Table of contents**

.. contents::
:local:

Changelog
=========

Changelog
=========

18.0.1.0.0 (2025)
-----------------

- Initial release.
- Add ``ir.attachment.deltatech_image_optimized`` marker field.
- Recompress original image attachments (``image_1920`` /
``image_variant_1920``): downscale to ``max_dim``, JPEG (quality
tuned) for opaque images, optimized PNG for images with alpha, skip
animated GIFs.
- Write the optimized image back through the owning record so Odoo
regenerates the resized variants from the smaller original.
- Configurable via ``ir.config_parameter`` (quality, max_dim, min_size,
batch, target_fields).
- Daily scheduled action
``Image Optimizer: recompress oversized images``, disabled by default.

Bug Tracker
===========

Bugs are tracked on `Terrabit Issues <https://www.terrabit.ro/helpdesk>`_.
In case of trouble, please check there if your issue has already been reported.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
-------

* Terrabit
* Dorin Hongu

Maintainers
-----------

.. |maintainer-dhongu| image:: https://github.com/dhongu.png?size=40px
:target: https://github.com/dhongu
:alt: dhongu

Current maintainer:

|maintainer-dhongu|

This module is part of the `dhongu/deltatech <https://github.com/dhongu/deltatech/tree/18.0/deltatech_image_optimize>`_ project on GitHub.

You are welcome to contribute.
1 change: 1 addition & 0 deletions deltatech_image_optimize/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
22 changes: 22 additions & 0 deletions deltatech_image_optimize/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# © 2025 Terrabit
# Dorin Hongu <dhongu(@)gmail(.)com
# See README.rst file on addons root folder for license details

{
"name": "Image Optimizer",
"version": "18.0.1.0.0",
"author": "Terrabit, Dorin Hongu",
"website": "https://www.terrabit.ro",
"summary": "Recompress oversized image attachments to reclaim filestore space",
"category": "Administration",
"depends": ["base"],
"data": [
"data/ir_config_parameter.xml",
"data/ir_cron.xml",
],
"license": "OPL-1",
"installable": True,
"application": False,
"development_status": "Beta",
"maintainers": ["dhongu"],
}
34 changes: 34 additions & 0 deletions deltatech_image_optimize/data/ir_config_parameter.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">

<!-- JPEG re-encode quality (1..95). 85 = good web quality, strong savings. -->
<record id="cp_quality" model="ir.config_parameter">
<field name="key">deltatech_image_optimize.quality</field>
<field name="value">85</field>
</record>

<!-- Downscale images whose largest side exceeds this many pixels. -->
<record id="cp_max_dim" model="ir.config_parameter">
<field name="key">deltatech_image_optimize.max_dim</field>
<field name="value">1920</field>
</record>

<!-- Only consider originals larger than this many bytes (100 KB). -->
<record id="cp_min_size" model="ir.config_parameter">
<field name="key">deltatech_image_optimize.min_size</field>
<field name="value">102400</field>
</record>

<!-- How many images to process per cron run. -->
<record id="cp_batch" model="ir.config_parameter">
<field name="key">deltatech_image_optimize.batch</field>
<field name="value">1000</field>
</record>

<!-- Original image fields to optimize (comma separated res_field values). -->
<record id="cp_target_fields" model="ir.config_parameter">
<field name="key">deltatech_image_optimize.target_fields</field>
<field name="value">image_1920,image_variant_1920</field>
</record>

</odoo>
17 changes: 17 additions & 0 deletions deltatech_image_optimize/data/ir_cron.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>

<record id="ir_cron_dt_image_optimize" model="ir.cron">
<field name="name">Image Optimizer: recompress oversized images</field>
<field name="model_id" ref="base.model_ir_attachment" />
<field name="state">code</field>
<field name="code">model._dt_image_optimize_cron()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<!-- Disabled by default: review the configuration, run on staging,
then enable it manually. -->
<field name="active" eval="False" />
<field name="nextcall" eval="(datetime.now() + relativedelta(days=1)).strftime('%Y-%m-%d 02:00:00')" />
</record>

</odoo>
1 change: 1 addition & 0 deletions deltatech_image_optimize/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import ir_attachment
164 changes: 164 additions & 0 deletions deltatech_image_optimize/models/ir_attachment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import base64
import io
import logging

from odoo import api, fields, models

_logger = logging.getLogger(__name__)

try:
from PIL import Image
except ImportError: # pragma: no cover
Image = None

DEFAULT_TARGET_FIELDS = "image_1920,image_variant_1920"


class IrAttachment(models.Model):
_inherit = "ir.attachment"

deltatech_image_optimized = fields.Datetime(
string="Image Optimized On",
copy=False,
index=True,
help="Set once the image optimizer has recompressed this image "
"attachment. Prevents reprocessing on the next run. Updated images "
"get a fresh attachment (empty flag) and are picked up automatically.",
)

# ------------------------------------------------------------------
# Configuration
# ------------------------------------------------------------------
@api.model
def _dt_image_optimize_params(self):
"""Read the optimizer configuration from ir.config_parameter."""
get = self.env["ir.config_parameter"].sudo().get_param
return {
"quality": max(1, min(95, int(get("deltatech_image_optimize.quality", 85)))),
"max_dim": int(get("deltatech_image_optimize.max_dim", 1920)),
"min_size": int(get("deltatech_image_optimize.min_size", 102400)),
"batch": int(get("deltatech_image_optimize.batch", 1000)),
"fields": [
name.strip()
for name in get("deltatech_image_optimize.target_fields", DEFAULT_TARGET_FIELDS).split(",")
if name.strip()
],
}

# ------------------------------------------------------------------
# Core recompression
# ------------------------------------------------------------------
@staticmethod
def _dt_image_recompress(raw, quality, max_dim):
"""Recompress raw image bytes.

- photos without transparency -> JPEG (quality tuned, progressive)
- images with transparency -> optimized PNG (alpha preserved)
- animated GIFs -> skipped (never flattened)

:return: smaller image bytes, or ``None`` when the image cannot be
optimized or the result would not be smaller.
"""
if not raw or Image is None:
return None
try:
img = Image.open(io.BytesIO(raw))
img.load()
except Exception: # noqa: BLE001 - any unreadable image is skipped
return None
fmt = (img.format or "").upper()
if fmt == "GIF" and getattr(img, "is_animated", False):
return None
resample = getattr(Image, "Resampling", Image).LANCZOS
if max_dim and max(img.size) > max_dim:
img.thumbnail((max_dim, max_dim), resample)
has_alpha = img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
buf = io.BytesIO()
if has_alpha:
img.convert("RGBA").save(buf, format="PNG", optimize=True)
else:
img.convert("RGB").save(buf, format="JPEG", quality=quality, optimize=True, progressive=True)
data = buf.getvalue()
return data if len(data) < len(raw) else None

# ------------------------------------------------------------------
# Batch runner
# ------------------------------------------------------------------
@api.model
def _dt_image_optimize_run(self, limit=None):
"""Optimize a batch of original image attachments.

The smaller image is written back through the owning record
(``record.write({res_field: ...})``) so Odoo regenerates the resized
variants (image_1024/512/256/128) from the new, smaller original.

:return: dict with ``scanned``, ``optimized`` and ``freed`` (bytes).
"""
if Image is None:
_logger.warning("Pillow (PIL) is not available; image optimizer skipped.")
return {"scanned": 0, "optimized": 0, "freed": 0}

params = self._dt_image_optimize_params()
limit = limit or params["batch"]
domain = [
("res_field", "in", params["fields"]),
("deltatech_image_optimized", "=", False),
]
if params["min_size"]:
domain.append(("file_size", ">", params["min_size"]))

# sudo() bypasses the record rule that hides field-stored attachments.
attachments = self.sudo().search(domain, order="file_size desc", limit=limit)

now = fields.Datetime.now()
optimized = 0
freed = 0
for att in attachments:
raw = att.raw
data = self._dt_image_recompress(raw, params["quality"], params["max_dim"])
if not data:
att.deltatech_image_optimized = now
continue
record = self.env[att.res_model].sudo().browse(att.res_id)
if not record.exists() or att.res_field not in record._fields:
att.deltatech_image_optimized = now
continue
try:
record.write({att.res_field: base64.b64encode(data)})
except Exception as exc: # noqa: BLE001
_logger.warning(
"Image optimize failed for %s(%s).%s: %s",
att.res_model,
att.res_id,
att.res_field,
exc,
)
att.deltatech_image_optimized = now
continue
# Writing the image field recreates the attachment: flag the new one
# so it is not reprocessed on the next run.
new_att = self.sudo().search(
[
("res_model", "=", att.res_model),
("res_id", "=", att.res_id),
("res_field", "=", att.res_field),
],
limit=1,
)
if new_att:
new_att.deltatech_image_optimized = now
freed += len(raw) - len(data)
optimized += 1

_logger.info(
"Image optimizer: scanned=%s optimized=%s freed=%.1f MB",
len(attachments),
optimized,
freed / 1048576.0,
)
return {"scanned": len(attachments), "optimized": optimized, "freed": freed}

@api.model
def _dt_image_optimize_cron(self):
"""Entry point for the scheduled action."""
self._dt_image_optimize_run()
8 changes: 8 additions & 0 deletions deltatech_image_optimize/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[build-system]
requires = [
"whool",
]
build-backend = "whool.buildapi"

[project]
name = "odoo-addon-deltatech-image-optimize"
Loading